Bash Exit Codes, pipefail, and PIPESTATUS for Reliable Pipelines
A failing command in the middle of a Bash pipe can be invisible by default. Learn pipefail, PIPESTATUS, and exit-code conventions to stop silent failures.
- #bash
- #python
- #reliability
Here is a bug that has cost real teams real outages: a Bash pipeline where the first command fails, but the script marches on as if everything succeeded. The classic shape is curl ... | tar xz — curl 404s, pipes nothing, tar “succeeds” on the empty input, and your deploy continues with no payload. By default, Bash reports only the exit code of the last command in a pipe, so the failure is invisible.
Getting exit codes right is the foundation of reliable automation. In this guide I will cover pipefail, the PIPESTATUS array, exit-code conventions, and how I let AI help without letting it paper over real errors.
How Bash reports pipeline exit codes
By default, a pipeline’s exit status is the status of its last command:
curl -s https://example.com/missing.tar.gz | tar xz
echo "exit code: $?" # prints 0 even though curl failed
tar consumed empty input and exited 0, so $? is 0. The curl failure vanished. This is the single most dangerous default in shell scripting, and the reason so many “it silently did nothing” bugs exist.
pipefail makes pipelines honest
set -o pipefail changes the rule: the pipeline’s exit status becomes the status of the last command to fail, or zero if all succeed.
set -o pipefail
curl -fsS https://example.com/missing.tar.gz | tar xz
echo "exit code: $?" # now reflects curl's failure
I put set -euo pipefail at the top of essentially every script:
-eexits on any unhandled error.-uerrors on unset variables.-o pipefailpropagates failures through pipes.
Combined, they turn silent failures into loud ones. The -f on curl also matters: by default curl exits 0 even on a 404, so -f makes it fail on HTTP errors. Each tool has its own quirks like this.
PIPESTATUS: the exit code of every stage
Sometimes you need to know which stage of a pipe failed, not just that one did. Bash records every stage’s exit code in the PIPESTATUS array:
curl -fsS "$url" | gzip -d | tar xf -
echo "${PIPESTATUS[@]}" # e.g. "0 0 0" or "22 0 0"
Capture it immediately — the very next command overwrites it:
curl -fsS "$url" | gzip -d | tar xf -
codes=("${PIPESTATUS[@]}")
if (( codes[0] != 0 )); then
echo "download failed (curl exit ${codes[0]})" >&2
exit 1
fi
PIPESTATUS is what you use when each stage needs distinct error handling, like distinguishing a download failure from a decompression failure.
Pro Tip: PIPESTATUS is a one-shot. Assign it to your own array on the line right after the pipeline, before any other command runs, or you will lose it.
Exit-code conventions that callers can rely on
Your scripts are also callees. Other scripts, CI jobs, and make targets check your exit code, so be deliberate about it:
0means success, full stop. Never exit 0 on a failure path.1is a generic failure.- Use distinct non-zero codes for distinct failures the caller might branch on (e.g.
2= bad arguments,3= missing dependency). - Reserve
>128— the shell uses128 + signalfor signal deaths (a Ctrl-C is130).
usage() { echo "usage: deploy <env>" >&2; exit 2; }
[[ $# -eq 1 ]] || usage
command -v terraform >/dev/null || { echo "terraform missing" >&2; exit 3; }
Documenting these codes in the script header lets callers handle failures intelligently instead of treating every non-zero the same.
The set -e gotchas worth knowing
set -e is essential but has surprising holes. It does not trigger inside a condition, and it is suppressed in a command whose result is tested:
set -e
# This does NOT exit on failure — the `if` consumes the error:
if grep -q pattern file; then ...; fi
# This DOES NOT exit either — `||` handles the error:
some_command || echo "handled"
# A function called in a condition disables -e *inside* the function:
check() { false; echo "still runs"; }
if check; then ...; fi # "still runs" prints
Know these holes so you do not assume set -e is catching something it is not. For critical checks, test the exit code explicitly rather than relying on -e.
The Python equivalent
In Python, exit codes are sys.exit() and subprocess returncode. The pipeline lesson carries over: when you chain subprocesses, check each one’s return code, do not trust only the last.
import sys, subprocess
p1 = subprocess.run(["curl", "-fsS", url], capture_output=True)
if p1.returncode != 0:
print(f"download failed: {p1.stderr.decode()}", file=sys.stderr)
sys.exit(1)
# ... feed p1.stdout to the next stage
subprocess.run(..., check=True) raises on non-zero, which is the Python equivalent of set -e for a single command. There is no implicit pipe-swallowing in Python because you check each stage explicitly — which is exactly the discipline pipefail and PIPESTATUS enforce in Bash.
Letting AI add the guardrails
Adding set -euo pipefail, PIPESTATUS checks, and meaningful exit codes to an existing script is mechanical work an AI assistant does well. I will paste a script into ChatGPT or Claude and ask it to add strict mode and propagate pipeline failures with clear error messages and distinct exit codes.
It is a fast junior engineer for this, but I review every change before it ships, because error handling that looks right but swallows failures is worse than none. I check:
- That the AI did not “fix” a failing command by appending
|| true, which silences the very error we are trying to catch. - That
PIPESTATUSis captured on the line immediately after each pipeline. - That exit codes are distinct and documented, not all collapsed to
exit 1. - That no real secrets were pasted into the prompt. Error-handling changes need none of your credentials.
I keep that checklist in the prompt workspace and run hardened scripts through the code review dashboard before production.
Conclusion
Reliable Bash starts with honest exit codes: set -euo pipefail so pipe failures propagate, PIPESTATUS when you need to know which stage failed, distinct documented exit codes so callers can react, and awareness of where set -e quietly does not fire. Carry the same check-every-stage discipline into Python. Let an AI add the guardrails, but watch for || true and other shortcuts that hide the failures you were trying to surface.
More in the Bash and Python automation category. Reusable starters are in the prompt library, and curated sets are in the prompt packs.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.