Bash scripting guide · part 19 of 32
Strict mode and its caveats
What set -euo pipefail actually does, the cases where -e silently does nothing, the traps in -u and pipefail, and a balanced way to use it.
"Strict mode" is the popular name for starting a script with
set -euo pipefail
Three options in one line: -e (errexit) exits when a command fails, -u (nounset) treats
an unset variable as an error, and -o pipefail makes a pipeline fail if any stage fails,
not only the last. Together they turn many silent failures into loud ones. They also have
rules and exceptions that surprise almost everyone, and a script that relies on -e for its
error handling is only as safe as your knowledge of those exceptions. Every caveat below is
run for real under bash 5.2.
What -e buys you
Without -e, bash keeps going after a failed command. The classic disaster is a cd that
fails, followed by a destructive command that then runs in the wrong directory.
Without -e, a script continues after a failure
#!/usr/bin/env bash
cd /no/such/dir
echo "would now delete files in: $PWD"
bash deploy.sh
deploy.sh: line 2: cd: /no/such/dir: No such file or directory would now delete files in: /home/dev/demo
cd fails and prints an error, and the script carries on in the original directory. Had the
next line been rm -rf ./*, it would have run against the wrong files.
With -e, the first failure stops the script
#!/usr/bin/env bash
set -e
cd /no/such/dir
echo "would now delete files in: $PWD"
bash deploy.sh
deploy.sh: line 3: cd: /no/such/dir: No such file or directory
The script exits with cd's status, 1, before reaching the echo. Note that -e prints
nothing of its own; the only message is the one cd wrote. Pair it with an ERR or EXIT
trap if you want to know where it stopped (see Traps and signals).
Caveat 1: -e is ignored in tested contexts
-e does not fire for a command whose status is being tested: the condition of if,
while and until, any command but the last in a && or || list, and anything after !.
That much is reasonable. The surprise is that the exemption covers everything that runs
inside that command, including the body of a function called from it. Inside such a
function, -e is simply off, and it cannot be turned back on there.
A function called from if runs without -e
#!/usr/bin/env bash
set -e
setup() {
false # should stop here
echo "setup: kept going after a failure"
}
if setup; then echo "setup reported success"; fi
setup || echo "never printed"
echo "--- now called plainly"
setup
echo "not reached"
bash check.sh
setup: kept going after a failure setup reported success setup: kept going after a failure --- now called plainly
Called from if and from the left side of ||, setup runs straight past false, and
because its last command (echo) succeeds, the function returns 0. Called plainly, the
same function stops at false and the script exits 1.
The practical consequence: a function written to rely on -e behaves differently depending
on how it is called. if ! mytask; then ... and mytask || handle_error both disable the very
checks inside mytask you were counting on. Functions that can fail should check their own
commands (cmd || return) instead.
Caveat 2: command substitution and local
A plain assignment x=$(cmd) takes the status of cmd, so -e catches a failure there.
But a command substitution used as an argument (echo "$(cmd)") does not affect the
outer command's status, and local x=$(cmd), export x=$(cmd) and declare x=$(cmd) return
the status of the builtin, which succeeds.
local x=$(false) hides the failure from -e
#!/usr/bin/env bash
set -e
echo "argument: [$(false)] survived"
f() {
local v=$(false)
echo "local v=\$(false) survived"
local w
w=$(false)
echo "not reached"
}
f
bash sub.sh
argument: [] survived local v=$(false) survived
The failed substitution inside an echo argument and the one in local v=$(false) both pass
unnoticed. Only the separate assignment w=$(false) carries the status 1 that makes -e exit.
Declare first, assign on the next line.
Caveat 3: inherit_errexit
There is a second problem inside $(...) itself: by default bash turns -e off in the
subshell that runs a command substitution. A multi-command substitution keeps going after a
failure, and if its last command succeeds the whole thing looks successful. Bash 4.4 added
shopt -s inherit_errexit to keep -e on in there.
-e inside a command substitution, before and after inherit_errexit
set -e
out=$(false; echo "kept going inside \$(...)")
echo "1: out=[$out]"
shopt -s inherit_errexit
out=$(false; echo "kept going inside \$(...)")
echo "2: not reached"
1: out=[kept going inside $(...)]
In the first substitution false is ignored and echo makes the assignment succeed. After
shopt -s inherit_errexit, the subshell exits at false, the assignment gets status 1, and
the outer -e ends the script.
Caveat 4: ((i++)) when i is 0
An arithmetic command (( expr )) returns 1 when the expression evaluates to 0. Post-increment
evaluates to the old value, so ((i++)) with i=0 "fails", and under -e it kills the
script on the very first iteration of a counter. It is one of the most common strict-mode bugs.
((count++)) exits the script when count was 0
#!/usr/bin/env bash
set -e
count=0
for f in *.txt; do
((count++))
echo "counted $f"
done
echo "total: $count"
bash count.sh; echo "status $?"
status 1
The loop dies on the first file, before printing anything: ((count++)) evaluated to 0 and
returned 1. Write count=$((count + 1)) or ((++count)) (which evaluates to the new value,
1), or ((count++)) || true.
Caveat 5: pipefail and SIGPIPE
Without pipefail, cmd | sort hides any failure of cmd. With it, the pipeline returns the
last non-zero status of any stage. The downside: a reader that exits early, such as head or
grep -q, closes the pipe, and the writer is killed by SIGPIPE the next time it writes. Its
status is 141 (128 + 13), and under pipefail that is a pipeline failure, even though
nothing went wrong.
pipefail exposes a failure in an earlier stage
cat missing.txt | wc -l; echo "default: $?"
set -o pipefail
cat missing.txt | wc -l; echo "pipefail: $?"
cat: missing.txt: No such file or directory 0 default: 0 cat: missing.txt: No such file or directory 0 pipefail: 1
Both runs print 0 from wc. By default the pipeline's status is wc's, 0. With pipefail
it is cat's 1.
head makes the writer die of SIGPIPE, exit 141
set -o pipefail
yes | head -n 2
echo "status $? stages ${PIPESTATUS[*]}"
seq 100000 | head -n 1
echo "status $? stages ${PIPESTATUS[*]}"
y y status 141 stages 141 0 1 status 141 stages 141 0
head stops after its lines and exits 0. yes writes forever, so it is guaranteed to hit the
closed pipe and die with 141, and pipefail makes the pipeline report 141. seq 100000
writes more than the pipe buffer holds, so it gets the same fate. With -e on, either line
would have ended the script. Whether a smaller writer survives depends on whether it finished
writing before head exited, which is a race.
When you mean to stop reading early, say so: { yes || true; } | head -n 2, or collect the
data first and then take the head. For grep -q on a large input, grep -c or grep ... >/dev/null
reads everything and avoids the problem.
Caveat 6: -u and empty arrays
-u catches typos in variable names, which is worth a lot. Use ${var:-} or ${var-default}
where unset is legitimately possible, such as optional environment variables and $1.
Arrays are the historical trap. In bash before 4.4 (notably the bash 3.2 that macOS still
ships as /bin/bash), expanding an empty array with "${arr[@]}" under -u is an
"unbound variable" error. Bash 4.4 and later treat an empty array as set. The sandbox runs
bash 5.2:
-u with an unset variable and an empty array in bash 5.2
set -u
args=()
echo "empty array: [${args[@]}] count ${#args[@]}"
echo "with default: [${MAYBE:-none}]"
echo "plain: $MAYBE"
echo "not reached"
empty array: [] count 0 with default: [none] /bin/bash: line 5: MAYBE: unbound variable
The empty array expands to nothing without complaint, and ${MAYBE:-none} supplies a default.
The bare $MAYBE is fatal: bash prints MAYBE: unbound variable and the non-interactive
shell exits at once. The status is 127 here because the example runs as bash -c '...'; the
same line in a script file run with bash script.sh exits with 1. An interactive shell only
aborts the current command line.
If your script must also run on bash 3.2, the portable spelling is
${arr[@]+"${arr[@]}"}: it expands to the quoted elements when the array is set and to
nothing otherwise. On 4.4+ you can drop it.
A balanced recommendation
Strict mode is a net win for most scripts, as long as you treat it as a safety net rather than your error handling:
- Use
set -euo pipefailat the top, and on bash 4.4+ addshopt -s inherit_errexit. - Check the commands that matter explicitly:
cd "$dir" || exit,cmd || die "cmd failed". Those checks still work in every context where-eis switched off. - Never write
local x=$(cmd); declare and assign separately. - Use
x=$((x + 1))for counters, not((x++)). - Expect
|| trueto be needed after commands where non-zero is normal (grepwith no matches,diffwith differences), and after pipelines that end inhead. - Add an
ERRtrap that prints$LINENOand$BASH_COMMAND, so a silent-eexit is never a mystery (see Debugging bash scripts).
Some experienced shell programmers skip -e entirely and check every command by hand,
because of the exceptions above. That is a defensible position for large scripts. For the
typical 50-line deployment or backup script, strict mode catches far more bugs than it causes.
A strict-mode header with a failure report
#!/usr/bin/env bash
set -euo pipefail
shopt -s inherit_errexit
trap 'echo "$0: line $LINENO: \"$BASH_COMMAND\" exited $?" >&2' ERR
count=0
for f in data/*.csv; do
count=$((count + 1))
done
echo "found $count CSV files"
matches=$(grep -c PANIC logs/app.log || true)
echo "PANIC lines: $matches"
sort data/nope.txt | head -n 1
bash job.sh
found 2 CSV files PANIC lines: 0 sort: cannot read: data/nope.txt: No such file or directory job.sh: line 13: "head -n 1" exited 2
The counter and the grep -c ... || true behave (grep -c prints 0 and exits 1 when nothing
matches). The last pipeline fails because sort cannot open the file: pipefail makes the
pipeline return sort's 2, the ERR trap reports line 13, and -e exits with that status.
Note that BASH_COMMAND names the pipeline's last command, head -n 1, not the stage that
failed; the line number is the reliable part.
Related
setTurn shell options such as -e, -u, -x and pipefail on or off, and replace the positional parameters.trapRun a command when the shell receives a signal or exits, typically to clean up temporary files.- Exit codesWhat exit statuses mean, how $? works, the reserved values 126, 127 and 128+N, PIPESTATUS, and how to choose codes for your own scripts.
- Traps and signalsRunning cleanup code with trap on EXIT, handling INT and TERM, the ERR pseudo-signal, and the timing and scoping rules that trip people up.
- Debugging bash scriptsCheck syntax with bash -n, trace with set -x, add file and line numbers through PS4, send the trace elsewhere with BASH_XTRACEFD, and report failures with trap ERR.
- Pipes and pipelinesHow cmd1 | cmd2 connects processes, which exit status a pipeline returns, PIPESTATUS, pipefail, SIGPIPE, and why variables set in a piped loop vanish.
- FunctionsDefining and calling bash functions, arguments, local variables, return status versus output, namerefs, and sourcing a function library.