skip to content

A bash script runs under `set -e`. Inside a function, the line `local out=$(some-command)` lets the script continue even when some-command exits non-zero, while the same assignment written without `local` aborts. Why does the status disappear, and how do you write it so the failure is caught?

level: seniorimportance: must knowfreq 44%

answer

  1. it is a builtin, not an assignment
  2. whose exit status does the line report
  3. empty string, no error
  4. split declaration from assignment
  5. ShellCheck has a code for this

basics

~20 s

local is a builtin command, so the line's exit status is local's own status — almost always 0 — and the command substitution's failure is thrown away. Split it into two lines: declare with local on one line, assign on the next, so the assignment carries the real status.

solid answer

~40 s

With `local out=$(cmd)`, the whole line is a call to the `local` builtin with one argument; the shell runs the command substitution while expanding that argument, then `local` returns its own status, which is 0 unless the name itself is invalid or read-only. `set -e` and any `||` you attach therefore see success, and the failure vanishes. A bare `out=$(cmd)` is different: it is an assignment statement, and its exit status *is* the status of the last command substitution, so errexit fires. The same masking applies to `declare`, `export`, `readonly` and `typeset`. The fix is to declare and assign separately — `local out; out=$(cmd)` — which is exactly what ShellCheck's SC2155 tells you to do. If you want explicit handling rather than errexit, write `local out; if ! out=$(cmd); then ...; fi`.

code

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

masked() {
  local out=$(false)              # status is local's own (0) - errexit never fires
  echo "masked() continued, out='${out}'"
}

caught() {
  local out
  out=$(false)                    # real assignment - status is false's, script aborts here
  echo "never reached"
}

masked
caught

go deeper

for a junior

Recognise the pattern: local x=$(cmd) is the shape to avoid, and declaring on one line then assigning on the next is the fix you should write by habit.

for a middle

Explain why the status disappears - local is a builtin, so the line reports the builtin's status while a bare assignment reports the command substitution's - and name the declare, export and readonly variants.

for a senior

Show the production angle: the real damage is the plausible empty value flowing into a path or a tag, how you would spot it in a review, and how enforcing ShellCheck SC2155 in CI eliminates the class.

for a principal

Own the wider lesson that errexit is a set of special cases rather than a guarantee, and decide what error-handling contract your shell code must meet before it is allowed to run unattended in production.

## Assignments and builtins are different kinds of line Bash treats `out=$(cmd)` and `local out=$(cmd)` as two different grammatical things, and that is the whole story. **A plain assignment** is not a command invocation. When it contains a command substitution, POSIX and bash specify that the exit status of the assignment is the exit status of the last command substitution performed. So: ```bash out=$(false) echo $? # 1 ``` Under `set -e`, that non-zero status is an unhandled failure and the shell exits. **`local out=$(false)` is a command**: the builtin `local`, with the single word `out=$(false)` as its argument. Argument expansion — including the command substitution — happens first, and its status is consumed in the process. Then `local` performs the assignment and returns *its* status, documented as zero unless `local` is used outside a function, an invalid name is supplied, or the name is read-only. `false` failing is not one of those conditions. ```bash f() { local out=$(false); echo $?; } f # 0 ``` Errexit sees a successful command and moves on. The variable is set to the empty string, and the script proceeds on data that does not exist. ## The same trap in four other spellings This is a property of *assignment-accepting builtins*, not of `local` specifically. All of these mask the status the same way: ```bash local out=$(cmd) declare out=$(cmd) typeset out=$(cmd) export out=$(cmd) readonly out=$(cmd) ``` `export VAR=$(cmd)` at the top level of a script is the version that most often slips past review, because it looks like a plain assignment and there is no function in sight. Attaching an operator does not rescue it, because the status is already 0 by the time the operator is evaluated: ```bash local out=$(cmd) || return 1 # never taken; local succeeded ``` ## The fix, and why it is two lines ```bash local out out=$(cmd) ``` The first line only declares the name and its scope; the second is a genuine assignment statement, so its status is the command substitution's status and `set -e`, `||`, and `if !` all behave as written. ShellCheck reports the one-line form as **SC2155**, "Declare and assign separately to avoid masking return values" — one of the most common findings on real scripts, and one worth fixing rather than suppressing. If you want to handle the failure rather than abort, the same split gives you a clean idiom: ```bash local out if ! out=$(cmd); then printf 'cmd failed: %s\n' "$out" >&2 return 1 fi ``` When you deliberately want to tolerate failure, say so explicitly: `out=$(cmd) || out=$default`. ## Why the bug is worse than it looks The damage is not the missing abort; it is the *plausible* empty value. A version string that is silently `""`, a resolved path that is silently `""` (so a later `rm -rf "$dir/$sub"` has a shorter path than intended), a commit SHA that is empty and gets tagged anyway — these are quiet corruption rather than a loud stop. Combining the split assignment with `set -u` helps, because a variable that never got assigned at all then triggers an unbound-variable error, but note that `local out; out=$(false)` under `set -euo pipefail` aborts on the *assignment*, which is what you actually want. It is also worth being honest about the neighbouring blind spot: `out=$(cmd)` only reports the status of the *last* command in the substitution, so `out=$(a | b)` reports `b` unless `pipefail` is in effect. That is the pipeline's own topic, but it is the reason the two-line fix alone is not a complete error-handling strategy. ## How to answer this in an interview Name the mechanism first — `local` is a builtin, so the line's status is the builtin's, not the substitution's — then give the two-line fix, then generalise to `declare`/`export`/`readonly` and cite SC2155. If you can add why the empty-string result is the real hazard, you have shown production judgment rather than trivia recall.

  • Does the same masking happen with `export VERSION=$(cmd)` at the top level of a script?
    Yes. `export`, `declare`, `readonly` and `typeset` are all builtins that accept assignment-shaped arguments, so the line reports the builtin's status and the command substitution's failure is discarded. It is the more dangerous spelling because there is no function in sight to prompt suspicion. Split it: `VERSION=$(cmd); export VERSION`.
  • Would appending `|| return 1` to `local out=$(cmd)` catch the failure?
    No. By the time `||` is evaluated, the command on its left is the `local` builtin, which already returned 0. The operator sees success and the right-hand side never runs. Only splitting declaration from assignment puts a real status on the left of the operator.
  • After splitting the lines, is the exit status now fully trustworthy?
    For a single command, yes. For a pipeline inside the substitution, `out=$(a | b)` reports only the last element's status unless `set -o pipefail` is enabled, so a failing first stage still passes silently. Pipeline status is its own mechanism and needs pipefail or PIPESTATUS on top of this fix.
  • How would a code review catch this automatically?
    Run ShellCheck in CI. It reports the pattern as SC2155, "Declare and assign separately to avoid masking return values", on every occurrence, including the `declare` and `export` variants. Treating SC2155 as an error rather than a suppressed warning removes the entire class from a codebase.

saying these in an interview costs you the question

  • Thinks local only affects scope and never the exit status
  • Believes set -e catches every non-zero command regardless of form
  • Adds || return 1 to the one-line form and calls it fixed
  • Suppresses SC2155 as noise instead of splitting the lines
  • Assumes an empty variable will fail loudly later on

context