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.
Table of Contents
- 1. trap in Bash: signals, pseudo-signals and the underlying mechanism
- 2. EXIT: the trap that fires guaranteed at every script end
- 3. ERR: the trap that fires on every failed command, except when it does not
- 4. The gaps of ERR: the same contexts that set -e also ignores
- 5. set -o errtrace: propagating ERR into functions and subshells
- 6. Building custom stack traces with BASH_LINENO and FUNCNAME
- 7. Combining ERR and EXIT: log once, clean up guaranteed
- 8. Practical example: a deployment script with complete error handling
- 9. ERR vs. EXIT compared: which trap is the right choice when
- 10. Summary
- 11. FAQ
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.