Bash gotcha: pipeline returns the wrong exit code

Bash pipelines return only the exit status of the last command unless `pipefail` is set. Enable it to propagate failures from any stage.

Error String

Pipeline exits 0 even though a command failed

Tldr

By default `a | b` returns `b`'s exit code, even if `a` failed. Set `set -o pipefail` so the pipeline fails if any stage fails. Combine with `set -e` for fail-fast scripts.

Cause

A `curl | bash` style invocation that fails the download but succeeds the bash exits 0 — and your CI thinks everything is fine.

Repro

false | true; echo "exit: $?"        # prints 0
set -o pipefail
false | true; echo "exit: $?"        # prints 1

Fix

#!/usr/bin/env bash
set -euo pipefail

if ! curl -fsSL https://example.com/installer.sh | bash; then
  echo "install failed" >&2
  exit 1
fi

Explanation

Combine `set -euo pipefail` and `curl -f` (fail on HTTP errors) for installers. Use `${PIPESTATUS[@]}` to inspect every stage individually when you need finer control.

Faq

Q

Is pipefail POSIX?

A

No. It exists in Bash, ksh and zsh; plain `sh` scripts need `PIPESTATUS`-free workarounds such as temporary files.

Q

Why does my grep pipeline now fail the script?

A

`grep` exits 1 when it finds nothing, and pipefail propagates that. Append `|| true` to that element if no match is a valid outcome.

Deep Dive

Heading

A pipeline reports only its last command by default

Body

Without `set -o pipefail`, `cmd1 | cmd2` exits with the status of `cmd2`, so a failing producer is hidden whenever the consumer succeeds — `curl bad-url | tee out.log` looks fine. With `pipefail` the pipeline returns the rightmost non-zero status instead, which is what makes `set -euo pipefail` the common safety preamble. `PIPESTATUS` holds every element status individually and must be copied immediately, because the next command overwrites it.

Heading

When pipefail causes surprising failures

Body

Commands that exit non-zero as normal behaviour trip it: `grep` returns 1 when nothing matched, and any producer writing into `head` gets SIGPIPE once the consumer stops reading, giving status 141. Handle these deliberately — `grep -q pattern file || true` when no match is acceptable, or restructure so the pipeline ends with the command whose status you care about. Blanket `|| true` on a whole pipeline throws away the safety you enabled pipefail for, so scope it to the one element that is allowed to fail.

Checklist

Copy `status=("${PIPESTATUS[@]}")` immediately after the pipeline to inspect each element.

Add `set -o pipefail` when a producer failure must fail the script.

Allow expected non-zero exits explicitly rather than disabling pipefail.

Remember status 141 means SIGPIPE from a consumer such as `head` closing early.