Two models of failure

In Bash, every command ends with an exit status: 0 for success, 1 to 255 for some kind of failure. Error messages go to standard error, and the shell simply moves on to the next line. Whether a failure matters is entirely up to the script.

PowerShell has a richer model. A failing cmdlet writes an error record (an object with the exception, the target, a category and the position in the script) to the error stream and to the automatic variable $Error. Whether the script then stops depends on the kind of error and on a preference you control. Native programs called from PowerShell still report through an exit status, which PowerShell puts in $LASTEXITCODE.

Both shells, in other words, keep going by default. That is helpful at the prompt, where you see every message, and dangerous in a script that runs unattended at 02:00: a failed cd followed by a delete, or an export that reports success while half the data is missing.

Checking exit statuses in Bash

The most reliable error handling in Bash is explicit: test the commands whose failure matters, and stop with a clear message on standard error. A small die function keeps that readable:

die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }

printf 'M-1042,4521\n' > readings.csv
cp readings.csv backup.csv || die "backup of readings.csv failed"
echo "Backup made"
cp missing.csv backup2.csv || die "backup of missing.csv failed"
echo "never printed"
Backup made
cp: cannot stat 'missing.csv': No such file or directory
ERROR: backup of missing.csv failed

cmd || die "…" runs die only when cmd fails. For several commands, use if ! cmd; then …; fi. The >&2 sends the message to standard error, so it still reaches the log or terminal when the script's normal output is redirected to a file.

Strict mode: set -euo pipefail

Checking every command by hand is tedious, so many scripts switch on three shell options at the top, which Shell Scripting introduces:

  • errexit (set -e): the shell exits as soon as a command fails whose status is not being checked.
  • nounset (set -u): using a variable that was never set is an error, instead of silently expanding to an empty string.
  • pipefail (set -o pipefail): a pipeline reports failure if any command in it fails, not only the last one.

Without pipefail, a pipeline's status is that of its last command. Here grep cannot read the log, but sort succeeds with empty input, so the pipeline "succeeds" and an empty error report is written. The array PIPESTATUS holds the status of every command of the last pipeline:

grep ERROR missing.log | sort > errors.txt
echo "without pipefail: status $?"
set -o pipefail
grep ERROR missing.log | sort > errors.txt
echo "with pipefail:    status $?"
grep ERROR missing.log | sort > errors.txt
echo "PIPESTATUS:       ${PIPESTATUS[*]}"
grep: missing.log: No such file or directory
without pipefail: status 0
grep: missing.log: No such file or directory
with pipefail:    status 2
grep: missing.log: No such file or directory
PIPESTATUS:       2 0

nounset catches typos in variable names and missing configuration. For variables that are allowed to be missing, give a default with ${NAME:-default}, which does not trigger the error:

cat > export.sh <<'EOF'
#!/bin/bash
set -u
log_dir=${LOG_DIR:-/var/log/kestrel}
echo "Logging to $log_dir"
echo "Exporting region $REGION"
EOF
bash export.sh;  echo "exit status $?"
LOG_DIR=./logs REGION=north bash export.sh
Logging to /var/log/kestrel
export.sh: line 5: REGION: unbound variable
exit status 1
Logging to ./logs
Exporting region north

The examples on this page write a script file and run it with bash, so they show the exit status a real script would have.

Where set -e does not help

The Bash manual lists the exceptions precisely: set -e does not exit for a failing command that is part of the test of an if, while or until, part of an && or || list (except the last command), any command in a pipeline but the last (unless pipefail is on), or a command whose status is inverted with !. The surprise is that this exemption covers everything a function does when the function itself is called in such a place:

cat > check.sh <<'EOF'
#!/bin/bash
set -e
check_feed() {
  false                       # this should stop the script...
  echo "after false inside the function"
}
if check_feed; then echo "check passed"; fi
check_feed || echo "check failed"
check_feed
echo "never printed"
EOF
bash check.sh; echo "exit status $?"
after false inside the function
check passed
after false inside the function
exit status 1

