The first thing that surprises a C# developer writing PowerShell is that try/catch often does nothing. Get-Item C:\does-not-exist writes a red error to the console but the script keeps running, and the catch block never fires. That is by design β PowerShell distinguishes terminating from non-terminating errors, and most cmdlets default to non-terminating so a long pipeline can survive one bad item. Once you understand the model, controlling it is easy.
This guide covers the full PowerShell error story: terminating vs non-terminating, $ErrorActionPreference, the -ErrorAction parameter, try/catch/finally, typed catches, $Error, the trap statement, and the patterns you should use in production. Free PDF cheat sheet at the bottom.
Table of Contents
Terminating vs non-terminating errors
A terminating error halts the current pipeline and is catchable by try/catch. A non-terminating error writes to the error stream but does not stop execution. Most cmdlets emit non-terminating errors by default β that is what lets Remove-Item *.log keep deleting the rest of the files even if one of them is locked.
To make a non-terminating error terminating, set -ErrorAction Stop on the cmdlet:
# Non-terminating - try/catch does nothing
try { Get-Item C:\does-not-exist }
catch { Write-Host "caught" }
# Output: red error from Get-Item, no "caught"
# Terminating - try/catch fires
try { Get-Item C:\does-not-exist -ErrorAction Stop }
catch { Write-Host "caught" }
# Output: "caught"
-ErrorAction and $ErrorActionPreference
-ErrorAction is a common parameter available on every advanced cmdlet. The values:
Continueβ write the error and keep going (default)Stopβ promote to terminating, raise an exceptionSilentlyContinueβ suppress the error message and keep goingIgnoreβ suppress AND do not add to$ErrorInquireβ prompt the user
$ErrorActionPreference sets the default for the current scope. Setting it to Stop at the top of a script is one of the highest-value things you can do β every non-terminating error becomes catchable, and surprises become impossible:
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
try {
Get-Item C:\maybe-here # would have been non-terminating
Remove-Item C:\file.txt # same
}
catch {
Write-Host "Failed: $_" -ForegroundColor Red
exit 1
}
try / catch / finally
Standard structure:
try {
# Code that might fail
}
catch {
# Handle the error - $_ or $PSItem is the ErrorRecord
}
finally {
# ALWAYS runs - cleanup
}
Inside catch, the special variable $_ (also $PSItem) is the ErrorRecord. The most useful properties:
catch {
$_.Exception.Message # The human-readable message
$_.Exception.GetType() # The .NET exception type
$_.ScriptStackTrace # Where it happened
$_.InvocationInfo.Line # The actual offending line
$_.CategoryInfo.Category # PowerShell error category
$_.FullyQualifiedErrorId # Unique error identifier
}
Catching specific exception types
Multiple catch blocks let you handle different exception types differently β same as in C#:
try {
$r = Invoke-RestMethod $url -ErrorAction Stop
}
catch [System.Net.WebException] {
Write-Host "Network error: $($_.Exception.Message)"
}
catch [System.IO.FileNotFoundException] {
Write-Host "Missing file: $($_.Exception.Message)"
}
catch {
# Generic fallback
Write-Host "Unexpected: $($_.Exception.GetType().FullName) - $($_.Exception.Message)"
throw # re-raise
}
Find the right type to catch by triggering the error once and inspecting $Error[0].Exception.GetType().FullName.
The $Error variable
$Error is an automatic ArrayList that holds the last 256 errors (configurable via $MaximumErrorCount) β even non-terminating ones, and even ones suppressed with SilentlyContinue (but not Ignore). The most recent error is $Error[0].
Get-Item C:\nope -ErrorAction SilentlyContinue
$Error[0].Exception # Inspect even though it was silenced
$Error.Clear() # Reset the buffer
-ErrorVariable
The -ErrorVariable common parameter captures the errors of a single command into a named variable, without affecting $Error:
Get-ChildItem C:\maybe -Recurse -ErrorAction SilentlyContinue -ErrorVariable scanErrors
if ($scanErrors) {
$scanErrors | ForEach-Object {
Write-Host "Could not scan: $($_.TargetObject)" -ForegroundColor Yellow
}
}
Useful when you genuinely want to keep going on most failures but log the ones you saw.
The trap statement
trap is a script-level fallback. It catches any terminating error in the current scope and runs the trap body. Combined with continue or break you can decide whether to keep going or unwind:
trap {
Write-Host "Trapped: $_" -ForegroundColor Red
continue # keep running the script
}
# Anywhere in this scope, terminating errors hit the trap
Get-Item C:\nope -ErrorAction Stop
Write-Host "still here"
In modern code, prefer try/catch for clarity. trap is occasionally useful as a script-wide safety net at the very top.
throw and Write-Error
To raise a terminating error from your own code, use throw:
throw "Cannot continue: configuration file is missing"
# Better - throw a typed exception
throw [System.IO.FileNotFoundException]::new("Config not found", $cfgPath)
For non-terminating errors (which a caller can choose to escalate with -ErrorAction Stop), use Write-Error:
Write-Error -Message "Skipped $name: not eligible" `
-Category InvalidOperation `
-ErrorId 'NotEligible' `
-TargetObject $name
Real-world patterns
The script-top safety block
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
try {
# Whole script body here
}
catch {
Write-Error "FAILED: $($_.Exception.Message)" -ErrorAction Continue
Write-Error "AT: $($_.ScriptStackTrace)" -ErrorAction Continue
exit 1
}
finally {
# Always-run cleanup
}
Per-item resilience
foreach ($item in $items) {
try {
Process-Item $item -ErrorAction Stop
} catch {
Write-Warning "Item $($item.Id) failed: $($_.Exception.Message)"
$failed += $item
continue
}
}
Write-Host "$($failed.Count) of $($items.Count) failed"
Resource cleanup with finally
$conn = $null
try {
$conn = [System.Data.SqlClient.SqlConnection]::new($cs)
$conn.Open()
# ... query ...
}
catch {
Write-Error "DB error: $($_.Exception.Message)"
throw
}
finally {
if ($conn) { $conn.Dispose() }
}
Cheat sheet
Every flag, every variable, the patterns above on a single PDF: PowerShell Error Handling Cheat Sheet.
FAQ
Why does try/catch around Get-Item not catch errors?
Get-Item produces non-terminating errors by default. Add -ErrorAction Stop to promote them to terminating, or set $ErrorActionPreference = 'Stop' at the top of the script.
What is the difference between throw and Write-Error?
throw raises a terminating error (callable code can catch it). Write-Error writes to the error stream β non-terminating by default, escalatable to terminating by the caller via -ErrorAction Stop. Use throw when the error is fatal to your function; Write-Error when it is a recoverable issue the caller might choose to ignore.
Should I use Set-StrictMode?
Yes, in any script you maintain. Set-StrictMode -Version Latest turns silent typos and uninitialized variables into errors. Combined with $ErrorActionPreference = 'Stop' it eliminates most "why did this run successfully and produce nothing" surprises.
How do I see the full exception chain?
$_.Exception.InnerException walks the chain. For a deep dump: $_ | Format-List * -Force; $_.Exception | Format-List * -Force.
Does try/catch work across scopes (e.g. inside a function called from try)?
Yes. Terminating errors propagate up the call stack until caught.
What about non-cmdlet calls β native binaries?
Native commands (like git, kubectl) do not raise PowerShell errors at all β they set $LASTEXITCODE. Wrap with: git pull; if ($LASTEXITCODE -ne 0) { throw "git pull failed" }.
Can I rethrow with extra context?
Yes β throw "Wrapping: $($_.Exception.Message)" creates a new error with your message, or use $PSCmdlet.ThrowTerminatingError($_) from inside an advanced function to keep full context.