set -euo pipefail, honestly
What it catches, what it famously does not.
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.
#!/usr/bin/env bash# cleanup.sh: no strict modecd /var/app/releases/stagingrm -rf ./*.oldecho "cleanup done"
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.
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.
`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.
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.
`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.
#!/usr/bin/env bashset -euo pipefailhost=$(cd /etc/nonexistent; hostname)echo "reporting as: $host"
$ ./report.sh./report.sh: line 3: cd: /etc/nonexistent: No such file or directoryreporting 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.
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.'
matches=$(grep -c 'ERROR' app.log || true) # 1-on-no-match is fine heren=$((n + 1)) # returns 0 even when n was 0if ! state=$(systemctl is-active nginx); thenecho "nginx is not active: $state" >&2 # handled, so errexit stays quietfi
A header you can trust
Here is the header worth pasting at the top of every operational script.
#!/usr/bin/env bashset -euo pipefailshopt -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.
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.