cheat sheet
PowerShell Error Handling
Master terminating vs non-terminating errors, $ErrorActionPreference, try/catch/finally, throw vs Write-Error, transcripts, and strict mode.
PowerShell Error Handling — try/catch, ErrorAction, and Strict Mode
What it is
PowerShell distinguishes between terminating errors (which halt the pipeline like a thrown exception in C#) and non-terminating errors (which write to the error stream but let the script continue) — a model unique among shells and the single most important concept to master for production scripts. Microsoft built error handling around this split because pipelines often process many objects: failing on one input object shouldn't kill the whole batch unless you say so. Reach for these patterns whenever you write a .ps1 that another human or a CI job will run unattended, because the default behavior is forgiving in a way that silently swallows bugs.
Install
Error handling is built into every PowerShell installation — no extra modules required. PowerShell 7 (pwsh) and Windows PowerShell 5.1 share the same model, but PS 7 adds the Get-Error cmdlet and the Clean block for richer diagnostics. PowerShell 7.6 LTS (released March 2026, built on .NET 10) is the current recommended LTS release; it preserves the full try/catch/ErrorAction/Get-Error model and adds a Break ActionPreference value that drops into the debugger on error.
# Verify your version (5.1 minimum for try/catch/finally; 7+ for Get-Error and clean{})
$PSVersionTable.PSVersion
Output:
Major Minor Patch PreReleaseLabel BuildLabel
----- ----- ----- --------------- ----------
7 4 6
Syntax
The two foundational constructs are try/catch/finally for trapping terminating errors and the per-cmdlet -ErrorAction parameter for controlling whether an error is terminating in the first place.
try { <code> } catch [<ExceptionType>] { <handler> } finally { <cleanup> }
<Cmdlet> -ErrorAction Stop | Continue | SilentlyContinue | Ignore | Inquire
Output: (none — exits 0 on success)
Essential settings
| Setting | Meaning |
|---|---|
-ErrorAction Stop | Promote a non-terminating error to terminating for this call |
-ErrorAction SilentlyContinue | Suppress error output, but still record in $Error |
-ErrorAction Ignore | Suppress and do not record (PS 3+) |
-ErrorAction Inquire | Prompt the user |
-ErrorAction Continue | Default — print error, keep going |
$ErrorActionPreference | Default -ErrorAction for the current scope |
$Error | Auto-array of recent errors; $Error[0] is newest |
$LASTEXITCODE | Exit code of the last native command |
$? | $true if the previous command succeeded |
Set-StrictMode -Version Latest | Treat uninitialized variables and typos as errors |
-ErrorVariable <name> | Capture errors into a variable in addition to $Error |
-WarningAction / $WarningPreference | Same controls for the warning stream |
Terminating vs non-terminating errors
A terminating error stops the running pipeline immediately and propagates up to the nearest try/catch, just like a thrown exception in C#. A non-terminating error reports a failure on a single pipeline item, writes it to the error stream, and lets the next item flow through. This split exists because PowerShell pipelines are stream-based — one bad input shouldn't kill the whole batch unless you say so.
# Non-terminating: Get-Item fails for 2 of 3 paths but reports each one
Get-Item C:\real-file.txt, C:\missing-1, C:\missing-2
# try/catch does NOT trigger here because the errors are non-terminating
try {
Get-Item C:\missing-file
} catch {
Write-Host "Caught!" # never executes
}
# Promote to terminating with -ErrorAction Stop
try {
Get-Item C:\missing-file -ErrorAction Stop
} catch {
Write-Host "Caught: $($_.Exception.Message)"
}
Output (non-terminating):
Get-Item : Cannot find path 'C:\missing-1' because it does not exist.
At line:1 char:1
+ Get-Item C:\real-file.txt, C:\missing-1, C:\missing-2
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Get-Item : Cannot find path 'C:\missing-2' because it does not exist.
…
Output (terminating with -ErrorAction Stop):
Caught: Cannot find path 'C:\missing-file' because it does not exist.
try / catch / finally
try/catch/finally is the structured block for handling terminating errors. The try body runs; if anything inside throws, the matching catch runs and $_ becomes the ErrorRecord. finally runs unconditionally, whether or not an exception was raised — the canonical place to release resources, close handles, or restore global state.
try {
$reader = [System.IO.File]::OpenText("C:\data.txt")
$line = $reader.ReadLine()
Write-Host "First line: $line"
}
catch [System.IO.FileNotFoundException] {
Write-Warning "File not found"
}
catch [System.UnauthorizedAccessException] {
Write-Warning "Access denied"
}
catch {
Write-Warning "Unexpected: $($_.Exception.Message)"
throw # rethrow to caller
}
finally {
if ($reader) { $reader.Dispose() }
Write-Host "Cleanup ran"
}
Output (when C:\data.txt is missing):
WARNING: File not found
Cleanup ran
Catching specific exception types
catch accepts one or more exception type literals so you can handle different failure modes differently. Order matters — the first matching type wins, so list most-specific types before more-general ones. A bare catch (no type) catches everything not matched by an earlier block and is the equivalent of catch (Exception) in C#.
function Read-Config {
param([string]$Path)
try {
$raw = Get-Content $Path -Raw -ErrorAction Stop
$cfg = $raw | ConvertFrom-Json -ErrorAction Stop
return $cfg
}
catch [System.IO.FileNotFoundException] {
Write-Warning "Config missing — using defaults"
return [pscustomobject]@{ Host = "localhost"; Port = 8080 }
}
catch [System.Management.Automation.RuntimeException] {
# ConvertFrom-Json wraps parse errors as RuntimeException
Write-Error "Invalid JSON in $Path"
throw
}
catch {
Write-Error "Unhandled error reading $Path: $($_.Exception.GetType().FullName)"
throw
}
}
# Discover the exception type of an error you've already seen
$Error[0].Exception.GetType().FullName
Output (calling against a missing file):
WARNING: Config missing — using defaults
Host Port
---- ----
localhost 8080
Output ($Error[0].Exception.GetType().FullName):
System.IO.FileNotFoundException
$ErrorActionPreference
$ErrorActionPreference is the default -ErrorAction value for the current scope — change it once and every cmdlet that follows behaves as if you wrote -ErrorAction <value> on the call. The most common production pattern is Stop, which makes the script abort on the first error so you can catch it cleanly. Use a try/finally to scope the change locally so it doesn't leak to outer callers.
# Top of a script — treat everything as terminating
$ErrorActionPreference = 'Stop'
try {
# Now every cmdlet failure jumps straight to catch
Get-Item C:\missing
Get-Process -Name nonexistent
}
catch {
Write-Host "Halted at first error: $($_.Exception.Message)"
}
# Scoped override (PS 7+) — only affects this function
function Get-SafeContent {
param($Path)
$ErrorActionPreference = 'SilentlyContinue'
Get-Content $Path
}
# Available values:
# Continue (default — print, keep going)
# Stop (promote to terminating)
# SilentlyContinue (suppress, but record in $Error)
# Ignore (suppress and do not record)
# Inquire (prompt user)
# Suspend (workflow-only; rarely used)
# Break (PS 7+ — break into the debugger)
Output:
Halted at first error: Cannot find path 'C:\missing' because it does not exist.
$Error and $LASTEXITCODE
$Error is an auto-populated array of every error raised during the session; the newest is always at index 0. $LASTEXITCODE holds the exit code of the most recently executed native (non-PowerShell) command and is the only reliable way to detect failure from git, npm, dotnet, and other external binaries — they do not raise PowerShell exceptions. $? is $true after a successful command and $false after a failure, including native ones.
# Inspect last error
$Error[0]
$Error[0].Exception.Message
$Error[0].InvocationInfo.PositionMessage # where the error happened
$Error[0].ScriptStackTrace # PS call stack
$Error[0].CategoryInfo # category + activity + reason
$Error.Count # how many errors so far
$Error.Clear() # reset
# Check native exit codes
git status
if ($LASTEXITCODE -ne 0) {
throw "git status failed with exit code $LASTEXITCODE"
}
# $? — true after the most recent command succeeded
robocopy C:\src C:\dst /MIR
if (-not $?) { Write-Warning "robocopy reported a failure" }
Output ($Error[0].InvocationInfo.PositionMessage):
At C:\scripts\deploy.ps1:14 char:3
+ Get-Item C:\missing -ErrorAction Stop
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Write-Error vs throw vs ThrowTerminatingError
Three cmdlets emit errors and they are not interchangeable. Write-Error writes a non-terminating error to the error stream — the script continues. throw raises a terminating error in the current scope and is the right choice in script code. $PSCmdlet.ThrowTerminatingError() is the cmdlet-author primitive for advanced functions, allowing you to attach an ErrorRecord with category, target object, and recommended action — what you want in a production module.
function Test-Errors {
[CmdletBinding()]
param([int]$Mode)
switch ($Mode) {
1 {
Write-Error "Non-terminating — script continues"
"still executing"
}
2 {
throw "Terminating — abort the pipeline"
"never runs"
}
3 {
# Cmdlet-style terminating error with rich metadata
$err = [System.Management.Automation.ErrorRecord]::new(
[Exception]::new("Database is offline"),
"DatabaseOffline",
[System.Management.Automation.ErrorCategory]::ResourceUnavailable,
$null
)
$PSCmdlet.ThrowTerminatingError($err)
}
}
}
Test-Errors -Mode 1
try { Test-Errors -Mode 2 } catch { "Caught: $_" }
try { Test-Errors -Mode 3 } catch { "Caught category: $($_.CategoryInfo.Category)" }
Output:
Test-Errors : Non-terminating — script continues
…
still executing
Caught: Terminating — abort the pipeline
Caught category: ResourceUnavailable
Rethrowing and inner exceptions
After catching an error you often want to wrap it with extra context (the file you were processing, the host you were talking to) and rethrow so the caller still knows something failed. The bare throw statement inside a catch re-raises the current $_ without losing the original stack. Set Exception.InnerException (via ErrorRecord.Exception) to preserve the chain.
function Import-Settings {
param([string]$Path)
try {
Get-Content $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
}
catch {
# Add context, preserve original
$msg = "Failed to load settings from '$Path'"
throw [System.Exception]::new($msg, $_.Exception)
}
}
try {
Import-Settings -Path C:\config\settings.json
}
catch {
"Outer: $($_.Exception.Message)"
"Inner: $($_.Exception.InnerException.Message)"
}
Output:
Outer: Failed to load settings from 'C:\config\settings.json'
Inner: Could not find file 'C:\config\settings.json'.
trap — script-wide error handler
trap is the older, statement-level equivalent of a try/catch block — it intercepts any terminating error in the current scope without wrapping the code in a try. The handler runs once, then by default execution continues on the next statement; add break to abort the scope or continue to suppress and resume. Prefer try/catch in new code, but trap remains useful at the top of a long script as a global safety net.
function Read-AndLog {
trap {
Write-Host "TRAP: $($_.Exception.Message)" -ForegroundColor Red
continue # eat the error, keep going
}
Get-Item C:\maybe-missing.txt -ErrorAction Stop
Get-Item C:\definitely-here.txt -ErrorAction Stop
Write-Host "Done"
}
Read-AndLog
Output:
TRAP: Cannot find path 'C:\maybe-missing.txt' because it does not exist.
…
Done
Strict Mode
Set-StrictMode makes PowerShell flag common authoring mistakes — referencing an uninitialized variable, calling a non-existent property, using positional parameters on undefined functions — as runtime errors. It is the equivalent of use strict in JavaScript or Option Explicit in VBScript. Enable it at the top of every non-trivial script: it costs nothing and catches typos that would otherwise silently produce $null.
# Always start scripts with the strictest mode
Set-StrictMode -Version Latest
# Now typos are caught at runtime
$Name = "Alice"
Write-Host $Naem # uninitialized var
# Likewise for properties that don't exist
$proc = Get-Process | Select-Object -First 1
$proc.Naem # property not found
# Available versions:
# 1.0 prohibits uninitialized variables
# 2.0 adds non-existent properties + method-call typos
# 3.0 adds out-of-bounds array indexing
# Latest aliases to the highest available
Output:
InvalidOperation: The variable '$Naem' cannot be retrieved because it has not been set.
InvalidOperation: The property 'Naem' cannot be found on this object. Verify that the property exists.
Get-Error — rich diagnostics (PS 7+)
Get-Error formats the most recent error (or any ErrorRecord piped in) as a fully expanded report — exception type, full stack trace, target object, PowerShell call stack, all nested inner exceptions. Far more useful than $Error[0] when debugging a failure in a remote script or a complex pipeline.
# Trigger an error
try { Get-Item C:\nope -ErrorAction Stop } catch { }
# Full report on the most recent error
Get-Error
# Or pipe a specific error
$Error[2] | Get-Error
# Show the last N errors
Get-Error -Newest 3
Output (Get-Error):
Exception :
Type : System.Management.Automation.ItemNotFoundException
ErrorRecord :
Exception :
Type : System.Management.Automation.ParentContainsErrorRecordException
Message : Cannot find path 'C:\nope' because it does not exist.
HResult : -2146233087
FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetItemCommand
ItemName : C:\nope
SessionStateCategory : Drive
TargetSite :
Name : ThrowTerminatingError
DeclaringType : System.Management.Automation.MshCommandRuntime
Message : Cannot find path 'C:\nope' because it does not exist.
HResult : -2146233087
CategoryInfo : ObjectNotFound: (C:\nope:String) [Get-Item], ItemNotFoundException
FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetItemCommand
InvocationInfo :
MyCommand : Get-Item
ScriptLineNumber : 1
OffsetInLine : 7
HistoryId : 12
Line : try { Get-Item C:\nope -ErrorAction Stop } catch { }
PositionMessage : At line:1 char:7
+ try { Get-Item C:\nope -ErrorAction Stop } catch { }
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
CommandOrigin : Internal
ScriptStackTrace : at <ScriptBlock>, <No file>: line 1
Transcripts — capturing console output
Start-Transcript mirrors the host's output (and stderr) to a file, including all prompts, commands, and error messages, until you call Stop-Transcript. This is the simplest way to capture a script's full session log for postmortem analysis, especially for scheduled tasks running unattended. The transcript records both stdout and the error stream in chronological order.
# Begin recording
$log = "C:\Logs\deploy-$(Get-Date -Format yyyyMMdd-HHmmss).log"
Start-Transcript -Path $log -Append
try {
$ErrorActionPreference = 'Stop'
Write-Host "Starting deployment"
Copy-Item .\app.zip \\fileserver01\releases\app.zip
Invoke-Command -ComputerName web01 -ScriptBlock { Restart-Service IIS }
Write-Host "Deployment complete"
}
catch {
Write-Error "Deployment FAILED: $($_.Exception.Message)"
exit 1
}
finally {
Stop-Transcript
}
Output (head of the transcript file):
**********************
Windows PowerShell transcript start
Start time: 20260524091500
Username: MYHOST\alicedev
RunAs User: MYHOST\alicedev
Configuration Name:
Machine: MYHOST (Microsoft Windows NT 10.0.22631.0)
Host Application: pwsh.exe
**********************
Starting deployment
…
**********************
Windows PowerShell transcript end
End time: 20260524091547
**********************
Streams — error, warning, verbose, debug, information
PowerShell has six output streams: success (1), error (2), warning (3), verbose (4), debug (5), and information (6). Each has its own cmdlet and preference variable, and each can be redirected independently. Write-Information (PS 5+) is the modern way to emit user-facing log lines that don't pollute the success stream and survive > file.txt redirection cleanly.
# Emit on each stream
Write-Output "1. success stream (real return value)"
Write-Error "2. error stream" 2>$null # suppress error stream
Write-Warning "3. warning stream" # controlled by $WarningPreference
Write-Verbose "4. verbose stream — only shown when -Verbose is set"
Write-Debug "5. debug stream — only shown when -Debug is set"
Write-Information "6. info stream" -InformationAction Continue
# Redirect a single stream to file
script.ps1 2> errors.log # error stream only
script.ps1 *> all.log # every stream combined
# Merge streams into output (useful for testing)
script.ps1 2>&1 | Select-String "ERROR"
# Capture verbose output to a variable
script.ps1 4>&1 | Out-File detail.log
# Preference variables (set in scope)
$VerbosePreference = 'Continue' # show Write-Verbose by default
$DebugPreference = 'SilentlyContinue'
$InformationPreference = 'Continue'
$WarningPreference = 'Continue'
$ErrorActionPreference = 'Stop'
Output (running with -Verbose + -InformationAction Continue):
1. success stream (real return value)
WARNING: 3. warning stream
VERBOSE: 4. verbose stream — only shown when -Verbose is set
6. info stream
Common pitfalls
- Forgetting
-ErrorAction Stop— atry/catcharoundGet-Item C:\missingwill never fire by default because the error is non-terminating. Either add-ErrorAction Stopto the call or set$ErrorActionPreference = 'Stop'for the script. - Using
Write-Errorthen expectingcatchto fire —Write-Erroris non-terminating by definition. Usethrowif you want execution to stop. - Checking native exit codes with
$?—$?reflects only the most recent command. Always read$LASTEXITCODEforgit,npm,dotnet, etc., and read it before the next command runs. $ErrorActionPreferenceleaking — setting it at the top of a script changes behavior for everything downstream until the script exits the scope. Wrap with a local override or restore the prior value infinally.- Catching the wrong type —
catch [Exception]works, butcatch [System.IO.FileNotFoundException]won't match aParentContainsErrorRecordExceptionwrapper. Use$_.Exception.GetType().FullNameto see what type you actually got. - Returning from inside
try—finallystill runs (good), but areturnincatchswallows the error chain. Always rethrow withthrowif the failure should propagate. Set-StrictModeenabled in profile — handy for ad-hoc work but can break legacy scripts that depend on uninitialized-variable-equals-$null. Enable it per-script, not globally.- Confusing
$Errorwith the current error —$Error[0]is whatever happened most recently in the session, not necessarily the one yourcatchblock is processing. Insidecatch, use$_(alias$PSItem). exitinside a function —exitterminates the whole script/runspace, not just the function. Usereturnto leave a function early.- Not setting an exit code for CI —
throwraises a PowerShell exception but the process still exits 0 unless something else translates it. End scripts with explicitexit 1on failure if a CI system reads exit codes.
Real-world recipes
Robust deploy script for CI
A typical CI deploy script: strict mode on, errors are terminating, every step is logged to a transcript, and the process exits with the right code so the pipeline marks the build as failed when something goes wrong.
#requires -Version 5.1
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Environment,
[Parameter(Mandatory)][string]$Version
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$logFile = "C:\Logs\deploy-$Environment-$(Get-Date -Format yyyyMMdd-HHmmss).log"
Start-Transcript -Path $logFile -Append | Out-Null
try {
Write-Host "===> Deploying $Version to $Environment"
$cred = Import-Clixml "$env:USERPROFILE\.creds\$Environment.xml"
$sess = New-PSSession -ComputerName "web-$Environment" -Credential $cred
Copy-Item ".\artifacts\app-$Version.zip" -Destination 'C:\releases\' -ToSession $sess
Invoke-Command -Session $sess -ScriptBlock {
param($v)
Expand-Archive "C:\releases\app-$v.zip" -DestinationPath 'C:\app' -Force
Restart-Service AppService
} -ArgumentList $Version
Remove-PSSession $sess
Write-Host "===> Deploy succeeded"
exit 0
}
catch {
Write-Error "Deploy FAILED at step: $($_.InvocationInfo.PositionMessage)"
Write-Error $_.Exception.Message
exit 1
}
finally {
Stop-Transcript | Out-Null
}
Output (CI build log on a successful run):
===> Deploying 1.4.2 to staging
===> Deploy succeeded
Retry with exponential backoff
A reusable wrapper that retries a script block on transient failures, doubling the wait between attempts and giving up after a maximum number of tries. Useful for flaky network operations.
function Invoke-WithRetry {
[CmdletBinding()]
param(
[Parameter(Mandatory)][scriptblock]$Script,
[int]$MaxAttempts = 5,
[int]$InitialDelaySeconds = 1
)
$attempt = 0
$delay = $InitialDelaySeconds
while ($true) {
$attempt++
try {
return & $Script
}
catch {
if ($attempt -ge $MaxAttempts) {
Write-Error "Giving up after $attempt attempts: $($_.Exception.Message)"
throw
}
Write-Warning "Attempt $attempt failed: $($_.Exception.Message) — retrying in ${delay}s"
Start-Sleep -Seconds $delay
$delay *= 2
}
}
}
# Use it
$response = Invoke-WithRetry -MaxAttempts 4 -Script {
Invoke-RestMethod "https://api.example.com/flaky-endpoint" -TimeoutSec 5
}
$response.status
Output:
WARNING: Attempt 1 failed: The operation has timed out. — retrying in 1s
WARNING: Attempt 2 failed: The remote server returned an error: (502) Bad Gateway. — retrying in 2s
ok
Aggregate errors from a bulk operation
When processing many items you want to collect every failure rather than stop at the first one. Capture errors per item, summarize at the end, and exit non-zero if anything failed.
$ErrorActionPreference = 'Stop'
$inputs = Get-ChildItem .\to-process -Filter *.json
$failures = [System.Collections.Generic.List[object]]::new()
foreach ($file in $inputs) {
try {
$data = Get-Content $file.FullName -Raw | ConvertFrom-Json
Invoke-RestMethod "https://api.example.com/ingest" `
-Method Post -Body ($data | ConvertTo-Json) -ContentType 'application/json'
}
catch {
$failures.Add([pscustomobject]@{
File = $file.Name
Error = $_.Exception.Message
})
}
}
Write-Host "Processed $($inputs.Count) files, $($failures.Count) failed"
if ($failures.Count -gt 0) {
$failures | Format-Table -AutoSize
exit 1
}
Output:
Processed 50 files, 2 failed
File Error
---- -----
record-017.json The remote server returned an error: (500) Internal Server Error.
record-042.json Cannot bind argument to parameter 'Body' because it is null.
Log errors with full context to a structured file
For long-running scripts, emit failures as JSON lines that can later be ingested by your log pipeline. Each line carries the error message, exception type, stack trace, and the script/line where it happened.
function Write-ErrorLog {
param(
[Parameter(Mandatory, ValueFromPipeline)]
[System.Management.Automation.ErrorRecord]$ErrorRecord,
[string]$Path = 'C:\Logs\app-errors.jsonl'
)
process {
$entry = [pscustomobject]@{
Time = (Get-Date).ToString('o')
Host = $env:COMPUTERNAME
Message = $ErrorRecord.Exception.Message
Type = $ErrorRecord.Exception.GetType().FullName
Category = $ErrorRecord.CategoryInfo.Category.ToString()
Script = $ErrorRecord.InvocationInfo.ScriptName
Line = $ErrorRecord.InvocationInfo.ScriptLineNumber
Position = $ErrorRecord.InvocationInfo.PositionMessage
Stack = $ErrorRecord.ScriptStackTrace
}
$entry | ConvertTo-Json -Compress | Add-Content -Path $Path -Encoding UTF8
}
}
# Usage
try {
Get-Item C:\definitely-missing -ErrorAction Stop
}
catch {
$_ | Write-ErrorLog
}
# Inspect
Get-Content C:\Logs\app-errors.jsonl | ConvertFrom-Json | Format-List
Output (single JSON-lines record):
{"Time":"2026-05-26T09:14:21.0123456-04:00","Host":"MYHOST","Message":"Cannot find path 'C:\\definitely-missing' because it does not exist.","Type":"System.Management.Automation.ItemNotFoundException","Category":"ObjectNotFound","Script":"C:\\scripts\\deploy.ps1","Line":42,"Position":"At C:\\scripts\\deploy.ps1:42 char:3","Stack":"at <ScriptBlock>, C:\\scripts\\deploy.ps1: line 42"}
Sources
- Announcing PowerShell 7.6 (LTS) GA Release — PowerShell Team, March 2026
- about_Preference_Variables — Microsoft Learn (PowerShell 7.6)
- about_Error_Handling — Microsoft Learn (PowerShell 7.6)
- How to use Try, Catch, Finally in PowerShell — LazyAdmin
- PowerShell Try-Catch: Error Handling Guide — Netwrix