Bash · lesson 14 of 22
Also part of: GitHub Actions
Bash Error Handling
Write robust scripts with set -euo pipefail, trap ERR, exit codes, meaningful error messages, and safe cleanup patterns.
By SkillByExample · Updated
Every example below is a complete script, run with bash 5.2 on Ubuntu 24.04.
Why Scripts Need set -e
By default Bash keeps going after a command fails:
cp missing.txt backup/
echo "backup complete"
$ bash no-e.sh
cp: cannot stat 'missing.txt': No such file or directory
backup complete
The script announced success after the one step that mattered had failed. set -e stops
the script at the first failing command:
set -e
cp missing.txt backup/
echo "backup complete"
$ bash with-e.sh; echo "exit status: $?"
cp: cannot stat 'missing.txt': No such file or directory
exit status: 1
Most scripts start with set -euo pipefail. -u makes an unset variable an error, and
pipefail makes a pipeline fail when any stage fails (see the input/output lesson). Some
style guides also add IFS=$'\n\t' so that unquoted expansions no longer split on spaces.
Exit Codes
Every command reports an exit code. Some values have fixed meanings:
ls /nonexistent 2>/dev/null; echo "ls missing file: $?"
nosuchcommand 2>/dev/null; echo "unknown command: $?"
touch plain.sh; ./plain.sh 2>/dev/null; echo "not executable: $?"
timeout 1 sleep 5; echo "timeout killed it: $?"
bash -c 'kill -TERM $$'; echo "killed by SIGTERM: $?"
$ bash codes.sh
ls missing file: 2
unknown command: 127
not executable: 126
timeout killed it: 124
Terminated
killed by SIGTERM: 143
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Misuse, bad arguments (GNU ls uses it for “cannot access”) |
| 124 | timeout killed the command |
| 126 / 127 | Not executable / command not found |
| 128+N | Killed by signal N (130 = Ctrl+C, 143 = SIGTERM) |
Use 1 and 2 for your own scripts. A code above 125 looks like a shell or signal failure to whoever reads it.
When set -e Kills a Script Silently
set -e treats every non-zero status as an error, but some commands use non-zero to mean
“no”.
set -euo pipefail
printf 'INFO ok\nINFO ok\n' > app.log
errors=$(grep -c ERROR app.log)
echo "errors: $errors"
$ bash grep-e.sh; echo "exit status: $?"
exit status: 1
The script died without printing anything. grep -c printed 0 and exited 1 because
there were no matches, and set -e treated the assignment as a failure. A clean log is
exactly the case that kills this script. Mark that status as acceptable, or test it in a
condition:
set -euo pipefail
printf 'INFO ok\nINFO ok\n' > app.log
errors=$(grep -c ERROR app.log || true)
echo "errors: $errors"
if grep -q ERROR app.log; then echo "found errors"; else echo "log is clean"; fi
$ bash grep-fixed.sh
errors: 0
log is clean
diff, cmp, test and (( )) all return non-zero for a normal “no” answer, so the
same applies to them.
Where set -e Is Switched Off
set -e doesn’t apply to commands in an if or while condition, or on the left of &&
or ||. That exemption also covers everything inside a function called from those places:
set -e
deploy() {
false
echo " ...deploy kept going after false"
}
echo "called under || true:"
( deploy ) || true
echo "called from if:"
if deploy; then echo " deploy 'succeeded'"; fi
$ bash exempt.sh
called under || true:
...deploy kept going after false
called from if:
...deploy kept going after false
deploy 'succeeded'
Both calls went past false, and the if even reported success, because the function’s
status is that of its last echo. The first call is exempt too: ( deploy ) runs on the
left of ||, so set -e is off inside it as well. Functions that must stop on a failure
need explicit checks (Practice task 2).
trap ERR: Report What Failed
An ERR trap runs when a command fails. It is the place to print which command it was and
on which line.
set -eEuo pipefail
on_error() {
echo "ERROR: '$BASH_COMMAND' exited $1 at line $2" >&2
}
trap 'on_error $? $LINENO' ERR
echo "Starting..."
cp /nonexistent /tmp/
echo "This never runs"
$ bash trap-err.sh
Starting...
cp: cannot stat '/nonexistent': No such file or directory
ERROR: 'cp /nonexistent /tmp/' exited 1 at line 8
$? still holds the failing command’s status when the handler starts, so reading it on
the handler’s first line would work too (that’s what cleanup does below). $LINENO is
different: read inside the function, it gives the handler’s own line. Passing both from the
trap string keeps them together and correct.
The capital -E makes functions and subshells inherit the trap. Without it, a failure
inside a function isn’t reported.
trap EXIT: Guaranteed Cleanup
An EXIT trap runs however the script ends: success, failure or exit.
set -euo pipefail
workdir=""
cleanup() {
local code=$?
[[ -n $workdir ]] && rm -rf -- "$workdir"
echo "cleanup ran (exit $code); workdir gone: $([[ -e $workdir ]] && echo no || echo yes)" >&2
}
trap cleanup EXIT
workdir=$(mktemp -d)
echo "working in a temp dir"
[[ ${1:-} == fail ]] && false
echo "work done"
$ bash trap-exit.sh; bash trap-exit.sh fail
working in a temp dir
work done
cleanup ran (exit 0); workdir gone: yes
working in a temp dir
cleanup ran (exit 1); workdir gone: yes
The temp directory was removed in both runs. Register the trap before you create the
resource, and have cleanup save $? in its first line so it can see the script’s real
exit status.
set -u: Catch Typos and Missing Config
set -u
name="${USERNAME:-anonymous}"
echo "name=$name"
if [[ -v MY_VAR ]]; then echo "MY_VAR set"; else echo "MY_VAR not set"; fi
echo "deploying to $TARGET_HOST"
echo "never printed"
$ bash unset.sh
name=anonymous
MY_VAR not set
unset.sh: line 5: TARGET_HOST: unbound variable
Without -u, the script would have printed “deploying to ” and carried on with an empty
host name. Give optional variables a ${VAR:-default}, and test for them with [[ -v VAR ]].
Validating Arguments Up Front
Check every argument before doing any work. The die helper prints the message to stderr
and exits with the right code:
set -euo pipefail
die() { echo "ERROR: $1" >&2; exit "${2:-1}"; }
usage() { echo "Usage: $(basename "$0") [-v] [-o FILE] <input>" >&2; exit 2; }
verbose=false output=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) [[ $# -gt 1 ]] || die "option $1 requires an argument" 2
output="$2"; shift 2 ;;
-v|--verbose) verbose=true; shift ;;
-h|--help) usage ;;
--) shift; break ;;
-*) die "unknown option: $1" 2 ;;
*) break ;;
esac
done
[[ $# -eq 1 ]] || usage
[[ -f $1 ]] || die "input file not found: $1"
$verbose && echo "input=$1 output=${output:-stdout}"
echo "processing $1"
$ touch data.csv; bash args.sh -v -o out.txt data.csv; bash args.sh -x data.csv; echo "exit $?"; bash args.sh -o; echo "exit $?"; bash args.sh; echo "exit $?"
input=data.csv output=out.txt
processing data.csv
ERROR: unknown option: -x
exit 2
ERROR: option -o requires an argument
exit 2
Usage: args.sh [-v] [-o FILE] <input>
exit 2
Each misuse got its own message and exit code 2. The [[ $# -gt 1 ]] check caught -o
with nothing after it. Without it, $2 would trip set -u with a confusing “unbound
variable” message. -- ends option parsing, so an input file can have a name that starts
with -.
Retries with Backoff
Temporary network failures are normal. Retry a limited number of times, and double the wait after each attempt:
set -euo pipefail
retry() {
local max="$1" delay="$2" attempt=1
shift 2
until "$@"; do
(( attempt >= max )) && { echo "failed after $max attempts: $*" >&2; return 1; }
echo "attempt $attempt failed; retrying in ${delay}s" >&2
sleep "$delay"
(( attempt++, delay *= 2 ))
done
}
flaky() { # succeeds on the 3rd call
local n=$(( $(cat .calls 2>/dev/null || echo 0) + 1 ))
echo "$n" > .calls
(( n >= 3 ))
}
retry 5 1 flaky && echo "flaky succeeded"
retry 2 1 false || echo "gave up on false"
$ bash retry.sh
attempt 1 failed; retrying in 1s
attempt 2 failed; retrying in 2s
flaky succeeded
attempt 1 failed; retrying in 1s
failed after 2 attempts: false
gave up on false
"$@" runs the command exactly as it was passed in, so retry 5 2 curl -sf "$url" -o out.json works unchanged. Put a timeout on each attempt as well (retry 5 2 timeout 10 curl ...), so a hung call can’t use up the whole retry budget.
Practice
-
Write
dieso its message includes the line number it was called from.Solution
Inside a function,
BASH_LINENO[0]is the line that called it:die() { echo "ERROR (line ${BASH_LINENO[0]}): $1" >&2; exit "${2:-1}"; } config="app.yml" echo "checking config" [[ -f $config ]] || die "config not found: $config" echo "never printed"$ bash p1.sh checking config ERROR (line 5): config not found: app.yml -
Change
deployfromexempt.shso it stops at the failing step and reports failure, even when called from anif.Solution
set -e deploy() { false || return echo " ...deploy kept going after false" } if deploy; then echo "deploy succeeded"; else echo "deploy failed with status $?"; fi$ bash p2.sh deploy failed with status 1A bare
returnpasses on the status of the last command, which isfalse’s 1. -
Make a job refuse to start while another copy is already running. Use a lock file that is always removed when the job exits.
Solution
set -C(noclobber) makes>fail when the file already exists, so creating the lock is an atomic test-and-set:set -euo pipefail lock=./job.lock if ! ( set -C; echo $$ > "$lock" ) 2>/dev/null; then echo "another run holds the lock (pid $(cat "$lock"))" >&2 exit 1 fi trap 'rm -f "$lock"' EXIT echo "got the lock; working" sleep 2 echo "finished"$ bash p3.sh & sleep 0.5; bash p3.sh; echo "second run exit: $?"; wait; ls job.lock 2>&1 got the lock; working another run holds the lock (pid 10) second run exit: 1 finished ls: cannot access 'job.lock': No such file or directoryThe second run was refused, and after the first one finished, its
EXITtrap removed the lock.