Sample output
0
1 0
The first line is wc reporting zero matching lines, and the second line is the part $? would have hidden: grep exited 1, wc exited 0. A 1 from grep means it read the log and found nothing, so zero is a real answer. Misspell the filename and the first line is still 0, but the second becomes 2 0, and that 2 is the difference between “no fatal errors” and “never looked”.
When you would reach for it
A CI step that pipes through tee and turns green on a failed build, a cron job that pipes a dump through gzip and keeps producing tiny valid archives of nothing, or a monitoring check that counts matches and cannot tell an empty log from a missing one. Any time a pipeline’s success depends on something other than its last command, $? is answering a different question from the one you asked.
Gotchas
- PIPESTATUS describes the most recent foreground command, and almost everything counts as one.
echo $?on the line before replaces it with0, and so does a[ ... ]test, so[[ ${PIPESTATUS[0]} -eq 0 ]]works once and then the array describes the test. Copy it into your own array on the very next line,rc=("${PIPESTATUS[@]}"), before doing anything else.echo $? "${PIPESTATUS[@]}"on one line also works, because both expansions happen before echo runs. - On macOS the default interactive shell is zsh, where
${PIPESTATUS[@]}expands to nothing. zsh’s equivalent is the lowercase$pipestatus, and its arrays start at index 1, so the first stage is$pipestatus[1]. The macOS/bin/bashis 3.2, which has PIPESTATUS, so scripts with a bash shebang behave the same as on Debian. set -o pipefailis the blunter fix: the pipeline returns the status of the rightmost stage that failed. It also counts SIGPIPE as failure, which surprises people.yes | head -1exits 0 normally, butyeswas killed when head stopped reading, so PIPESTATUS is141 0(128 plus signal 13), and under pipefail the whole pipeline returns 141. Any producer that outruns aheadcan trip a pipefail script for no real reason.- Negating with
!flips$?but leaves PIPESTATUS alone.! grep FATAL server.log | wc -lsets$?to 1 while the array still reads1 0, so the two can disagree on purpose. Debian’s/bin/shis dash, which has no PIPESTATUS and, in the version Debian 12 ships, rejectsset -o pipefailas an illegal option. Alpine’s BusyBox ash supports pipefail but not the array, so install bash there if you need per-stage codes.
Variants
$ set -o pipefail; grep FATAL server.log | wc -l; echo $?
Let $? report the failure instead. With pipefail on, the pipeline returns grep’s 1 rather than wc’s 0. The option stays set for the rest of the shell session, so in an interactive terminal it changes every pipeline you run afterward
$ grep FATAL server.log | wc -l; rc=("${PIPESTATUS[@]}"); echo "grep=${rc[0]} wc=${rc[1]}"
Save the codes before anything else overwrites them. The array assignment captures every stage at once, and after that rc holds the values no matter how many tests and echoes come after it
$ ./run-tests.sh 2>&1 | tee test.log; exit "${PIPESTATUS[0]}"
Keep a log without letting tee swallow a failing test run. This belongs as the last line of a script or CI step, where exit hands the test runner’s status back to the caller. Typed at an interactive prompt, the exit closes your shell