skip to content

Pipeline Exit Status, pipefail and PIPESTATUS

By default `false | true` succeeds, which is how failing pipelines quietly pass in CI. pipefail and the PIPESTATUS array are the two ways to see the truth, and interviewers pair this with grep-returns-1 and the SIGPIPE-141 case from piping into head.

part ofBashoverview, primer and where to startread it →
on this pageshow

questions

5

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

open as a page

What does `set -o pipefail` change about the exit status of a bash pipeline, and when several stages fail, which one's status do you actually get?

level: middleimportance: must knowfreq 62%

basics

~20 s

With set -o pipefail, a bash pipeline returns the status of the rightmost command that exited non-zero, or 0 if every stage succeeded. Without it, only the last stage's status counts, so earlier failures vanish.

open as a page

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%

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[@]}").

open as a page

After a team enables `set -o pipefail`, the CI line `zcat access.log.gz | grep ' 500 ' | wc -l` starts reporting failure on days with no HTTP 500s. Why does a successful search fail the pipeline, and how do you write the check so an empty result is not an error?

level: seniorimportance: should knowfreq 42%

basics

~20 s

grep exits 1 when it finds no matching lines, which is a result, not an error. pipefail promotes that 1 to the pipeline's status even though wc succeeded. Tolerate it explicitly with || true, or check the stage's status via PIPESTATUS and accept 1.

open as a page

A bash script running with `set -o pipefail` intermittently exits with status 141 on the line `producer | head -n 20`. What does 141 mean there, why is it intermittent, and how should the script handle it?

level: seniorimportance: nice to knowfreq 34%

basics

~20 s

141 is 128 plus 13, the status bash records for a process killed by SIGPIPE. head exits after 20 lines and closes the pipe, so the producer dies on its next write, and pipefail promotes that 141 to the whole pipeline's status.

open as a page