skip to content

This bash script runs under `set -euo pipefail`, yet it prints `deploying` with an empty version and carries on after `get_version` fails: get_version() { cat VERSION; } main() { local ver=$(get_version); echo "deploying $ver"; } Why does the failure not stop the script, and what is the fix?

level: seniorimportance: should knowfreq 46%

answer

  1. the line has a command word on it
  2. whose exit status wins?
  3. the builtin succeeded, so the line succeeded
  4. two lines instead of one
  5. ShellCheck has a code for exactly this

basics

~20 s

local is itself a command, and its exit status — not the substitution's — becomes the status of the line, so the failure is masked and set -e never fires. Declare and assign on separate lines: local ver; ver=$(get_version). ShellCheck flags the original as SC2155.

solid answer

~40 s

A command substitution's exit status is the exit status of the command it ran, and for a **plain** assignment like `ver=$(get_version)` that status becomes the status of the assignment, so `set -e` aborts as expected. But `local ver=$(get_version)` is not a plain assignment — it is a call to the `local` builtin with an argument. Bash expands the argument first, then runs `local`, and `local` succeeds, returning 0. The substitution's non-zero status is thrown away, `set -e` sees a successful command, and the script continues with `ver` empty. The same trap applies to `declare`, `typeset`, `export` and `readonly`. The fix is to split the declaration from the assignment: `local ver; ver=$(get_version)`. ShellCheck reports this as SC2155, "Declare and assign separately to avoid masking return values".

code

bash · 15 lines
bash
#!/usr/bin/env bash
set -euo pipefail

masked() { local v=$(false); echo "masked: status=$?"; }
masked    # prints masked: status=0  -- set -e never fires

# the shape you actually want in production code
read_version() {
  local ver
  if ! ver=$(cat VERSION); then
    echo "VERSION unreadable" >&2
    return 1
  fi
  printf '%s\n' "$ver"
}

go deeper

for a junior

Recognise the two-line habit and copy it: declare with local on one line, assign on the next. Knowing that local x=$(cmd) is discouraged, even before you can explain why, already prevents the bug.

for a middle

Explain the mechanics: local is a command, its own status becomes the line's status, and the substitution's failure is discarded during argument expansion — while a plain assignment propagates it. Name SC2155.

for a senior

Demonstrate the diagnosis path from the symptom: a green job, an empty variable, an error on stderr nobody read. Show the if ! ver=$(...) shape, and articulate set -e's other blind spots rather than treating strict mode as a guarantee.

for a principal

Own the systemic answer: mandatory ShellCheck in CI turns this class of silent failure into a build error, and scripts that must not half-succeed need explicit failure handling plus post-conditions rather than reliance on shell defaults.

