SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Pipe output to two commands with tee

A pipe has one reader, so feeding two commands needs tee. Copy a stream to a file, append with tee -a, fan out with process substitution, and read PIPESTATUS.

Why a pipe cannot feed two commands

A pipe has one reader, so you cannot pipe output to two commands with a pipe alone. tee is the program that copies one stream to more than one place: it reads standard input, writes it to standard output, and writes the same bytes to every file you name on the command line.

Adding a stage does not help. cmd | grep 500 | wc -l is a chain, so wc counts what grep produced and never sees what cmd produced. No pipe operator hands one stream to two readers, because a pipe is a single kernel buffer with one read end. Something has to duplicate the bytes. The name tee comes from the T shaped fitting a plumber uses to split one pipe into two.

If you only need the text in a shell variable, that is command substitution with the $( ) form and not tee. If you want to run a command again every few seconds and look at the newest result, that is the watch command. Reach for tee when one stream has to arrive in more than one place at the same time.

Build a log to practise on

Every command below runs against the same eight lines, so you can check each result against your own terminal.

mkdir -p ~/teedemo
cd ~/teedemo
cat > app.log <<'EOF'
2026-09-05T09:14:02Z web-01 200 GET /api/health
2026-09-05T09:14:07Z web-01 200 GET /
2026-09-05T09:14:11Z web-01 502 GET /api/orders
2026-09-05T09:14:19Z web-02 200 GET /assets/app.css
2026-09-05T09:14:23Z web-02 502 GET /api/orders
2026-09-05T09:14:31Z web-01 404 GET /favicon.ico
2026-09-05T09:14:38Z web-02 200 GET /api/health
2026-09-05T09:14:44Z web-01 500 POST /api/checkout
EOF
wc -l < app.log

wc -l prints 8. Quote the heredoc delimiter as 'EOF' so the shell leaves the text alone instead of expanding anything inside it.

tee writes a file and passes the stream on

Here is the job that comes up on a real server: keep the failed requests in a file, and count them in the same pass over the log.

grep ' 50[0-9] ' app.log | tee errors.log | wc -l
3

tee wrote the matching lines into errors.log and sent the same lines down the pipe to wc, which counted 3. cat errors.log now shows the two 502 lines and the one 500 line. Without tee you would run grep twice over the same input, and on a log that is still being written the second pass can see different data than the first, so the count and the file disagree.

tee takes as many filenames as you give it, so ... | tee errors.log audit.log | wc -l writes two copies and still feeds wc.

Append with tee -a instead of overwriting

tee truncates every file it opens. Run the same pipeline twice and the second run throws away the first result, which is how a deploy log ends up holding only the last attempt. -a appends instead.

grep ' 502 ' app.log | tee -a errors.log | wc -l
wc -l < errors.log

The first line prints 2 and the second prints 5: the three lines from the previous section, plus the two appended now. Drop the -a and the second number is 2, because the file was emptied before the first byte was written.

That is the form to use while a long job runs, so its output is on your screen and in a file at once.

sudo apt-get install -y nginx 2>&1 | tee -a ~/deploy.log

2>&1 matters here. apt-get sends warnings and errors to standard error, and tee only reads standard output, so without that redirect the log is missing exactly the lines you will want later. The redirect has to sit before the |. In bash, |& is a shorthand for 2>&1 |.

Pipe output to two commands with process substitution

To reach two commands, give tee a process in the place where it expects a filename. Bash writes that as >( ... ).

tee >(grep ' 50[0-9] ' > errors.log) >(wc -l > total.count) < app.log > /dev/null
cat total.count

total.count holds 8 and errors.log holds the three failing requests. One read of app.log, two commands fed from it.

The mechanism is worth knowing, because it explains every limit below. Bash starts each command inside >( ) and connects that command's standard input to an entry it creates under /dev/fd. Bash then substitutes that path onto tee's command line. tee opens what looks like an ordinary file and never learns that a process is reading the other end.

The trailing > /dev/null discards tee's own standard output. Leave it off and the whole log prints to your terminal as well, because tee still copies everything to standard output whether or not you asked for it. That is the part most people miss on the first try.

Add as many destinations as you need. tee >(a) >(b) >(c) > /dev/null runs three commands off one stream.

One caution before you put this in a script: bash does not wait for the commands inside >( ) before it runs the next line. A script that reads total.count on the following line can read an empty file, because wc has not finished writing yet. When a later step depends on those outputs, use the named pipe form below, where the readers are ordinary background jobs.

Process substitution needs bash, not sh

>( ) exists in bash, ksh and zsh. It is not part of POSIX shell. On Debian and Ubuntu /bin/sh is dash, which has no such syntax.

sh -c 'tee >(wc -l) < app.log > /dev/null'
sh: 1: Syntax error: "(" unexpected

That is a parse error, not a runtime error, so nothing in the file runs at all, including the lines above the offending one. A script that works when you type bash deploy.sh and fails when cron runs sh deploy.sh is usually this. Which shell actually runs your script decides the outcome, and the shebang line only applies when the file is executed directly. It also explains why the same script behaves differently across servers: on Fedora and Rocky /bin/sh is bash, which keeps process substitution even in POSIX mode, so the failure only appears once you deploy to a Debian family box.

The mkfifo version for any POSIX shell

A named pipe does the same job with nothing but POSIX tools, and it is the safer choice inside scripts.

mkfifo err.pipe count.pipe
grep ' 50[0-9] ' < err.pipe > errors.log &
wc -l < count.pipe > total.count &
tee err.pipe count.pipe < app.log > /dev/null
wait
rm -f err.pipe count.pipe

