OnCallReady

Lesson 6.3 · Bash Scripting · 14 min read

What set -e does NOT catch

In plain words

Imagine a smoke alarm in a house. It goes off for most fires, which is great. But it was designed to stay quiet in the kitchen while you are cooking on purpose, in rooms where you said "I will handle it", and in the cellar where only the last room of a chain of rooms is watched. And one clever thing fools it completely: a fire hidden inside a box labelled "local".

set -e is that alarm. It does not fire for commands in an if or while condition, for anything followed by && or ||, for any stage of a pipeline except the last (unless pipefail), and for local x=$(cmd), where local's own success hides the failure. Knowing the blind spots is what makes the alarm useful.

Why this lesson

set -e sounds like "stop on any failure". It is not. There are four places where a command can fail and the script carries on as if nothing happened. Real scripts have shipped broken releases through every one of them, so you learn them now, before you rely on -e.

What you need to know already: the header and set -e (6.1); && and || (1.7); what a pipe | does (1.7).

Three pieces of syntax first

The examples use three things you have not written yet. In one line each:

Hole 1: anything in a condition

if grep -q pattern file; then ...    # grep failing is the whole POINT here
while read -r line; do ...           # read failing (end of file) ends the loop

grep -q searches quietly: no output, only the exit status (0 = found). If set -e fired on a failing condition, if would be useless - so bash switches -e off inside conditions. The price: a command buried in a condition can fail for a real reason (the file is missing) and you will never know.

Hole 2: anything followed by && or ||

important_command || echo "warning"    # -e does not fire

With || you told bash what to do on failure, so the failure counts as "handled" and set -e stays quiet. That is why cmd || true is the standard way to say "this may fail, carry on" - and why a stray || echo hides a real failure.

Hole 3: every stage of a pipeline except the last

./build.sh | tee build.log       # the exit status is TEE's

tee FILE copies its input to the screen and into FILE - a common way to keep a log. tee succeeds, so the pipeline succeeds, so set -e does nothing, even though the build failed. This is the one that ships broken releases.

set -o pipefail fixes it. Bash also keeps every stage's status in an array (a variable holding a list) called PIPESTATUS: ${PIPESTATUS[0]} is the first stage's.

Hole 4: local x=$(cmd) - the genuinely surprising one

f() {
  local x=$(false)     # false failed... but $? is 0 !
  echo "status: $?"    # prints 0
}

local is itself a command, a builtin (a command built into bash, not a separate program), and it succeeded. Its status replaces the one from $(false), so the failure vanishes and set -e sees success. The same happens with export x=$(cmd) and declare x=$(cmd).

The fix is to split it into two lines:

local x
x=$(cmd)             # a plain assignment keeps cmd's status; set -e works

shellcheck - a tool that reads a script and points out likely bugs, each with a code number (6.22) - reports exactly this as SC2155.

So is set -e worth it?

Yes. It catches the common case: a command failing in a plain line-after-line script. Treat it as a safety net, not a guarantee. For anything important, check the command explicitly:

if ! do_the_thing; then          # ! flips success and failure
  echo "do_the_thing failed" >&2 # >&2: send this message to stderr
  exit 1
fi

What you can now do

Why it helps

These four holes are behind real incidents: a build step make | tee that shipped a broken artifact, a deploy || echo "warning" that hid a failed deploy, a helper function whose local token=$(get_token) failed silently and sent requests with an empty token. They are also some of the most common bash interview questions.

Knowing them changes how you review scripts: you look for || true and || echo on important commands, pipelines without pipefail, and SC2155 warnings, and you ask for explicit if ! cmd; then ... exit 1; fi around steps that must not fail silently.

Commands in this lesson

bash

FAQ

Why does set -e ignore failures in if conditions?

Because a condition is supposed to fail sometimes: if grep -q x file is asking a question, and exiting when the answer is no would make if useless. The same applies to while/until conditions, the left side of && and ||, and commands after !. The downside is that functions called in a condition also run with -e effectively disabled inside them, which surprises people.

Is cmd || true a good pattern?

It is the explicit way to say "this command may fail and I do not care", for example rm -f of a file that may not exist, or grep that may find nothing. It is fine when deliberate. The danger is using it, or || echo warn, on commands that matter, which turns real failures into log noise. Prefer handling the specific expected failure, like checking a file exists first.

Does set -e work inside functions?

Yes, in normal calls. But if the function is called as part of a condition (if myfunc, myfunc || handle, myfunc && next), errexit is ignored for the entire body, so a failing command in the middle does not stop it; the function carries on and returns the status of its last command. That makes functions used in conditions a common hidden gap. Check statuses explicitly inside them.

What about set -e in command substitutions?

A plain assignment x=$(cmd) takes the status of the substitution, so set -e triggers if cmd fails. Inside the substitution, bash does not inherit errexit by default unless shopt -s inherit_errexit (bash 4.4+) is set, so x=$(false; echo ok) succeeds. And echo "$(cmd)" never fails, because echo's status is what counts.

How do I find these gaps in an existing script?

Run shellcheck: it flags SC2155 (local x=$(...)), SC2181 (checking $? indirectly) and several others. Then read by hand for pipelines without pipefail, || true or || echo on important commands, and functions called in conditions. Testing failure paths deliberately, by making a dependency fail, is the only way to be sure the script stops.

In an interview Junior

In which cases does set -e not stop a script when a command fails?

Four holes:

  1. Conditions - a command after if or while. A failing condition is the point of if, so bash switches -e off there; a real failure (a missing file) looks like "false".
  2. && and || - important_command || echo warning counts as handled. That is why cmd || true means "may fail", and why a stray || echo hides a failure.
  3. Pipelines - every stage except the last: ./build.sh | tee build.log succeeds because tee did. Fix: set -o pipefail; PIPESTATUS has each stage's status.
  4. local x=$(cmd) - local is itself a command, it succeeds, and its status replaces the failed $(cmd). Same with export and declare. Fix: local x on one line, x=$(cmd) on the next; shellcheck reports it as SC2155.

For anything important, check explicitly: if ! do_the_thing; then echo "failed" >&2; exit 1; fi.

Also asked: What is PIPESTATUS, and when would you use it? · Is set -e worth using, given its holes? · What does cmd || true mean in a script?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.