Bash Strict Mode: Fail Loudly, Not Silently
A shell script is the only program you will ever write that responds to catastrophic failure
by carrying on regardless. A compiler that meets a mistake stops and tells you. Bash meets a
mistake, writes a line to stderr that nobody is reading, and proceeds to the next command in
excellent spirits — which is how a backup script whose cd failed spends three months
faithfully archiving an empty directory over the top of the good copy.
Two lines at the top of the file change the default:
#!/usr/bin/env bashset -euo pipefailThat is “bash strict mode”, and it is the single highest-value thing you can do to a script. It is also, in the places where it quietly declines to apply, the source of a particular kind of three-hour debugging session. Both halves are below.
The short version
Section titled “The short version”| Flag | What it does | Where it gives up |
|---|---|---|
set -e |
Exits when a command fails | Conditions, &&/|| chains, local x=$(...), command substitution |
set -u |
Exits when you expand an unset variable | Variables that are set but empty |
set -o pipefail |
A pipeline fails if any stage fails | Nothing — but it surfaces SIGPIPE deaths you didn’t care about |
IFS=$'\n\t' |
Splits unquoted expansions on newlines and tabs only | Everything you remembered to quote anyway |
Strict mode is a smoke alarm, not a sprinkler system. It tells you the script has failed; it does not make the script safe, and the most famous shell disaster of all time would have run straight through it — see what strict mode won’t save you from.
The header worth pasting
Section titled “The header worth pasting”#!/usr/bin/env bashset -euo pipefail#!/usr/bin/env bash rather than #!/bin/bash, because on macOS /bin/bash is version 3.2 —
released in 2006, frozen when bash changed licence, and missing roughly two decades of
improvements. Anyone who has installed a newer bash gets it via env; everyone else is no
worse off. #!/bin/sh is a different promise entirely: it means “POSIX shell”, and everything
bash-specific below is then fair game for breaking.
Some templates add IFS=$'\n\t' as a third line. It is more marginal than its fame suggests;
see below.
set -e: the promise, and the small print
Section titled “set -e: the promise, and the small print”The manual’s wording is worth reading closely, because every word of it is load-bearing:
Exit immediately if a pipeline, which may consist of a single simple command, a list, or a compound command, returns a non-zero status.
Then comes the exemption list. The shell does not exit if the failing command is:
- part of the condition of an
if,whileoruntil - any command in a
&&or||list except the last one - any command in a pipeline but the last (unless
pipefailis on) - inverted with
!
None of that is a bug. It is what makes if grep -q pattern file; then possible at all — a
shell that exited whenever grep found nothing would be useless. But the exemptions compose in
ways that surprise people, and they are the reason set -e has a reputation in some quarters
as unreliable. It isn’t unreliable. It is precisely specified, and the specification is not
what most people assume.
Four places set -e quietly gives up
Section titled “Four places set -e quietly gives up”1. A function called as a condition
Section titled “1. A function called as a condition”The exemption applies to everything inside the function too, all the way down:
#!/usr/bin/env bashset -euo pipefail
check() { false # does not abort anything echo "still running"}
if check; then echo "check passed"fistill runningcheck passedThe false is inside the if condition — via a function, but inside it nonetheless — so
errexit is suspended for the whole call. Worse, the function returns the status of its last
command, which is a successful echo, so the test passes and the script congratulates itself.
The fix is not a cleverer flag. It is to write functions used as conditions so that they return their own verdict explicitly:
check() { if ! some-test; then return 1 fi echo "still running"}2. local swallowing the exit status
Section titled “2. local swallowing the exit status”This one has cost more people more time than the rest put together:
#!/usr/bin/env bashset -euo pipefail
masked() { local out=$(false); echo "masked: still running"; }unmasked() { local out; out=$(false); echo "unmasked: never reached"; }
maskedunmaskedmasked: still runninglocal is itself a command. When you write local out=$(false), the exit status of the line is
local’s — cheerfully zero — and the failure of the substitution is discarded. Split the
declaration from the assignment and the status is the substitution’s again, which is why the
second function aborts as intended. The same trap applies to export, declare and
readonly.
3. Command substitution doesn’t inherit errexit
Section titled “3. Command substitution doesn’t inherit errexit”set -eout=$(false; echo "carried on")echo "[$out]" # prints [carried on]The substitution runs in a subshell, and errexit applies to each shell environment separately. Bash 4.4 added an option to change that:
shopt -s inherit_errexitOn macOS’s stock bash you will get shopt: inherit_errexit: invalid shell option name for your
trouble, since 3.2 predates it by roughly a decade. If your script must run on both, don’t rely
on the option — check the value you got back instead.
4. Arithmetic that evaluates to zero
Section titled “4. Arithmetic that evaluates to zero”((...)) is a command, and its exit status reflects the value of the expression: non-zero
value, status 0; zero value, status 1. Post-increment returns the old value. So:
i=0((i++)) # status 1, because the value was 0Under strict mode that line can end the script — on your colleague’s machine and in CI, but not necessarily on yours. Bash changed the scope of errexit between 4.0 and 4.1, and macOS’s 3.2 shrugs this off entirely, so the same file genuinely behaves differently in two places. It is a miserable bug to chase from a CI log.
Both fixes are one character longer than the problem:
i=$((i + 1)) # an assignment: status is always 0((i++)) || true # or say out loud that you don't careset -u: variables that were never there
Section titled “set -u: variables that were never there”Without it, a typo expands to nothing and the script presses on with a hole where the value should be. With it:
set -uecho "deploying to $DEPLOY_TARGT"bash: DEPLOY_TARGT: unbound variableTwo idioms make it liveable. For optional values, supply a default with :-, which counts as
handling the variable:
verbose="${VERBOSE:-0}"first_arg="${1:-}" # positional parameters are unset tooFor values the script cannot proceed without, :? turns a silent empty string into a loud
death with a message you chose:
: "${DEPLOY_TARGET:?must be set}"bash: DEPLOY_TARGET: must be setpipefail: the pipeline that lies
Section titled “pipefail: the pipeline that lies”A pipeline’s exit status is the status of its last command, which means this is a success:
false | true; echo $? # 0Every curl ... | jq ... you have ever written has this shape. The download fails, jq gets an
empty stream, the pipeline reports success, and the script continues with nothing. pipefail
makes the pipeline report the rightmost failure instead:
set -o pipefailfalse | true; echo $? # 1There is one genuinely annoying consequence, and it is better to meet it here than at 2am:
set -o pipefailyes | head -3 >/dev/null; echo $? # 141head exits the moment it has its three lines, yes keeps writing into a closed pipe, and the
kernel kills it with SIGPIPE — status 141, which is 128 + 13. Without pipefail nobody notices;
with pipefail and set -e the script dies on a pipeline that did exactly what you wanted. Any
something-long | head -n 5 is a candidate. If you need the idiom, say so explicitly:
{ big-command || true; } | head -n 5IFS: the third line nobody can justify
Section titled “IFS: the third line nobody can justify”The claim is that it stops unquoted expansions splitting on spaces:
v="a b:c"for x in $v; do echo "[$x]"; done # [a] [b:c]
IFS=$'\n\t'for x in $v; do echo "[$x]"; done # [a b:c]True, as far as it goes. But it only affects unquoted expansions, and the correct answer to an unquoted expansion is to quote it. Set it if you like the belt-and-braces; just don’t mistake it for the fix, and do remember it is there the day a script starts mysteriously refusing to split a space-separated list you fed it on purpose.
Quoting is the actual safety feature
Section titled “Quoting is the actual safety feature”Strict mode cannot save an unquoted variable. Here is the whole problem in four lines:
f="my report.txt"rm $frm: my: No such file or directoryrm: report.txt: No such file or directoryThe file is still there, which today is a lucky escape and tomorrow is a deleted report.txt
belonging to someone else. Quote it and rm gets one argument. The rules are short enough to
memorise:
- Quote every expansion:
"$var","$(cmd)","${arr[@]}". There is no prize for the ones you leave bare. "$@"passes your arguments through unchanged.$*mashes them into one string. Use"$@".- End option lists with
--before user-supplied paths, so a file called-rfstays a file:rm -- "$file". - Prefer
[[ ]]to[ ]in bash: it doesn’t word-split its operands, which removes a whole category of accident.
If you install exactly one tool from this article, make it ShellCheck, which finds these automatically:
brew install shellcheck # macOSsudo apt install shellcheck # Debian, Ubuntusudo dnf install ShellCheck # Fedora — yes, capitalisedshellcheck script.sh and read what it says. The unquoted-expansion warning, SC2086, is the one
you will meet first and most often, and it is right essentially every time.
Cleaning up after yourself
Section titled “Cleaning up after yourself”Anything that creates a temporary file should remove it on the way out, including the way out
that strict mode just caused. mktemp plus a trap on EXIT covers all of them:
#!/usr/bin/env bashset -euo pipefail
workdir=""
cleanup() { if [[ -n "$workdir" ]]; then rm -rf "$workdir" fi}trap cleanup EXIT
workdir="$(mktemp -d)"# ... use "$workdir" ...Three details in that snippet are the result of getting it wrong first:
The variable is global, and initialised. A trap on EXIT runs after the function that set
the variable has returned, so a local workdir is out of scope by the time cleanup needs it —
and under set -u the cleanup then dies with workdir: unbound variable, leaking the very
directory it exists to remove. Declare it at the top, empty.
The guard is an if, not &&. Written as [[ -n "$workdir" ]] && rm -rf "$workdir", the
function returns 1 whenever the variable is empty — and the exit status of an EXIT trap can
replace the script’s own. An if that doesn’t match returns 0.
EXIT alone is enough. It fires on normal exit, on exit, on a strict-mode abort, and on
SIGTERM. Adding INT TERM to the trap list runs your cleanup twice on a signal and resets the
exit status to 0, which converts an interrupted script into a script that claims it succeeded.
Trap EXIT, and write cleanup that wouldn’t mind running twice anyway.
Making failures say where they happened
Section titled “Making failures say where they happened”Strict mode exits silently by design, which is fine for a small script and infuriating for a big
one. An ERR trap adds the line number:
#!/usr/bin/env bashset -euo pipefailtrap 'echo "error: ${BASH_SOURCE##*/}:$LINENO exited with status $?" >&2' ERR
echo "step one"grep -q nothing /etc/hostsecho "step two"step oneerror: backup.sh:6 exited with status 1For anything you can’t reproduce by reading, trace it. set -x prints each command as it runs,
and PS4 decides how much context comes with it:
#!/usr/bin/env bashPS4='+ ${BASH_SOURCE##*/}:${LINENO}: 'set -x
name=worldecho "hello $name"+ trace.sh:5: name=world+ trace.sh:6: echo 'hello world'hello worldNote that the trace shows the command after expansion — hello world, not hello $name —
which is usually the thing you actually wanted to know. Turn it on for a region and off again
with set +x rather than living with it.
And before running anything at all, bash -n script.sh parses without executing:
broken.sh: line 3: syntax error: unexpected end of fileA template worth stealing
Section titled “A template worth stealing”Everything above, in the shape most scripts eventually want: options, arguments, validation, a temporary working directory that always gets cleaned up, and errors on stderr.
#!/usr/bin/env bashset -euo pipefail
readonly script_name="${BASH_SOURCE##*/}"workdir=""
cleanup() { if [[ -n "$workdir" ]]; then rm -rf "$workdir" fi}trap cleanup EXIT
die() { echo "$script_name: $*" >&2 exit 1}
usage() { cat <<USAGEUsage: $script_name [-n] SOURCE DEST -n dry run: report what would happen, write nothing -h this messageUSAGE}
main() { local dry_run=0 opt while getopts ':nh' opt; do case "$opt" in n) dry_run=1 ;; h) usage; return 0 ;; \?) usage >&2; die "unknown option: -$OPTARG" ;; esac done shift "$((OPTIND - 1))"
if (( $# != 2 )); then usage >&2 die "expected 2 arguments, got $#" fi
local source="$1" dest="$2" [[ -d "$source" ]] || die "not a directory: $source"
workdir="$(mktemp -d)" local archive="$workdir/backup.tar.gz" tar -czf "$archive" -C "$source" .
local size size="$(du -h "$archive" | cut -f1)"
if (( dry_run )); then echo "$script_name: would write $size to $dest/backup.tar.gz" return 0 fi
mkdir -p "$dest" cp "$archive" "$dest/backup.tar.gz" echo "$script_name: wrote $size to $dest/backup.tar.gz"}
main "$@"Two things in there are strict-mode-specific rather than stylistic. (( $# != 2 )) is an
arithmetic command that returns 1 when the test is false — fine here, because it sits in an if
condition where errexit is suspended, and a bug waiting to happen if you ever lift it out onto a
line of its own. And size is declared before it is assigned, for the local reason above.
Wrapping the body in main is not ceremony either: bash reads scripts incrementally, so editing
a long script while it runs can make it resume at the wrong byte offset. A main "$@" on the
last line means the file is fully parsed before anything happens.
What strict mode won’t save you from
Section titled “What strict mode won’t save you from”In 2015 the Steam client for Linux deleted users’ home directories. The code was, in essence:
STEAMROOT="$(cd "${0%/*}" && echo $PWD)"rm -rf "$STEAMROOT/"*When the cd failed, STEAMROOT ended up empty, and rm -rf "/"* ran with the user’s full
privileges. Now notice what strict mode would have done about it: nothing at all. set -u
catches unset variables, and STEAMROOT was set — to an empty string. set -e doesn’t apply
inside the command substitution. Every line passes.
What would have caught it is two characters:
rm -rf "${STEAMROOT:?}/"*So: strict mode is the floor, not the ceiling. The rest of the floor is refusing to interpolate
a path into a destructive command without asserting it is non-empty, using -- before
user-supplied arguments, preferring mktemp to hand-built paths in /tmp, and running the
dangerous version only after the dry run prints what you expected.
Two more things worth knowing before you paste the header everywhere:
- Don’t
sourcea strict script into your interactive shell. The flags apply to the shell that runs them, so a sourcedset -emeans your terminal exits the next time a command returns non-zero — which, in an interactive session, is constantly. - Failing fast leaves things half-done. A script that stops in the middle is only an
improvement if stopping in the middle is safe. Write the steps so they can be re-run
(
mkdir -p,rsync,cpto a temporary name thenmv), because with strict mode on, being re-run is exactly what will happen.
When bash is the wrong tool
Section titled “When bash is the wrong tool”Bash is superb glue and a poor programming language. The honest signals that you have crossed
over: you are simulating structured data with parallel arrays, doing arithmetic that needs a
decimal point, parsing JSON with sed, or nursing a file past a few hundred lines that someone
else will have to modify. Well before that point, Python or Go will cost less. For JSON
specifically, reach for jq rather than regular expressions, and
note that jq -e sets a meaningful exit code so strict mode can act on it.
Bash earns its place when the job is mostly “run these programs in this order, and stop if one of them fails”. Which is precisely what the two lines at the top are for.
Where next
Section titled “Where next”Scripts live in the same place as everything else you have tuned: put them in
git before the disk does its worst. If your scripts are mostly
wrangling API responses, the jq guide is the companion piece to
this one, and fzf turns the interactive ones into something worth
using — its local file; file=$(...) || return shape is the local rule above, in the wild.
For the shell you type into rather than the ones you write, there’s zsh without the
bloat, and for the long-running jobs these scripts kick off,
tmux so they survive the connection dropping.