Skip to content
Shell & TerminalDeep Dive Published Updated 3 min readViews unavailable

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:

Sources:

Comments