Start the readers before tee. Opening a FIFO for writing blocks until another process opens it for reading, so if tee goes first it stops on err.pipe and nothing moves. There is no error message and no timeout, only a command that never returns.

wait is dependable here because grep and wc are ordinary background jobs with real process IDs, which is exactly what the >( ) form does not give you.

Delete the FIFOs when the work is done. A leftover FIFO looks like a normal file in ls, and the next command that tries to read it will block in the same silent way.

sudo tee writes the root owned file a redirect cannot

Run the commands in this section on your own server. They change system configuration.

echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-forward.conf > /dev/null
sudo sysctl --system

The reason this idiom exists is the order of operations. In sudo echo 'net.ipv4.ip_forward = 1' > /etc/sysctl.d/99-forward.conf, your shell opens the output file before sudo starts, and your shell is running as you, not as root.

bash: /etc/sysctl.d/99-forward.conf: Permission denied

With sudo tee, the process that opens the file is tee, and tee is the thing running as root. The > /dev/null only stops the content being echoed back at you.

Use -a when you are adding to a file that already has content, such as echo '10.0.0.5 db-01' | sudo tee -a /etc/hosts > /dev/null. Forgetting -a there replaces the whole file with that one line. Both forms create the file owned by root, which is what you want under /etc on a box where you work from a normal account and reach for sudo only when a command needs it.

For a config longer than one line, feed a quoted heredoc: sudo tee /etc/nginx/conf.d/app.conf > /dev/null <<'EOF'. The quotes around EOF stop the shell expanding $ inside the config before tee ever sees it.

tee hides the exit status of the command before it

This is the mistake that reaches production.

false | tee run.log > /dev/null
echo "$?"
0

false failed. The exit status of a pipeline is the status of its last command, and the last command is tee, which wrote its file without trouble. So apt-get install -y nginx | tee install.log reports success even when apt installed nothing, and set -e will not stop the script.

Bash records every stage in the PIPESTATUS array.

false | tee run.log > /dev/null
echo "${PIPESTATUS[@]}"
1 0

Read it on the very next line. Any command in between replaces it, including a plain assignment, so copy the array first if you need it later: stages=("${PIPESTATUS[@]}").

The simpler fix for a script is pipefail, which makes the pipeline return the rightmost non-zero status.

set -o pipefail
false | tee run.log > /dev/null
echo "$?"
1

Put set -o pipefail at the top of any deploy script that pipes into tee. In zsh the array is $pipestatus and it counts from 1 rather than 0.

Why tee shows nothing while you follow a live log

tail -f app.log | grep ' 50[0-9] ' | tee errors.log can sit silent for a long time and then print a burst of lines. tee is not the cause. grep switches to block buffering when its standard output is a pipe instead of a terminal, so it holds output back until it has a few kilobytes to write.

Fix it at the command that buffers, not at tee. Use grep --line-buffered ' 50[0-9] ', or put stdbuf -oL in front of a program that has no such option of its own.

When tee stops early or writes to a deleted file

Two failures that both look like lost data.

grep ' 50[0-9] ' app.log | tee errors.log | head -2 gives head its two lines, head exits, and the kernel sends SIGPIPE to tee. tee dies there, so errors.log holds only what it managed to write first. Use tee -p, which is --output-error=warn-nopipe, when the file matters more than the terminal view: tee then keeps writing its files after standard output closes.

The second one is slower to notice. tee -a holds its file open by inode, not by name. If logrotate renames or deletes that file while tee is still running, tee keeps writing into the old inode, which now has no directory entry. Nothing appears in the new log, and the space is not freed until the process exits, which is exactly the case where df reports a full disk and du cannot find the files. Restart whatever runs the pipeline after a rotation, or send the stream through logger and let the system journal own the file.

FAQ

How do I send output to two commands at once?

Use tee with bash process substitution: cmd | tee >(first) >(second) > /dev/null. tee copies its input to every destination you name, and >( ) makes each command look like a file to tee. The > /dev/null stops tee's own copy printing to your terminal as well. In a POSIX shell without >( ), create two named pipes with mkfifo, start the readers in the background first, then run tee pipe1 pipe2.

Why does my tee command fail with Syntax error: "(" unexpected?

The script is running under dash or another POSIX shell, and >( ) is a bash, ksh and zsh feature. On Debian and Ubuntu /bin/sh is dash, so both sh script.sh and a #!/bin/sh shebang produce that message. It is a parse error, so nothing in the file runs, not even the lines before it. Change the shebang to #!/bin/bash and execute the script directly, or rewrite the fan out with mkfifo.

Why does my script report success when the command before tee failed?

A pipeline exits with the status of its last command, and in cmd | tee log that is tee. tee almost always succeeds, so $? is 0 and set -e sees nothing wrong. Add set -o pipefail so the pipeline returns the rightmost non-zero status, or read ${PIPESTATUS[@]} on the line immediately after the pipeline, before any other command overwrites it.

Why do I need sudo tee instead of sudo with a redirect?

In sudo command > /etc/somefile, your shell performs the redirect, and your shell is not root, so you get Permission denied before sudo even starts. sudo tee /etc/somefile moves the file opening into a process that is itself running as root. Add > /dev/null to keep the content off your screen, and add -a when you mean to append rather than replace the file.

Does tee overwrite the file every time?

Yes. tee file truncates the file to zero length as it opens it, so a pipeline you run twice keeps only the second run. tee -a file appends instead. Both create the file if it does not exist, using your current umask for the permission bits, and both write to standard output as well.

#bash#linux#cli#pipes#tee