Skip to content
Shell & TerminalDeep Dive Published Updated 7 min readViews unavailable

Bash Pipeline Status: pipefail, PIPESTATUS, and Reliable Error Checks

Capture failures inside Bash pipelines without confusing the final command's status, pipefail's summary, and per-command PIPESTATUS values.

By default, Bash gives a foreground pipeline the exit status of its last command. A successful consumer can therefore hide a failed producer:

false | cat
printf 'pipeline status: %s\n' "$?"

The status is zero because cat succeeded. That does not mean false succeeded, nor does it establish that every byte reaching cat was valid. A pipeline’s status is a compact control-flow value, not a complete execution report. To make the compact value fail when a component fails, enable Bash’s pipefail option:

set -o pipefail
producer | transform | consumer

With pipefail, the pipeline status is the status of the rightmost command in pipeline order that returned non-zero, or zero if all commands succeeded. This is useful when the caller needs one pass/fail decision. It does not tell you which stages failed, and it is not equivalent to checking every result independently.

Capture each stage before another command runs

Bash stores statuses for the most recently executed foreground pipeline in the PIPESTATUS array. The next simple command overwrites that array. Capture it immediately, before printf, logging, a conditional test, or command substitution can replace it:

set -o pipefail
if producer | transform | consumer; then
    captured=( "$?" "${PIPESTATUS[@]}" )
else
    captured=( "$?" "${PIPESTATUS[@]}" )
fi

pipeline_status=${captured[0]}
stage_status=( "${captured[@]:1}" )
printf 'pipeline=%s producer=%s transform=%s consumer=%s\n' \
    "$pipeline_status" "${stage_status[0]}" \
    "${stage_status[1]}" "${stage_status[2]}"

Both expansions on the first assignment in each branch are evaluated while the pipeline results are still current. The if condition also makes the example safe under set -e: a command used as the condition of if is one of Bash’s contexts where errexit does not immediately exit the shell. The body then stores the aggregate and per-stage statuses together. The arrays require Bash, so do not place this code under a portable #!/bin/sh interpreter.

If your script does not need the aggregate status, the essential pattern is shorter:

producer | transform | consumer
stage_status=( "${PIPESTATUS[@]}" )

Do not write rc=$? first and capture PIPESTATUS on the next line: that assignment itself becomes the latest simple command, so the pipeline array is gone. Likewise, echo "${PIPESTATUS[*]}" reads the values while printing but is not a durable diagnostic record unless you capture them for later decisions.

Decide what failure means for this data path

Treat each stage as a contract. A producer may fail to read its input; a transform may reject malformed data; a consumer may fail to write a destination or exit early by design. pipefail is often right for an ETL pipeline where every stage must process the full stream. It may be wrong for a search pipeline that intentionally stops when it finds the first match. When the consumer closes its read end, a still-writing producer can receive SIGPIPE; many systems expose that signal as a non-zero status. An expected early close should be modeled explicitly instead of hidden with a broad || true that also suppresses real I/O errors.

For example, if a pipeline converts a file and publishes it, write to a temporary destination and only rename it after every required stage succeeds. Do not truncate the final file before checking the pipeline result. A pipeline that includes a logging stage needs its own policy too: if tee cannot write its log, decide whether the primary operation must fail or whether the log is best-effort.

errexit does not replace a pipeline policy

set -e has context-sensitive exceptions involving conditionals, && and || lists, negation, and functions used in tested contexts. It is not a general exception mechanism. A pipeline in an if test, as above, gives the script a deliberate place to collect status without changing the caller’s option state. If code must temporarily alter shell options, save the relevant option state and restore that exact state; do not unconditionally run set -e afterward and accidentally change a caller that started with it disabled.

Also be careful about negation. ! producer | consumer changes the status exposed to the surrounding command and can make an aggregate check mean the opposite of what its reader expects. Keep the pipeline and its failure test visually adjacent, and name a variable such as pipeline_status rather than relying on an unexplained $? several commands later.

The aggregate is intentionally lossy. If both a producer and a later transform fail, pipefail returns the rightmost non-zero status in pipeline order, not the earliest failure and not a list. That return code can also be the application’s own ordinary error code, so a caller should not infer which stage failed from its numeric value. Save the per-command array when diagnosis or retry decisions need that detail. Conversely, do not make every caller parse a log just to learn whether the whole operation succeeded; return a clear aggregate status and keep richer diagnostics as a separate channel.

Consider what the pipeline means when a command exits early. A command like head is often used to take a prefix, so upstream SIGPIPE can be expected once the consumer has enough data. A decompressor feeding a full-file checksum is different: a broken producer usually means the checksum is incomplete and must fail. Tests should assert the intended result for the data path, not just that pipefail is enabled. If a pipeline is part of a library function, test it both as a simple top-level command and from the same conditional/function context callers use, because errexit behavior depends on context.

Foreground and asynchronous pipelines are different cases

For a foreground pipeline, Bash waits for the component commands before returning the status. An asynchronous pipeline ending in & is different: the immediate status reports that the asynchronous list was started, not the eventual result of all its processes. Save $! immediately and later call wait to collect the job’s status. If you need a distinct status for every command inside an asynchronous pipeline, wrap and supervise those commands explicitly; PIPESTATUS from the parent shell is not a future-results array.

Pipelines also affect variable scope. Bash normally executes the commands in a multi-command pipeline in subshell environments. The lastpipe shell option can allow the final command to run in the current shell when job control is inactive. This can preserve a loop’s variable changes, but it is a separate scope decision; enabling it does not repair status handling, and it may make behavior differ between interactive and non-interactive contexts.

The same scope distinction matters when a pipeline is nested in command substitution, a subshell, or a function whose caller only sees its final return value. If the caller needs per-stage diagnostics, collect and serialize them at the point where the pipeline runs, using a representation that cannot be confused with ordinary output. Do not expect a parent shell to read PIPESTATUS after a child shell has run the pipeline. This is especially easy to miss when code is refactored from a top-level sequence into result=$(some_function): command substitution runs in a subshell environment and captures standard output, not arbitrary arrays or state changes.

Keep diagnostics separate from the stream being transformed. Printing stage statuses into the pipeline itself changes its data; printing to standard error is usually safer, but the caller still needs a defined interface if it must parse those diagnostics. A shell function can return only one numeric status to its caller, so use that for the high-level result and a log, manifest, or explicit output variable for richer information. If status reporting is part of a reusable library, document how many pipeline components are represented and whether expected SIGPIPE or “no match” exits count as success. This avoids making callers infer semantics from an arbitrary non-zero value.

Make status checks part of acceptance testing

Test at least these cases with a small, deterministic pipeline: every stage succeeds; only the producer fails; only a middle transform fails; only the consumer fails; the consumer exits early; and a command name is missing. Record both the overall result and the relevant PIPESTATUS values. Then test the actual script with and without pipefail, since shell options are process state and an interactive profile is not a reliable substitute for initializing them in the script.

The right design is not “always turn on every strict option.” It is to state what each pipeline promises, select either a single aggregate decision or per-stage diagnostics, and make intentional early termination visible. That turns pipeline status from a surprising shell detail into a documented interface between producer, transform, consumer, and caller.

Related:

Sources:

Comments