Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Bash & Python Automation By James Joyner IV · · 10 min read

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 xzcurl 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:

  • -e exits on any unhandled error.
  • -u errors on unset variables.
  • -o pipefail propagates 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:

  • 0 means success, full stop. Never exit 0 on a failure path.
  • 1 is 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 uses 128 + signal for signal deaths (a Ctrl-C is 130).
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 PIPESTATUS is 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.

Free download · 368-page PDF

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.