The traps in set -euo pipefail
Bash strict mode is a good default, but -e has exceptions that surprise everyone at least once. The cases that bit me, with fixes.
On this page
Almost every bash script I write starts with the same line:
set -euo pipefailWhat each flag does:
-eexits on the first failing command;-utreats unset variables as errors;-o pipefailmakes a pipeline fail if any command in it fails, not just the last one.
I still recommend it. But I’ve watched people (me included) assume it makes bash behave like a language with exceptions, and it doesn’t. Here are the cases that have actually bitten me.
1. -e is off inside conditions#
set -e is ignored for any command that’s part of an if condition, a while condition, or the left side of && and ||. That’s by design, otherwise if grep -q foo file would kill your script. The surprise is that it applies to everything called from there, including whole functions:
set -e
deploy() {
cp build/app /srv/app # fails...
systemctl restart app # ...but this still runs
echo "deployed"
}
if deploy; then
echo "ok"
fiBecause deploy runs as an if condition, errexit is disabled for its entire body. The cp fails, the restart happens anyway, and you get “ok”. Same with deploy || echo "failed".
Fix: don’t rely on -e inside functions you call in a condition. Either check explicitly:
deploy() {
cp build/app /srv/app || return
systemctl restart app || return
}or call the function on its own line and inspect $? afterwards.
2. local swallows the exit code#
get_version() {
local version=$(curl -fsS https://example.com/version)
echo "$version"
}If curl fails, the script keeps going: the exit status of that line is the status of local, which succeeded. export and readonly behave the same way.
Fix: split the declaration and the assignment.
get_version() {
local version
version=$(curl -fsS https://example.com/version)
echo "$version"
}3. Command substitution doesn’t inherit -e#
In bash, subshells created by $(...) don’t inherit errexit unless you ask for it:
set -e
result=$(false; echo "still running")
echo "$result" # prints "still running"The assignment as a whole takes the exit status of the substitution, and since echo succeeded, nothing fails.
Fix (bash 4.4+):
shopt -s inherit_errexitI add it to my strict mode header on every system where I know bash is new enough.
4. Arithmetic that evaluates to zero#
set -e
count=0
((count++))
echo "never printed"((expr)) returns status 1 when the expression evaluates to 0. Post-increment returns the old value, 0, so the script exits on the very first increment.
Fix: use plain assignment.
count=$((count + 1))5. pipefail and SIGPIPE#
set -o pipefail
first=$(generate-big-report | head -n 1)head reads one line and exits. The next time generate-big-report writes, it gets SIGPIPE and dies with status 141. With pipefail that turns the whole pipeline into a failure, and with -e your script exits. Whether it happens depends on output size and timing, so it’s flaky in exactly the way you don’t want.
A related one: grep exits with 1 when it finds nothing, so grep ERROR app.log | wc -l fails on a clean log. Options:
errors=$(grep -c ERROR app.log || true) # -c counts, || true accepts "zero"
first=$(generate-big-report | head -n 1) || trueBe deliberate about || true; it hides real failures too. For grep specifically, I’d rather check for status 1 and let 2 (a real error, like a missing file) through:
grep -q ERROR app.log && status=0 || status=$?
if (( status > 1 )); then
echo "grep failed" >&2
exit "$status"
fi6. -u and optional values#
set -u catches typos in variable names, which is great. It also fires on legitimately optional values like positional arguments and environment variables. Use defaults:
target=${1:-staging}
debug=${DEBUG:-0}On bash older than 4.4, expanding an empty array with "${files[@]}" under -u also fails with “unbound variable”. If you still support old systems (hello, CentOS 7 boxes nobody wants to touch), ${files[@]+"${files[@]}"} works everywhere, though it’s ugly enough that I’d rather bump the bash requirement.
Getting line numbers on failure#
When a strict script dies, the default message tells you nothing. An ERR trap helps:
set -Eeuo pipefail
trap 'echo "error: line $LINENO: $BASH_COMMAND (exit $?)" >&2' ERRNote the -E: without it the ERR trap isn’t inherited by functions, command substitutions or subshells, so failures inside functions go unreported.
My header#
#!/usr/bin/env bash
set -Eeuo pipefail
shopt -s inherit_errexit
trap 'echo "error: line $LINENO: $BASH_COMMAND (exit $?)" >&2' ERR| Trap | Symptom | Fix |
|---|---|---|
| function in a condition | keeps going after a failure | explicit || return |
local x=$(cmd) | failure ignored | declare, then assign |
$(...) | -e not inherited | shopt -s inherit_errexit |
((count++)) from 0 | script exits | count=$((count + 1)) |
| head, grep no match | random or false failures | handle status explicitly |
optional args under -u | “unbound variable” | ${1:-default} |
Strict mode is a safety net with holes in it. It catches the common case, the command that fails in the middle of a script. The rest is on me: ShellCheck, explicit checks in functions, and keeping scripts short enough that I can still reason about every exit code.