Industrial-grade error handling in Bash

set -Eeuo pipefail honestly, ERR traps, error propagation and stack traces.

Advanced35 min · lesson 2 of 15

A production Bash script should fail loudly, at the first sign of trouble, with enough context to debug it — and never leave the system half-changed. That takes more than set -e. You want the full strict-mode preamble, an ERR trap that prints where it died, and a deliberate view of what errexit does and does not catch, so you are never surprised by a script that sailed past a failure.

The honest strict-mode preamble

set -Eeuo pipefail is the baseline: -e exits on an uncaught error, -u treats unset variables as errors, -o pipefail makes a pipeline fail if any stage fails, and -E makes the ERR trap survive into functions, subshells and command substitutions. Setting IFS to just newline and tab removes a whole class of word-splitting surprises. But be honest about the limits — errexit has famous blind spots, and pretending it is a safety net it is not will bite you.

the preamble that earns its keep
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
# a clear failure with location, then run any cleanup
trap 'rc=$?; echo "ERR rc=$rc at ${BASH_SOURCE[0]}:${LINENO}: ${BASH_COMMAND}" >&2' ERR
trap 'cleanup' EXIT
cleanup() { [[ -n "${tmp:-}" ]] && rm -rf "$tmp"; }
tmp="$(mktemp -d)"

Where errexit silently does nothing

errexit is suppressed whenever a command’s exit status is being inspected: anything on the left of && or ||, any command in an if or while condition, a function called in those positions, and (crucially) a bare command substitution used in an assignment. So local out=$(cmd) will not abort on cmd’s failure — the local builtin succeeds. Separate the declaration from the assignment, and prefer explicit checks for the paths that matter.

the assignment blind spot
# BROKEN: script continues even though curl failed, because `local` succeeds
fetch() { local body=$(curl -fsS "$1"); echo "$body"; }
# FIX: declare first, assign second — now errexit sees the real status
fetch() {
local body
body=$(curl -fsS "$1") || { echo "fetch failed: $1" >&2; return 1; }
printf '%s' "$body"
}
# check a pipeline stage explicitly
if ! grep -q ready <(status_cmd); then echo "not ready" >&2; exit 1; fi

ERR traps and pseudo stack traces

With -E set, an ERR trap fires on any untrapped failure anywhere. Combined with the BASH_SOURCE, BASH_LINENO and FUNCNAME arrays you can print a real call stack, which turns a one-line "it broke" into a map of exactly how execution got there. For a tool other people run, that stack trace is the difference between a self-service fix and a support ticket.

a real bash stack trace on error
trace() {
local rc=$? i=0
echo "FATAL rc=$rc: ${BASH_COMMAND}" >&2
while caller "$i" >/dev/null 2>&1; do
# caller prints: <lineno> <function> <source>
echo " at $(caller "$i")" >&2; i=$((i+1))
done
exit "$rc"
}
trap trace ERR
What each strict-mode flag actually buys you
catches
-e errexit
abort on uncaught non-zero
-u nounset
unset var is a hard error
-o pipefail
any pipe stage failing fails the pipe
-E errtrace
ERR trap reaches functions/subshells
does NOT catch
x=$(fail)
assignment masks the status
if fail; then
tested commands are exempt
fail || true
you explicitly swallowed it
background &
async failures are not seen
Strict mode is a floor, not a guarantee. The exempt cases are exactly where real scripts leak past failures.
set -u bites arrays and "$@" in old bash
Under set -u, expanding an empty array as "${arr[@]}" errors on bash < 4.4, and "$*"/"$@" with no args can too. Guard with "${arr[@]:-}" or a length check, and test your scripts on the oldest bash you must support (macOS still ships 3.2). Strict mode that only works on your laptop is a portability bug waiting for CI.