Error Handling
Making scripts fail safely: exit codes, set -euo pipefail and trap in Bash, and terminating errors, try and catch and ErrorActionPreference in PowerShell.
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:
-
grepreturns 1 when it finds nothing, and(( count++ ))returns 1 when the old value was 0. Underset -eboth stop the script silently. Writegrep … || truewhere "no match" is fine, andcount=$(( count + 1 )). -
With
pipefail, a producer that is cut off early, as inlong_command | head -n 1or| 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 -eunless you also setshopt -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:
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
|