PowerShell error handling is easy to ignore while a script is still small. A command fails, the red text appears, and you fix the problem while looking at the console. That stops working when the same script runs from Task Scheduler, a deployment tool, a remote session, or a service account. At that point, the script needs to explain what failed without you watching it live.

For production scripts, I usually want two things at the same time: a clear message on the screen when I run the script manually, and a log file that remains after the console is closed. try, catch, and finally are the basic tools for that pattern.

Quick answer

Use try for the commands that may fail, catch to record and handle the error, and finally for cleanup that must run whether the script succeeds or fails. Set $ErrorActionPreference = 'Stop' or use -ErrorAction Stop so non-terminating errors reach catch. In catch, write the error message to both the console and a log file, then return a non-zero exit code when the script really failed.


Why catch does not catch every error by default

This is the part that confuses many PowerShell users. Not every PowerShell error behaves the same way.

PowerShell has terminating errors and non-terminating errors. A terminating error stops the current operation and can be caught by catch. A non-terminating error writes an error message but may allow the script to continue.

For example, Get-Item C:\NoSuchFolder may write an error but continue depending on the command and error action settings. In an unattended script, that can be dangerous. The script may fail to read an input file, continue anyway, and then create a bad report.

For scripts that need predictable behavior, I normally use this near the top:

$ErrorActionPreference = 'Stop'

That tells many cmdlets to treat errors as terminating errors. You can also set it per command:

Get-Item -Path 'C:\NoSuchFolder' -ErrorAction Stop

I still use command-level -ErrorAction Stop when a specific command is important, even if the script already sets $ErrorActionPreference. It makes the intent obvious to the next person reading the script.


A basic try and catch example

This is the smallest useful pattern:

try {
    Get-Item -Path 'C:\NoSuchFolder' -ErrorAction Stop
    Write-Host 'Folder exists'
}
catch {
    Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
}

Inside catch, the current error is stored in $_. The most useful starting point is usually:

$_.Exception.Message

That gives a readable error message without dumping the entire error object. When troubleshooting, I also use these properties:

$_.Exception.GetType().FullName
$_.InvocationInfo.ScriptLineNumber
$_.InvocationInfo.Line

Those tell me what kind of exception happened and where PowerShell was running when it failed.


Add a log function

For production scripts, console output is not enough. I want the error in a file too.

This function writes each log line to a file and can also print it to the console. For errors, I usually want both.

$ScriptName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath)
$RunId = Get-Date -Format 'yyyyMMdd-HHmmss'
$LogRoot = 'C:\Logs\PowerShell'
$script:LogPath = Join-Path $LogRoot "$ScriptName-$RunId.log"

if (-not (Test-Path $LogRoot)) {
    New-Item -Path $LogRoot -ItemType Directory -Force | Out-Null
}

function Write-Log {
    param(
        [Parameter(Mandatory)]
        [string]$Message,

        [ValidateSet('INFO', 'WARN', 'ERROR')]
        [string]$Level = 'INFO',

        [switch]$ToScreen
    )

    $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    $line = "$timestamp [$Level] $Message"

    Add-Content -Path $script:LogPath -Value $line -Encoding utf8

    if ($ToScreen) {
        switch ($Level) {
            'ERROR' { Write-Host $line -ForegroundColor Red }
            'WARN'  { Write-Host $line -ForegroundColor Yellow }
            default { Write-Host $line }
        }
    }
}

Normal informational messages can go only to the log file:

Write-Log "Starting inventory script"

Important progress or errors can go to both places:

Write-Log "Could not read input file" 'ERROR' -ToScreen

This keeps the console useful during manual runs without losing the permanent record.


A complete script pattern

Here is a realistic starter pattern for a script that imports a CSV file and processes each row.

param(
    [Parameter(Mandatory)]
    [string]$InputPath
)

$ErrorActionPreference = 'Stop'

$ScriptName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath)
$RunId = Get-Date -Format 'yyyyMMdd-HHmmss'
$LogRoot = 'C:\Logs\PowerShell'
$script:LogPath = Join-Path $LogRoot "$ScriptName-$RunId.log"

if (-not (Test-Path $LogRoot)) {
    New-Item -Path $LogRoot -ItemType Directory -Force | Out-Null
}

function Write-Log {
    param(
        [Parameter(Mandatory)]
        [string]$Message,

        [ValidateSet('INFO', 'WARN', 'ERROR')]
        [string]$Level = 'INFO',

        [switch]$ToScreen
    )

    $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    $line = "$timestamp [$Level] $Message"

    Add-Content -Path $script:LogPath -Value $line -Encoding utf8

    if ($ToScreen) {
        switch ($Level) {
            'ERROR' { Write-Host $line -ForegroundColor Red }
            'WARN'  { Write-Host $line -ForegroundColor Yellow }
            default { Write-Host $line }
        }
    }
}

