DEV BOX

'26/10/13 UPDATE

SHELL / BASH / FIELD NOTES

I added set -e, so why does my script keep running after a command fails?

set -e does not stop on every failure. Execution continues after a failing command early in a pipeline, a command used as a test in if, && or ||, any function called from there, and a line like local v=$(…) that combines a declaration with a command substitution. Using real runs on macOS's Bash 3.2.57 and the GNU Bash manual, this note finds the cause of each and fixes them with pipefail, || return and a split declaration.

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.

bash 3.2.57 output. cases.sh pipe: after grep cannot find missing.log, 'after pipeline: still running' is printed and exit 0. pipefail: the same error, then exit 2. andlist: 'after && list: still running', exit 0. iffunc: after the cp error, 'inside load: cp failed but kept going' and 'if says load succeeded' are printed; called again outside a condition, the cp error is followed by exit 1.
Actual output of scripts with set -e on. Early in a pipeline, in an && list and inside a function called by if, execution continued after a failure.

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.

set -e does not treat the failure of a command used as a test as an error, and a function called from such a place runs its whole body without -e.

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?

  1. Command substitution

    $(cat missing.txt)

    • cat fails with status 1
    • The result is empty
  2. local assigns it

    local v=$(…) → 0

    • The line reports local's status
    • Declaring worked, so 0
  3. 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.

The status of a local v=$(…) line belongs to local, so it is 0 even when the substitution fails. Split declaration and assignment and set -e sees the failure.

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.

bash 3.2.57 output. cases.sh local: after a cat error, local v=$(...) kept going, v=''; after another cat error, export E=$(...) kept going; after a third cat error, exit 1. cmdsub: out='inner line after false', exit 0. fixed: after the cp error, if saw load fail (status 1); after the cat error, exit 1.
Combining local or export with a command substitution made the failure disappear; the version that splits declaration from assignment and adds || return 1 stopped where it failed.

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).

set -euo pipefail is only a starting point. Functions used as tests and lines that combine local or export with $(…) need their own fixes before a failure stops the script.

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.

THE ENDBack to the library
COMMENTS BOX

Add your perspective.

Share a question, another approach, or something you have tried.

Newest first

Checking sign-in…

Loading comments…