Shell pipelines and exit status: pipefail, PIPESTATUS and reliable automation
Explain Bash pipeline status, pipefail, PIPESTATUS and errexit exceptions. Build an automation check that distinguishes a failed command from a successful log writer.
TL;DR: Bash normally reports the last command's exit status for a pipeline. Enable
pipefailwhen any failed stage should fail the operation, and capturePIPESTATUSimmediately when the individual failures matter.
Logging can hide a failed build
In build-command | tee build.log, the shell launches both pipeline commands. If the build exits with an error while tee successfully writes its input, the pipeline can return success. A deployment script that checks only $? then promotes a failed build.
Bash's pipefail option changes the result to the status of the rightmost command that failed, or zero when all commands succeed. It does not return every status and does not mean “the first failure.” The Bash pipeline manual specifies this behavior. Other shells may provide different features; declare the intended interpreter instead of assuming /bin/sh is Bash.
A producer can also receive SIGPIPE because a consumer deliberately exits early. For example, reading a short prefix of a large stream can close the pipe while the producer is writing. Decide whether that outcome is expected in the command's contract; treating every nonzero status as an infrastructure incident is just as misleading as ignoring failures.
Capture evidence before the next command overwrites it
Run this bounded example with Bash. The pipeline produces one line, fails with status 7 and lets the consumer finish normally. The assignment captures the array before another command replaces it.
#!/usr/bin/env bash
set -u
set -o pipefail
printf_command() { printf 'partial result\n'; return 7; }
printf_command | cat > /dev/null
statuses=("${PIPESTATUS[@]}")
if (( statuses[0] != 0 || statuses[1] != 0 )); then
printf 'producer=%s consumer=%s\n' "${statuses[0]}" "${statuses[1]}"
exit 1
fi
The expected diagnostic is producer=7 consumer=0, followed by exit status 1 from this wrapper. Moving echo done before the array assignment loses the pipeline's evidence. The diagnostic itself changes shell status, so keep the saved values when subsequent decisions need them.
This example intentionally handles failure explicitly. A production wrapper should also clean up temporary artifacts and ensure downstream stages cannot mistake a partial file for a completed result. Write to a temporary destination and publish the final artifact only after verification, with a procedure appropriate to that storage system.
| Construct | Useful guarantee | Boundary to remember |
|---|---|---|
$? | Status of the most recent command | Easy to overwrite |
pipefail | Failure if a pipeline stage fails | Reports only the rightmost nonzero status |
PIPESTATUS | Per-stage statuses in Bash | Capture immediately |
set -e | Exits in certain unhandled failure contexts | Many conditional contexts are exceptions |
Why errexit needs deliberate control flow
set -e is sensitive to syntactic context. Commands used as conditions, parts of && or || lists, and negated commands have exceptions. Function calls placed inside these contexts can produce surprising behavior too. The Bash manual describes errexit and its interaction with pipelines and command substitution.
For an expected failure, use an explicit condition and preserve the original status. Be careful with if ! command; then status=$?; fi: inside that branch, $? reflects the successful negation. Prefer if command; then ...; else status=$?; ...; fi when you need the command's actual failure code.
Exit codes also carry command-specific meaning. grep uses 1 for no selected lines, while a larger failure status indicates another problem. An automation that treats “no matching warning” as a failed scan may reject healthy builds. A missing command commonly returns 127; a found command that cannot execute commonly returns 126. Log enough context to distinguish these from an application-level validation failure.
PowerShell automation uses a different contract: object pipelines, cmdlet error records and explicit handling of native exit codes.
Test the negative path
For a CI wrapper, exercise a successful producer, a failed producer with a successful log writer, and a failed destination. Use temporary files and a controlled command that returns a known code. Verify both the wrapper's status and whether it published an output artifact. Checking only the happy path cannot expose a masked upstream failure.
Self-check: generate | validate | upload returns statuses 3 0 5 with pipefail enabled. Which status does the pipeline report? It reports 5, the rightmost nonzero status. Both generation and upload failed. Preserve all three statuses if the operator must distinguish an invalid partial input from an independent upload failure before deciding what can safely be retried.