Why set -e Is Not Exception Handling: The Real Rules of Shell Errexit
A control-flow model for shell errexit covering tested commands, lists, pipelines, functions, subshells, command substitutions, traps, and explicit errors.
set -e asks a shell to exit after certain untested nonzero command statuses. The exceptions are extensive because nonzero is also how shell programs express conditions. It does not unwind a stack, attach an exception object, or automatically preserve the command that logically failed. Robust scripts still need explicit control flow and error messages.
Context determines whether nonzero is fatal
Commands used as the condition of if, while, or until, negated with !, or placed in nonfinal positions of AND-OR lists are expected to report false and are generally exempt from immediate exit. Otherwise basic constructs would be unusable:
if grep -q pattern file; then
printf '%s\n' 'found'
else
status=$?
[ "$status" -eq 1 ] || exit "$status"
fi
Here grep status 1 means no match while greater values mean an error. set -e cannot infer that semantic difference. The explicit branch can.
Pipeline status normally comes from the last command in POSIX shell. If producer fails but consumer exits zero, set -e sees success. Some shells provide pipefail, but it is not portable POSIX and can make benign early pipe closure—such as a producer receiving SIGPIPE when head has enough input—fatal. Use temporary files, explicit status channels, or a shell-specific, tested pipeline policy when every stage matters.
Functions inherit syntactic context
A function called in a tested context may execute with errexit behavior suppressed for commands inside it, depending on the specified context and shell semantics:
do_work() {
step_one
step_two
}
if do_work; then
report_success
fi
The caller is testing the function as a whole, so relying on -e to stop at step_one is fragile. Write step_one || return and step_two || return, or have each operation return a checked result with context.
Subshells, grouped commands, traps, and command substitutions introduce more boundaries. Shells differ in whether command substitutions inherit errexit by default; Bash has an inherit_errexit option and changes behavior in POSIX mode. A script whose correctness depends on that ambient detail has not defined its error contract.
Cleanup needs explicit status preservation
An EXIT trap can remove temporary resources, but a cleanup command can overwrite $? unless captured immediately:
tmp=
cleanup() {
status=$?
[ -z "$tmp" ] || rm -rf -- "$tmp"
exit "$status"
}
trap cleanup EXIT HUP INT TERM
Signal traps and EXIT traps have additional interactions, so make cleanup idempotent and test interruption. Never embed a variable-expanded command in the trap string; define a function so later values cannot become shell syntax.
Use -e as a backstop, not a design
Enable it only after understanding the target shells, and pair it with explicit checks at state-changing boundaries:
if ! output=$(compile "$source"); then
printf 'compile failed for %s\n' "$source" >&2
exit 1
fi
Validate commands in conditions, pipelines, functions, subshells, substitutions, and traps. Inject failures at each step and assert both status and cleanup. Static analysis can flag common traps but cannot decide application semantics.
Assignments deserve special attention. A plain assignment with command substitution generally receives the substitution’s status, while local value=$(command) in shells that provide local may report the declaration builtin’s success instead and hide the command failure. Split declaration from assignment and check the operation:
value=
if ! value=$(produce_value); then
printf '%s\n' 'produce_value failed' >&2
exit 1
fi
Also avoid using cmd || true as a blanket exception. If failure is acceptable, capture the status values that are acceptable and explain why; otherwise genuine I/O, permission, or syntax errors disappear with the expected “not found” result.
The reliable rule is simple: wherever failure would make the next action unsafe, test it and emit context there. set -e can catch an overlooked standalone failure; it cannot replace a deliberately designed error path.
Related:
- Terminal Line Discipline and termios: Canonical Input, Signals, Echo, and Raw Mode
- Fixing a Terminal Left Without Echo or Line Editing After a Command Crashes
Sources: