Skip to content

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 bash
set -euo pipefail

That 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.

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.

#!/usr/bin/env bash
set -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.

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, while or until
  • any command in a && or || list except the last one
  • any command in a pipeline but the last (unless pipefail is 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.

The exemption applies to everything inside the function too, all the way down:

#!/usr/bin/env bash
set -euo pipefail
check() {
false # does not abort anything
echo "still running"
}
if check; then
echo "check passed"
fi
still running
check passed

The 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:

Terminal window
check() {
if ! some-test; then
return 1
fi
echo "still running"
}

This one has cost more people more time than the rest put together:

#!/usr/bin/env bash
set -euo pipefail
masked() { local out=$(false); echo "masked: still running"; }
unmasked() { local out; out=$(false); echo "unmasked: never reached"; }
masked
unmasked
masked: still running

local 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”
Terminal window
set -e
out=$(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:

Terminal window
shopt -s inherit_errexit

On 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.

((...)) 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:

Terminal window
i=0
((i++)) # status 1, because the value was 0

Under 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:

Terminal window
i=$((i + 1)) # an assignment: status is always 0
((i++)) || true # or say out loud that you don't care

Without it, a typo expands to nothing and the script presses on with a hole where the value should be. With it:

Terminal window
set -u
echo "deploying to $DEPLOY_TARGT"
bash: DEPLOY_TARGT: unbound variable

Two idioms make it liveable. For optional values, supply a default with :-, which counts as handling the variable:

Terminal window
verbose="${VERBOSE:-0}"
first_arg="${1:-}" # positional parameters are unset too

For values the script cannot proceed without, :? turns a silent empty string into a loud death with a message you chose:

Terminal window
: "${DEPLOY_TARGET:?must be set}"
bash: DEPLOY_TARGET: must be set

A pipeline’s exit status is the status of its last command, which means this is a success:

Terminal window
false | true; echo $? # 0

Every 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:

Terminal window
set -o pipefail
false | true; echo $? # 1

There is one genuinely annoying consequence, and it is better to meet it here than at 2am:

Terminal window
set -o pipefail
yes | head -3 >/dev/null; echo $? # 141

head 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:

Terminal window
{ big-command || true; } | head -n 5

The claim is that it stops unquoted expansions splitting on spaces:

Terminal window
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.

Strict mode cannot save an unquoted variable. Here is the whole problem in four lines:

Terminal window
f="my report.txt"
rm $f
rm: my: No such file or directory
rm: report.txt: No such file or directory

The 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 -rf stays 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:

Terminal window
brew install shellcheck # macOS
sudo apt install shellcheck # Debian, Ubuntu
sudo dnf install ShellCheck # Fedora — yes, capitalised

shellcheck 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.

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 bash
set -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.

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 bash
set -euo pipefail
trap 'echo "error: ${BASH_SOURCE##*/}:$LINENO exited with status $?" >&2' ERR
echo "step one"
grep -q nothing /etc/hosts
echo "step two"
step one
error: backup.sh:6 exited with status 1

For 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 bash
PS4='+ ${BASH_SOURCE##*/}:${LINENO}: '
set -x
name=world
echo "hello $name"
+ trace.sh:5: name=world
+ trace.sh:6: echo 'hello world'
hello world

Note 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 file

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 bash
set -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 <<USAGE
Usage: $script_name [-n] SOURCE DEST
-n dry run: report what would happen, write nothing
-h this message
USAGE
}
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.

In 2015 the Steam client for Linux deleted users’ home directories. The code was, in essence:

Terminal window
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:

Terminal window
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 source a strict script into your interactive shell. The flags apply to the shell that runs them, so a sourced set -e means 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, cp to a temporary name then mv), because with strict mode on, being re-run is exactly what will happen.

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.

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.