try {
    Write-Log "Starting $ScriptName" -ToScreen
    Write-Log "Input path: $InputPath"
    Write-Log "Log path: $script:LogPath" -ToScreen

    if (-not (Test-Path $InputPath)) {
        throw "Input file not found: $InputPath"
    }

    $rows = Import-Csv -Path $InputPath -ErrorAction Stop
    Write-Log "Loaded $($rows.Count) rows from input file"

    foreach ($row in $rows) {
        Write-Log "Processing item: $($row.Name)"

        # Do real work here.
    }

    Write-Log "Completed successfully" -ToScreen
    exit 0
}
catch {
    Write-Log "Script failed: $($_.Exception.Message)" 'ERROR' -ToScreen
    Write-Log "Error type: $($_.Exception.GetType().FullName)" 'ERROR'
    Write-Log "Line number: $($_.InvocationInfo.ScriptLineNumber)" 'ERROR'
    Write-Log "Command: $($_.InvocationInfo.Line.Trim())" 'ERROR'
    exit 1
}
finally {
    Write-Log "Finished run. Log file: $script:LogPath"
}

This pattern gives a readable console message and a log file with more detail. If the script fails under Task Scheduler, the log file still shows the error message, type, line number, and command.


What finally is for

finally runs whether the try block succeeds or the catch block handles an error. That makes it useful for cleanup.

Good uses for finally include:

  • Closing files
  • Disconnecting sessions
  • Removing temporary files
  • Stopping transcripts
  • Releasing locks
  • Writing a final log line

For example:

$session = $null

try {
    $session = New-PSSession -ComputerName 'SERVER01' -ErrorAction Stop
    Invoke-Command -Session $session -ScriptBlock {
        Get-Service Spooler
    }
}
catch {
    Write-Log "Remote command failed: $($_.Exception.Message)" 'ERROR' -ToScreen
}
finally {
    if ($session) {
        Remove-PSSession -Session $session
        Write-Log "Removed remote session"
    }
}

The cleanup runs even if the remote command fails. That matters in scripts that open remote sessions, lock files, or create temporary folders.


Catch expected errors close to the command

Not every error should stop the entire script. If a script processes 500 computers, one offline computer may be a warning, not a fatal failure.

In that case, catch the expected error inside the loop and continue:

foreach ($computer in $ComputerName) {
    try {
        Write-Log "Checking $computer"

        $os = Get-CimInstance -ClassName Win32_OperatingSystem `
            -ComputerName $computer `
            -ErrorAction Stop

        Write-Log "$computer is running $($os.Caption)"
    }
    catch {
        Write-Log "Could not query $computer: $($_.Exception.Message)" 'WARN' -ToScreen
        continue
    }
}

This is different from a missing input file. If the input file is missing, the whole script cannot continue. If one computer is offline, the script may still process the rest.

The rule I use is simple: catch close to the command when the script can safely continue. Catch at the top level when the script must stop.


Do not hide errors with SilentlyContinue

-ErrorAction SilentlyContinue has a place, but I avoid it in production scripts unless I am deliberately ignoring a known safe condition.

This is risky:

Get-Item -Path $InputPath -ErrorAction SilentlyContinue

If the file is required, the script should fail loudly and log the reason:

try {
    Get-Item -Path $InputPath -ErrorAction Stop | Out-Null
}
catch {
    Write-Log "Required file is missing: $InputPath" 'ERROR' -ToScreen
    throw
}

Silent errors make troubleshooting slower. They also make automation less trustworthy because the final output may look successful even when part of the work failed.


Use throw when you need to stop

Sometimes the script detects a bad condition before PowerShell throws an error. In that case, use throw.

if ($rows.Count -eq 0) {
    throw "Input file contains no rows: $InputPath"
}

That sends the script into catch, where the normal error logging happens.

I prefer this over writing an error message and continuing. If the script cannot do useful work, stop clearly.


What the log should show

When a production script fails, I want the log to answer these questions:

  • What script ran?
  • Which account and computer ran it?
  • What input did it use?
  • What step was running?
  • What error message occurred?
  • Which line failed?
  • Did the script exit successfully or fail?

Add these startup lines if the script runs unattended:

Write-Log "Computer: $env:COMPUTERNAME"
Write-Log "User: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
Write-Log "PowerShell version: $($PSVersionTable.PSVersion)"

Those lines are small, but they save time later. Many script problems come from running under the wrong account, on the wrong server, or under a different PowerShell version than expected.


Final pattern I use

For most admin scripts, this is the shape I want:

  • Set $ErrorActionPreference = 'Stop'.
  • Create one log file per run.
  • Use a Write-Log function with INFO, WARN, and ERROR.
  • Print important messages to screen with -ToScreen.
  • Wrap the main workflow in try, catch, and finally.
  • Catch expected per-item failures inside loops.
  • Use throw when a required condition is missing.
  • Exit with 0 on success and 1 on failure.

Good error handling does not make a script complicated. It makes the script honest. When it succeeds, the log says what happened. When it fails, the console and log file both show the reason.