The first line of a deploy script says set -e. That is supposed to stop the script the moment a command fails, so it feels safe, yet the log shows "done" on the line right after grep complained that a file was missing. set -e is not broken: there are specific places where it deliberately ignores a failure. After reading this you will be able to point to those places and change a script so that a failure really does stop it.
The examples were run on 2026-10-12 with the /bin/bash 3.2.57 that ships with macOS 27.0.1. Each case ran in its own bash process so that one exit could not prevent the next case, and files such as missing.log were deliberately not created. The behaviour was checked against the GNU Bash 5.3 manual (edition of 2025-05-18). An option that exists only in Bash 4.4 and later could not be run on this Mac, so it is marked as checked in the documentation only.
A pipeline reports only its last command
Start with the most common case.
set -e
grep 'ERROR' missing.log | sort > errors.txt
echo "after pipeline: still running"
Running it printed grep: missing.log: No such file or directory, then the next line anyway, and the script ended with exit code 0. By default a pipeline's exit status is that of its last command, and here the last command, sort, received empty input and finished normally. The Bash manual's description of set -e also says the shell does not exit when the failing command is any command in a pipeline but the last.
Adding set -o pipefail to the same script changed the result. The next line was not printed and the exit code was 2. With pipefail on, a pipeline's status is that of the rightmost command that exited with a non-zero status; here that was the 2 grep returns when it cannot find the file, and it became the script's exit code. The option is off by default.

Inside if, && and ||, a failure is just an answer
The second place is a condition. In test -f config.env && echo "config found", test fails when the file is missing, but the script moved on to the next line. The manual lists the exceptions: the condition after while or until, the test of an if, any command in an && or || list except the one after the final && or ||, and a command whose status is inverted with !. There the failure is not treated as an error but as the answer that picks a branch.
The catch is that this exception reaches into functions. The load function below fails on its first line, the cp.
load() {
cp missing.conf copy.conf
echo "inside load: cp failed but kept going"
}
if load; then echo "if says load succeeded"; fi
load
Called as if load, the echo inside the function ran after the cp error, and because that echo, the function's last command, succeeded, if treated the call as a success. Called on its own outside a condition, the same function stopped right at cp and the script ended with exit code 1. The manual explains that when a function or compound command runs in a context where -e is ignored, none of the commands inside it are affected by -e. Writing set -e again inside the function has no effect until that context ends.
DEV NOTES
Where set -e looks the other way
A command that fails here does not stop the script
Early in a pipeline
grep … | sort- Only the last status counts
- pipefail changes this
if, while, until tests
if load; then …- Failure means 'false'
- It picks a branch
Early in && and || lists
test -f x && echo ok- Only the final command counts
- A ! command is exempt too
Functions called there
if load → cp: 1 · echo: 0- The whole body runs without -e
- set -e inside has no effect yet
Per the GNU Bash 5.3 manual, The Set Builtin. Examples run on macOS /bin/bash 3.2.57 on 2026-10-12.
local x=$(…) hides the failure
The third place is a variable declaration. Inside a function, local v=$(cat missing.txt) let the next line run even though cat failed, and v was an empty string. The status of that line belongs to the local command, not to cat. The manual says local returns non-zero only when it is used outside a function, a name is invalid, or the variable is read-only. Whether the command substitution failed is not part of that.
Splitting the declaration from the assignment changes this. With local w first and w=$(cat missing.txt) on the next line, the line's status became the substitution's, and the script stopped with exit code 1. export behaves the same way: in the same function, export E=$(cat missing.txt) moved on to the next line even though cat failed. The fix is the same split.
The inside of a command substitution needs its own look. Running out=$(false; echo "inner line after false") executed the echo after false, stored that string in out, and, since the last command succeeded, the assignment succeeded too. The manual states that when Bash is not in POSIX mode it clears -e in the subshells that run command substitutions. According to the Bash 4.4 NEWS file and the manual, from Bash 4.4 shopt -s inherit_errexit makes those subshells keep -e. macOS's Bash 3.2.57 does not have this option, so it was not run here.
DEV NOTES
Why doesn't local v=$(…) stop?
Whose exit status does the line report?
Command substitution
$(cat missing.txt)- cat fails with status 1
- The result is empty
local assigns it
local v=$(…) → 0- The line reports local's status
- Declaring worked, so 0
Split it in two
local w; w=$(…) → 1- Assignment reports the substitution
- set -e stops here
export E=$(…) hid the failure the same way when run. Inside a command substitution, Bash runs without -e unless it is in POSIX mode.
Making the script stop where it should
The last run is a version with all three problems fixed. The first line became set -euo pipefail (-u turns the use of an unset variable into an error). In a function that may be called from a condition, every step that can fail returns its failure explicitly, as in cp missing.conf … || return 1. The variable is declared and assigned on two lines, local w and w=$(…).
The result was as expected. This time if load saw the failure, took the else branch and printed status 1, and read_version stopped on the line where cat failed, ending the script with exit code 1. None of the lines after it ran.

set -e is closer to "stop when a command that is not being used as a test fails" than to "stop on every failure". So rather than relying on the option alone, check the result yourself at the steps that must not fail. The checks, in order:
DEV NOTES
Making failures actually stop the script
Four places to check in a script
The first line
set -euo pipefail- Catch failures early in pipes
- -u makes unset variables errors
Functions used as tests
cp a b || return 1- Return on each step that can fail
- Then if sees the failure
local, export and $(…)
local w; w=$(cmd)- Declare and assign on two lines
- The assignment carries the failure
Inside $(…)
shopt -s inherit_errexit- Only the last command counts
- Available from Bash 4.4
inherit_errexit was added in Bash 4.4, so macOS's default Bash 3.2.57 lacks it; it was not run for this note (checked in the manual and NEWS).
One thing to try on a script you have now: find the lines with a |, the functions called from if or &&, and the lines where local or export sits next to $(…). Those three are where set -e quietly lets a failure through.
Add your perspective.
Share a question, another approach, or something you have tried.
Checking sign-in…
Loading comments…