PowerShell - Debugging Scripts with Cmdlets

Published 22.4.2025


After some discussions on the PDQ Discord, it occurred to me that perhaps some people may be unfamiliar with running the PowerShell Debugger manually. Naturally, I feel the need to spread the love around.

But why would you want to do this?

First of all, there's some street cred that comes along with it. You can be one of those rad dudes that flexes on the bois when it comes time to talk shop.

*What did you do this weekend Bill?*

*Oh I wrote some PowerShell and used the debugger in VS Code to fix some issues. What did you do?*

*The same, except I ran the debugger... IN WINDOWS TERMINAL!*

While I enjoy a good flex like any Chad, it mostly boils down to differences in comfort.

See, I come from the world of GDB, and my body bears the scars from it. LLDB is a lot nicer, but I don't get to use it as often as I'd like/want to. Visual debuggers are great, especially when you don't want to deal with the usually arcane keystrokes required to navigate them. Fortunately, PowerShell's debugger isn't super offensive in this regard, and can be useful until you're doing something stupid like repositioning the cursor in a script you're debugging (yeah, I do that a lot). The visual debugger in VSC allows you to navigate through the code using your mouse. You can view the current state of the code in different visual panes in VSC, which is nice because of the way that it allows you to break down Objects in a tree-like display.

The CLI debugger does the same things, but it's all through a text-based interface so its invocations aren't really obvious from the outset. I'm not going to cover every little aspect of the PowerShell CLI debugger, but I'll include enough to whet one's appetite.

Starting the Debugger

Comparing how to do something in VSC with how one would do it in the CLI would probably be the best approach since you get a feel for accomplishing well-known workflows in a different way. So that's what we'll do here.

In VSC, using the debugger starts with setting a breakpoint. There's a breakpoint gutter on the immediate left of the line number gutter which will, on mouse hover, show the shadow of a breakpoint. You can click in any location here to associate a breakpoint to a line, which will change the visual representation from a shadow circle to a completely opaque one. Clicking on this circle again will disassociate the breakpoint.

INSERT SCREENSHOT HERE

The CLI debugger is initialized using the same objective but different method. You use the Set-PSBreakpoint cmdlet to associate a breakpoint. You'll provide the cmdlet with at minimum the script you want to debug and a line in that script to associate a breakpoint with. Let's assume that the current directory has a script named GigaUltraAwesomeScript.ps1, that has 500 lines in it, you could set a breakpoint on line 273 (assuming it has an actionable statement on it) like so:

            Set-PSBreakpoint -Script .\GigaUltraAwesomeScript.ps1 -Line 273
        

In VSC, once you have at least one breakpoint associated to a line, you can start the debugger in a number of ways. To make things simple, one could open the Run and Debug pane in the Sidebar by pressing Ctrl+Shift+D, then clicking the Run and Debug button. This will run the script until the call stack reaches the breakpoint. This event pauses execution of the script at the breakpoint. VSC will visually guide you to the location in the file where the breakpoint was encountered at. The synonym for this in the CLI is to simply run the script you referenced in the previous Set-PSBreakpoint command. Given the previous example, we would just run the script using the dot source method:

            .\GigaUltraAwesomeScript.ps1
        

