skip to content

A bash script runs `build_tool | tee build.log`, then stores `rc=$?` on the next line, then checks `${PIPESTATUS[0]}` — and always sees 0 even when build_tool fails. Why, and how do you capture each stage's status correctly?

level: middleimportance: should knowfreq 48%

answer

  1. most recently executed pipeline only
  2. every command overwrites it
  3. even a bare assignment resets it
  4. copy the whole array first
  5. unsubscripted gives element zero

basics

~20 s

PIPESTATUS describes only the most recently executed pipeline, and every command overwrites it — including the plain assignment rc=$?, which resets it to a single 0. Copy the whole array on the line immediately after the pipeline: statuses=("${PIPESTATUS[@]}").

solid answer

~40 s

`PIPESTATUS` is an array holding one exit status per stage of the **most recently executed** foreground pipeline, and a simple command counts as a one-stage pipeline. So `rc=$?` is itself a command: it succeeds, and `PIPESTATUS` is replaced by `(0)`. By the time the script reads `${PIPESTATUS[0]}` it is inspecting the assignment, not the build. The fix is to copy the array first, on the very next line, before anything else runs: `local -a st=("${PIPESTATUS[@]}")` — quoted and subscripted with `@` so each element stays separate. Then `${st[0]}` is `build_tool`'s status and `${st[1]}` is `tee`'s. Note `$PIPESTATUS` without a subscript is not the array; it expands to element 0 only.

code

bash · 17 lines
bash
#!/usr/bin/env bash

set -o pipefail

report() {
  local -a st
  false | true | cat            # stage 0 fails
  st=("${PIPESTATUS[@]}")       # capture on the very next line
  echo "stages: ${st[*]}"       # 1 0 0
  echo "producer status: ${st[0]}"
}
report

# The bug: an intervening assignment resets PIPESTATUS to (0)
false | true
rc=$?
echo "clobbered: ${PIPESTATUS[*]}"   # 0

go deeper

for a junior

Know that PIPESTATUS is an array with one exit status per pipeline stage, index 0 being the leftmost command, and that you must read it right after the pipeline.

for a middle

Explain that any command in between overwrites it, including a bare assignment, and write the correct capture line with the quoted @ subscript from memory.

for a senior

Show the judgment call: use pipefail when any failure is equally fatal, use PIPESTATUS when stages need different handling, such as tolerating grep's no-match while still failing on the producer.

for a principal

Point out that per-stage error handling in shell is a portability and maintainability cliff — no POSIX equivalent exists — and set the threshold at which a script should be restructured or rewritten instead.

