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

Bash ERR Trap Inheritance: Why a Failure Hook Is Not an Exception Handler

Trace Bash ERR trap conditions, function and subshell inheritance, errtrace, and the contexts where failure hooks intentionally do not run.

Bash’s ERR trap looks like a centralized error callback, but its semantics are deliberately narrower than “run whenever any command fails.” It follows nearly the same exception rules as errexit (set -e): several syntactic contexts suppress it because nonzero status is being used as ordinary control flow. In addition, a function, command substitution, or subshell normally does not inherit the parent’s ERR trap unless error tracing is enabled with set -E or set -o errtrace.

This is useful for diagnostics, but dangerous as a cleanup or recovery guarantee. A script that treats the trap like a language-level catch block can skip recovery on common conditions, run a trap in a context that should have been handled locally, or recursively trigger its own logging path. Use it as a carefully scoped observability hook, and write required cleanup explicitly with trap ... EXIT or a structured function boundary.

The syntax around a failing command matters

The GNU Bash manual lists contexts where the ERR trap is not executed. Among them are commands used as the condition of if or elif, the test command of while or until, non-final commands in && and || lists, non-final pipeline elements in the ordinary case, and a command whose status is inverted with !. The details interact with pipefail, compound commands, and the way a function is invoked.

For example, the first command below is an expected branch test, so treating it as an unhandled exception would be wrong:

if grep -q 'enabled' settings.conf; then
    enable_feature
else
    use_default
fi

Likewise, this is ordinary conditional control flow:

if deploy_preview; then
    promote_preview
else
    report_preview_failure
fi

The ERR trap is not a substitute for the else branch. A function’s behavior can also depend on whether the caller places the function itself in a condition. Build minimal tests for the actual syntactic form in the script instead of assuming that a function’s internal failed command always reaches the trap.

Inheritance is opt-in

By default, ERR traps are not inherited by shell functions, command substitutions, or subshell environments. set -E (equivalently, set -o errtrace) changes that inheritance behavior. This is separate from set -e: enabling error tracing does not turn a trap into a universal exception mechanism, and enabling errexit does not erase the manual’s suppression contexts.

An inherited trap can fire at several nested levels. If its handler calls a function that fails, the handler itself may become a source of another failure report unless it guards its operations. Keep trap code minimal and nonrecursive. Capture the triggering status immediately, then emit diagnostics using operations whose own failure behavior is controlled.

on_error() {
    local status=$?
    trap - ERR
    printf 'command failed (status=%d) at %s:%d\n' \
        "$status" "${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}" \
        "${BASH_LINENO[0]:-0}" >&2
}

set -E
trap on_error ERR

This example is a diagnostic sketch, not a promise that every frame index is correct for every nested context. Bash’s BASH_SOURCE, BASH_LINENO, and FUNCNAME arrays describe a stack whose useful index depends on where the trap runs. Test the output for top-level commands, functions, sourced files, and command substitutions. Removing the trap inside its handler also changes later behavior; reinstall it only if that is the intended policy.

Pipelines have two independent questions

For a pipeline, ask first which status the pipeline itself returns; then ask whether the ERR trap applies to the command’s syntactic location. By default, the pipeline status is usually that of its last command. set -o pipefail changes the pipeline status when a component fails, but it does not make every component an independently trapped command. The manual also describes the ERR conditions in terms of pipeline position and pipefail.

If a pipeline is intentionally allowed to fail, handle its result explicitly rather than relying on whether ERR fires:

if producer | consumer; then
    printf 'pipeline completed successfully\n'
else
    status=$?
    printf 'pipeline failed with status %d\n' "$status" >&2
fi

If the caller needs each component’s status, capture Bash’s PIPESTATUS array immediately after the pipeline and before running another command. A global trap that prints only $? cannot reconstruct all pipeline component outcomes after they have been overwritten.

ERR, EXIT, and explicit cleanup are different tools

ERR concerns selected nonzero command returns. EXIT runs when the shell exits, subject to the shell’s lifecycle and trap semantics. A resource that must be released, such as a temporary directory, lock file, mount, or background worker, should have a dedicated cleanup path registered with EXIT and should also be handled explicitly where early return or child failure requires it. An ERR trap may be used to record why the script is leaving, but it should not be the only code that guarantees cleanup.

Signal traps are different again. Trapping TERM or INT lets the script request orderly shutdown, but the handler must coordinate with child processes and may need to forward a signal to a process group. A handler that exits immediately can bypass normal expectations; a handler that waits indefinitely can turn graceful shutdown into a hang. Use a bounded shutdown protocol and preserve the original failure status.

Avoid making set -e look like reliable control flow

The similarity between ERR and errexit is informative, not a reason to combine them blindly. set -e has context-sensitive behavior that is surprising in functions, command substitutions, tests, and pipelines. ERR follows the same broad conditions but is a trap, not a language exception; it does not automatically transfer a meaningful recovery value back to the caller.

For essential steps, use explicit status handling:

if generate_manifest; then
    :
else
    status=$?
    printf 'manifest generation failed: %d\n' "$status" >&2
    return "$status"
fi

This style says what the script will do and avoids asking the reader to remember which nested syntactic contexts suppress the trap. Reserve a global ERR handler for supplemental diagnostics, and document which failures are intentionally consumed by conditions.

Build a failure-context test matrix

Before deploying a script with an ERR handler, test a failure at top level, within a function, within a command substitution, in a subshell, in the last and a non-last pipeline position, in the condition of if, in an && list, under !, and while the handler itself reports a problem. Repeat with and without set -E and with and without pipefail. Record both the final shell status and every trap invocation.

Do not test only by running a file directly. Sourcing a file runs it in the caller’s environment and can change traps, options, variables, and exit behavior. A script that handles direct invocation correctly may affect an interactive shell unexpectedly when sourced. Keep executable entry points separate from sourced libraries and avoid installing global traps from a reusable library unless it exposes an explicit opt-in function.

Use the GNU Bash Reference Manual’s trap and shell-option sections as the contract for the target release. This host’s built-in macOS Bash may be older than the GNU release used in deployment, so run the matrix under every supported Bash version. The safe mental model is simple: a trap can observe some unhandled failures, but explicit branches define required recovery and cleanup.

Related:

Sources:

Comments