set -euo pipefail, honestly

What it catches, what it famously does not.

Beginner20 min · lesson 9 of 14

Every command your shell runs hands back one number when it finishes. That number is its exit status: 0 means it worked, and anything from 1 to 255 means some flavor of failure. It works like a courier who, after every drop-off, texts you a single digit. Sitting at the keyboard, you read those texts without noticing. You see `No such file or directory` scroll past, you stop, you fix the path, you try again. You are the error handler.

A script has no eyes. It cannot see the red text go by. And bash's built-in reaction to a command that fails is the same as a distracted line cook's when an order gets dropped: shrug, reach for the next ticket, keep moving. `set -euo pipefail`, the line most people call strict mode, throws that shrug out and installs three specific rules that turn a quiet failure into an immediate, loud stop.

The three switches

`set` is the shell's own control panel. It is a builtin, a command baked into the shell itself rather than a separate program on disk, and its job is to flip shell-wide options on and off. A minus sign turns an option on, a plus sign turns it off. (That backwards-looking convention is a Unix quirk you get used to.) The strict-mode line turns on three of these switches.

`-e` (its long name is `errexit`) is the supervisor on the line who halts everything the instant a worker flags a problem. The moment a command returns non-zero, the shell exits with that same status instead of running the next line. `-u` (`nounset`) refuses to act on a blank order form: expanding a variable that was never set becomes a fatal error instead of quietly turning into an empty string. `-o pipefail` changes how a pipeline reports back. A pipeline is a bucket brigade of commands joined by `|`, each running as its own process, passing its output down the line to the next. Normally a pipeline's exit status is whatever the last command returned, so `broken | tee log` counts as success as long as `tee` writes its file. With `pipefail`, a failure at any stage stays visible.

Here is why the default shrug is dangerous. This script clears out old release directories.

