skip to content

In bash, the pipeline `false | true` leaves `$?` at 0. Which command's exit status does a pipeline report by default, and why does that let a failing build pass silently in a CI step?

level: juniorimportance: must knowfreq 70%

answer

  1. only one stage's status survives
  2. the rightmost one wins
  3. tee and wc almost never fail
  4. pipefail and PIPESTATUS reveal the rest

basics

~20 s

By default a bash pipeline reports only the exit status of its last command; every earlier stage's status is discarded. So false | true returns 0, and a failing build piped into tee or grep looks successful to CI.

solid answer

~40 s

A pipeline's exit status in bash is, by default, the status of the **rightmost** command only. `false | true` returns 0 because `true` was the last stage; `true | false` returns 1 for the same reason. That default is what makes `make build 2>&1 | tee build.log` a classic CI trap: `make` fails, `tee` succeeds copying the error text into the log, the pipeline reports 0, and the job goes green with a broken artifact. The same applies to `cmd | sort`, `cmd | jq .`, `cmd | wc -l` — the last stage almost always succeeds. Two mechanisms let you see the truth: `set -o pipefail`, which makes the pipeline report the rightmost failing stage, and the `${PIPESTATUS[@]}` array, which holds every stage's status individually.

code

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

false | true; echo "false | true -> $?"   # 0
true | false; echo "true | false -> $?"   # 1

# The CI shape: a failing build piped into tee still reports success.
simulated_build() { echo "compile error" >&2; return 2; }

if ! simulated_build 2>&1 | tee build.log; then
  echo "failure detected"
else
  echo "step passed even though the build returned 2"
fi

go deeper

for a junior

Be able to state the rule plainly: the pipeline's exit status is the last command's, so false piped into true gives 0. Name pipefail and PIPESTATUS as the two ways to see the rest.

for a middle

Explain that all stages run concurrently and every status is collected, but only the rightmost is exposed by default, and show the tee-in-CI example where that turns a failed build green.

for a senior

Demonstrate that you review for this: point at pipelines ending in tee, wc or jq, decide per case whether to enable pipefail, inspect PIPESTATUS, or restructure so the important command is checked on its own.

for a principal

Own the standard: decide whether every script in the estate opts into pipefail by default, what the team does about the benign non-zero cases that then surface, and how CI templates enforce it so silent green builds cannot recur.

## The rule Bash defines the exit status of a pipeline as the exit status of the **last command in it**, full stop. Every other stage runs, may fail loudly, may write to stderr, may die on a signal — and none of that reaches `$?`. ```bash false | true; echo $? # 0 -> last stage succeeded true | false; echo $? # 1 -> last stage failed false | false; echo $? # 1 ``` The stages run concurrently in separate processes, so this is not "the pipeline stops at the first failure" — all commands are started at once and each one's status is collected. The shell simply chooses the last one as the value of the whole construct. ## Why bash does it this way A pipeline is a data-flow expression: the interesting result is what came out of the far end. `grep pattern file | head -n 5` is asking "give me five lines", and the meaningful answer is whether `head` produced them. Non-zero statuses from earlier stages are frequently not errors at all — `grep` returns 1 for "no matches found", and a producer killed by SIGPIPE because a downstream reader quit early is completely normal. Reporting the last stage keeps those benign cases quiet. The cost is that genuine failures are equally quiet. ## Where it bites in production The damage is concentrated where the last stage is a formatter, a logger, or a counter — something that essentially cannot fail: ```bash # CI step: the compiler fails, tee copies the error into the log and exits 0 make build 2>&1 | tee build.log # step status: 0 # Deployment gate: curl fails to connect, jq parses nothing, status is jq's curl -s https://api.example.com/health | jq -r .status # Metric collection: the query blows up, wc counts zero lines, status 0 run_query | wc -l > rows.txt ``` Each line above reports success on a real failure. In a CI job, the step's exit status is the script's exit status, so this is precisely how a red build turns green. Wrapping the pipeline in `if`, `&&`, or a `[[ ... ]]` check does not help: they all consult the same single value the pipeline produced. A subtler variant: assigning a pipeline through command substitution inherits the same rule. ```bash count=$(run_query | wc -l) # $? is wc's status, not run_query's ``` ## Making the failure visible There are two mechanisms, and they answer different questions. `set -o pipefail` changes the value of the construct: the pipeline reports the rightmost command that exited non-zero, or 0 when all succeeded. It is a one-line fix for "did anything in here fail?" and is why the option appears in nearly every hardened script header. ```bash set -o pipefail false | true; echo $? # 1 ``` `${PIPESTATUS[@]}` answers "which stage failed, and with what?" — it is an array holding one status per stage of the most recently executed foreground pipeline, and it is populated whether or not `pipefail` is set. ```bash false | true | cat echo "${PIPESTATUS[@]}" # 1 0 0 ``` Use `pipefail` when any failure anywhere should sink the pipeline; use `PIPESTATUS` when different stages need different treatment (tolerate `grep`'s 1, reject the producer's 2). ## Restructuring instead Sometimes the cleanest fix is to stop using a pipeline for control flow. Writing to a file and checking separately makes the status unambiguous: ```bash if ! make build > build.log 2>&1; then cat build.log exit 1 fi ``` Here `make`'s status is the whole story, because there is no second stage to overwrite it. This is worth reaching for when the downstream stage exists only to tee or format output. ## Things people get wrong - "The pipeline returns the first failure." It does not, with or without `pipefail`. Without it you get the last stage; with it you get the *rightmost* failing stage. - "`&&` between pipeline stages would fix it." `a && b` is not a pipeline at all — no data flows from `a` to `b`. - "An error message on stderr means non-zero status." Stderr text and exit status are independent channels; `tee` happily copies an error message and returns 0. The practical takeaway for review: whenever you see a pipeline whose last stage is `tee`, `cat`, `sort`, `jq`, `wc`, or `column`, ask what happens when the first stage fails. If nothing does, the script has a silent-failure bug.

  • Does the pipeline's status change if an earlier stage is killed by a signal rather than exiting normally?
    Not by default — the status still comes from the last stage. The signalled stage's status is recorded as 128 plus the signal number, but you only ever see it through `${PIPESTATUS[@]}` or with `pipefail` enabled. A producer killed by SIGPIPE, for example, records 141 while the pipeline as a whole still reports whatever the final command returned.
  • If a pipeline's stages run concurrently, when exactly does the shell decide the status?
    The shell starts every stage, then waits for all of them to terminate before evaluating the pipeline. So a failing first stage does not short-circuit anything — the later commands still run to completion on whatever input they received. Only after the last process is reaped does bash assign `$?` and populate `PIPESTATUS`.
  • How would you spot this bug when reviewing someone else's script?
    Scan for pipelines whose final stage is a formatter or sink — `tee`, `cat`, `wc`, `sort`, `jq`, `column`, `xargs echo`. Ask what the pipeline returns when stage one fails; if the answer is 0, the script has a silent-failure path. ShellCheck will not flag it for you, because the construct is perfectly valid shell.

saying these in an interview costs you the question

  • Says a pipeline returns the first command that fails
  • Thinks an error printed to stderr guarantees a non-zero pipeline status
  • Believes stages run one at a time and stop at a failure
  • Claims quoting or exit-code checks around the pipeline fix it
  • Assumes command substitution around a pipeline preserves the producer's status

context