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 — run commands when SIGNAL (a real signal like INT/TERM, or a pseudo-event like EXIT/ERR) occurs. commands is usually a function name.
  • trap - SIGNAL — reset SIGNAL back 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:

  1. Bash parses the script and applies set -euo pipefail, then defines the on_error function without running it.
  2. trap on_error ERR stores an entry in Bash’s internal trap table: on the ERR event, run on_error.
  3. df -h / runs and exits 0, so no trap fires and execution continues normally.
  4. systemctl restart nonexistent-service runs as a real child process and exits with status 5.
  5. Bash sees this failure happened in a plain simple-command context, exactly the kind errexit is watching. Before terminating, it looks up the ERR trap and runs on_error in the current shell (not a subshell), so the function can still see the failing command’s $?.
  6. on_error prints its diagnostic line to standard error.
  7. With the trap handler finished, Bash proceeds with what set -e demands: it terminates the script, propagating the same exit status (5) that the failing command returned. Any EXIT trap, if one had also been registered, would fire at this final step, after the ERR trap.

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 pipefail unless you have a specific reason not to.
  • Register a trap cleanup EXIT for 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 || true or an if/else, rather than letting errexit react to it by accident.
  • Combine trap ... ERR with $LINENO, $BASH_COMMAND, and $? to produce an actionable log line instead of a bare "script failed".
  • Quote every variable expansion ("$var") so set -u catches 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.sh that copies a directory ~/build into /var/www/html, starting with set -euo pipefail. Point ~/build at 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.lock near the top of deploy.sh, register a trap on EXIT that removes it, then force a failure partway through the script and confirm the lock file is still removed afterward.
  • Add an ERR trap to deploy.sh that prints the failing line number and command to stderr using $LINENO and $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 inside if/while conditions, before &&/||, and in non-last pipeline stages.
  • set -o pipefail closes the pipeline blind spot by making a pipeline’s exit status reflect its rightmost failing stage, not just the last one.
  • set -u turns references to unset variables into errors, catching typos early.
  • trap COMMAND EXIT guarantees cleanup code runs no matter how the script ends; trap COMMAND ERR lets you log diagnostics the instant a command fails.
  • Commands like grep that return nonzero for "no match" can unexpectedly trigger set -e — handle that case explicitly with || true or an explicit if.