Robust Scripts · about 40 min

Strict mode: set -e, -u and pipefail

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:

  1. the condition of if, elif, while or until;
  2. any command in an && or || list except the very last one;
  3. negated with !;
  4. any command in a pipeline except the last (without pipefail, see below);
  5. 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 -e entirely because of the exceptions above, and check every important command explicitly instead. Both camps agree on -u and pipefail. This course uses all three and teaches you where -e stops helping.

Common mistakes

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

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?

Exercises

Exercise 1 · quiz

When does set -e not fire?

A script starts with set -e. In which of these lines does the failing command (false, or a grep that finds nothing) not stop the script? Select all that apply.

Answer
  • if grep -q root /nonexistent; then echo yes; fi

  • false && echo never

  • ! false

errexit ignores a failure whose status is being tested: the condition of if, while and until, any command except the last in an &&/|| list, and anything negated with !. A plain false stops the script, and so does an assignment whose command substitution fails: the status of count=$(...) is the status of the substitution, so a grep that finds no match (status 1) kills the script right there. That last one surprises people constantly.

Exercise 2 · quiz

Status of a pipeline

What is the exit status of the pipeline false | true in bash with set -o pipefail, and what is it without? Answer in the form with,without, for example 5,7.

Answer

Without pipefail a pipeline's status is the status of its last command, so false | true is 0 and the failure of false is invisible to set -e and to if. With pipefail the status is that of the rightmost command that failed, here 1. This matters for pipelines like curl ... | tar x or grep pattern file | sort, where the important command is not the last one.

Exercise 3 · write code

A strict script that survives zero matches

script.sh counts the lines containing ERROR in a log file given as its only argument and prints errors: N. It uses strict mode. The starter has two problems:

  1. When the log has no ERROR lines, it prints nothing and fails, because grep -c exits with status 1 when it finds no match (while still printing 0).
  2. When called with no argument it dies with $1: unbound variable (status 1).

Fix it so that:

Keep the set -euo pipefail line.

File: script.sh

Starting point

set -euo pipefail
file=$1
count=$(grep -c ERROR "$file")
echo "errors: $count"

Example with arguments app.log

expected output

errors: 2

Example with arguments clean.log

expected output

errors: 0
Hint 1

Check $# before touching $1; $# is always set, even under set -u.

Hint 2

A failure on the left of || is not fatal under set -e. What could you put on the right that always succeeds?

Hint 3

count=$(grep -c ERROR "$file" || true): grep still prints 0, and || true makes the status 0.

Reference solution
set -euo pipefail
if [ $# -ne 1 ]; then
  echo "usage: script.sh LOGFILE" >&2
  exit 2
fi
file=$1
count=$(grep -c ERROR "$file" || true)
echo "errors: $count"