Skip to content

tee and the exit status

Level: 201 · your build runs as make 2>&1 | tee build.log and has never once failed

One line: cmd | tee log is a pipeline, and a pipeline reports its last command, so a build that fails through tee reports tee's success. PIPESTATUS, set -o pipefail in bash and zsh, or fish's $pipestatus bring the failure back. tee also has a status of its own: 1 when it cannot write a file, and 141 when the reader after it stops early, which cuts the log short.

Measured

Verified output of tee_status_sh.sh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ false | tee log.txt; echo "status $?"
status 0

$ build() { echo compiling; echo "error: x is undefined" >&2; return 2; }

$ build 2>&1 | tee build.log; echo "status $?, PIPESTATUS ${PIPESTATUS[*]}"
compiling
error: x is undefined
status 0, PIPESTATUS 2 0

$ if build 2>&1 | tee build.log > /dev/null; then echo "build succeeded?"; else echo "build failed"; fi
build succeeded?

$ set -o pipefail; if build 2>&1 | tee build.log > /dev/null; then echo "build succeeded?"; else echo "build failed"; fi; set +o pipefail
build failed

$ mkdir locked; chmod 555 locked

$ echo data | tee locked/log.txt; echo "status $?"
tee: locked/log.txt: Permission denied
data
status 1

$ chmod 755 locked

$ seq 1 1000000 | tee all.txt | head -n 1; echo "PIPESTATUS ${PIPESTATUS[*]}"
1
PIPESTATUS 141 141 0

$ if [ "$(wc -l < all.txt)" -lt 1000000 ]; then echo "all.txt stopped early"; fi
all.txt stopped early
  • false | tee is 0. This is A pipeline reports its last command with a log file attached.
  • The build failed with 2, and the pipeline said 0. PIPESTATUS kept the 2. So if build 2>&1 | tee build.log took the success branch, and a CI step written that way stays green.
  • set -o pipefail makes the rightmost failing stage count, and the same if took the failure branch.
  • tee fails on its own terms. It could not create locked/log.txt and said so, still copied data to its stdout, and exited 1.
  • tee is a stage like any other. head -n 1 exited after one line. tee's next write went into a pipe nobody was reading, SIGPIPE killed it (141), and seq went the same way. all.txt holds only what tee copied before it died, far fewer than the million lines seq would have written.

So a log is only complete if everything after tee reads to the end. cmd | tee log | head saves the start of the output, not all of it.

On a Mac

Nothing changes. The key is shared: bash 3.2 has PIPESTATUS and pipefail, and BSD tee is killed by SIGPIPE exactly as GNU tee is.

In zsh and fish

zsh reads the stages from $pipestatus, counted from 1, and uses |& for 2>&1 |:

Verified output of tee_status_zsh.zsh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ false | tee log.txt; echo "status $?, pipestatus $pipestatus"
status 0, pipestatus 1 0

$ build() { echo compiling; echo "error: x is undefined" >&2; return 2 }

$ build |& tee build.log; echo "status $?, pipestatus $pipestatus"
compiling
error: x is undefined
status 0, pipestatus 2 0

$ setopt pipefail; if build |& tee build.log > /dev/null; then echo "build succeeded?"; else echo "build failed"; fi; unsetopt pipefail
build failed

$ seq 1 1000000 | tee all.txt | head -n 1; echo "pipestatus $pipestatus"
1
pipestatus 141 141 0

fish has no pipefail, so the check is written by hand:

Verified output of tee_status_fish.fish, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ false | tee log.txt; echo "status $status, pipestatus $pipestatus"
status 0, pipestatus 1 0

$ function build; echo compiling; echo "error: x is undefined" >&2; return 2; end

$ build &| tee build.log; echo "status $status, pipestatus $pipestatus"
compiling
error: x is undefined
status 0, pipestatus 2 0

$ build &| tee build.log > /dev/null; set -l stages $pipestatus; if test $stages[1] -ne 0; echo "build failed with $stages[1]"; end
build failed with 2

$ seq 1 1000000 | tee all.txt | head -n 1; echo "pipestatus $pipestatus"
1
pipestatus 141 141 0

Copy $pipestatus into a variable before testing it: test is itself a command, and it would replace $pipestatus with its own. The pipeline lesson covers that trap in full.

bash zsh fish
did cmd in cmd \| tee log fail? ${PIPESTATUS[0]} ${pipestatus[1]} $pipestatus[1]
make any failure count set -o pipefail setopt pipefail no option; test $pipestatus

If you are coming from another library

See also