trap ERR vs. trap EXIT: When Each Trap Actually Fires in Bash
AI generated
$_
#!/
Bash · Error Handling · Traps · Debugging
trap ERR vs. trap EXIT
When each trap actually fires in Bash, and where both have gaps

trap ERR and trap EXIT sound like two flavors of the same mechanism, but they behave completely differently. EXIT fires guaranteed, ERR shares the exact same blind spots as set -e. Combining both makes clear when custom stack traces built with BASH_LINENO become necessary, and how to log failures reliably instead of letting them slip by silently.

16 min read trap ERR/EXIT · errtrace · BASH_LINENO Bash 4.x · 5.x · Linux

1. trap in Bash: signals, pseudo-signals and the underlying mechanism

The trap command registers a command or function that Bash invokes as soon as a specific event occurs. Besides real operating-system signals like SIGTERM or SIGINT, Bash also knows two pseudo-signals no kernel ever sends, but which Bash triggers internally: EXIT and ERR. Both are registered with the same syntax, trap 'command' EXIT and trap 'command' ERR, yet they behave fundamentally differently when triggered, which regularly leads to wrong assumptions in practice.

Anyone using trap for the first time often assumes ERR and EXIT are simply two variants of the same cleanup mechanism, one for the failure case and one for the normal end. In reality EXIT is the reliable base mechanism that fires in practically every case, while ERR is a tool with clearly defined, but easily overlooked, exceptions. Understanding this distinction is the foundation for any robust error handling in production Bash scripts.

2. EXIT: the trap that fires guaranteed at every script end

A trap ... EXIT runs as soon as the script terminates, regardless of whether it finishes normally, aborts via exit, receives an unhandled signal, or terminates early because of set -e. That reliability makes EXIT the default tool for cleanup tasks: deleting temp files, releasing locks, killing background processes. A single EXIT trap at the top of a script covers practically every exit path without needing manual cleanup scattered throughout the code.

Note that an EXIT trap inside a function does not work as most people expect by default: traps are always script-wide, not function-local, and an EXIT trap registered inside a function simply overwrites the one set previously. Cleanup logic tied to a specific function needs either a central EXIT trap that runs through a list of cleanup functions, or resetting the trap at the end of that function.


#!/usr/bin/env bash
set -euo pipefail

TMP_DIR="$(mktemp -d)"

cleanup() {
  local exit_code=$?
  rm -rf "$TMP_DIR"
  echo "Cleanup done, script exited with code $exit_code" >&2
}

# Fires on: normal exit, explicit exit, set -e abort, most signals
trap cleanup EXIT

echo "Working in $TMP_DIR"
false  # would abort here because of set -e; cleanup still runs

3. ERR: the trap that fires on every failed command, except when it does not

A trap ... ERR is meant to trigger as soon as a simple command ends with a non-zero exit status, using the same criterion set -e relies on to abort a script. In practice this makes ERR the right tool to log a diagnostic line at the moment a failure happens, instead of ending up with a bare exit code and no context at all.

The decisive catch is that Bash's definition of simple command is narrower than most people expect. A failing command inside an if condition, after && or ||, or as part of a command list whose result is explicitly checked, does not trigger the ERR trap in many cases. This is not an inconsistency, it is exactly the same rule set that also decides when set -e aborts and when it does not.

4. The gaps of ERR: the same contexts that set -e also ignores

Because ERR is tied to the same criterion as set -e, the same exceptions apply. A command in the condition of an if, while, or until does not trigger the trap, because its exit status is deliberately queried as control flow, not treated as a failure. Likewise ERR does not fire for a command on the left of && or the right of ||, because there too the exit code is deliberately evaluated instead of signaling an unexpected error.

Another gap concerns pipelines: without set -o pipefail, only the exit code of the last command in a pipe chain counts, so a failing command in the middle of the pipeline goes unnoticed and never triggers the ERR trap. Anyone unaware of these three gaps (conditions, &&/||, pipelines without pipefail) wrongly assumes ERR catches every error, and later wonders why scripts complete cleanly despite a real failure occurring.