Called from if or before ||, the function runs on past the failing false and returns the status of its last command, so "check passed" is printed although the check failed. Only the plain call on its own line stops the script. A second trap is local (and export and declare): combined with a command substitution, the line's status is that of local, which always succeeds. ShellCheck warns about this as SC2155:

cat > mask.sh <<'EOF'
#!/bin/bash
set -euo pipefail
get_reading() { echo "partial"; return 1; }
masked()   { local value=$(get_reading); echo "masked: got '$value'"; }
separate() { local value; value=$(get_reading); echo "separate: got '$value'"; }
masked
separate
echo "never printed"
EOF
bash mask.sh; echo "exit status $?"
masked: got 'partial'
exit status 1

Other pitfalls you will meet:

  • grep returns 1 when it finds nothing, and (( count++ )) returns 1 when the old value was 0. Under set -e both stop the script silently. Write grep … || true where "no match" is fine, and count=$(( count + 1 )).
  • With pipefail, a producer that is cut off early, as in long_command | head -n 1 or | grep -q, can fail with status 141 (killed by the signal SIGPIPE) depending on timing. That makes such pipelines fail only sometimes.
  • Command substitutions do not inherit set -e unless you also set shopt -s inherit_errexit (Bash 4.4 and later).

Cleaning up and reporting with trap

trap is the Bash builtin that registers a command to run when the shell receives a signal or reaches a special event. Two events matter most in scripts: EXIT, which runs when the script ends for any reason (normal end, exit, or a stop by set -e), and ERR, which runs whenever a command fails in a place where set -e would act. Inside an ERR trap, $LINENO is the failing line, $BASH_COMMAND the command and $? its status. Add set -E (errtrace) so the ERR trap also works inside functions:

cat > backup.sh <<'EOF'
#!/bin/bash
set -eEuo pipefail
tmp=$(mktemp)
trap 'rm -f "$tmp"; echo "cleanup: temporary file removed"' EXIT
trap 'echo "error on line $LINENO: $BASH_COMMAND (status $?)" >&2' ERR
printf 'M-1042,4521\n' > "$tmp"
cp "$tmp" readings.csv
cp missing.csv backup.csv
echo "never printed"
EOF
bash backup.sh; echo "exit status $?"
cp: cannot stat 'missing.csv': No such file or directory
error on line 8: cp missing.csv backup.csv (status 1)
cleanup: temporary file removed
exit status 1

The EXIT trap is the right place to remove temporary files, release locks and restore anything the script changed temporarily, because it runs on every way out. Writing Robust and Safe Scripts uses it together with mktemp and flock.

Terminating and non-terminating errors

PowerShell distinguishes errors by what they do to the running code. A non-terminating error is reported (written to the error stream and added to $Error) while the command, and the script, continue. Most cmdlet errors are of this kind, because a cmdlet that processes a hundred files should not give up on the other ninety-nine when one is missing:

'M-1042,4521' | Set-Content readings.csv
Get-Item ./missing.csv, ./readings.csv | ForEach-Object Name
'The script carries on after the error'
Get-Item: /home/amara/scripts/example.ps1:2
Line |
   2 |  Get-Item ./missing.csv, ./readings.csv | ForEach-Object Name
     |  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Cannot find path '/home/amara/scripts/missing.csv' because it does not
     | exist.
readings.csv
The script carries on after the error

A terminating error stops the current code and transfers control to the nearest catch block; without one, it stops the current statement or the whole script, depending on its kind. Microsoft's documentation splits them in two: statement-terminating errors (for example a parameter that fails validation, or a .NET method that throws, such as [int]::Parse('abc')) stop only the current statement, while script-terminating errors (throw, and errors escalated with -ErrorAction Stop or $ErrorActionPreference = 'Stop' in an ordinary script) unwind the whole script. Non-terminating errors do not trigger catch at all:

