Error Handling in Bash Scripts (set -e, trap)
By default, a Bash script keeps running even after a command inside it fails — it will happily delete files, deploy code, or restart services against a broken intermediate state, one line after the command that should have stopped it. Robust scripts don’t rely on you never making a mistake; they detect failure and react to it on purpose. This lesson covers Bash’s two core error-handling tools: the set -e family of shell options, which changes what happens when a command fails, and trap, which runs cleanup or logging code automatically when the script exits or receives a signal.
Overview: How Bash Handles Command Failure
Every command, when it finishes, returns an exit status (also called an exit code) to the shell that ran it — an integer from 0 to 255. By convention, 0 means success and any nonzero value means some kind of failure, though the specific nonzero number’s meaning is defined by that program, not by the kernel. Bash stores the most recently finished foreground command’s exit status in the special variable $?.
Without any options set, if a command in a script fails, Bash prints nothing special and simply moves on to the next line. Only the script’s own final exit status (the exit status of its last command) reflects that anything went wrong at all. This is dangerous whenever later steps depend on earlier ones succeeding — for example, cding into a directory and then running rm -rf ./*: if the cd silently fails, the rm runs in the wrong directory.
set -e (long form set -o errexit) changes this: once enabled, Bash exits immediately, as if it had hit exit <status>, the moment any simple command returns a nonzero status, instead of continuing. There’s an important nuance: set -e deliberately does not trigger for a command whose exit status is being tested — that is, a command used as the condition of an if, while, or until; a command before && or ||; a command negated with !; or any command in a pipeline other than the last one (unless pipefail is also set). Bash assumes that in these positions, you’re already deliberately checking the result.
set -u (set -o nounset) makes referencing an unset variable an error instead of silently expanding it to an empty string. This catches typos in variable names before they cause a command to run with a missing or wrong argument.
set -o pipefail fixes a blind spot in pipelines. In a pipeline like cmd1 | cmd2 | cmd3, each command runs as its own process, connected by kernel-buffered pipes; by default, Bash only reports the exit status of the last process in the chain, even if an earlier one failed, because from the shell’s point of view the pipe itself was successfully connected. With pipefail set, the pipeline’s exit status becomes the rightmost nonzero status among all its stages (or 0 if every stage succeeded), so a failure anywhere in the chain is no longer invisible.
trap lets you register a command or function that Bash runs when the shell receives a signal or reaches certain pseudo-events. Bash keeps an internal table mapping each signal or event name to a string of commands; when that event occurs, Bash evaluates the string in the current shell environment before doing whatever it would otherwise do next. The pseudo-event EXIT fires whenever the script exits for any reason — normal completion, an explicit exit, or a termination triggered by set -e — which makes it perfect for cleanup like removing temp files or releasing locks. The pseudo-event ERR fires whenever a command fails in one of the same contexts set -e checks, which makes it useful for logging exactly what failed and where, using variables like $LINENO, $BASH_COMMAND, and $?.
Syntax
The error-handling options are turned on with set, and handlers are registered with trap:
set -e— exit immediately on most command failures (errexit).set -u— treat expansion of an unset variable as an error (nounset).set -o pipefail— make a pipeline’s exit status reflect its rightmost failing stage.set -euo pipefail— the common shorthand that turns all three on at once.trap 'commands' SIGNAL— runcommandswhenSIGNAL(a real signal likeINT/TERM, or a pseudo-event likeEXIT/ERR) occurs.commandsis usually a function name.trap - SIGNAL— resetSIGNALback to its default behavior, removing any handler you registered.
set -e
set -u
set -o pipefail
set -euo pipefail
trap 'commands' SIGNAL
trap 'commands' EXIT
trap - SIGNAL
The most useful trap targets in scripts are the pseudo-signals EXIT and ERR, alongside the real signals a user or another process might send:
| Name | Type | Fires when | Typical use in trap |
|---|---|---|---|
EXIT |
pseudo-event | the script exits, for any reason | remove temp files, release locks |
ERR |
pseudo-event | a command fails in a context set -e checks |
log the failure and its location |
INT |
signal 2 | Ctrl-C at the terminal | catch and clean up before exiting |
TERM |
signal 15 | kill‘s default signal |
graceful shutdown |
HUP |
signal 1 | controlling terminal closes | reload config or ignore |
Examples
Example 1: set -e stopping a backup script
#!/usr/bin/env bash
set -euo pipefail
SOURCE_DIR="/var/www/myapp"
BACKUP_DIR="/backups/myapp-$(date +%F)"
echo "Starting backup of $SOURCE_DIR"
cd "$SOURCE_DIR"
mkdir -p "$BACKUP_DIR"
tar -czf "$BACKUP_DIR/site.tar.gz" .
echo "Backup complete: $BACKUP_DIR/site.tar.gz"
Output (when /var/www/myapp doesn’t exist):
Starting backup of /var/www/myapp
backup.sh: line 8: cd: /var/www/myapp: No such file or directory
The cd fails and returns a nonzero status. Because set -e is active and cd is a plain simple command (not inside an if, not before &&), Bash exits the script right there with cd‘s own exit status. The mkdir, tar, and final echo never run. Without set -e, the script would have stayed in whatever directory it started in, and tar would have archived the wrong files into a backup that looked successful.
Example 2: trap EXIT guaranteeing cleanup
#!/usr/bin/env bash
set -euo pipefail
TMP_FILE="$(mktemp)"
cleanup() {
echo "Cleaning up $TMP_FILE"
rm -f "$TMP_FILE"
}
trap cleanup EXIT
echo "Downloading report to $TMP_FILE"
curl -fsS "https://example.com/report.csv" -o "$TMP_FILE"
echo "Processing report"
wc -l "$TMP_FILE" > /var/log/report-lines.log
echo "Done"
Output:
Downloading report to /tmp/tmp.Xk3F92aLq9
Processing report
Done
Cleaning up /tmp/tmp.Xk3F92aLq9
Notice the order: cleanup prints last, after "Done", because the EXIT trap fires only once the script has finished running its last command and is about to actually terminate. If curl or wc had failed partway through instead, set -e would have ended the script immediately at that point — but the EXIT trap would still fire on the way out, so $TMP_FILE gets deleted either way. That’s the whole point of registering cleanup on EXIT rather than just writing it as the last line of the script.
Example 3: trap ERR for diagnostics
#!/usr/bin/env bash
set -euo pipefail
on_error() {
local exit_code=$?
echo "ERROR: command '$BASH_COMMAND' failed on line $LINENO with exit code $exit_code" >&2
}
trap on_error ERR
echo "Checking disk usage"
df -h /
echo "Restarting service"
systemctl restart nonexistent-service
echo "Service restarted"
Output:
Checking disk usage
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 50G 12G 36G 25% /
Restarting service
ERROR: command 'systemctl restart nonexistent-service' failed on line 14 with exit code 5
systemctl runs fine as a program but reports that the unit doesn’t exist, returning exit status 5. Bash detects that this is a checked context under set -e, so before exiting it invokes the registered ERR handler in the current shell, where $BASH_COMMAND, $LINENO, and $? are all still meaningful. The handler logs a precise diagnostic, and only then does the script terminate — "Service restarted" never prints.
How It Works Step by Step
Walking through what Bash actually does when Example 3 hits its failure:
- Bash parses the script and applies
set -euo pipefail, then defines theon_errorfunction without running it. trap on_error ERRstores an entry in Bash’s internal trap table: on theERRevent, runon_error.df -h /runs and exits 0, so no trap fires and execution continues normally.systemctl restart nonexistent-serviceruns as a real child process and exits with status 5.- Bash sees this failure happened in a plain simple-command context, exactly the kind
errexitis watching. Before terminating, it looks up theERRtrap and runson_errorin the current shell (not a subshell), so the function can still see the failing command’s$?. on_errorprints its diagnostic line to standard error.- With the trap handler finished, Bash proceeds with what
set -edemands: it terminates the script, propagating the same exit status (5) that the failing command returned. AnyEXITtrap, if one had also been registered, would fire at this final step, after theERRtrap.
Common Mistakes
Mistake 1: Trusting set -e alone inside a pipeline
#!/usr/bin/env bash
set -e
grep "OutOfMemory" /var/log/app.log | sort | uniq -c > /tmp/oom-summary.txt
echo "Summary written"
If /var/log/app.log doesn’t exist, grep fails, but sort and uniq still run successfully on empty input, so the pipeline’s overall exit status is 0 (from uniq, the last stage). set -e alone never sees a failure, and "Summary written" prints even though nothing meaningful happened. Fix it by also setting pipefail, so the pipeline’s status becomes the rightmost nonzero code:
#!/usr/bin/env bash
set -euo pipefail
grep "OutOfMemory" /var/log/app.log | sort | uniq -c > /tmp/oom-summary.txt
echo "Summary written"
Mistake 2: Checking $? after it has already been overwritten
#!/usr/bin/env bash
set -u
curl -fsS "https://example.com/health" -o /dev/null
echo "Ping finished"
if [ $? -ne 0 ]; then
echo "Health check failed" >&2
exit 1
fi
The echo "Ping finished" command runs and succeeds after curl, which overwrites $? to 0 before the if ever checks it — so a real health-check failure is silently missed. Check the command directly instead of caching its status through an unrelated line:
#!/usr/bin/env bash
set -u
if ! curl -fsS "https://example.com/health" -o /dev/null; then
echo "Health check failed" >&2
exit 1
fi
echo "Ping finished"
Mistake 3: grep’s "no match" exit status tripping set -e
#!/usr/bin/env bash
set -euo pipefail
echo "Scanning log for warnings"
grep "WARN" /var/log/app.log > /tmp/warnings.txt
echo "Scan complete"
grep exits with status 1 whenever it finds zero matching lines — that’s not really an error, just "nothing found," but set -e can’t tell the difference. Whenever the log happens to have no WARN lines, the script silently stops before ever printing "Scan complete," with no error message at all. Make the allowed case explicit:
#!/usr/bin/env bash
set -euo pipefail
echo "Scanning log for warnings"
grep "WARN" /var/log/app.log > /tmp/warnings.txt || true
echo "Scan complete"
Best Practices
- Start every non-trivial script with
set -euo pipefailunless you have a specific reason not to. - Register a
trap cleanup EXITfor anything that must happen no matter how the script ends: deleting temp files, releasing locks, closing connections. - When a command is allowed to fail, say so explicitly with
|| trueor anif/else, rather than lettingerrexitreact to it by accident. - Combine
trap ... ERRwith$LINENO,$BASH_COMMAND, and$?to produce an actionable log line instead of a bare "script failed". - Quote every variable expansion (
"$var") soset -ucatches real typos instead of tripping over unrelated word-splitting bugs. - Give your script meaningful exit codes so callers, cron, and CI systems can distinguish one failure reason from another.
- Deliberately test the failure paths of your scripts — rename a file, revoke a permission — don’t only run the happy path.
Practice Exercises
- Write a script
deploy.shthat copies a directory~/buildinto/var/www/html, starting withset -euo pipefail. Point~/buildat a directory that doesn’t exist and confirm the script stops immediately instead of continuing to the copy step. - Add a lock file: create
/tmp/deploy.locknear the top ofdeploy.sh, register atraponEXITthat removes it, then force a failure partway through the script and confirm the lock file is still removed afterward. - Add an
ERRtrap todeploy.shthat prints the failing line number and command to stderr using$LINENOand$BASH_COMMAND, then test it by temporarily renaming the source directory.
Summary
- Bash does not stop a script on command failure by default; every command leaves its exit status in
$?. set -e(errexit) exits the script immediately on most command failures, except insideif/whileconditions, before&&/||, and in non-last pipeline stages.set -o pipefailcloses the pipeline blind spot by making a pipeline’s exit status reflect its rightmost failing stage, not just the last one.set -uturns references to unset variables into errors, catching typos early.trap COMMAND EXITguarantees cleanup code runs no matter how the script ends;trap COMMAND ERRlets you log diagnostics the instant a command fails.- Commands like
grepthat return nonzero for "no match" can unexpectedly triggerset -e— handle that case explicitly with|| trueor an explicitif.