## The rule that is being violated Two separate rules collide here, and you need both to explain the bug. **Rule 1 — a command substitution carries the command's exit status.** `$(get_version)` runs `get_version` in a subshell; if that fails, the substitution's status is the failure status. **Rule 2 — where that status ends up depends on what kind of command the line is.** For a simple command consisting only of assignments — no command word at all — the exit status of the line is the exit status of the last command substitution performed. So: ```bash set -e ver=$(false) # status 1 -> set -e fires, script exits echo unreachable ``` That works. The trouble starts the moment there *is* a command word. ## Why `local` changes everything `local` is a shell builtin — a command. `local ver=$(get_version)` is parsed as: the command `local`, with one argument, and that argument happens to contain a command substitution. Bash performs the expansion to build the argument, then executes `local` with the resulting string. `local` does what it is asked (creates a function-local variable and assigns it) and returns 0. The substitution's status was consumed during argument expansion and is not reported anywhere. `$?` after the line is `local`'s status, which is 0. `set -e` is watching the *command's* status, sees success, and does nothing. ```bash f() { local v=$(false); echo "status=$?"; } # prints status=0 g() { local v; v=$(false); echo "status=$?"; } # prints status=1 ``` The same applies to every declaration builtin: `declare`, `typeset`, `export`, `readonly`. `export TOKEN=$(some_failing_cmd)` masks failure exactly the same way, and so does `readonly CONF=$(cat missing.conf)`. ## Why the symptom is so nasty The script does not just continue — it continues with an **empty variable**, because the failed command wrote nothing to stdout. You might hope `set -u` would rescue you, but `set -u` only triggers on *unset* variables, and `ver` is very much set; it is set to the empty string. So the script sails on and does something with an empty version: tags an image `myapp:`, deploys to a path with a missing component, or writes a config with a blank field. The error message from `cat` did appear on stderr, but in a CI log with thousands of lines nobody sees it, because the job went green. ## The fix Split declaration from assignment: ```bash main() { local ver ver=$(get_version) # plain assignment -> status propagates, set -e fires echo "deploying $ver" } ``` If you want to handle the failure rather than abort, put the assignment in a condition, which is both explicit and immune to `set -e`'s own blind spots: ```bash local ver if ! ver=$(get_version); then echo "cannot read VERSION" >&2 return 1 fi ``` A third pattern, for a value that has a sensible default: ```bash local ver ver=$(get_version) || ver=unknown ``` ## The lint rule ShellCheck emits **SC2155**, "Declare and assign separately to avoid masking return values", on `local x=$(...)`, `export x=$(...)` and friends. It is one of the highest-value ShellCheck findings precisely because the code looks fine and the failure is silent. Teams that run ShellCheck in CI never ship this bug; teams that do not, ship it repeatedly. ## Adjacent blind spots worth naming The interviewer is often probing whether you believe `set -e` is a complete safety net. It is not, and this is one of several holes: - A command whose status is *consumed* by the shell — in an `if` condition, on the left of `&&`/`||`, or after `!` — never triggers `set -e`. - A failing command inside a function that is itself used in a condition does not trigger it either. - And this case: a command substitution whose status is absorbed by a declaration builtin. Knowing that `set -euo pipefail` is a strong default *with named exceptions* is the senior-level answer; treating it as a guarantee is the junior one. ## The short answer to give "`local` is a command, so its own zero status is what the line returns; the substitution's failure is discarded during argument expansion. Split it — `local ver; ver=$(get_version)` — and let ShellCheck's SC2155 catch the rest."

  • Does the same masking happen with a plain `ver=$(get_version)` at the top level of a script?
    No. A simple command made up only of assignments has no command word, so its exit status is that of the last command substitution it performed. `ver=$(false)` returns 1 and `set -e` aborts. The bug needs a command word in front — `local`, `declare`, `typeset`, `export` or `readonly` — to absorb the status.
  • Would `set -u` have caught the empty version here?
    No. `set -u` aborts on an *unset* parameter, and `ver` was successfully set — to the empty string, because the failing command printed nothing. Empty is not unset. If you want an emptiness guard you have to write it: `: "${ver:?VERSION is empty}"` fails when the variable is unset *or* empty.
  • Name another situation where `set -e` does not fire even though a command failed.
    Whenever the shell consumes the status itself: a command used as an `if` or `while` condition, anything but the last element of an `&&`/`||` chain, and any command preceded by `!`. Crucially this propagates into functions — a function called in a condition runs with `set -e` effectively suspended throughout, so failures deep inside it will not abort.
  • Is there a way to keep the one-line form and still catch the failure?
    Not reliably, and that is the point of SC2155 — the status is gone by the time `local` runs. You can only recover it by not letting the builtin swallow it: declare first and assign separately, or assign inside an `if`/`||` construct. Keeping the one-liner and adding a separate emptiness check tests a symptom, not the failure.

saying these in an interview costs you the question

  • Blames set -e for being disabled inside functions
  • Thinks set -u would have caught the empty value
  • Believes plain assignments mask status the same way
  • Says the fix is checking whether the variable is empty
  • Assumes set -euo pipefail catches every failure

context