Bash Error Handling: What Happens When You Forget set -e
A practical guide to Bash error handling with set -euo pipefail, trap ERR for guaranteed error catching, subshell pitfalls, and detection patterns for production shell scripts.
Bash error handling has more edge cases than most developers expect. The set -e flag is supposed to exit on error — but it silently fails in conditionals, ||/&& chains, and subshells. This guide covers where set -e doesn’t work, how trap ERR fills the gap, and the set -euo pipefail pattern that catches 90% of bash error bugs before they ship. [1]
The Problem: set -e Is Not Your Safety Net
#!/bin/bash
set -e
echo "Before error"
false
echo "After error" # This should NOT run with set -e
With set -e, false exits with code 1, so “After error” should not execute. Right?
Wrong. In some contexts, set -e does NOT propagate. Let me test my understanding:
Where set -e Fails
#!/bin/bash
set -e
# Context 1: Inside a conditional
if false; then
echo "This never runs"
fi
echo "After conditional" # This DOES run — set -e is suppressed inside conditions
# Context 2: Right side of || or &&
false || echo "Fallback" # Suppressed
echo "After OR chain"
# Context 3: Command substitution
result=$(false)
echo "After subshell: $?" # Subshell exit code is 1, but main script continues
The key insight: set -e is “inactive” inside conditionals, ||/&& chains, and subshells. This is by design, but it’s surprising.
Proper Error Handling Pattern
#!/bin/bash
set -euo pipefail
ERROR_COUNT=0
cleanup() {
local exit_code=$?
echo "Cleanup: exit code $exit_code, $ERROR_COUNT errors"
rm -f /tmp/tempfile_*
}
trap cleanup EXIT
error_handler() {
local line=$1 [2]
local cmd=$2 [3]
local code=$3 [4]
ERROR_COUNT=$((ERROR_COUNT + 1))
echo "[ERROR] Line $line: '$cmd' failed with code $code" >&2
}
trap 'error_handler $LINENO "$BASH_COMMAND" $?' ERR
do_work() {
local stage=$1 [5]
echo "Stage $stage"
# Simulate work
sleep 0.1
return $((stage % 3)) # Return 0, 1, or 2
}
for i in 1 2 3; do
do_work "$i" || true # Don't let failures propagate, we count them
done
echo "Pipeline complete with $ERROR_COUNT errors"
exit $((ERROR_COUNT > 0 ? 1 : 0))
Edge Case: set -e and Functions
#!/bin/bash
set -e
failing_func() {
false
echo "This should NOT run"
}
# This exits because set -e is ON inside the function
# failing_func
# But this does NOT exit:
result=$(failing_func) # Subshell swallows the error
echo "Subshell result: $result" # Empty, but script continues
A function called directly respects set -e. A function called in a subshell ($()) does not — the subshell exits, but the parent continues.
The Pipefail Pattern
#!/bin/bash
set -euo pipefail
# Without pipefail: exit code is from last command in pipe
# With pipefail: exit code is from first non-zero exit in pipe
generate_data() {
echo "data1"
echo "data2"
return 1
}
# Without pipefail, this would "succeed" because head exits 0
if ! generate_data | head -n 1 > /dev/null; then
echo "Caught pipe failure" # With pipefail, this runs
fi
What This Means for Practitioners
-
Adopt
set -euo pipefailas your team’s shebang standard — not justset -e. The-uflag catches unset variables that would silently expand to empty strings, preventingrm -rf / $unset_varcatastrophes. The-o pipefailflag ensures pipeline failures (likecurl | jqwherecurlfails) are detected instead of silently reporting the last command’s exit code. Enforce this with ShellCheck in CI. -
Use
trap ERRfor guaranteed error coverage —set -eis suppressed inside conditionals,||/&&chains, and subshells.trap 'error_handler $LINENO "$BASH_COMMAND" $?' ERRfires in ALL these contexts. Add both:set -efor the common case,trap ERRfor the edge cases. -
Always add a cleanup trap —
trap cleanup EXITensures temp files are removed, background processes are killed, and state is restored even on error. The EXIT trap fires on normal exits,exitcalls, and signals like SIGINT. Only SIGKILL (which can’t be trapped) bypasses it. -
Watch for subshell error swallowing — any command inside
$(...)runs in a subshell. If that command calls a function withset -e, the subshell exits but the parent continues. Either call functions directly, or add explicit$?checks after every$()that could fail.
How to Detect This in Your Code
Quick Detection: grep for Danger Patterns
# 1. Find scripts without proper set -euo pipefail
grep -rn "^set -e$" scripts/*.sh # Has -e but missing -u and pipefail
grep -rnL "set -e" scripts/*.sh # Missing error handling entirely
# 2. Find command substitutions (subshell swallowing)
grep -rn '\$(' scripts/*.sh # Every hit is a potential error-swallow point
# 3. Find scripts without cleanup traps
grep -rnL "trap.*EXIT" scripts/*.sh # No cleanup on exit = orphan temp files
# 4. Find pipelines that could fail silently
grep -rn '|' scripts/*.sh # Any pipe needs set -o pipefail to detect failures
Run This on Any Bash Script Longer Than 20 Lines
-
Check for
set -euo pipefail— grep forset -eand confirm-uand-o pipefailare included. If onlyset -eis present, you have silent$varexpansion risks and swallowed pipeline failures. Fix: replace withset -euo pipefail. -
Identify subshell-pattern calls — grep for
$(— every command substitution creates a subshell that swallowsset -e. If a function containingset -eis called inside$(), its error exits won’t propagate. Fix: call functions directly, or add explicit$?checks after the substitution. -
Verify cleanup traps — grep for
trap.*EXIT. If missing, add:trap cleanup EXITwith a function that removes temp files. Without this, a mid-script error leaves orphan files in/tmp/. -
Test ERR trap coverage — add
trap 'echo "ERR at line $LINENO: $BASH_COMMAND" >&2' ERRtemporarily. Run the script and check if errors inside conditionals or||chains are still logged. If they are, yourERRtrap works whereset -edoesn’t. -
Validate pipeline mode — grep for pipes (
|). If any pipeline command could fail (e.g.,curl | jqwhere curl could 404), confirmset -o pipefailis active. Without it,generate_data | headsucceeds even whengenerate_datafails, becauseheadexits 0. -
Shebang every new file — add
#!/bin/bash\nset -euo pipefail\ntrap cleanup EXITas a snippet to your editor. This catches 90% of bash error-handling bugs before they ship. [6]
See Bash Reference Manual: The Set Builtin [1], Bash Error Handling Guide [2], and ShellCheck wiki [3] for authoritative references.
Key Takeaways
- Use
set -euo pipefailas your baseline — not justset -e. The-uflag catches undefined variables (preventingrm -rf / $unset_vardisasters), andpipefailensures pipeline failures aren’t silently swallowed. Add this to every new script’s shebang. - Expect
set -eto be suppressed in conditionals,||/&&chains, and subshells — this is by design. If you need guaranteed error trapping in these contexts, usetrap 'error_handler $LINENO' ERRwhich fires regardless ofset -estate. - Always call functions directly (not in
$()) if you wantset -eto propagate — subshells swallow the error. For functions that must run in a subshell, check the exit code explicitly with$?or||. - Implement a cleanup trap for every script —
trap cleanup EXITensures temp files are removed even on error. The EXIT trap fires on all normal exits and most signals, making it a reliable safety net. - Validate your error handling with explicit test scripts — run a minimal test (like the ones in this post) against your shell version. Bash 4.x and 5.x differ slightly in
set -ebehavior inside conditionals.
|`
📖 Related Reads
- ToolBrain — tool reviews, LLM comparisons, and AI workflow guides
Cross-links automatically generated from CodeIntel Log.
References
- [1] (citation needed)
- [2] (citation needed)
- [3] (citation needed)
- [4] (citation needed)
- [5] (citation needed)
- [6] (citation needed)