## What PIPESTATUS is `PIPESTATUS` is a bash array variable containing the exit statuses of the processes in the **most recently executed foreground pipeline** — including a pipeline of exactly one command. It is the only way to see per-stage statuses, because `$?` can hold just one number. ```bash false | true | cat echo "${PIPESTATUS[@]}" # 1 0 0 ``` Index 0 is the leftmost stage. It is maintained whether or not `pipefail` is enabled; the two mechanisms are independent, and after `set -o pipefail; false | true` you get `$?` of 1 while the array still reads `1 0`. ## Why the naive read is always 0 The words "most recently executed" are load-bearing. Every command the shell runs afterwards replaces the array — and in bash a bare variable assignment *is* a command: ```bash false | true rc=$? # this assignment succeeds... echo "${PIPESTATUS[*]}" # 0 <- ...and PIPESTATUS is now just (0) ``` So the script in the question has already destroyed the data before it reads it. The same happens with an `echo`, a `[[ ... ]]` test, a `date` call for a log line, or anything else slipped between the pipeline and the read. This is the single most common `PIPESTATUS` bug, and it is silent: the array still exists and still contains a plausible-looking 0. ## The correct pattern Copy the entire array on the line immediately following the pipeline, before any other command: ```bash build_tool | tee build.log statuses=("${PIPESTATUS[@]}") # first thing after the pipeline build_rc=${statuses[0]} tee_rc=${statuses[1]} if (( build_rc != 0 )); then echo "build_tool failed with ${build_rc}" >&2 exit "${build_rc}" fi ``` Three details make that copy correct: - `"${PIPESTATUS[@]}"` with `@` and quotes expands to one word per element, which is what the array assignment `( ... )` needs. `"${PIPESTATUS[*]}"` would collapse the statuses into one space-joined string — fine for printing, wrong for copying. - `$PIPESTATUS` with no subscript is *not* the array. Unsubscripted array expansion in bash yields element 0, so it silently gives you only the first stage's status. - If you also want `$?`, capture it in the *same* statement or derive it from the copy; a separate `rc=$?` line placed first is exactly the bug. Inside a function, declare the copy local so it does not leak: `local -a statuses=("${PIPESTATUS[@]}")`. ## Choosing between PIPESTATUS and pipefail They answer different questions, and mature scripts use both. `pipefail` answers "did anything fail?" with a single number — right when every stage failing is equally fatal. `PIPESTATUS` answers "which stage failed, with what?" — right when the stages need different treatment. The canonical case is tolerating a benign non-zero from one stage while still failing on the others: ```bash log_reader | grep -c WARN | tee warn_count.txt st=("${PIPESTATUS[@]}") (( st[0] == 0 )) || { echo "reader failed: ${st[0]}" >&2; exit 1; } # grep returning 1 just means "no matches" - not an error here (( st[1] <= 1 )) || { echo "grep errored: ${st[1]}" >&2; exit 1; } (( st[2] == 0 )) || { echo "tee failed: ${st[2]}" >&2; exit 1; } ``` That granularity is impossible with `pipefail` alone, which flattens all of it to one number. ## Portability and scope `PIPESTATUS` is a bash feature. `zsh` has its own differently-named equivalent, and `dash` and other strict POSIX `/bin/sh` implementations have nothing comparable — a script relying on it must run under bash via an explicit `#!/usr/bin/env bash` shebang. There is no POSIX-portable way to recover per-stage statuses, which is one honest reason to reach for a real programming language when a script's error handling gets this fine-grained. One more scope note: the array reflects the most recent **foreground** pipeline in the current shell. A pipeline you run inside a command substitution or a background job updates that inner shell's copy, not the caller's, so plan to read it in the same shell that ran the pipeline. ## Reviewing for it When you see `PIPESTATUS` referenced anywhere except the line directly after a pipeline, treat it as a defect until proven otherwise. Scan upward: if any command — assignment included — sits between the pipeline and the read, the values are gone.

  • Why is `statuses=($PIPESTATUS)` wrong?
    Two bugs at once. Unsubscripted array expansion yields only element 0, so you lose every stage but the first; and the unquoted expansion is then subject to word splitting, which happens to be harmless for digits but is the wrong habit. The correct form is `statuses=("${PIPESTATUS[@]}")`, which expands to one word per element.
  • If pipefail is already enabled, is PIPESTATUS still useful?
    Yes, and they compose. Pipefail collapses the pipeline to a single number, which tells you something failed but not what. PIPESTATUS keeps the per-stage detail, so you can accept a benign non-zero from one stage — grep finding no matches, say — while still treating a producer failure as fatal. Error messages naming the failing stage also come from the array.
  • How do you get the same per-stage detail in a script that must run under /bin/sh?
    You cannot, directly — there is no POSIX equivalent. The usual workarounds are restructuring so the important command is not in a pipeline at all (write to a temp file, check its status, then process the file), or having each stage write its own status somewhere. If the logic really needs per-stage error handling, that is a strong signal to require bash or move to another language.

saying these in an interview costs you the question

  • Reads PIPESTATUS after an intervening rc=$? assignment
  • Uses $PIPESTATUS unsubscripted expecting the whole array
  • Thinks PIPESTATUS persists until the next pipeline specifically
  • Believes PIPESTATUS is only populated when pipefail is set
  • Assumes PIPESTATUS works the same in dash or plain POSIX sh

context