How PowerShell handles an error. A cmdlet error goes through the ErrorAction setting. With Continue, the default, the error is logged and the script goes on. With Stop, it becomes a terminating error, just like throw. A terminating error inside try runs the catch block and then finally; outside try, the script stops and pwsh exits with code 1. cmdlet error -ErrorAction or $ErrorActionPreference Continue (default) error is logged, script goes on throw Stop terminating error inside try: catch { } runs, then finally { } not inside try: script stops, exit code 1
The error action decides whether a cmdlet error is only logged or becomes a terminating error. Only terminating errors reach catch.

try, catch and finally

PowerShell's try/catch/finally works like the try, catch and finally of other languages, with one PowerShell-specific catch: the error has to be terminating. The first block below looks safe but catches nothing; the second adds -ErrorAction Stop to the cmdlet, which turns its error into a terminating one:

try {
    Get-Content -Path ./readings.csv
    'after Get-Content'
} catch {
    "caught: $($_.Exception.Message)"
}
'--- with -ErrorAction Stop'
try {
    Get-Content -Path ./readings.csv -ErrorAction Stop
    'after Get-Content'
} catch {
    "caught: $($_.Exception.Message)"
}
Get-Content: /home/amara/scripts/example.ps1:2
Line |
   2 |      Get-Content -Path ./readings.csv
     |      ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Cannot find path '/home/amara/scripts/readings.csv' because it does not
     | exist.
after Get-Content
--- with -ErrorAction Stop
caught: Cannot find path '/home/amara/scripts/readings.csv' because it does not exist.

Inside catch, $_ is the error record. Besides the message it carries the exception type, the object that failed and an identifier you can match on. A catch can name an exception type, and the first matching block wins; finally runs whether or not an error occurred, which makes it the place for cleanup:

try {
    $readings = Get-Content -Path ./readings.csv -ErrorAction Stop
    "Read $($readings.Count) lines"
} catch [System.Management.Automation.ItemNotFoundException] {
    "Not found:   $($_.TargetObject)"
    "Error ID:    $($_.FullyQualifiedErrorId)"
    "Category:    $($_.CategoryInfo.Category)"
} catch {
    "Other error: $($_.Exception.Message)"
} finally {
    'finally: always runs'
}
Not found:   /home/amara/scripts/readings.csv
Error ID:    PathNotFound
Category:    ObjectNotFound
finally: always runs

To raise your own terminating error, use throw with a clear message. Uncaught, it stops the script and shows where it happened; this is the PowerShell equivalent of the die function above:

$meterId = 'M-9999'
$known = 'M-1042', 'M-1043'
if ($meterId -notin $known) {
    throw "Meter $meterId not found in the register"
}
'never printed'
Exception: /home/amara/scripts/example.ps1:4
Line |
   4 |      throw "Meter $meterId not found in the register"
     |      ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Meter M-9999 not found in the register

ErrorActionPreference

$ErrorActionPreference is the preference variable that sets the default reaction to non-terminating errors for everything in its scope; the common parameter -ErrorAction overrides it for one command. The values you will use:

Value Effect
Continue (default) Show the error, record it in $Error, continue
Stop Turn the error into a terminating error
SilentlyContinue Do not show the error, but still record it in $Error, and continue
Ignore Do not show the error and do not record it in $Error (terminating errors it suppresses are still recorded); continue
Inquire Ask the user what to do; never in unattended scripts

Setting $ErrorActionPreference = 'Stop' at the top of a script is PowerShell's counterpart of set -e, with fewer exceptions: every cmdlet error now stops the script (or jumps to catch), and the uncaught error makes pwsh exit with code 1:

$ErrorActionPreference = 'Stop'
'Exporting readings...'
Get-Item -Path ./readings.csv
'never printed'
Exporting readings...
Get-Item: /home/amara/scripts/example.ps1:3
Line |
   3 |  Get-Item -Path ./readings.csv
     |  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Cannot find path '/home/amara/scripts/readings.csv' because it does not
     | exist.

