Bash Error Handling: What Happens When You Forget set -e
A deep dive into bash error propagation, trap handlers, and why set -e is not a silver bullet — with testable scripts.
I’ve been writing bash scripts for over a decade. I still get bitten by error handling edge cases. This post tests my understanding of set -e, trap handlers, and pipefail.
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
local cmd=$2
local code=$3
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
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 I Got Wrong
trap ... ERRfires regardless ofset -e— even inside conditionals, the ERR trap fires. I thought it requiredset -eto be active.trap ... EXITruns on ALL exits — evenkill -9? No, SIGKILL can’t be trapped. But normal exits,exitcalls, and signals like SIGINT all trigger EXIT.set -udoesn’t catch all unset variables —${var-default}and${var:-default}are fine. But${var}with noset -usilently produces empty string, which can causerm -rf / $unset_vardisasters.
Score: 7/10 — strong on basic patterns, weak on subshell interaction with set -e.