PowerShell Error Handling Patterns Every Sysadmin Should Use
Executive Snapshot
| Category | Recommendation |
|---|---|
| Default error action | Set $ErrorActionPreference = 'Stop' at script top |
| Catching errors | Always use Try/Catch/Finally for terminating errors |
| Non-terminating errors | Promote with -ErrorAction Stop per cmdlet |
| Error detail | Inspect $_.Exception.Message and $_.Exception.GetType() |
| Logging | Write structured logs; always capture $Error[0] after failures |
| Exit codes | Set exit 1 (or $LASTEXITCODE) so orchestrators detect failures |
| Retries | Use exponential back-off for transient network/API errors |
TL;DR
flowchart TD
A[Run risky command] --> B{Error type?}
B -->|Non-terminating| C[Set ErrorAction Stop to force catch]
B -->|Terminating| D[try block]
C --> D
D --> E{Exception thrown?}
E -->|No| F[Continue normally]
E -->|Yes| G[catch block handles error]
G --> H{Retryable & attempts left?}
H -->|Yes| I[Wait + increment attempt]
I --> A
H -->|No| J[Log / rethrow]
F --> K[finally - cleanup runs always]
J --> K
classDef bad fill:#7a1f2b,stroke:#ff5a6e,color:#fff
class G,J bad
Try/Catchonly catches terminating errors — you must force non-terminating cmdlets to throw with-ErrorAction Stop.$ErrorActionPreference = 'Stop'is the single highest-value line you can add to any production script.- Always inspect
$_.ExceptioninsideCatch, not just$_, to surface the real error type and message. - Structured logging and meaningful exit codes are what separate scripts that work in a pipeline from scripts that silently lie.
- Retry wrappers with exponential back-off eliminate the majority of transient failure pages in cloud and network automation.
Introduction
PowerShell is the backbone of Windows and hybrid-cloud automation, but most scripts in the wild share a dangerous trait: they assume everything will work. A missed file, a timed-out API call, or an access-denied response can leave automation half-executed — and without proper PowerShell error handling, you won't even know it happened.
The built-in default of Continue means PowerShell happily prints a red error and keeps running, a behaviour that can corrupt data, leave services in broken states, or silently skip critical steps. Worse, many sysadmins wrap code in Try/Catch blocks and then wonder why exceptions are never caught — because most cmdlets throw non-terminating errors that Catch ignores by design.
This tutorial closes those gaps. You'll learn the PowerShell error handling patterns that production automation teams actually rely on: correct use of Try/Catch/Finally, promoting non-terminating errors, structured logging, meaningful exit codes, and a reusable retry wrapper you can drop into any script today.
Prerequisites
- PowerShell 5.1 or PowerShell 7.x (examples are compatible with both; PS7 differences are called out)
- Comfortable reading and writing basic PowerShell scripts
- A test environment — never develop error handling changes directly in production
- Optional: VS Code with the PowerShell extension for inline error highlighting
1. Understand the Two Types of PowerShell Errors
Before writing a single Catch block, you need to understand a fundamental split that trips up even experienced scripters.
Terminating errors stop the current pipeline or script immediately. They are generated by Throw, by the PowerShell engine itself (syntax errors, missing parameters), or by cmdlets that explicitly call ThrowTerminatingError(). These are caught by Try/Catch.
Non-terminating errors are polite complaints. A cmdlet like Get-Content on a missing file writes to the error stream and continues. These are not caught by Try/Catch unless you force them to terminate.
flowchart TD
A([Script Runs]) --> B{Error Occurs}
B -->|Terminating| C[Pipeline Stops]
B -->|Non-Terminating| D{$ErrorActionPreference?}
D -->|Continue default| E[Write to Error Stream\nScript Continues]
D -->|Stop| F[Promoted to Terminating Error]
C --> G[Try/Catch block fires]
F --> G
E --> H[Silent failure / partial execution]
G --> I([Structured recovery or exit])
The practical takeaway: always decide which category your error falls into before deciding how to handle it.
2. Set $ErrorActionPreference at the Top of Every Script
This one line changes the default behaviour of every cmdlet in your script from "complain and continue" to "stop and be catchable":
#Requires -Version 5.1
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
With 'Stop' set globally, any non-terminating error that would normally just print red text now becomes a terminating error — which means your Try/Catch blocks will actually fire.
The four $ErrorActionPreference values you need to know:
| Value | Behaviour | Use When |
|---|---|---|
Continue |
Print error, keep going (default) | Interactive console only |
Stop |
Convert to terminating error | All production scripts |
SilentlyContinue |
Suppress error output | Intentional "best effort" operations |
Inquire |
Prompt user to continue | Never — breaks automation |
3. Write Correct Try/Catch/Finally Blocks
3.1 Basic Structure
$ErrorActionPreference = 'Stop'
Try {
$content = Get-Content -Path 'C:\Config\settings.json'
$config = $content | ConvertFrom-Json
Write-Host "Config loaded: $($config.AppName)"
}
Catch [System.Management.Automation.ItemNotFoundException] {
Write-Error "Configuration file not found. Verify the path and retry."
exit 1
}
Catch [System.ArgumentException] {
Write-Error "Configuration file contains invalid JSON: $($_.Exception.Message)"
exit 1
}
Catch {
# Catch-all for unexpected error types
Write-Error "Unexpected error: $($_.Exception.GetType().FullName) — $($_.Exception.Message)"
exit 1
}
Finally {
# Runs whether or not an exception occurred — ideal for cleanup
Write-Host "Config load attempt complete."
}
Key points
- Type-specific
Catchblocks come before the genericCatch {}. PowerShell matches top-to-bottom. $_.Exception.Messagegives you the human-readable error string.$_.Exception.GetType().FullNamereveals the .NET exception type — critical for writing specificCatchfilters.Finallyalways runs — use it to close connections, remove temp files, or release COM objects.
3.2 Discovering the Right Exception Type
When you're not sure which exception type a cmdlet throws, run it interactively without $ErrorActionPreference = 'Stop' and inspect $Error[0]:
# After a failed command, run:
$Error[0].Exception.GetType().FullName
$Error[0].Exception.Message
$Error[0].InvocationInfo.ScriptLineNumber
Paste the full type name into your Catch [TypeName] filter. This is dramatically more reliable than catching everything and guessing.
4. Handle Non-Terminating Errors Per-Cmdlet
Global $ErrorActionPreference = 'Stop' is powerful but sometimes too broad. Some operations — like checking whether a registry key exists — are genuinely expected to fail. Override the global setting per-cmdlet with -ErrorAction:
$ErrorActionPreference = 'Stop'
# Intentionally suppress — we're testing for existence
$regValue = Get-ItemProperty -Path 'HKLM:\Software\MyApp' `
-Name 'Version' `
-ErrorAction SilentlyContinue
if ($null -eq $regValue) {
Write-Host "MyApp registry key not found — treating as first run."
}
else {
Write-Host "Detected version: $($regValue.Version)"
}
# This one MUST succeed — keep Stop behaviour
$criticalData = Get-Content -Path 'C:\critical\data.txt' -ErrorAction Stop
This pattern — global Stop, selective SilentlyContinue — gives you safety by default with surgical escape hatches where needed.
5. Build a Structured Logging Function
Printing errors to the console is not logging. Production scripts need timestamped, severity-tagged output that survives pipeline redirection and can feed into SIEM or log aggregation tools.
function Write-Log {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Message,
[ValidateSet('INFO','WARN','ERROR','DEBUG')]
[string]$Level = 'INFO',
[string]$LogFile = 'C:\Logs\automation.log'
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$entry = "$timestamp [$Level] $Message"
# Write to console with colour coding
switch ($Level) {
'ERROR' { Write-Host $entry -ForegroundColor Red }
'WARN' { Write-Host $entry -ForegroundColor Yellow }
'DEBUG' { Write-Host $entry -ForegroundColor Gray }
default { Write-Host $entry }
}
# Append to log file (create directory if missing)
$logDir = Split-Path $LogFile
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
}
Add-Content -Path $LogFile -Value $entry
}
# Usage inside a Catch block:
Catch {
Write-Log -Message "Failed to connect to database: $($_.Exception.Message)" -Level 'ERROR'
Write-Log -Message "Stack trace: $($_.ScriptStackTrace)" -Level 'DEBUG'
exit 1
}
Why structured logging matters: When a script runs as a Scheduled Task or inside Azure Automation / Jenkins, you have no interactive console. A log file with timestamps and severity levels is your only post-mortem tool.
6. Add a Retry Wrapper for Transient Failures
Network operations, REST API calls, and database connections fail transiently. Rather than letting a single blip fail an entire workflow, wrap the operation in an exponential back-off retry loop:
function Invoke-WithRetry {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[scriptblock]$ScriptBlock,
[int]$MaxAttempts = 3,
[int]$InitialDelayMs = 500,
[string]$OperationName = 'Operation'
)
$attempt = 0
$delay = $InitialDelayMs
while ($attempt -lt $MaxAttempts) {
$attempt++
Try {
Write-Log -Message "$OperationName — attempt $attempt of $MaxAttempts" -Level 'INFO'
& $ScriptBlock
return # Success — exit the loop
}
Catch {
Write-Log -Message "$OperationName failed (attempt $attempt): $($_.Exception.Message)" -Level 'WARN'
if ($attempt -ge $MaxAttempts) {
Write-Log -Message "$OperationName exhausted all $MaxAttempts attempts." -Level 'ERROR'
throw # Re-throw to caller
}
Write-Log -Message "Retrying in $($delay)ms..." -Level 'DEBUG'
Start-Sleep -Milliseconds $delay
$delay *= 2 # Exponential back-off
}
}
}
# Usage:
Invoke-WithRetry -OperationName 'SQL Connection' -MaxAttempts 4 -ScriptBlock {
$connection = New-Object System.Data.SqlClient.SqlConnection($connectionString)
$connection.Open()
Write-Log -Message "SQL connection established." -Level 'INFO'
}
The throw inside the final Catch re-throws the original exception to the calling scope, preserving the full stack trace. Never swallow exceptions silently at the retry boundary.
7. Always Set Exit Codes
Scripts called by task schedulers, CI/CD pipelines, or RMM tools determine success or failure by exit code. PowerShell's default exit code is 0 (success) even after unhandled errors — unless you explicitly set it.
# At the END of a successful script run:
Write-Log -Message "Script completed successfully." -Level 'INFO'
exit 0
# Inside any Catch that means "this run failed":
Catch {
Write-Log -Message "Fatal error: $($_.Exception.Message)" -Level 'ERROR'
exit 1
}
# For external executables, check $LASTEXITCODE:
& robocopy.exe C:\Source D:\Dest /MIR
if ($LASTEXITCODE -gt 7) {
# Robocopy codes 0-7 are success/warning; 8+ are failures
Write-Log -Message "Robocopy failed with exit code $LASTEXITCODE" -Level 'ERROR'
exit 1
}
PowerShell 7 note: In PS7,
$?is$falseafter any non-terminating error, making it a quick inline check — but always prefer explicit exit codes for scripts called externally.
8. Common Mistakes (Pitfalls to Avoid)
❌ Mistake 1: Try/Catch without $ErrorActionPreference = 'Stop'
# WRONG — Catch never fires for Get-Content missing file
Try {
Get-Content 'C:\missing.txt'
}
Catch {
Write-Error "File not found"
}
# RIGHT — Force termination or use -ErrorAction Stop
Try {
Get-Content 'C:\missing.txt' -ErrorAction Stop
}
Catch [System.Management.Automation.ItemNotFoundException] {
Write-Error "File not found: $($_.Exception.Message)"
}
❌ Mistake 2: Swallowing exceptions silently
# WRONG — error disappears; caller thinks script succeeded
Catch {
# do nothing
}
At minimum, always log and set a non-zero exit code in every Catch block.
❌ Mistake 3: Using $Error[0] instead of $_ inside Catch
Inside a Catch block, $_ is the current exception object. $Error[0] is the most recent error from the global error collection and can be overwritten by subsequent cmdlets running inside the Catch block itself.
❌ Mistake 4: Forgetting Finally for resource cleanup
If you open a file handle, database connection, or COM object inside a Try, a Catch block that exits early will leak it. Always close resources in Finally.
❌ Mistake 5: Catching [System.Exception] as a type filter
[System.Exception] is the base class of everything — it behaves identically to a bare Catch {} and hides your intent. Use specific types or the bare catch-all, never the base class.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
Catch block never fires |
Non-terminating error with default Continue preference |
Add -ErrorAction Stop or set $ErrorActionPreference = 'Stop' |
| Exception type filter doesn't match | Wrong type name in Catch [TypeName] |
Run $Error[0].Exception.GetType().FullName after a failure to get the exact type |
exit 1 doesn't propagate to caller |
Script is dot-sourced (. .\script.ps1) |
Run scripts with & '.\script.ps1'; dot-sourcing runs in the caller's scope |
$LASTEXITCODE is always 0 |
Native command wrote to stderr but returned 0 | Check both $LASTEXITCODE and $?; inspect stderr with 2>&1 redirection |
Finally block throws a new error |
Cleanup cmdlet also fails | Wrap Finally body in its own Try/Catch for defensive cleanup |
| Log file not created | Directory path doesn't exist | Use New-Item -Force or check with Test-Path before Add-Content |
Key Takeaways
$ErrorActionPreference = 'Stop'is the most important single line in any production PowerShell script — add it unconditionally.Try/Catchonly catches terminating errors; promote non-terminating cmdlets with-ErrorAction Stopor the global preference.- Type-specific
Catchblocks make your error handling self-documenting and prevent over-broad exception swallowing. $_.Exception.GetType().FullNamein an interactive session is the fastest way to find the right exception type for a specificCatchfilter.- Structured logging with timestamps and severity levels transforms a throwaway script into an auditable, maintainable automation asset.
- Exit codes are mandatory for any script invoked by a scheduler, pipeline, or RMM — without them, failures are invisible to orchestrators.
- Exponential back-off retry wrappers eliminate the majority of transient failure incidents in cloud and network-dependent automation.
Next Steps
- Refactor one existing production script using the patterns above — start with
$ErrorActionPreference = 'Stop'and add typedCatchblocks. - Integrate
Write-Logoutput with Windows Event Log usingWrite-EventLogor forward to your SIEM via a log shipper. - Explore
$PSCmdlet.ThrowTerminatingError()if you're building advanced functions and modules — it gives callers proper error records instead of raw exceptions. - Test your error handling by deliberately introducing failures (wrong paths, invalid JSON, disconnected network shares) in a staging environment before shipping.
- Review PowerShell 7's
Ternaryand&&/||pipeline chain operators — they offer concise inline error checking for simple one-liners.
Related Articles
- [Building Idempotent PowerShell Scripts for Safe Re-Runs] —
/automation/idempotent-powershell-scripts/ - [PowerShell Logging Best Practices: From Write-Host to Centralized Observability] —
/automation/powershell-logging-best-practices/ - [CI/CD Pipeline Integration for PowerShell: GitHub Actions and Azure DevOps] —
/devops/powershell-cicd-pipelines/
Leave a Reply