Where an error is expected, say so for that one command, and keep the error for inspection with -ErrorVariable (the name is given without $):

$found = Get-Item -Path ./override.conf -ErrorAction SilentlyContinue -ErrorVariable problem
if (-not $found) {
    "No override file, using defaults ($($problem.Count) error recorded)"
    "Reason: $($problem[0].Exception.Message)"
}
No override file, using defaults (1 error recorded)
Reason: Cannot find path '/home/amara/scripts/override.conf' because it does not exist.

Native programs inside PowerShell

When PowerShell runs a native program (robocopy, git, bash), a non-zero exit code is not an error record. PowerShell stores the code in $LASTEXITCODE and sets $? to false, and the script continues, whatever the preference says. Check $LASTEXITCODE yourself, or, since PowerShell 7.4, set $PSNativeCommandUseErrorActionPreference = $true so that a non-zero exit code becomes an error that follows $ErrorActionPreference:

bash -c 'echo "checking meter feed"; exit 4'
"LASTEXITCODE: $LASTEXITCODE"

$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
try {
    bash -c 'exit 4'
} catch {
    "caught: $($_.Exception.Message)"
}
checking meter feed
LASTEXITCODE: 4
caught: Program "bash" ended with non-zero exit code: 4 (0x04).

Some Windows tools use non-zero codes for success: robocopy returns 1 when it copied files, and only codes of 8 and higher mean failure. For such tools, keep the preference off and check the code against the tool's documentation.

Exit codes for whoever runs the script

cron, systemd, Task Scheduler and CI pipelines judge a script by its exit code. In Bash that is the status of the last command, or the number given to exit. In PowerShell, pwsh -File follows a different rule: a script that runs to its end exits with 0, even if non-terminating errors were written on the way; an uncaught terminating error gives 1, and exit N gives N. This example runs three small scripts with pwsh and prints their exit codes:

'Get-Item ./missing.csv' | Set-Content continues.ps1
"`$ErrorActionPreference = 'Stop'`nGet-Item ./missing.csv" | Set-Content stops.ps1
@'
try {
    Get-Item ./missing.csv -ErrorAction Stop
} catch {
    [Console]::Error.WriteLine("Export failed: $($_.Exception.Message)")
    exit 2
}
'@ | Set-Content handles.ps1

foreach ($script in 'continues.ps1', 'stops.ps1', 'handles.ps1') {
    pwsh -NoProfile -File $script 2>$null
    "{0,-14} exit code {1}" -f $script, $LASTEXITCODE
}
continues.ps1  exit code 0
stops.ps1      exit code 1
handles.ps1    exit code 2

The first script failed but reports success, which is exactly the situation that leaves a broken nightly export unnoticed for weeks. End every scheduled PowerShell script with a clear verdict: $ErrorActionPreference = 'Stop' at the top, or a catch that writes the reason to standard error and calls exit with a non-zero code. In Bash, the same rule: make sure the last thing the script does on failure is exit with a non-zero status. Scheduling and Logging shows how schedulers report those codes.

Side by side

Task Bash PowerShell
Did the last command succeed? $? (0 = yes) $? ($true = yes); $LASTEXITCODE for native programs
Stop on errors set -euo pipefail (with exceptions) $ErrorActionPreference = 'Stop'
Stop for one command cmd || exit 1 -ErrorAction Stop
Handle an error if ! cmd; then …; fi try { } catch { }
Always clean up trap '…' EXIT finally { }
Report where it failed trap '…$LINENO…' ERR with set -E $_.InvocationInfo, $_.ScriptStackTrace
Raise an error echo "…" >&2; exit 1 throw "…", or Write-Error (non-terminating)
Treat unset variables as errors set -u Set-StrictMode -Version Latest
Exit code after errors that were only reported Status of the last command 0 with pwsh -File, unless you exit or a terminating error escapes