A bash script starts with `set -e`, yet a command that fails inside it does not stop the script. In which contexts does bash deliberately ignore `set -e`, and how do you make the failure surface anyway?
answer
- status used as data, not as error
- if, while, &&, ||, !
- the suspension is inherited
- declare and assign separately
- errexit is a backstop, not a check
basics
~20 sBash suspends set -e wherever a non-zero status is being used as data: the condition of if, while or until, every command in a && or || list except the last, and any command negated with !. A function called from such a position runs its whole body with set -e suspended.
solid answer
~50 s`set -e` is suspended whenever a command's exit status is being consumed rather than checked — in an `if`, `while` or `until` condition, in every element of a `&&` or `||` list except the final one, and when the status is inverted with `!`. The trap that surprises people is that this is inherited: if the tested command is a shell function, the *entire function body* runs with errexit off, so a `false` halfway through does not stop it, and the function returns the status of whatever line ran last. Two more blind spots matter in practice: a declaration builtin masks status, so `local out=$(failing_cmd)` reports `local`'s own zero; and a failure in a non-final pipeline stage is invisible unless `pipefail` is set. The fix is to stop relying on errexit for anything you actually care about — check explicitly with `cmd || die "..."`, declare and assign on separate lines, and add an ERR trap so a failure is at least reported.
code
bash · 11 lines#!/usr/bin/env bash
set -e
check() {
false
echo "still running"
}
if check; then
echo "check succeeded"
figo deeper
Know that a failing command inside an if condition is not treated as an error, because the shell is asking a question there rather than running a step. That single fact explains most of the surprises.
Explain the underlying rule — errexit is suspended where a status is consumed as data — and list the contexts: if/while/until conditions, non-final elements of && and || lists, and ! inversion.
Demonstrate the inherited case: a function called from a tested position runs its whole body unprotected and returns its last command's status. Show the local masking bug and describe the explicit checks you use so the script does not depend on errexit for anything critical.
Frame it as a policy question. Argue what a team should mandate in review or in a linter, when defensive shell has grown past the point where these semantics are reviewable, and what error-handling contract scripts must meet before they are allowed to run unattended.
## The rule behind the exceptions The exceptions look arbitrary until you see the principle: **bash suspends `errexit` wherever a non-zero exit status is being used as a value rather than treated as an error.** If you wrote `if grep -q PATTERN file; then`, a non-zero status from `grep` means "not found", which is the answer you asked for — killing the script there would make `if` useless. The documented contexts where `set -e` does not act are: - any command in the condition list of `if`, `elif`, `while` or `until`; - any command in a `&&` or `||` list **except the last one**; - any command whose status is inverted with `!`; - any command in a pipeline other than the final one (this one is really about how a pipeline's status is computed, and `set -o pipefail` is the knob that changes it). ## The exception that actually bites: functions Suspension is not per-command; it applies to everything executed in that position, including the whole body of a shell function invoked there. ```bash #!/usr/bin/env bash set -e check() { false # errexit is suspended here... echo "still running" } if check; then # ...because check runs as an if condition echo "check succeeded" fi ``` This prints `still running` and then `check succeeded`. Both surprises are present at once: the `false` did not stop the function, and the function's return status is the status of its last command — the successful `echo` — so the `if` takes the true branch. A validation function written this way reports success no matter what it finds. The same thing happens with `check && deploy`, or `if ! check; then`. This is why "wrap it in an `if` to be safe" is bad advice: putting a call in a tested position *removes* protection from everything it runs. ## The assignment blind spot A plain assignment does propagate the status of its command substitution, so under `set -e` this aborts as you would hope: ```bash out=$(exit 3) # the assignment's status is 3 — errexit fires ``` But when the assignment goes through a declaration builtin — `local`, `declare`, `export`, `readonly` — the status reported is the *builtin's* status, which is 0 unless the builtin itself was misused: ```bash fetch() { local out=$(exit 3) # status is local's, which is 0 echo "survived: ${out:-empty}" } ``` The fix is to split declaration from assignment: ```bash fetch() { local out out=$(exit 3) # now the assignment's own status is seen echo "unreachable under set -e" } ``` Static analysis flags this pattern, and it is worth internalising because it appears in almost every real script that captures command output into a scoped variable. ## Other places nothing fires - **Backgrounded commands.** `cmd &` returns immediately with the status of the fork, not of `cmd`; the eventual failure surfaces only when you collect it. - **Arithmetic commands that evaluate to zero.** `((count++))` returns status 1 when the value it produced is 0, which under `set -e` terminates the script on a perfectly ordinary counter increment. Writing `count=$((count + 1))`, or `((count++)) || true`, avoids it. - **Commands that fail without a non-zero status.** Errexit only sees exit codes. A tool that prints an error to stderr and exits 0 is invisible to it. ## What to do instead Treat `set -e` as a backstop for the failures you did not anticipate, and handle the ones you did anticipate explicitly: 1. **Check what matters.** `cmd || die "cmd failed"` works in every context, including the ones where errexit is suspended, and it produces a message. 2. **Restructure rather than test.** If a function must abort on failure, do not call it from an `if`. Let it run in a plain statement position and have it call `die` itself. 3. **Capture the status when you need the value.** `if ! out=$(cmd); then die "..."; fi` keeps both the output and the check. 4. **Split declaration from assignment** in every function that captures output. 5. **Add an ERR trap** so that when errexit does fire, the script says where and why instead of dying silently. The interview point is not memorising the list. It is showing that you know `set -e` encodes a policy with holes in it, and that anything the script genuinely depends on gets checked by hand.
- If `set -e` is suspended for the whole body of a function called from an `if`, how do you write a validation function that still aborts on a real error?Do not put it in a tested position. Have the function call `die` itself on the conditions that are fatal and `return` a status only for the question the caller is actually asking. If the caller must both test and abort, capture the status explicitly: `if ! validate; then die "validation failed"; fi` keeps the abort in the caller, where errexit is active.
- Why does `((count++))` sometimes kill a script that runs under `set -e`?An arithmetic command returns status 1 when the expression it evaluates is zero. With `count` at 0, `((count++))` yields the old value 0 and so returns 1, which errexit treats as a failure. Use `count=$((count + 1))`, the pre-increment `((++count))`, or append `|| true` when the value can legitimately be zero.
- Does `set -e` still apply inside an explicit subshell such as `( cmd1; cmd2 )`?Yes — the subshell inherits errexit and aborts internally at the first failure, returning non-zero to the parent. But whether the parent then dies follows the same rules: if the subshell sits in an `if` condition or on the left of `&&`, the parent's errexit is suspended for it, so the failure is reported as a status and nothing more.
saying these in an interview costs you the question
- Wrapping calls in if statements to make them safer
- Assumes set -e fires everywhere in the script
- Thinks local x=$(cmd) propagates cmd's exit status
- Believes a function returns non-zero as soon as any line fails
- Says pipefail alone fixes all set -e blind spots