5. set -o errtrace: propagating ERR into functions and subshells

By default, an ERR trap set in the main script does not automatically become active inside functions, command substitutions, or subshells. A failure deep inside a called function stays invisible to the outer trap, even though that is exactly where the most interesting failures tend to happen. The option set -o errtrace (short: set -E) flips this behavior: the ERR trap is then consistently propagated into function calls and subshells.

In practice set -o errtrace belongs alongside set -euo pipefail in practically every production Bash script that relies on a global ERR trap, otherwise error handling only works at the top level and lets exactly the failures slip through that occur in deeper nested functions. Without this option, an ERR trap looks more reliable than it actually is, which creates false confidence in larger scripts with many functions.


#!/usr/bin/env bash
set -euo pipefail
set -o errtrace  # equivalent to set -E: propagate ERR into functions

report_error() {
  echo "ERROR in ${FUNCNAME[1]:-main} at line ${BASH_LINENO[0]}" >&2
}
trap report_error ERR

deploy_step() {
  # Without errtrace, a failure here would NOT trigger the outer trap
  rsync -a --delete ./build/ /var/www/app/
}

deploy_step

6. Building custom stack traces with BASH_LINENO and FUNCNAME

Bash maintains two parallel arrays that together form a complete call stack: FUNCNAME holds the names of all currently active functions, from the innermost to the outermost, and BASH_LINENO holds, for each of those calls, the line number where the next deeper function was invoked. Combining both arrays inside an ERR trap lets you assemble a complete, human-readable stack trace that shows exactly through which function calls a failure occurred.

For production deployment or maintenance scripts, a small, reusable function that prints this stack trace on every ERR event, and optionally forwards it to a logging system, pays off quickly. It matters to register the trap body as a function call rather than a complex inline string, so the arrays get evaluated in the right context and no quoting errors occur, which is a common problem specifically with nested quotes in trap strings.


#!/usr/bin/env bash
set -euo pipefail
set -o errtrace

