This guide assumes Powershell 5.1 or 7+. The error model is identical on both.
The single most common Powershell bug is "I wrapped it in try/catch and it still didn't catch." This post explains why and how to make every error in your scripts behave the way you actually want.
Two Kinds of Errors
Powershell has two distinct error categories, and they don't behave the same way.
- Terminating errors stop the pipeline immediately.
try/catchsees them. - Non-terminating errors are written to the error stream and the pipeline keeps going.
try/catchdoes not see them by default.
# Terminating - caught
try { 1/0 } catch { "caught: $_" } # → caught: ...
# Non-terminating - silently slipped past
try
{
Get-Item C:\does-not-exist.txt
"this still runs"
}
catch { "never reached" }
That's the bug. Most cmdlet errors (file not found, host unreachable, AD object missing) are non-terminating.
The Fix: -ErrorAction Stop
Promote a single call to terminating with -ErrorAction Stop:
try
{
Get-Item C:\does-not-exist.txt -ErrorAction Stop
}
catch
{
"caught: $_"
}
Or promote everything in a scope with $ErrorActionPreference:
$ErrorActionPreference = 'Stop'
try
{
Get-Item C:\does-not-exist.txt
}
catch
{
"caught: $_"
}
Set
$ErrorActionPreference = 'Stop'at the top of any script that has real error handling. It's the single highest-value habit in Powershell error code. Most scripts inherit the defaultContinueand quietly press on after failures.
try / catch / finally
try
{
$conn = Open-Connection -Server $s -ErrorAction Stop
Use-Connection $conn
}
catch [System.Net.Sockets.SocketException]
{
Write-Warning "Network failure: $_"
}
catch [System.Management.Automation.ItemNotFoundException]
{
Write-Warning "Not found: $_"
}
catch
{
Write-Warning "Unexpected: $_"
throw # re-raise after logging
}
finally
{
if ($conn) { $conn.Dispose() }
}
Three rules:
- Type-specific
catchblocks come first, generic last. The first match wins. finallyalways runs pipeline success, caught error, uncaught error, even Ctrl-C.- Re-throw with bare
throwto preserve the original exception.throw $_works but loses the call stack on older versions.
The $_ Inside catch
$_ (also $PSItem) is an ErrorRecord, not an Exception. The properties you'll use most:
catch
{
$_.Exception.Message # the readable message
$_.Exception.GetType().FullName # type for filtering
$_.FullyQualifiedErrorId # stable id you can branch on
$_.InvocationInfo.PositionMessage # where it failed
$_.ScriptStackTrace # call stack at failure
}
FullyQualifiedErrorIdis the contract. Cmdlet authors set it deliberately and it doesn't drift across versions. Compare against it (-eq 'WidgetNotFound,My.Module.GetWidget') instead of parsing message strings.
throw vs Write-Error
throw "boom" # terminating - always caught
Write-Error "boom" # non-terminating by default
Write-Error "boom" -ErrorAction Stop # promoted, will be caught
Inside a function, throw exits the function. Write-Error writes to the stream and returns. Pick based on whether the caller should be able to keep going.
For richer errors:
$err = [System.Management.Automation.ErrorRecord]::new(
[InvalidOperationException]::new("Service offline"),
'ServiceOffline',
[System.Management.Automation.ErrorCategory]::ConnectionError,
$targetObject)
$PSCmdlet.ThrowTerminatingError($err)
ThrowTerminatingError is the cmdlet author's throw. It records the right cmdlet, parameter set, and target much better diagnostics than a bare throw.
$Error The Ring Buffer
Every error (caught or not) lands in $Error[0]. Newest first. Handy in a debugger:
$Error.Count # how many since the session started
$Error[0] # most recent
$Error[0..2] # last three
$Error.Clear() # reset
$MaximumErrorCount controls how many are kept. Default 256.
-ErrorVariable Capture Without Try/Catch
For non-terminating errors you want to inspect without promoting them to terminating:
Get-ChildItem C:\does-not-exist -ErrorAction SilentlyContinue -ErrorVariable err
if ($err)
{
Write-Warning "Issue: $($err[0].Exception.Message)"
}
-ErrorVariable err captures the errors into $err without touching $Error. Add a + (-ErrorVariable +err) to append instead of overwrite.
trap The Old Way
Predates try/catch. Still works, occasionally useful in scripts where you want one handler at the top:
trap
{
Write-Warning "Caught at top: $_"
continue # 'continue' = swallow; 'break' = re-raise
}
DoSomething
DoSomethingElse
Most modern code uses try/catch. trap only earns its keep when you genuinely want one handler covering an entire script with no nesting.
Common Pitfalls
Native commands don't throw. Powershell only sees their exit code:
git push # non-zero exit code does NOT throw
if ($LASTEXITCODE -ne 0) { throw "git push failed" }
In Powershell 7.4+, $PSNativeCommandUseErrorActionPreference = $true makes native commands honor $ErrorActionPreference. Until you've enabled that everywhere, check $LASTEXITCODE after every shell-out.
-ErrorAction doesn't apply to advanced functions unless they themselves implement it. If you wrote the function, use [CmdletBinding()] and call $PSCmdlet.ThrowTerminatingError(...) rather than throw.
try { ... } catch { throw } is not a no-op. It re-throws but loses any preceding logic. Use it deliberately to add logging or cleanup, not as a placeholder.
Pipeline errors don't always stop on first failure. A foreach over a thousand items will throw on the first one if you set Stop, but the items already produced still flow. Use try inside the loop if you want per-item handling.
A Production-Ready Pattern
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string[]]$Server
)
begin
{
$ErrorActionPreference = 'Stop'
}
process
{
$results = foreach ($s in $Server)
{
try
{
$info = Get-Health -Server $s
[pscustomobject]@{
Server = $s
Status = 'ok'
Info = $info
}
}
catch [System.Net.Sockets.SocketException]
{
[pscustomobject]@{
Server = $s
Status = 'unreachable'
Info = $_.Exception.Message
}
}
catch
{
[pscustomobject]@{
Server = $s
Status = 'error'
Info = $_.Exception.Message
}
Write-Warning "Unexpected on $s - $_"
}
}
}
end
{
$results | Format-Table -AutoSize
}
That's the shape: Stop at the top, try/catch per logical unit of work, type-specific catches for known failures, generic for the rest, and structured output rather than crashing.
What to Do Next
Set $ErrorActionPreference = 'Stop'. Wrap real I/O in try/catch. Use type-specific catches for known failures. Re-throw with bare throw when you can't recover. Watch $LASTEXITCODE after native commands. Those five reflexes turn errors from a debugging session into information your script can reason about.
Three concrete moves for the next script you write:
- Open your most production-critical script and add
$ErrorActionPreference = 'Stop'at the top, then run it. Anything that suddenly throws was already broken; you just couldn't see it. - Identify one
try/catchthat catches[System.Exception](the catch-all). Replace it with the specific exception type the cmdlet actually throws. Your catch becomes a real error handler instead of a silent swallow. - For every native-command call (
& somecmd ...) in that script, audit the next line. If it isn't checking$LASTEXITCODE, the script is silently ignoring a class of failures.
Pairs naturally with the logging post (every meaningful catch should write a structured log line) and the Pester post (testing the error path is half of what makes a function trustworthy).


