What `set -e` actually does (and why your script still leaks)
Bourne errexit is the most misunderstood line in every devops onboarding doc. Here is what it actually does, step by step, with the rules in the POSIX spec and the bash extensions that contradict them.
You’ve seen this line. You probably wrote it this morning. Almost every “robust bash script” preamble on the internet starts with some variant of:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'It is, line for line, the most copy-pasted snippet in the DevOps world. And almost every engineer I have worked with, including (until embarrassingly recently) me, has been wrong about what set -e does.
This post is about the rules. Not the folklore.
The folklore
The shorthand engineers carry in their heads tends to be one of:
“It makes the script exit when any command fails.”
“It exits if any command returns non-zero.”
“It’s like try/catch but for bash.”
All three are wrong in ways that matter. The third one is wrong in ways that have caused outages.
Let’s walk them.
Rule 1: errexit is suppressed inside conditions
This is the one that catches everyone. The exit-on-error behavior is suspended for any command whose exit status is being tested. That includes:
- The condition part of
if,while,until. - Anything to the left of
&&or||. - A command negated with
!. - Any command in a pipeline except the last (unless
pipefailis set, of which more below).
So this script does not exit on the failing grep:
#!/usr/bin/env bash
set -e
if grep "needle" haystack.txt; then
echo "found"
else
echo "not found"
fi
echo "still running"That’s intentional and useful. You’d never be able to write conditional logic otherwise. But it leads to a less obvious gotcha:
#!/usr/bin/env bash
set -e
check_thing() {
grep "needle" haystack.txt # might fail
echo "found needle"
}
if check_thing; then
echo "ok"
fiPeople expect check_thing to abort the script when grep fails. It doesn’t. Because check_thing is being called in a condition, errexit is suspended for the entire function body. The function prints found needle even when grep returned non-zero, because the next line doesn’t care about the previous line’s exit status.
This is sometimes called “the inherited errexit problem”. The bash manual carved it into the spec in 4.4 with this sentence:
If a compound command other than a subshell returns a non-zero status because a command failed while -e was being ignored, the shell does not exit.
Read it slowly. The condition under which errexit is suspended propagates into compound commands called from that context. A function that fails in the middle and continues from there is doing exactly what the spec says it should.
Rule 2: pipelines only check the last command
set -e
false | true
echo "still here"Prints still here. The non-zero exit from false is swallowed because a pipeline’s exit status is whatever the last command in the pipe returned, and true returned 0.
That’s what set -o pipefail is for. With pipefail enabled, the pipeline’s exit status is the rightmost non-zero exit, or zero if every command succeeded. Combined with errexit:
set -eo pipefail
false | true
echo "still here" # never printspipefail is a bash extension. POSIX sh doesn’t have it. If you’re targeting /bin/sh on a stripped-down container (BusyBox, Alpine’s ash), you don’t get it, and you’ll have to check $? after the pipeline yourself. In bash you also have PIPESTATUS:
some_command | grep -v noise
echo "${PIPESTATUS[0]}" # exit code of some_command, not of grepIn POSIX sh, the cleanest workaround is a temp file:
tmp=$(mktemp) || exit 1
trap 'rm -f "$tmp"' EXIT
some_command > "$tmp"
status=$?
grep -v noise < "$tmp"If you want to do it without a temp file, the genuinely portable trick is a file-descriptor shuffle that captures the inner command’s exit status onto fd 3 while the rest of the pipeline runs:
exec 4>&1
status=$(
{ some_command; echo "$?" >&3; } 3>&1 >&4 | grep -v noise >&4
)
exec 4>&-
echo "some_command exited with: $status"There is a reason dash is unpopular. There is also a reason that “we’ll just keep this in POSIX sh, it’ll be fine” tends not to survive contact with reality.
Rule 3: command substitutions are their own subshells
This one bit me last quarter:
#!/usr/bin/env bash
set -e
CONFIG_HASH=$(sha256sum missing-file.txt | cut -d' ' -f1)
echo "Hash is $CONFIG_HASH"You might expect the script to abort at sha256sum. It does not. CONFIG_HASH ends up empty, echo "Hash is " prints fine, and the script continues into whatever downstream step uses an empty hash.
Two errexit-bypass mechanisms stack here. First, sha256sum is not the last command in its pipeline; cut is. cut happily reads its empty stdin and exits 0, so the pipeline’s exit status is 0. Second, even if you fixed that, command substitution $(...) runs in a subshell, and the subshell does not inherit errexit by default.
The fix is to check explicitly:
if ! CONFIG_HASH=$(sha256sum missing-file.txt | cut -d' ' -f1); then
echo "couldn't hash config" >&2
exit 1
fiOr, in bash 4.4+, shopt -s inherit_errexit so command substitutions actually honor -e. It’s off by default. Most “robust bash” templates online don’t include it.
Rule 4: || true kills errexit silently
A pattern you see in every CI script:
set -e
some_step || echo "step failed, continuing anyway"
later_stepFine. The intent is visible, the operator knows what happened.
This one isn’t fine:
set -e
result=$(some_step) || true
process "$result"If some_step fails, result is empty and process runs on no input. The || true turns the failure into a silent success. No log line, no exit code, nothing for an operator to grep for. The bug surfaces three layers down inside process with a generic message like expected non-empty argument, and now you’re debugging a different function.
This is the shell equivalent of try { x() } catch { /* swallow */ }. It’s how production scripts develop two-year-old silent corner cases.
Verifying Rule 1
Save the following as errexit-test.sh:
#!/usr/bin/env bash
set -e
step() {
echo " step: about to fail"
false
echo " step: still alive after failure"
}
echo "calling step directly:"
step
echo "after direct call (should never print)"Run it:
$ bash errexit-test.sh
calling step directly:
step: about to fail
$ echo $?
1Good. Now add the call inside an if:
echo "calling step in if:"
if step; then
echo " step returned ok"
else
echo " step returned non-zero"
fi
echo "after if (will print)"$ bash errexit-test.sh
calling step in if:
step: about to fail
step: still alive after failure
step returned ok
after if (will print)step returned zero. Because the echo after false succeeded, and that was the last statement of the function. The false was swallowed by the inherited errexit suspension; the function silently lies about its own success.
This is exactly the class of bug that turns a deploy script into an at-fault outage.
Reference card
| Construct | set -e triggers? |
|---|---|
cmd standalone |
yes |
cmd || true |
no (RHS swallows it) |
cmd && other |
no for cmd (it’s a condition) |
if cmd; then ... |
no for cmd |
! cmd |
no |
cmd1 | cmd2 (no pipefail) |
only if cmd2 fails |
cmd1 | cmd2 (pipefail) |
yes |
var=$(failing_cmd) |
yes (subshell’s exit propagates) |
var=$(failing_cmd | other) (no pipefail) |
no |
var=$(failing_cmd; succeeding_cmd) (no inherit_errexit) |
no |
var=$(failing_cmd; succeeding_cmd) (inherit_errexit) |
yes |
func called as if func |
no, for the entire function body |
(cmd) subshell |
yes (subshells inherit -e) |
Pin it somewhere you’ll see it.
So what should you do?
A short list, in priority order:
- Treat
set -eas a backstop, not a strategy. Check exit codes explicitly where it matters.set -ecatches the cases you forgot; it should never be your first line of defense. - Always pair errexit with
pipefail,nounset(-u), andinherit_errexit. These close the obvious leaks. - Trap
ERRwith-Eso propagation works inside functions and subshells.LINENOandBASH_COMMANDare both useful in the trap body. - Past fifty lines, write it in a real language. Bash is the wrong tool past a certain complexity threshold. The threshold is lower than your taste suggests.
The shell is a beautiful thing: a fifty-year-old language that still runs the world’s deploys. Its error model was designed for an interactive REPL where the human catches mistakes. When you turn that REPL into an unattended cron job, you have to do the catching yourself.
Further reading:
- BashFAQ #50: I’m trying to put a command in a variable, but the complex cases always fail! — the canonical reference, irreverent and correct.
- The POSIX-2017 errexit specification — read the eight exceptions.
- Greg’s Wiki on
set -e— exhaustive, occasionally polemical, indispensable.