opscape

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

deploy.sh
#!/usr/bin/env bash
cd /no/such/dir
echo "would now delete files in: $PWD"
bash deploy.sh
explain
output
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

deploy.sh
#!/usr/bin/env bash
set -e
cd /no/such/dir
echo "would now delete files in: $PWD"
bash deploy.sh
explain
outputexit 1
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

check.sh
#!/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
explain
outputexit 1
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

sub.sh
#!/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
explain
outputexit 1
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"
outputexit 1
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

count.sh
#!/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 $?"
explain
output
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: $?"
output
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[*]}"
output
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"
outputexit 127
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 pipefail at the top, and on bash 4.4+ add shopt -s inherit_errexit.
  • Check the commands that matter explicitly: cd "$dir" || exit, cmd || die "cmd failed". Those checks still work in every context where -e is switched off.
  • Never write local x=$(cmd); declare and assign separately.
  • Use x=$((x + 1)) for counters, not ((x++)).
  • Expect || true to be needed after commands where non-zero is normal (grep with no matches, diff with differences), and after pipelines that end in head.
  • Add an ERR trap that prints $LINENO and $BASH_COMMAND, so a silent -e exit 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

job.sh
#!/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
explain
outputexit 2
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