PowerShell Error Handling Best Practices for Production Scripts

A production script that crashes silently at 2 AM, leaves resources in a broken state, and sends no alert is worse than no script at all. Solid PowerShell error handling best practices mean every failure is logged, cleaned up, and surfaced to the right person before the morning stand-up. This post covers the patterns that separate reliable production automation from fragile one-liners: structured logging, retry logic, guaranteed cleanup, and failure alerting — all built with native PowerShell constructs.
Global ErrorActionPreference Setup
By default, PowerShell’s $ErrorActionPreference is Continue, meaning non-terminating errors print a red message but the script keeps running. Production scripts should set it to Stop at the top so every error becomes a terminating exception that your try/catch blocks can handle consistently:
#Requires -Version 5.1
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
Set-StrictMode -Version Latest adds referencing undefined variables and calling non-existent properties as errors, catching typos that would otherwise produce wrong results silently.
Structured Logging Pattern
Plain Write-Host output is unstructured, hard to parse, and disappears the moment the console closes. A simple structured logging function writes to a file with a timestamp and severity, while also displaying to the console:
function Write-Log {
param(
[string]$Message,
[ValidateSet('INFO','WARN','ERROR')]
[string]$Level = 'INFO'
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$entry = "$timestamp [$Level] $Message"
Add-Content -Path 'C:\Logs\script.log' -Value $entry
switch ($Level) {
'ERROR' { Write-Host $entry -ForegroundColor Red }
'WARN' { Write-Host $entry -ForegroundColor Yellow }
default { Write-Host $entry }
}
}
Write-Log "Starting backup process"
Write-Log "Disk below threshold" -Level WARN
Retry Logic with Exponential Backoff
Network operations, database connections, and API calls fail transiently. Rather than crashing on the first failure, a retry loop with exponential backoff gives transient issues time to resolve:
function Invoke-WithRetry {
param(
[scriptblock]$Action,
[int]$MaxAttempts = 3,
[int]$BaseDelaySeconds = 2
)
$attempt = 0
do {
$attempt++
try {
& $Action
return
}
catch {
if ($attempt -ge $MaxAttempts) {
Write-Log "Action failed after $MaxAttempts attempts: $_" -Level ERROR
throw
}
$delay = $BaseDelaySeconds * [Math]::Pow(2, $attempt - 1)
Write-Log "Attempt $attempt failed. Retrying in ${delay}s: $_" -Level WARN
Start-Sleep -Seconds $delay
}
} while ($attempt -lt $MaxAttempts)
}
# Usage
Invoke-WithRetry -Action {
Invoke-RestMethod -Uri "https://api.corp.com/data" -Method Get
}
Cleanup with Try-Finally
The finally block executes regardless of whether the try succeeded or the catch ran. Use it for cleanup tasks — closing file handles, removing temp files, releasing COM objects, or closing database connections — so resources are never leaked even when the script fails:
$tempFile = [System.IO.Path]::GetTempFileName()
try {
Write-Log "Writing data to temp file $tempFile"
Get-Process | Export-Csv -Path $tempFile -NoTypeInformation
Copy-Item $tempFile "\\nas\share\process-snapshot.csv"
Write-Log "Export complete"
}
catch {
Write-Log "Export failed: $_" -Level ERROR
throw
}
finally {
if (Test-Path $tempFile) {
Remove-Item $tempFile -Force
Write-Log "Temp file cleaned up"
}
}
Email Alert on Failure
Wrap the entire script body in a try/catch and send an email in the catch block. Include the exception message and stack trace so whoever is on call has enough context to diagnose without opening the server:
try {
# Main script logic here
Write-Log "Backup started"
# ... backup operations ...
Write-Log "Backup completed successfully"
}
catch {
$subject = "ALERT: Backup Script Failed on $env:COMPUTERNAME"
$body = @"
Script failed at $(Get-Date)
Error: $($_.Exception.Message)
Stack Trace:
$($_.ScriptStackTrace)
"@
$mailParams = @{
From = '[email protected]'
To = '[email protected]'
Subject = $subject
Body = $body
SmtpServer = 'smtp.corp.com'
}
Send-MailMessage @mailParams
Write-Log $body -Level ERROR
exit 1
}
Testing Error Paths
Untested error paths are surprises waiting to happen. Use throw to simulate failures during development, and verify that logging, cleanup, and alerts behave correctly before the script runs in production:
# Simulate a failure to test your error handling
try {
throw [System.IO.IOException]::new("Simulated disk full error")
}
catch [System.IO.IOException] {
Write-Log "Caught IO exception: $($_.Exception.Message)" -Level ERROR
}
catch {
Write-Log "Unexpected error: $_" -Level ERROR
}
finally {
Write-Log "Cleanup ran"
}
Common Errors and Fixes
-
Swallowing errors in catch without logging. An empty
catch { }block silently discards failures. Always log at minimum the exception message ($_.Exception.Message) and ideally the full$_record. Silent failures create debugging nightmares. -
Not cleaning up resources in finally block. If you open a file, start a transcript, or create a CIM session inside
try, the cleanup must be infinally, not at the end of thetryblock. An exception before that line leaves the resource open indefinitely.
Related Cmdlets / See Also
Wrapping Up
Robust error handling is non-negotiable for production scripts. Set $ErrorActionPreference = 'Stop', log every failure with timestamps, retry transient operations, always clean up in finally, and alert on critical errors. Scripts built this way earn trust — they either succeed reliably or fail loudly enough for someone to fix them.