print_stacktrace() {
  local i
  echo "Stacktrace (most recent call first):" >&2
  for ((i = 0; i < ${#FUNCNAME[@]} - 1; i++)); do
    echo "  at ${FUNCNAME[$i]}() called from line ${BASH_LINENO[$i]}" >&2
  done
}
trap print_stacktrace ERR

level_two() { grep "pattern" /nonexistent-file; }
level_one() { level_two; }

level_one

7. Combining ERR and EXIT: log once, clean up guaranteed

In robust scripts, ERR and EXIT take on different, complementary jobs: ERR handles diagnostics, logging right at the point where a failure happens, with line number and function name, while EXIT guarantees cleanup regardless of success or failure. Setting both traps at once is not a contradiction, it is the standard pattern: ERR provides the context, EXIT provides the guarantee.

A common mistake is calling exit directly inside the ERR trap while forgetting that this call itself triggers the EXIT trap again. That is usually intended, but it can cause duplicate output if the EXIT trap logs the same failure status a second time. A clean pattern stores the exit code once in a variable and passes it along between the traps, instead of recomputing or guessing it multiple times.

8. Practical example: a deployment script with complete error handling

The value of this combination shows most clearly in a real deployment script: if a step like rsync, a database migration, or a health check fails, the team should get an informative message immediately, including the function and line where it happened, and at the same time the script should guarantee it releases lock files and removes temp directories no matter how it ends.

The following pattern combines an ERR trap with stack trace output, an EXIT trap for cleanup, and set -o errtrace so that failures in nested functions are also reliably captured. This skeleton can be reused almost unchanged for most production Bash scripts and is noticeably more robust than a single global trap ... EXIT without targeted error diagnostics.


#!/usr/bin/env bash
set -euo pipefail
set -o errtrace

LOCK_FILE="/var/lock/deploy.lock"

on_error() {
  local exit_code=$?
  echo "DEPLOY FAILED (exit $exit_code) in ${FUNCNAME[1]:-main} at line ${BASH_LINENO[0]}" >&2
}
trap on_error ERR

on_exit() {
  rm -f "$LOCK_FILE"
}
trap on_exit EXIT

acquire_lock() { : > "$LOCK_FILE"; }
run_migrations() { bin/magento setup:upgrade; }
health_check() { curl -fsS https://shop.example.com/health >/dev/null; }

acquire_lock
run_migrations
health_check
echo "Deploy successful

9. ERR vs. EXIT compared: which trap is the right choice when

Once both traps are understood, they get chosen by purpose rather than habit: EXIT for anything that must happen guaranteed regardless of outcome, ERR for targeted diagnostics right at the point of failure. A script that only uses EXIT cleans up reliably but loses the exact location of the failure. A script that only uses ERR logs precisely but risks orphaned resources whenever a failure path skips the trap for one of the known reasons.

The table below summarizes the key differences and helps decide which trap belongs where in a script, including the well-known gaps ERR shares with set -e.

Aspect trap ... EXIT trap ... ERR Practical recommendation
Trigger Every script end, normal or abnormal A failing simple command Register both together
Conditions (if, while) Not relevant, always fires Does not trigger Check failures there manually
&&, || Not relevant Does not trigger Treat deliberately as control flow
Functions/subshells Always active Only with set -o errtrace Always set errtrace
Typical use Cleanup, releasing locks Diagnostics, logging a stack trace ERR for context, EXIT for guarantee

Mironsoft

Shell automation, DevOps tooling and deployment infrastructure

Shell scripts that hold up in production?

We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.

Code Review

ShellCheck analysis and manual review for critical Bash pattern violations.

Refactoring

Retrofitting error handling, logging and safe file operations.

CI Integration

Wiring ShellCheck and BATS into pipelines and building regression tests.

10. Summary

trap ERR vs. trap EXIT: The Essentials at a Glance

EXIT

Fires guaranteed at every script end, normal or abnormal. The right tool for cleanup tasks like locks and temp files.

ERR

Fires on failing simple commands, but not inside if conditions, after && or ||, or in pipelines without pipefail.

errtrace

set -o errtrace (set -E) propagates ERR into functions and subshells. Without it, failures in deeper functions stay invisible.

Stack trace

FUNCNAME and BASH_LINENO together supply function name and line number for every level of the call stack inside an ERR trap.

11. FAQ: trap ERR vs. trap EXIT: The Essentials at a Glance

1Why does my trap ... ERR not fire on an obvious failure?
The command is likely inside an if condition, after && or ||, or in a pipeline without pipefail. These are exactly the same contexts where set -e also does not abort.
2Is set -e alone enough, or do I also need trap ... ERR?
set -e ends the script but gives no context about the failure. trap ... ERR adds diagnostic information such as line number and function name before the abort.
3What exactly does set -o errtrace do?
It makes sure an ERR trap also fires inside functions, command substitutions, and subshells, instead of only in the main script context.
4Does trap ... EXIT fire on a hard signal like SIGKILL?
No. SIGKILL cannot be caught by any process, not even Bash itself, so no trap runs in that case.
5How do I read BASH_LINENO correctly?
BASH_LINENO[i] holds the line where the function named in FUNCNAME[i] was called. Index 0 is the most recently called, innermost function.
6Can I register multiple ERR traps at once?
No, each new trap ... ERR call overwrites the previous one. For multiple actions, register a single function that internally runs several steps.
7Does ERR also fire on a failing command inside a command substitution?
Only with set -o errtrace. Without it, a failure inside $(...) goes unreported just like a failure inside a function.
8Should I call exit directly inside the ERR trap?
Usually unnecessary, since set -e already ends the script right after the ERR trap runs. An explicit exit call can also trigger the EXIT trap a second time with the same failure.
9Why does my ERR trap not fire inside a pipeline?
Without set -o pipefail, only the exit code of the last command in the pipe chain counts. A failure in the middle goes unnoticed as long as the last command succeeds.
10Is trap ... DEBUG the same as trap ... ERR?
No. DEBUG fires before every single command in the script and is mostly used for tracing, not error handling. ERR fires exclusively on failing commands.