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/catch sees them.
  • Non-terminating errors are written to the error stream and the pipeline keeps going. try/catch does 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 default Continue and 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:

  1. Type-specific catch blocks come first, generic last. The first match wins.
  2. finally always runs pipeline success, caught error, uncaught error, even Ctrl-C.
  3. Re-throw with bare throw to 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
}

FullyQualifiedErrorId is 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:

  1. 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.
  2. Identify one try/catch that 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.
  3. 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).