cleanup.sh
#!/usr/bin/env bash
# cleanup.sh: no strict mode
cd /var/app/releases/staging
rm -rf ./*.old
echo "cleanup done"
~/secopslog — bash
$ ./cleanup.sh echo $?
./cleanup.sh: line 3: cd: /var/app/releases/staging: No such file or directory cleanup done 0

Read that slowly. The `cd` failed, bash printed the error to stderr (the stream where programs send error text), and then ran `rm -rf ./*.old` anyway, in whatever directory the script happened to start in. If cron (the background job scheduler on Linux) launched it from `/root`, you just deleted files you never meant to touch. And the script's exit status is 0, because the last command that ran, `echo`, succeeded. Your monitoring sees green. Your CI (continuous integration) pipeline sees green. Every `&&` chain that calls this script sees green. That is the exact failure strict mode exists to stop: a mid-script error that silently changes what every later line does.

Add the strict header on line 2 and run it again.

~/secopslog — bash
$ ./cleanup.sh # now with 'set -euo pipefail' on line 2 echo $?
./cleanup.sh: line 4: cd: /var/app/releases/staging: No such file or directory 1

The `cd` failed, so the script stopped there. `rm` never ran, and the exit status is 1, the real one. Monitoring can finally tell the difference between a job that worked and a job that fell over on its second line.

How bash decides to stop

`errexit` is a check bash runs after every command finishes. If the status is non-zero and the command was not already being tested, the shell exits with that status. That word 'tested' is the whole game. When a command sits somewhere you are clearly asking a question rather than giving an order, bash assumes you will handle the answer yourself and switches `-e` off for that spot. Those spots are the condition of an `if`, `while`, or `until`; anything to the left of `&&` or `||`; and any command flipped with `!`. There, a non-zero status is data, not a crash.

A command just returned non-zero. Does the shell exit?
errexit is on. A command failed.
in an if / while / until test, left of && or ||, or after !
No, errexit is suspended
You are testing the result, so bash hands it to you to deal with
anywhere else
Yes, the shell exits with that status
errexit fires at once; the next line never runs

`nounset` works earlier, during parameter expansion (the step where bash swaps `$VAR` for its value). Touch a name that was never set and the script aborts before the command even starts. An empty-but-set variable passes fine, and `${VAR:-default}` is the escape hatch for when 'unset' is a legitimate state. `pipefail` works at the far end, when a pipeline tears down: bash gathers every stage's exit status and reports the rightmost non-zero one instead of only the last stage's.

Here is that last rule doing real work. You are auditing whether root can log in over SSH (secure shell, the standard encrypted remote-login protocol), and you fat-finger the config path.

~/secopslog — bash
$ grep 'PermitRootLogin' /etc/ssh/sshd_confg | tail -1 echo $?
grep: /etc/ssh/sshd_confg: No such file or directory 0

Exit status 0. Your audit script reads that as 'the check ran, root login looks fine' when the check never ran at all. Turn on `pipefail` and the truth comes back.

~/secopslog — bash
$ set -o pipefail grep 'PermitRootLogin' /etc/ssh/sshd_confg | tail -1 echo $?
grep: /etc/ssh/sshd_confg: No such file or directory 2

`grep` returns 2 for a real error like a missing file. (It returns 1 only when the file is readable but nothing matched.) Without `pipefail`, `tail`'s clean 0 hid it. With `pipefail`, grep's 2 is what the pipeline reports, so your script stops instead of certifying a config it never opened.

Where strict mode lies to you

Now the honest part, the sharp edges the one-line tutorials skip. The first: a function called inside an `if` condition runs its entire body with `errexit` switched off. Every command in it runs unchecked, because bash decided you were testing the function's result and stepped back. Wrap a big function in an `if` and you have quietly disarmed strict mode for everything inside it.

The second edge is command substitution, the `$(...)` that runs a command and pastes its output in place. It runs in a subshell (a separate child copy of bash), and by default that child does not inherit `errexit`. So a step inside `$(...)` can fail without stopping anything.

report.sh
#!/usr/bin/env bash
set -euo pipefail
host=$(cd /etc/nonexistent; hostname)
echo "reporting as: $host"
output
$ ./report.sh
./report.sh: line 3: cd: /etc/nonexistent: No such file or directory
reporting as: web-01

The `cd` failed inside the subshell, but the subshell shrugged (no `errexit` there), ran `hostname`, and handed back a value. Strict mode never fired. On line 3 you built a variable on top of a step that failed, and the script sailed straight on. Hold that thought; the fix lands in the next section.

There is a flip side too. Some perfectly healthy commands return non-zero on purpose, and strict mode will kill your script over them if you let it. `grep` returns 1 when it finds nothing, which is often a normal result. The arithmetic command `((n++))` returns 1 whenever the expression works out to 0, so a counter that starts at zero takes down a strict script on its very first increment. And a pipeline like `yes | head -3` reports failure: `head` reads three lines and closes the pipe, `yes` keeps writing into it, gets hit with SIGPIPE (signal 13, 'the program reading you has gone away'), and exits 141. The same 141 bites `find / -type f | head -1`, a pattern you reach for constantly.

~/secopslog — bash
$ set -o pipefail yes | head -3 echo $?
y y y 141

The answer is never to rip out strict mode. It is to make each expected failure explicit, so bash can tell 'this one is fine' apart from 'this one is a real problem.'

strict-patterns.sh
matches=$(grep -c 'ERROR' app.log || true) # 1-on-no-match is fine here
n=$((n + 1)) # returns 0 even when n was 0
if ! state=$(systemctl is-active nginx); then
echo "nginx is not active: $state" >&2 # handled, so errexit stays quiet
fi
local and export hide the failure you care about
`local dir=$(mktemp -d)` looks safe under strict mode, but it is not. The exit status of that line belongs to the `local` builtin, which succeeds even when `mktemp` fails, so `dir` ends up empty and the script keeps running with a blank path. Split it in two: declare the name first (`local dir`), then assign on its own line (`dir=$(mktemp -d)`), and the substitution's failure is fatal again. The same trap applies to `export` and `declare`. ShellCheck (a static analysis tool for shell scripts) flags this as SC2155.

A header you can trust

Here is the header worth pasting at the top of every operational script.

strict-header.sh
#!/usr/bin/env bash
set -euo pipefail
shopt -s inherit_errexit # bash 4.4+: $(...) subshells keep errexit on

That last line closes the command-substitution gap from earlier. `inherit_errexit` is a shell option (`shopt` is the builtin that toggles this second family of options). It tells those `$(...)` child shells to keep `errexit` on, so the failing `cd` in `report.sh` would stop the script instead of quietly feeding you the wrong hostname.

The security argument for `-u` is short. An unset variable, a typo in a name, an environment variable that never got exported, expands to nothing and silently rewrites the command around it. `nounset` turns that into an instant abort. The famous cautionary tale is Valve's Steam client, which once shipped `rm -rf "$STEAMROOT/"*` in a script where `$STEAMROOT` could come out empty, expanding the line to `rm -rf "/"*` and erasing users' files. Be precise about the mechanics, though. In that bug `STEAMROOT` was set to an empty string by a failed command substitution, not left unset, and an empty-but-set value sails straight past `nounset`. The guard that catches both halves is `${STEAMROOT:?}`, which aborts when the variable is unset or empty. `-u` covers the unset half across the whole script for free; `:?` covers the empty half at the exact spot where an empty value would be catastrophic.

Quick check
01A script runs `rm -rf "$STEAMROOT/"*`, and `STEAMROOT` gets set to an empty string by a command substitution that failed. The script already uses `set -u`. Why does `-u` not save you here, and what does?
Correct — nounset checks 'set or not', not 'empty or not'; `:?` checks both.
Incorrect — `-u` does not fire on an empty-but-set variable, and it has existed for decades.
Incorrect — `nounset` and `pipefail` are independent and check unrelated things.
Incorrect — `${VAR:?}`, or validating inputs before any destructive command, prevents exactly this.
02With `set -e` (errexit) active, a command that exits non-zero normally stops the script. In which position is that non-zero status treated as data instead, so the script keeps running?
Correct — in those spots Bash assumes you are testing the result rather than giving an order, so it suspends errexit and hands you the status.
Incorrect — a failure inside `$(...)` is a real subtlety, but the outer command using the substitution is still subject to errexit; capture alone does not make a top-level failure safe.
Incorrect — position in the file is irrelevant; the final command's non-zero status still triggers errexit.
Incorrect — privilege level has nothing to do with whether errexit fires.
03A script starts with `set -euo pipefail`, sets `count=0`, then inside a loop increments with `((count++))`. It dies on the very first iteration with no error message. Why, and what is a safe fix?
Incorrect — integer declaration is not required for `((...))`, and its absence is not what stops the script.
Correct — `((expr))` returns non-zero whenever the expression evaluates to 0, so a counter starting at 0 trips errexit on its first post-increment.
Incorrect — `count=0` sets the variable, so nounset has nothing to complain about.
Incorrect — there is no pipeline in `((count++))`, so pipefail is irrelevant.

Two limits are worth respecting. Strict mode lives inside one bash process, so it does not travel with you into `ssh host 'commands'` or into a child script you call; each of those needs its own header. And it does not belong in an interactive shell or in a library you `source`, where it changes (or kills) the calling shell out from under whoever ran it. The deeper limit is what strict mode cannot see at all. It catches commands that fail loudly. It does nothing about a command that succeeds while doing the wrong thing, and the biggest source of that in shell is the unquoted variable. `rm -rf $target/old` with `target='release notes'` deletes `release` and `notes/old`, two paths you never named, and returns exit status 0 with strict mode fully satisfied. That mechanism is called word splitting, and the quoting discipline that shuts it down is the next lesson.

Related