Linux & Bash · Part I · Module 20 · Defensive Scripting
set -e, set -u, set -o pipefail — and Their Limits
This is the line every guide tells you to put at the top of a script, usually without qualification. It is worth putting there. It is also surrounded by exemptions that make it inert in several of the exact cases Chapter 20.1 catalogued — including, measurably, the entire body of a function you called inside an if. This chapter is that table, run rather than quoted.
Advanced24 min readLinuxPart IBashset -epipefailDefensive ScriptingScripting
Part I · Module 20 · Chapter 20.2 · set -e, set -u, set -o pipefail — and Their Limits
#!/usr/bin/env bash
set -euo pipefailPut it at the top of your scripts. Then read the rest of this chapter, because the gap between what people believe that line does and what it measurably does is the single most common source of misplaced confidence in shell scripting.
1. What Each One Is
| Option | Long form | Effect |
|---|---|---|
-e | set -o errexit | exit the script when a command fails |
-u | set -o nounset | treat an unset variable as an error |
-o pipefail | — | a pipeline fails if any stage failed, not just the last |
Written out, which is what a script read by other people should use:
set -o errexit
set -o nounset
set -o pipefailThree lines instead of one, each greppable and each independently removable while debugging. The compact set -euo pipefail is fine and is what you will see most often; nothing in this chapter depends on which spelling you choose.
2. Where set -e Acts
Measured. These abort the script:
$ bash -c 'set -e; false; echo REACHED'
$ echo "rc=$?"
rc=1
$ bash -c 'set -e; ls /nope; echo REACHED'
ls: /nope: No such file or directory
$ echo "rc=$?"
rc=1
$ bash -c 'set -e; f() { false; }; f; echo REACHED'
$ echo "rc=$?"
rc=1REACHED never printed in any of the three. A plain failing step — including a bare call to a function that fails — stops the script. That is the behaviour you wanted, and it covers most of what people assume the option does.
Two more that abort, and the first surprises almost everybody:
$ bash -c 'set -e; v=$(false); echo "REACHED v=[$v]"'
$ echo "rc=$?"
rc=1
$ bash -c 'set -e; (false); echo REACHED'
$ echo "rc=$?"
rc=1A plain assignment from a failing command substitution does abort. v=$(false) stops the script. Hold on to that, because §4 shows the one-word change that makes it not.
3. Where set -e Does Nothing
Each of these prints REACHED:
$ bash -c 'set -e; if false; then :; fi; echo REACHED'
REACHED
$ bash -c 'set -e; false && echo X; echo REACHED'
REACHED
$ bash -c 'set -e; false || echo X; echo REACHED'
X
REACHED
$ bash -c 'set -e; ! true; echo REACHED'
REACHED
$ bash -c 'set -e; while false; do :; done; echo REACHED'
REACHED
$ bash -c 'set -e; false | true; echo REACHED'
REACHED
$ bash -c 'set -e; i=0; (( i++ )); echo REACHED'
REACHED
$ bash -c 'set -e; (( 0 )); echo REACHED'
REACHEDThere is one rule behind all of them: set -e steps aside wherever the shell can see that you are examining the status yourself. A condition, either side of && or ||, a negation, a non-final pipeline stage — in each case the status is being consumed by the construct, so aborting would make the construct impossible to write.
That rule is reasonable. Its consequence is not.
4. The Exemption That Matters
Where the abort-on-failure option acts, and where it steps aside
Here is the measurement. One function, three call sites:
$ f() { echo " line1"; false; echo " line2 AFTER FALSE"; }
$ bash -c 'set -e; f() { echo " line1"; false; echo " line2 AFTER FALSE"; }; f; echo " REACHED"'
line1
$ bash -c 'set -e; f() { echo " line1"; false; echo " line2 AFTER FALSE"; }; f || true; echo " REACHED"'
line1
line2 AFTER FALSE
REACHED
$ bash -c 'set -e; f() { echo " line1"; false; echo " line2 AFTER FALSE"; }; if f; then echo " then"; else echo " else"; fi; echo " REACHED"'
line1
line2 AFTER FALSE
then
REACHEDRead those three.
Called bare, set -e works: the function stops at false and the script stops with it.
Called with || true, the suspension applies to the whole body. line2 AFTER FALSE ran. The function did not stop at its failure; it carried on doing work on a false premise — which is exactly Chapter 20.1's shape one, inside a script that has set -e at the top.
Called in an if, the same — and worse. The then branch ran. The function's last command was a successful echo, so the function returned 0, so if f took the true branch. The failure is now invisible twice over: once because set -e was suspended, and once because Chapter 18.3 §3's tail-echo masked the status.
$ cat t1.sh
set -e
f() { false; echo " tail echo ran"; }
if f; then echo " f SUCCEEDED — the tail echo masked the failure"; else echo " f failed"; fi
$ bash t1.sh
tail echo ran
f SUCCEEDED — the tail echo masked the failureAnd the one-word version of the same thing, which Chapter 18.4 §5 measured from the other side:
$ bash -c 'set -e; v=$(false); echo REACHED'
$ echo "plain assignment rc=$?"
plain assignment rc=1
$ bash -c 'set -e; f() { local v=$(false); echo " IN FN"; }; f; echo " REACHED"'
IN FN
REACHED
$ bash -c 'set -e; f() { local v; v=$(false); echo " IN FN"; }; f; echo " REACHED"'
$ echo "split form rc=$?"
split form rc=1v=$(false) aborts. local v=$(false) does not. local v; v=$(false) aborts again. The middle one is exempt because the command whose status set -e sees is local, and local succeeded. Declare on one line and assign on the next — the same rule Chapter 18.4 gave for detecting errors at all, now with a second reason.
And one more exemption, which completes the picture in an uncomfortable way. A function called inside a command substitution is also in a tested position:
$ printf 'TEST PASSED\n' > clean.log
$ cat a1.sh
set -e
f() { local n; n=$(grep -c UVM_ERROR clean.log); printf '%s\n' "$(( n ))"; }
v=$(f)
echo "REACHED v=[$v]"
$ bash a1.sh
REACHED v=[0]
$ echo "rc=$?"
rc=0
$ cat a2.sh
set -e
f() { local n; n=$(grep -c UVM_ERROR clean.log); printf 'count=%s\n' "$(( n ))"; }
f
echo "REACHED"
$ bash a2.sh
$ echo "rc=$?"
rc=1The same function. Called via $(f) it runs past a failing assignment; called bare it aborts. (grep -c returns 1 when the count is zero — measured — so that assignment genuinely fails.)
Which means something worth stating plainly: whether set -e protects a function's body depends on its call site, and the call site is not visible from inside the function. You cannot read a function and know whether its unguarded steps are protected. That is the final argument for Chapter 18.3's rule — a function handles its own failures — not as style, but because set -e provably cannot be relied on to do it for you.
5. pipefail, and the Abort That Depends on Your Data
$ bash -c 'false | true; echo "no pipefail: $?"'
no pipefail: 0
$ bash -c 'set -o pipefail; false | true; echo "pipefail: $?"'
pipefail: 1That is the fix for Chapter 20.1's shape three, and it is the reason pipefail belongs in the line. With set -e as well, such a pipeline aborts the script.
Two details before you trust it.
It reports the last failing stage, not the first:
$ bash -c "set -o pipefail; bash -c 'exit 3' | bash -c 'exit 4' | true; echo \"status=\$? PIPESTATUS=\${PIPESTATUS[*]}\""
status=4 PIPESTATUS=3 4 0Status 4, not 3. PIPESTATUS (Chapter 12.8, an array per Chapter 19.2) has all three. If you need to know which stage broke, read the array.
And now the one that costs people real time. A consumer that exits before reading all its input makes the producer die of SIGPIPE, and pipefail counts that as a failure:
$ bash -c 'set -o pipefail; yes | head -1; echo "rc=$?"'
y
rc=141141 is 128 + 13, Chapter 7.6's signal arithmetic: SIGPIPE. Under set -e that aborts the script, in a pipeline that did exactly what it was supposed to do.
It gets worse, because whether it happens depends on how much data there is:
$ for n in 10 1000 100000; do
> seq 1 $n > f$n.txt
> printf ' %6d lines: ' "$n"
> bash -c "set -o pipefail; cat f$n.txt | head -1 > /dev/null; echo rc=\$?"
> done
10 lines: rc=0
1000 lines: rc=0
100000 lines: rc=141The same line of code, rc 0 on a small file and rc 141 on a large one. cat finishes writing before head exits when the file is small enough to fit in the pipe buffer, and gets killed when it is not. So a script with set -eo pipefail and a | head in it works all through development and dies in production on a bigger input.
6. set -u, and What It Does Not Cover
$ bash -c 'set -u; echo "[$UNDEFINED_VAR]"'
bash: UNDEFINED_VAR: unbound variable
$ bash -c 'set -u; echo "[${UNDEFINED_VAR:-default}]"'
[default]An unset variable is an error; a default supplies a value and is fine. That is Chapter 8.2's ${var:-word}, and it is why defensive scripts are full of ${1:-}.
It covers positional parameters and empty arrays too:
$ cat t6.sh
set -u
echo "[$1]"
$ bash t6.sh
t6.sh: line 2: $1: unbound variable
$ echo "rc=$?"
rc=1
$ bash -c 'set -u; a=(); echo "${a[@]}"'
bash: a[@]: unbound variableThe array case is Chapter 19.2 §5, and the precise table there matters: "${a[@]}", "${a[*]}" and "${a[0]}" abort, while "${!a[@]}" and ${#a[@]} do not.
Two gaps worth knowing:
$@ and $* with zero arguments are fine:
$ cat t7.sh
set -u
echo "count=$#"
echo "star=[$*]"
echo "at=[$@]"
echo REACHED
$ bash t7.sh
count=0
star=[]
at=[]
REACHEDSo $1 is an error when there are no arguments and $@ is not — which is consistent (an empty argument list is a legitimate state; a missing first argument is a question about something that is not there) and worth knowing before you write a guard that relies on the wrong one.
And set -u is about unset, not empty:
$ bash -c 'set -u; dir=""; echo rm -rf "$dir/"'
rm -rf /dir is set. It is set to nothing. set -u has no objection, and Chapter 20.1's shape four is untouched by it. That is Chapter 20.4's entire subject, and it is the clearest example of why this one line is not a defence on its own.
7. Where to Put It, and When Not To
Put it in a script, after the shebang, before anything else:
#!/usr/bin/env bash
set -euo pipefailDo not put it in a library you expect others to source. Chapter 18.6 §2 measured that sourcing shares shell options, so set -euo pipefail in lib.sh silently changes the error behaviour of every script that sources it — including scripts written years earlier that depend on the old defaults. A library sets no options; the script that uses it decides.
Do not rely on it in a #!/bin/sh script. set -e and set -u are POSIX; pipefail is not, and dash rejects it:
$ dash -c 'set -o pipefail; echo ok'
dash: 1: set: Illegal option -o pipefailAnd do not add it to a large existing script without testing. A script written under the old defaults may well depend on them — a grep -q that is expected to return 1 sometimes, a pipeline with a head in it. Turning the options on is a behaviour change, not a safety net you can apply retroactively for free.
8. A Complete Script
The options turned on, plus the explicit handling they do not provide:
#!/usr/bin/env bash
# summarise.sh — count errors across a results tree
# usage: summarise.sh <dir>
#
# exit: 0 no errors · 1 errors found · 2 unusable arguments
#
# note: set -e is suspended inside any function called in a condition,
# so every function here returns explicitly on failure.
set -o errexit
set -o nounset
set -o pipefail
note() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }
die() { note "$*"; exit 2; }
# usable <dir> -> 0 if we can work with it. called in a condition, so
# set -e is OFF in here: every failure path returns explicitly.
usable() {
[ $# -eq 1 ] || return 2
[ -d "$1" ] || return 1
[ -r "$1" ] || return 1
[ -x "$1" ] || return 1 # needed to resolve names inside it
return 0
}
# count_in <file> -> prints the count. no pipeline, so no pipefail risk.
count_in() {
[ $# -eq 1 ] || return 2
local n
n=$(grep -c 'UVM_ERROR' "$1") || true # grep returns 1 on zero matches
printf '%s\n' "$(( n ))"
}
dir=${1:-}
[ -n "$dir" ] || die "usage: ${0##*/} <dir>"
if ! usable "$dir"; then
die "$dir is not a usable directory"
fi
# -print0 and a read loop: no pipe, so the array survives (19.4)
declare -a logs=()
while IFS= read -r -d '' f; do
logs+=("$f")
done < <(find "$dir" -type f -name '*.log' -print0)
if [ "${#logs[@]}" -eq 0 ]; then
note "no logs under $dir"
exit 0
fi
total=0
worst_n=0
worst=""
for f in "${logs[@]}"; do
n=$(count_in "$f")
total=$(( total + n ))
if (( n > worst_n )); then
worst_n=$n
worst=$f
fi
done
printf 'logs %d\n' "${#logs[@]}"
printf 'errors %d\n' "$total"
[ -z "$worst" ] || printf 'worst %s (%d)\n' "$worst" "$worst_n"
[ "$total" -eq 0 ]$ mkdir -p tree/sub
$ printf 'UVM_ERROR : a\nUVM_ERROR : b\n' > tree/one.log
$ printf 'TEST PASSED\n' > tree/two.log
$ printf 'UVM_ERROR : c\n' > tree/sub/three.log
$ ./summarise.sh tree
logs 3
errors 3
worst tree/one.log (2)
$ echo $?
1
$ rm tree/one.log tree/sub/three.log
$ ./summarise.sh tree
logs 1
errors 0
$ echo $?
0
$ mkdir bare && ./summarise.sh bare
summarise.sh: no logs under bare
$ echo $?
0
$ ./summarise.sh /nope
summarise.sh: /nope is not a usable directory
$ echo $?
2Five decisions that exist because of this chapter's measurements:
The header comment says set -e is suspended in a tested function, and usable is written accordingly — four explicit || return lines rather than four bare tests. Without them, a usable whose first test failed would run the remaining three anyway.
n=$(grep -c ... ) || true. grep -c returns 1 when the count is zero — measured — which is not a failure in this context, and §2 showed that a plain assignment from a failing substitution does abort under set -e.
As it happens, removing the || true from this script changes nothing, because count_in is only ever called as n=$(count_in "$f") and §4's last measurement showed that position is exempt. Which is exactly why it stays: the guard documents the function's own contract rather than relying on a property of its one current call site. Move that call to a bare count_in "$f" > /dev/null and the unguarded version dies on the first clean log.
local n then n=$(...) inside count_in — §4's split form, so the assignment's status is visible rather than masked by local.
No pipeline anywhere. find ... -print0 feeds a read loop through process substitution (Chapter 19.4), and grep -c writes to a capture. There is nothing for pipefail to be surprised by — which is a design choice, not an accident.
The empty-tree case exits 0 with a message on stderr, not 2. Chapter 17.1 §5's question answered deliberately: a results directory with no logs in it has not failed, it has nothing to report, and the distinction is the script's to make.
Practice
- Reproduce the §2 list:
false,ls /nope, a bare failing function call,v=$(false), and(false). Confirm all five abort and thatREACHEDnever prints. - Reproduce the §3 list. For each one, say in your own words which construct is consuming the status.
- Run the three call sites of
f() { echo line1; false; echo line2; }underset -e— bare, with|| true, and in anif. Write down which printline2. - Add a trailing
echoto a function that fails, call it in anifunderset -e, and confirm thethenbranch runs. Name the two separate mechanisms that hid the failure. - Rewrite
check_treefrom the §4 callout so that it is correct when called in a condition. How many|| returnlines did it need? - Measure
v=$(false),local v=$(false)andlocal v; v=$(false)underset -e. Explain the middle one. - Run
false | truewith and withoutpipefail. Then run a three-stage pipeline where two stages fail and print both$?and"${PIPESTATUS[*]}". - Reproduce the
seq/headmeasurement at 10, 1,000 and 100,000 lines underpipefail. Find the smallest line count on your machine that produces 141. - Replace a
grep PATTERN big.log | head -1withgrep -m1. Confirm the status underset -eo pipefailfor both. - Under
set -u, test$UNDEFINED,${UNDEFINED:-d},$1with no arguments,$@with no arguments, anddir="". Which two are not errors, and why is the last one the reason Chapter 20.4 exists? - Put
set -euo pipefailin a library, source it from a script that relies on agrep -qreturning 1, and watch what happens. Then state the rule. - Take
summarise.sh, remove the|| truefrom thegrep -cline, and run it on a tree whose logs contain no errors. It still works — explain why using §4's last measurement. Then change the call site to a barecount_in "$f" > /dev/nulland run it again.
Check Your Understanding
- What are the three options, and what does each do?
- Name three places
set -edoes not abort. - What is the single rule behind all of those exemptions?
- A function fails on its second line and is called with
|| true. What happens to the rest of its body? What if it is called asv=$(f)? - Why does
local v=$(false)not abort whenv=$(false)does? pipefailreports status 4 for a pipeline whose first stage exited 3. Why, and where is the 3?- Why can the same
| head -1pipeline abort on one file and not another? - Does
set -ucatchdir=""?
9. Remember
- Chapter 34.7 is why an explicit
diebeats relying onset -ein a flow: it measures two flows on a source that does not compile, one returning 4 with a message and the other printingRESULT: PASS. set -euo pipefailbelongs at the top of a script. It is worth having and it is not a defence on its own.set -eacts on a plain failing step, including a bare call to a failing function,v=$(false), and(false).- It steps aside wherever you examine the status yourself — conditions,
&&/||,!, non-final pipeline stages. - That suspension covers a whole function body. A function called with
|| true, in anif, or asv=$(f)runs past its own failures — so whetherset -eprotects a body depends on the call site, which is invisible from inside the function. - So a function that may be called in a condition must
|| returnon every failure path it cares about. local v=$(cmd)is exempt;v=$(cmd)is not. Declare, then assign.pipefailreports the last failing stage.PIPESTATUShas them all.pipefailplus an early-exiting consumer gives 141, and whether it happens depends on the size of the input. Prefergrep -m1or no pipe at all.set -ucovers unset variables, unset$1, and an empty array's"${a[@]}"— but not$@/$*with no arguments, and not an empty variable.- Never put these options in a sourced library. Sourcing shares options; the script decides.
pipefailis not POSIX anddashrejects it.
Chapter 20.3 handles what set -u cannot: stating, once and at the top, what the script requires — so that a missing input is reported by name instead of becoming an empty expansion four steps later.
Where this fits
Part of the Linux & Bash curriculum.
