Why `set -e` does not exit on failure

set -e has exceptions: commands in if, ||, &&, pipelines without pipefail, and functions called from conditionals never trigger an exit. The full list.

Error String

set -e is on but the script continues after a failure

Tldr

`set -e` is intentionally lenient: it ignores failures inside conditionals (`if`, `&&`, `||`, `!`), inside the test of a loop, and inside pipelines (only the last command's exit status counts unless `pipefail` is set). Add `set -o pipefail` and check command exit codes explicitly when you need strict failure semantics.

Cause

POSIX defines specific contexts where `set -e` is suppressed so common idioms like `if grep …; then` don't abort. People treat `set -e` as "fail on any error" but it really means "fail on unchecked top-level errors".

Repro

#!/usr/bin/env bash
set -e
false | true             # no abort — last command (true) succeeded
if false; then :; fi     # no abort — false is the condition
my_fn() { false; }
my_fn || echo "handled"  # no abort — || handles the failure

Fix

#!/usr/bin/env bash
set -euo pipefail        # add pipefail and nounset

# Check critical commands explicitly:
if ! critical_command; then
  echo "failed" >&2
  exit 1
fi

Explanation

`set -euo pipefail` is the recommended "strict mode" baseline. For functions that should propagate failures, avoid calling them with `||` unless you genuinely want to handle the error.

Faq

Q

Does set -e work inside functions?

A

Yes, unless the function is invoked in a condition context — then failures inside it are suppressed too.

Q

Why is `local x=$(false)` not fatal?

A

`local` is a command that succeeds, and it becomes the statement exit status, hiding the substitution failure.

Deep Dive

Heading

The documented exceptions

Body

`set -e` ignores failures in a command that is part of a condition: anything in `if`, `while`, `until`, on the left of `&&` or `||`, or negated with `!`. A function called in such a context inherits the suppression for its whole body, which is the most surprising case — a helper that works standalone stops aborting when someone writes `if my_helper; then`. Failures inside `$(...)` used in an assignment are also ignored, because the assignment itself succeeds.

Heading

Making failures visible anyway

Body

Add `set -o pipefail` so a failing producer in a pipeline is not masked by a successful consumer, and check important commands explicitly: `cmd || { echo "cmd failed" >&2; exit 1; }`. Separate declaration from assignment — `local out; out=$(cmd)` — so the command status is the statement status. An `ERR` trap with `set -o errtrace` gives a stack trace on failure, and ShellCheck flags several of the patterns where `set -e` silently does nothing.

Checklist

Check whether the failing command sits in a condition or after `&&`/`||`.

Split `local var=$(cmd)` into declaration and assignment.

Add `set -o pipefail` for pipelines.

Use explicit `|| { ...; exit 1; }` for commands that must not fail.