PowerShell Error Handling Best Practices for Production Scripts

PowerShell Error Handling Best Practices for Production Scripts

PowerShell Tips Editor 2 min read
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 in finally, not at the end of the try block. 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.

Send-Item -To