Why this matters
By default bash is forgiving to a fault. A command fails, bash prints the error and carries on with the next line as if nothing happened. A variable name is misspelled, bash quietly expands it to nothing. Consider this three-line "cleanup" script:
cd /srv/app/cache
rm -rf ./*
echo "cache cleared"
If /srv/app/cache does not exist, cd fails, bash keeps going, and rm -rf ./* runs in whatever directory the script happened to start in. Now consider rm -rf "$cache_dir/"* with $cache_dir misspelled as $cahce_dir: it becomes rm -rf /*. Real outages and lost home directories have exactly these shapes. Three shell options, usually turned on together and nicknamed strict mode, make scripts stop at the first sign of trouble instead.
#!/usr/bin/env bash
set -euo pipefail
This lesson explains each one precisely, including the places where it does not protect you, because a safety net you misunderstand is worse than none.
set -e (errexit): stop on failure
With set -e (long name set -o errexit), bash exits as soon as a command returns a non-zero status. The script's own exit status is that command's status.
$ cat demo.sh
set -e
echo one
ls /nope
echo two
$ bash demo.sh; echo "script status: $?"
one
ls: cannot access '/nope': No such file or directory
script status: 2
echo two never ran, and the script reported ls's status 2 to its caller.
What happens underneath
After bash runs each command it gets the status from the kernel's wait call (remember module 1) and stores it in $?. With errexit on, bash then asks one more question: is this status being tested by something? If the answer is no and the status is non-zero, bash runs any EXIT trap (next lesson) and exits.
The "being tested" part is where all the subtlety lives. A failure does not trigger errexit when the command is:
- the condition of
if,elif,whileoruntil; - any command in an
&&or||list except the very last one; - negated with
!; - any command in a pipeline except the last (without pipefail, see below);
- anywhere inside a function or group that is itself being tested by one of the above.
The reasoning: in all those positions the script has said "I expect this might fail and I will handle it". This is why these are fine under set -e:
if grep -q "^$user:" /etc/passwd; then echo "exists"; fi
mkdir "$dir" || echo "already there"
[ -f config ] && source config
But rule 5 has a nasty consequence:
set -e
check() {
false # you expect the script to stop here...
echo "still running"
}
if check; then echo "check passed"; fi
still running
check passed
Because check is the condition of an if, errexit is switched off for everything inside it, including the false. A function that relies on set -e for its error handling silently stops doing so the moment someone calls it in an if or with ||. Write functions that check their own critical commands explicitly (lesson 3 shows patterns).
The command substitution trap
An assignment's status is the status of the last command substitution in it:
set -e
count=$(grep -c ERROR app.log) # grep finds nothing: prints 0, exits 1
echo "errors: $count" # never reached
grep -c prints 0 and exits with 1 ("no lines selected"), so the assignment fails and the script dies without a word. Many tools use non-zero statuses for non-error results: grep (no match), diff (files differ), cmp, test. When "failure" is an acceptable answer, say so:
count=$(grep -c ERROR app.log || true)
|| true makes the failure "tested" and the overall status 0, while grep's output is still captured.
Conversely, some failures are lost. local x=$(cmd) and export x=$(cmd) return the status of local/export (0), not of cmd; lesson 4 returns to this. And an arithmetic command (( count++ )) returns 1 when the result is 0, which kills a set -e script the first time count is incremented from 0. Use count=$((count + 1)) instead.
set -u (nounset): unset variables are errors
With set -u, expanding a variable that was never set is a fatal error instead of an empty string:
$ bash -c 'set -u; name=world; echo "hello $nmae"'
bash: line 1: nmae: unbound variable
$ echo $?
1
This catches typos, and it catches missing arguments: $1 is unset when the script got no arguments.
$ bash -c 'set -u; file=$1' myscript
myscript: line 1: $1: unbound variable
When a variable is legitimately optional, give it a default with ${var:-default} (module 7); that expansion is allowed under set -u:
set -u
verbose=${VERBOSE:-0} # 0 if VERBOSE is unset or empty
dest=${2:-/tmp/out} # second argument is optional
$#, $?, $$ and $@ are always set, so if [ $# -lt 1 ] is the right way to check for missing arguments before you touch $1. (In bash versions before 4.4, "$@" with no arguments counted as unbound; you may meet old scripts working around that.)
set -o pipefail: failures anywhere in a pipeline
Normally a pipeline's status is the status of its last command only:
$ grep ERROR missing.log | sort; echo $?
grep: missing.log: No such file or directory
0
sort happily sorted nothing and succeeded, so the pipeline "succeeded" and set -e did nothing. With pipefail, the pipeline's status is that of the rightmost command that failed (or 0 if all succeeded):
$ set -o pipefail
$ grep ERROR missing.log | sort; echo $?
grep: missing.log: No such file or directory
2
All commands in a pipeline still run (they are started at the same time, remember module 5); pipefail only changes which status is reported. ${PIPESTATUS[@]} still gives you every individual status if you need them.
A pipefail gotcha: producer | head -n 1. head exits after one line, the producer's next write fails with SIGPIPE and it dies with status 141, so under pipefail the pipeline fails even though you got exactly what you wanted. Signals are covered in module 13; for now, know that early-exiting consumers like head, grep -q and grep -m1 can cause this.
Putting it together
The common header:
#!/usr/bin/env bash
set -euo pipefail
-e, -u and -o pipefail combined. You can turn an option off temporarily with +:
set +e # allow failures for a moment
risky_command
status=$?
set -e
set -o with no arguments shows the state of every option, and $- contains the single-letter options currently on (echo $- prints something like ehB inside a strict script).
Strict mode is a safety net, not error handling. It turns "silently wrong" into "loudly stopped", which is a big improvement, but a script that just dies with grep: config: No such file or directory is still not a friendly tool. Lessons 2 and 3 cover cleaning up and reporting errors properly.
Note: Some experienced shell programmers avoid
set -eentirely because of the exceptions above, and check every important command explicitly instead. Both camps agree on-uandpipefail. This course uses all three and teaches you where-estops helping.
Common mistakes
- Silent death from
grep,diffortestin an assignment or at top level. The script stops with no message because the "failing" command printed nothing to stderr. Add|| truewhere a non-zero status is a normal answer, or test it in anif. - Relying on
set -einside a function called fromifor||. errexit is off for the whole call. (( i++ ))withi=0. Status 1, script exits. Usei=$((i + 1)).$1: unbound variable. Check$#first, or use${1:-}when an empty value is fine.- Forgetting pipefail.
set -ealone does not see failures on the left of a pipe. - Putting
set -euo pipefailin your interactive shell. Your terminal closes the first time a command fails. Strict mode is for scripts.
Exercises
Two quizzes on the exact rules, then a script that has to stay strict and still treat "no matches" as a normal result.
Recap
set -eexits on the first untested failure; conditions ofif/while, non-final parts of&&/||,!, and everything inside functions called from those positions are exempt.- An assignment fails when its command substitution fails;
|| truemarks acceptable failures. set -umakes unset variables fatal; use${var:-default}for optional ones and$#to check arguments.set -o pipefailmakes a pipeline fail if any part fails; beware SIGPIPE withhead.- Turn options off temporarily with
set +e; inspect them withset -oor$-.
Going further: read help set and find the paragraph on -e. Then search man bash (once it is installed) for "The shell does not exit if the command that fails" for the official list of exceptions. Experiment: what does set -e; false | true; echo reached print, with and without pipefail?