skip to content

After a team enables `set -o pipefail`, the CI line `zcat access.log.gz | grep ' 500 ' | wc -l` starts reporting failure on days with no HTTP 500s. Why does a successful search fail the pipeline, and how do you write the check so an empty result is not an error?

level: seniorimportance: should knowfreq 42%

answer

  1. non-zero can mean result, not error
  2. grep: 0 match, 1 none, 2 error
  3. pipefail promotes the benign one
  4. accept 0 or 1 from that stage
  5. annotate the line, keep the option

basics

~20 s

grep exits 1 when it finds no matching lines, which is a result, not an error. pipefail promotes that 1 to the pipeline's status even though wc succeeded. Tolerate it explicitly with || true, or check the stage's status via PIPESTATUS and accept 1.

solid answer

~50 s

GNU grep uses three exit codes: 0 for "lines matched", 1 for "no lines matched", and 2 for a real error such as an unreadable file. "No 500s today" is the 1 case — a perfectly good answer that happens to be non-zero. Without `pipefail` you never noticed, because the pipeline reported `wc`'s 0; with pipefail the rightmost failing stage is grep, so the pipeline returns 1 and the CI step goes red. The fix is to say explicitly which non-zero statuses are acceptable rather than to abandon pipefail: append `|| true` if nothing downstream cares, or capture `${PIPESTATUS[@]}` and accept 0 or 1 from the grep stage while still failing on 2 and on any zcat failure. Restructuring with `grep -c` on its own line, or `if grep -q ...`, often reads better than either.

code

bash · 12 lines
bash
#!/usr/bin/env bash

set -o pipefail
printf 'INFO ok\n' > build.log

# grep exits 1 for "no match"; pipefail makes it the pipeline's status
count=$(grep 'WARN' build.log | wc -l)
echo "status: $?  count: $count"        # 1, count 0

# Explicit tolerance for a line where an empty result is fine
count=$(grep 'WARN' build.log | wc -l) || true
echo "tolerated: $?  count: $count"     # 0, count 0

go deeper

for a junior

Remember grep's three exit codes: 0 found something, 1 found nothing, 2 something went wrong. Non-zero does not always mean the command broke.

for a middle

Explain why pipefail exposed a status that was always there, and show the fix on the line itself with either || true or a conditional, rather than editing the script header.

for a senior

Diagnose it as a false failure rather than a bug, and defend keeping pipefail by pointing at the zcat failure it still catches; use PIPESTATUS to accept 1 from grep while failing on 2.

for a principal

Plan the rollout: enabling strict pipelines across an estate surfaces a predictable set of benign non-zero patterns, so publish the sanctioned tolerance idiom before the first team quietly deletes the option.

## Non-zero is not the same as broken The Unix convention is that 0 means success and non-zero means failure, but a large family of tools uses non-zero statuses to carry *results*. `grep` is the canonical example, and its three codes are documented and stable: - 0 — at least one line matched - 1 — no lines matched (the search ran perfectly) - 2 — an error occurred, for example the input could not be read `diff` behaves the same way (1 means the files differ), as do `cmp`, `git diff --exit-code`, and many linters. A script that treats every non-zero as a fault will misread all of them. ## What pipefail changed Before pipefail, `zcat ... | grep ' 500 ' | wc -l` reported `wc`'s status, which is 0 essentially always. The pipeline was silent about everything upstream — including a genuinely broken `zcat`. After pipefail, the pipeline reports the rightmost non-zero stage, so grep's benign 1 surfaces: ```bash set -o pipefail echo 'INFO ok' | grep 'WARN' | wc -l echo "$? / ${PIPESTATUS[*]}" # 1 / 0 1 0 ``` The array makes the situation legible: `zcat` fine, grep found nothing, `wc` fine. Nothing failed. The enabling of pipefail did not introduce a bug — it converted an invisible-failure problem into a false-failure problem, and the false failure is the one you must now classify. ## Making the tolerance explicit The worst response is to remove `set -o pipefail` from the script, because that also re-hides a `zcat` failure on a truncated archive — the exact case the option was added for. Instead, mark the line that is allowed to be non-zero. **Blanket tolerance**, when nothing downstream depends on distinguishing the cases: ```bash count=$(zcat access.log.gz | grep ' 500 ' | wc -l) || true ``` This is honest and readable, but it also swallows a `zcat` failure on that one line, so use it where the count being 0 is an acceptable answer to any problem. **Precise tolerance**, when you want grep's 1 accepted and everything else fatal: ```bash set -o pipefail zcat access.log.gz | grep ' 500 ' | wc -l > count.txt st=("${PIPESTATUS[@]}") (( st[0] == 0 )) || { echo "zcat failed: ${st[0]}" >&2; exit 1; } (( st[1] <= 1 )) || { echo "grep errored: ${st[1]}" >&2; exit 1; } (( st[2] == 0 )) || { echo "wc failed: ${st[2]}" >&2; exit 1; } ``` The `st[1] <= 1` line encodes the domain knowledge — 0 and 1 are results, 2 is an error — and it is worth a comment so the next reader does not "fix" it. **Restructuring**, often the best answer. Make the exit status stop being a side effect of a pipeline: ```bash # grep -c already prints the count; the pipeline shrinks to one stage if ! count=$(zcat access.log.gz | grep -c ' 500 '); then count=0 # grep -c prints 0 and exits 1 when nothing matched fi # Or, when you only need a yes/no answer: if zcat access.log.gz | grep -q ' 500 '; then echo "500s present" fi ``` Note that `grep -q` and `grep -c` still exit 1 on no match — `-q` suppresses output, it does not change the status. What makes the `if` form safe is that a shell conditional consumes the status as a boolean rather than letting it escape as the script's result. ## The general rule When you adopt pipefail across an estate, budget for a sweep of the pipelines that legitimately end non-zero. In practice they cluster in a few shapes: `grep` used as a filter or a test, `diff`/`cmp` used to detect change, a producer SIGPIPE'd by an early-exiting reader, and tools that encode results in exit codes. Each one should get an explicit, commented tolerance — never a silent `set +o pipefail` at the top of the file. The reviewable version of the rule is: strict by default, exceptions annotated at the line that needs them. Anyone reading `... | grep X | wc -l || true` can see a decision was made; anyone reading a script with pipefail quietly missing cannot tell whether that was intentional.

  • Does adding `grep -q` make the no-match case exit 0?
    No. `-q` only suppresses output and lets grep exit early on the first match; the status is still 0 for a match and 1 for none. What makes `if cmd | grep -q X; then` safe is the `if`, which consumes the status as a boolean instead of letting it become the pipeline's escaping result.
  • Why not just drop pipefail from this script and be done?
    Because it also hides the failures you enabled it for. In this pipeline a truncated or corrupt archive makes `zcat` fail, and without pipefail the step still reports success with a count of 0 — silently reporting "no errors today" when the data was never read. The benign grep case is a line-level exception, not a reason to remove the file-level guarantee.
  • Name another common tool whose non-zero exit is a result rather than an error.
    `diff` returns 1 when the files differ and 2 on trouble; `cmp` behaves similarly; `git diff --exit-code` returns 1 when there are changes, which is exactly how CI checks for uncommitted formatting. Any of these in a pipefail script needs the same explicit tolerance treatment as grep.

saying these in an interview costs you the question

  • Treats every non-zero exit as an error condition
  • Removes pipefail entirely to silence the grep case
  • Claims grep -q exits 0 when nothing matches
  • Cannot distinguish grep's no-match 1 from its error 2
  • Adds || true to every pipeline in the script as policy

context