Again, assuming that the code at line 273 is able to be reached, the PowerShell Debugger will be invoked. At the CLI, the PowerShell Debugger will take over your prompt and a kind of subshell (not quite, but it's more like a session prompt hijack). Assuming you haven't modified anything in this regard - because you can - your shell prompt will look like this (by the way, we've transitioned from the GigaUltaAwesomeScript.ps1 file to my PowerShell game file titled EldoriaAlpha.ps1):

            
Entering debug mode. Use h or ? for help. 

Hit Line breakpoint on 'C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1:248'
At C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1:247 char:1 

+ [Hashtable]$Script:TheSceneImages = @{ 
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [DBG]:
            
        

Navigating the Debugger

As the good prompt says, we can press either the h or ? keys to get an inline summary of the keystrokes we can use to interact with the debugger:

            
[DBG]: h 

s, stepInto Single step (step into functions, scripts, etc.) 
v, stepOver Step to next statement (step over functions, scripts, etc.) 
o, stepOut Step out of the current function, script, etc.

c, continue Continue operation 
q, quit Stop operation and exit the debugger 
d, detach Continue operation and detach the debugger. 

k, Get-PSCallStack Display call stack 
l, list List source code for the current script. 

Use "list" to start from the current line, "list m" to start from line m, and "list m n" to list n lines starting from line m

enter Repeat last command if it was stepInto, stepOver or list ? 
h displays this help message. 
For instructions about how to customize your debugger prompt, type "help about_prompt".    
            
        

These controls are quite similar to ones you'd use in the Visual Debugger in VSC. Each command has a two ways to invoke it, either a single character or a full phrase. Use whichever one tickles your fancy; my preference is for the single characters. Let's play with the debugger. Astute readers will have noticed that we set a breakpoint at line 248 but the program has actually stopped on line 247. To understand why, let's look at the immediate code area where we've stopped at.

The list command will do this for us. So we can just type the letter l at our prompt:

            
[DBG]: l 

242: [Map] $Script:CurrentMap = $null 
243: [Map] $Script:PreviousMap = $null 
244: Write-Progress -Activity 'Setting up Globals' -Id 1 -PercentComplete -1 -Completed 
245: 
246: Write-Progress -Activity 'Creating Scene Images' -Id 2 -PercentComplete 0 
247:* [Hashtable]$Script:TheSceneImages = @{ 
248: 'FieldPlainsNoRoad' = [SIFieldPlainsNoRoad]::new() 
249: 'FieldPlainsRoadNorth' = [SIFieldPlainsRoadNorth]::new() 
250: 'FieldPlainsRoadSouth' = [SIFieldPlainsRoadSouth]::new() 
251: 'FieldPlainsRoadEast' = [SIFieldPlainsRoadEast]::new() 
252: 'FieldPlainsRoadWest' = [SIFieldPlainsRoadWest]::new() 
253: 'FieldPlainsRoadNorthEast' = [SIFieldPlainsRoadNorthEast]::new() 
254: 'FieldPlainsRoadNorthWest' = [SIFieldPlainsRoadNorthWest]::new() 
255: 'FieldPlainsRoadNorthSouth' = [SIFieldPlainsRoadNorthSouth]::new() 
256: 'FieldPlainsRoadEastWest' = [SIFieldPlainsRoadEastWest]::new() 
257: 'FieldPlainsRoadNorthSouthEast' = [SIFieldPlainsRoadNorthSouthEast]::new()
            
        

Line 248 is part of a single assignment operation that's spread out over multiple lines, so the break actually occurs at line 247. This would be true for any of the lines in the Hashtable (here, 248-257). In the debugger output, the line where the break occurred at is noted with an asterisk. The usual controls for debugging apply here. We can step into the current operation with either the stepInto command or the s key:

            
[DBG]: s 

At C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1:9182 char:89 
+ … ad() : base("$(Get-Location)\Image Data\SIFieldPlainsNoRoad.json") {} + ~ 

[DBG]: l 

9177: Class SIFieldNorthRoad : SIInternalBase { 
9178: SIFieldNorthRoad() : base("$(Get-Location)\Image Data\SIFieldNorthRoadNew.json") {} 
9179: } 
9180: 
9181: Class SIFieldPlainsNoRoad : SIInternalBase { 
9182:* SIFieldPlainsNoRoad() : base("$(Get-Location)\Image Data\SIFieldPlainsNoRoad.json") {} 
9183: } 
9184: 
9185: Class SIFieldPlainsRoadNorth : SIInternalBase { 
9186: SIFieldPlainsRoadNorth() : base("$(Get-Location)\Image Data\SIFieldPlainsRoadNorth.json") {} 
9187: } 
9188: 
9189: Class SIFieldPlainsRoadSouth : SIInternalBase { 
9190: SIFieldPlainsRoadSouth() : base("$(Get-Location)\Image Data\SIFieldPlainsRoadSouth.json") {} 
9191: } 
9192:
            
        

The debugger has jumped from line 247 to line 9182. We can see that the next break occurred based on the location of the asterisk. To understand how we got here, let's remember how we started the debugger. We set a breakpoint with the Set-PSBreakpoint cmdlet at line 248, but the break occurred at line 247. That doesn't mean that you can't inspect each individual statement in the Hashtable because that's exactly what's happening. Line 248 in the first debugger output was

            
248: 'FieldPlainsNoRoad' = [SIFieldPlainsNoRoad]::new()
            
        

This statement creates a key in a Hashtable called FieldPlainsNoRoad and assigns to it a new instance of the class SIFieldPlainsNoRoad. Calling ::new() invokes the default constructor for the SIFieldPlainsNoRoad class, so the program control has jumped to where the default constructor for that class is defined at. Let's step into this statement:

            
[DBG]: s At C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1:9182 char:37 

+ SIFieldPlainsNoRoad() : base("$(Get-Location)\Image Data\SIFieldP … 
+ ~~~~~~~~~~~~ 

[DBG]:
            
        

Our current step is delinated with the tilde characters. The statement here contains a subexpression operator, so that gets evaluated next. In this case, the subexpression is an invocation of the Get-Location cmdlet. This cmdlet will be called and its value returned into the subexpression, which is then interpolated into the string given to the base class constructor as a parameter. Next step:

            
[DBG]: s 

At C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1:9108 char:16 

+ ) : base() { 
+ ~ 

[DBG]: l 

9103: $this.ColorMap = New-Object 'ATBackgroundColor24[]' ([Int32](([Int32]([SceneImage]::Width)) * ([Int32]([SceneImage]::Height)))) 
9104: } 
9105: 
9106: SIInternalBase( 
9107: [String]$JsonConfigPath 
9108:* ) : base() { 
9109: Write-Progress ` 
9110: -Activity 'Creating Scene Images' ` 
9111: -Id 2 ` 
9112: -CurrentOperation "Creating $([System.IO.Path]::GetFileNameWithoutExtension($JsonConfigPath))" ` 
9113: -PercentComplete (($Script:SceneImagesLoaded / $Script:SceneImagesToLoad) * 100) 
9114: 
9115: [Hashtable]$JsonData = @{} 
9116: $this.ColorMap = New-Object 'ATBackgroundColor24[]' ([Int32](([Int32]([SceneImage]::Width)) * ([Int32]([SceneImage]::Height)))) 
9117: 
9118: If($(Test-Path $JsonConfigPath) -EQ $true) { 
    
[DBG]:
            
        

The next break shows that we've moved to line 9108. Here, we're entering one of the SIInternalBase class constructors. SIInternalBase is the base class for the SIFieldPlainsNoRoad class, and this tertiary constructor takes one argument: JsonConfigPath. By now, you should be getting an idea of how the stepping works. We'll step out of this constructor momentarily, but let's inspect some data. The prompt for the debugger is just like any old PowerShell prompt. So let's say that you want to inspect the value of the JsonConfigPath parameter. Just type it into your prompt:

            
[DBG]: $JsonConfigPath C:\Users\greg\OneDrive\Documents\Source\Eldoria\Image Data\SIFieldPlainsNoRoad.json 

[DBG]:
            
        

If you wanted to inspect the current instance's member values at this point, you can use the Get-Member cmdlet like this:

            
[DBG]: $this | Get-Member 

TypeName: SIFieldPlainsNoRoad 

Name MemberType Definition 
---- ---------- ---------- 
CreateSceneImageATString Method void CreateSceneImageATString(ATBackgroundColor24[] ImageColorMap) 
Equals Method bool Equals(System.Object obj) 
GetHashCode Method int GetHashCode() 
GetType Method type GetType() 
ToAnsiControlSequenceString Method string ToAnsiControlSequenceString() 
ToString Method string ToString() 
ColorMap Property ATBackgroundColor24[] ColorMap {get;set;} 
Image Property ATSceneImageString[,] Image {get;set;} 

[DBG]:
            
        

Since we're a few calls deep, this would be a good time to examine the Call Stack. You either use the Get-PSCallStack cmdlet or press the k key:

            
[DBG]: k 

Command Arguments Location 
------- --------- -------- 
{} EldoriaAlpha.ps1: line 9108
{} EldoriaAlpha.ps1: line 9182 EldoriaAlpha.ps1 
{} EldoriaAlpha.ps1: line 247  
{} <No file> 
    
[DBG]:
            
        

The Call Stack is displayed from the most recent break to the oldest ones. Working from the bottom up, we can visually trace how we arrived where we're at now. The bottom entry was the start of the script. We initially broke at line 247 (despite us making a breakpoint at 248), stepped into the SIFieldPlainsNoRoad class default constructor defined at line 9182, then stepped into the base class SIInternalBase tertiary constructor defined at line 9108. We don't need to inspect this constructor any further, and we're going to assume that the rest of the instantiations in the Hashtable at line 247 are okay and we're going to step out. Stepping out can be accomplished with either the stepOut command or o key:

            
[DBG]: o

InvalidOperation: C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1:9113 

Line | 9113 | … plete (($Script:SceneImagesLoaded / $Script:SceneImagesToLoad) * 100) | ~~~~~~~~~~~~~~~~~~~~~~~~~ | 

The variable '$Script:SceneImagesToLoad' cannot be retrieved because it has not been set. 

At C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1:285 char:1 

+ Write-Progress -Activity 'Creating Scene Images' -Id 2 -Completed 
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

[DBG]: l 

280:    'SIRiverOnWestAtSouth' = [SIRiverOnWestAtSouth]::new() 
281:    'SIRiverOnWestEastAtSouth' = [SIRiverOnWestEastAtSouth]::new() 
282:    'SIRiverOnWestNorthAtNorth' = [SIRiverOnWestNorthAtNorth]::new() 
283:    'SIRiverOnWestNorthSouthAtNorth' = [SIRiverOnWestNorthSouthAtNorth]::new() 
284: } 
285:* Write-Progress -Activity 'Creating Scene Images' -Id 2 -Completed 
286: 
287: 
288: 
289: 
290: 
291: ############################################################################### 
292: # 
293: # MAP WARP FUNCTION 
294: # 
295: # THIS IS LIKELY A PRETTY NAIEVE APPROACH TO THIS AT THE MOMENT, BUT WHAT THE
            
        

We encountered an error here, but we're in the debugger and this one can be explained away, so it's not the end of the world. You can continue the script operation by using either the continue keyword or the c key. If you want to terminate the program along with the debugger, use the quit keyword or the q key.

Toggling/Removing Breakpoints

PowerShell keeps track of the breakpoints that you add using the *-PSBreakpoint cmdlets. You can view all the managed breakpoints by using the Get-PSBreakpoint cmdlet:

            
greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > Get-PSBreakpoint

ID Script          Line Command Variable Action 
-- ------          ---- ------- -------- ------ 
1 EldoriaAlpha.ps1 248
            
        

Breakpoints are enforced by default, but you can disable them if you need to. You do this with the Disable-PSBreakpoint cmdlet:

            
greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > Disable-PSBreakpoint -Id 1 

greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > Get-PSBreakpoint 

ID Script          Line Command Variable Action
-- ------          ---- ------- -------- ------ 
1 EldoriaAlpha.ps1 248
            
        

Unfortunately, the default view for the result of the Get-PSBreakpoint cmdlet doesn't include if a breakpoint is enabled or disabled, so you can add it by piping Get-PSBreakpoint into Format-Table:

            
greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > Get-PSBreakpoint | Format-Table Id, Script, Line, Command, Variable, Action, Enabled

Id Script                                                          Line Command Variable Action Enabled
-- ------                                                          ---- ------- -------- ------ -------
1 C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1 248                          False
            
        

If a breakpoint is disabled and you run the debugger, the breakpoint will be skipped. Since there's only one breakpoint currently managed, this is effectively the same as running the script without the debugger. Toggling breakpoints has value if you're using more than two to troubleshoot areas of the script. We've disabled this breakpoint, so we enable it again using the Enable-PSBreakpoint cmdlet:

            
greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > Enable-PSBreakpoint 1

greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > Get-PSBreakpoint | Format-Table Id, Script, Line, Command, Variable, Action, Enabled

Id Script                                                          Line Command Variable Action Enabled
-- ------                                                          ---- ------- -------- ------ -------
1 C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1 248                          True
            
        

Now, we've used a breakpoint, found the issue with our code, and we want to ensure that no breakpoints are set so that the debugger isn't invoked when the script is run. We remove breakpoints with the Remove-PSBreakpoint cmdlet:

            
greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > Remove-PSBreakpoint 1

greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > Get-PSBreakpoint | Format-Table Id, Script, Line, Command, Variable, Action, Enabled

            
        

Conditional Breakpoint Execution

Finally, we can talk about something a little more sophisticated.

One debugging tool that's easily expressed in the visual debugger is conditional expressions associated to breakpoints. If a breakpoint doesn't have a conditional expression associated with it, it will always break once the debugger hits it. In most cases, this is fine. However, there are cases where this is inconvenient. A common example of this occurs when you're dealing with debugging a loop. If you only want to execute a breakpoint on a loop when an iterator reaches a specific value, you'll want to tether a conditional expression to that breakpoint. The debugger will evaluate the expression with each iteration and will only halt the program if the expression evaluates to true. We can demonstrate this with a very simple example. In the Eldoria game program, there's a script variable defined on line 215:

            
[Boolean]$Script:IsBattleBgmPlaying = $false
            
        

Previously, on line 181, there's a script variable defined:

            
[Int]$Script:SceneImagesLoaded = 0
            
        

What we're going to do is to set a breakpoint on line 215 to break ONLY IF the value of $Script:SceneImagesLoaded is equal to zero. The way we do this is use the -Action parameter for the Set-PSBreakpoint cmdlet. We assign to it an anonymous ScriptBlock that contains the conditional expression (this ScriptBlock will have visibility to the variables in your script):

            
greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > Set-PSBreakpoint -Script EldoriaAlpha.ps1 -Line 215 -Action { If($Script:SceneImagesLoaded -EQ 0) { break } Else { continue } }
            
        

Next we'll create a breakpoint that does the opposite. It'll execute the breakpoint if the value of $Script:SceneImagesLoaded is greater than 0:

            
greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > Set-PSBreakpoint -Script EldoriaAlpha.ps1 -Line 215 -Action2 { If($Script:SceneImagesLoaded -GT 0) { break } Else { continue } }
            
        

So we can look at our list of breakpoints and see the Actions:

            
greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > Get-PSBreakpoint | Format-Table Id, Script, Line, Action

Id Script                                                          Line Action
-- ------                                                          ---- ------
5 C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1  215 If($Script:SceneImagesLoaded -EQ 0) { break } Else { continue }
6 C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1  215 If($Script:SceneImagesLoaded -GT 0) { break } Else { continue }
            
        

Let's enable breakpoint 6 so we can see that the program won't break when the debugger encouters it. This is because the expression at this point is false:

            
greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > Enable-PSBreakpoint 6

greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > .\EldoriaAlpha.ps1 

@--~---~---~---~---@  @-<>--<>--<>--<>--<>--<>--<>--<>--<>--<>--<>--<>-@ | Steve | | | | | | | | H 1000 | | | | / 1000 | | | | M 500 | | | | / 500 | | | | | | | | 500G | | | @--~---~---~---~---@ | | | | @--~---~---~---~---@ | | | | | | | | | | | | | | | | | | | | | | |``````````````````| | | | | | | @--~---~---~---~---@ @-<>--<>--<>--<>--<>--<>--<>--<>--<>--<>--<>--<>-@

Invoke-Command: C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1:20445 Line | 20445 | 
Invoke-Command $Script:TheGlobalStateBlockTable[$Script:TheGl … 
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | 
Index was outside the bounds of the array.

greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) >
            
        

Let's disable breakpoint 6 and enable breakpoint 5 so we can see that when the expression evaluates to true, the program will halt at the breakpoint:

            
greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > Disable-PSBreakpoint 6; Enable-PSBreakpoint 5 

greg@DP7520 ~\OneDrive\Documents\Source\Eldoria git:(psmodule3) (0) > .\EldoriaAlpha.ps1 

Get-ChildItem: C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1:180 Line | 180 | 

… Load = $(Get-ChildItem "$(Get-Location)\Image Data").Count 
         | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | 
Cannot find path 'C:\Users\greg\OneDrive\Documents\Source\Eldoria\Image Data' because it does not exist.
PropertyNotFoundException: C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1:180 Line | 180 | 

… ceneImagesToLoad = $(Get-ChildItem "$(Get-Location)\Image … 
                                     | ~~~~~~~~~~~~ | 
The property 'Count' cannot be found on this object. Verify that the property exists.

Hit Line breakpoint on 'C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1:215' At C:\Users\greg\OneDrive\Documents\Source\Eldoria\EldoriaAlpha.ps1:215 char:1

+ [Boolean] $Script:IsBattleBgmPlaying … 
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

[DBG]: