tee

Write a stream to a file and pass it on

Updated 2026-08-19

tee reads standard input, writes an unmodified copy to every file you name, and passes the same bytes along to standard output. It is named after a T-junction in plumbing, which is exactly what it does to a pipeline.

That covers two jobs that look unrelated. The first is keeping a copy of something you are watching go past, without running the command twice: make | tee build.log puts the build on screen and on disk at once.

The second is the one people arrive here for. sudo echo something > /etc/somefile does not work, and the reason surprises everyone: sudo applies to echo, but the redirect is performed by your shell, which is still you. sudo tee moves the writing into the command that was elevated, which is why every guide tells you to pipe into it.

Sample files used on this page

Every example below was run against these files. Recreate them to follow along.

wordlist.txt the same wordlist the sort and uniq pages use - duplicates and mixed case

banana
apple
Cherry
apple
date
banana
apple
Elderberry
cherry

access.log standard combined log format, for the examples that watch a pipeline go past

203.0.113.5 - - [13/Aug/2026:09:12:01] "GET /index.html HTTP/1.1" 200 512
203.0.113.5 - - [13/Aug/2026:09:12:03] "GET /style.css HTTP/1.1" 200 231
198.51.100.7 - - [13/Aug/2026:09:14:22] "GET /index.html HTTP/1.1" 200 512
198.51.100.7 - - [13/Aug/2026:09:14:25] "GET /missing.html HTTP/1.1" 404 162
203.0.113.5 - - [13/Aug/2026:09:15:47] "GET /index.html HTTP/1.1" 200 512
192.0.2.44 - - [13/Aug/2026:09:16:03] "POST /login HTTP/1.1" 302 0
192.0.2.44 - - [13/Aug/2026:09:16:04] "GET /dashboard HTTP/1.1" 200 4021
198.51.100.7 - - [13/Aug/2026:09:18:51] "GET /index.html HTTP/1.1" 200 512
203.0.113.5 - - [13/Aug/2026:09:19:10] "GET /api/status HTTP/1.1" 500 89
192.0.2.44 - - [13/Aug/2026:09:20:33] "GET /dashboard HTTP/1.1" 200 4021
26 outputs, collapsed by default

Save a copy without losing the stream

> sends output to a file instead of the screen. tee sends it to a file as well as the screen, and on to the next command in the pipeline if there is one.

Write output to a file and still see it

sort wordlist.txt | tee sorted.txt

The sorted list appears on screen exactly as it would without tee, and sorted.txt now holds the same bytes. Compare sort wordlist.txt > sorted.txt, which shows you nothing.

Show output
Cherry
Elderberry
apple
apple
apple
banana
banana
cherry
date

Keep the file, skip the screen

sort -u wordlist.txt | tee unique.txt > /dev/null && cat unique.txt

Redirecting tee's own output to /dev/null throws away the copy that would have gone to the terminal. Pointless on its own - that is just > - but it is the standard shape for sudo tee, further down.

Show output
Cherry
Elderberry
apple
banana
cherry
date

Put tee in the middle of a pipeline

sort wordlist.txt | tee sorted.txt | wc -l

wc receives the full stream unchanged while sorted.txt captures it in passing. This is what tee is for: a tap on a pipe, not a fitting on the end of one.

Show output
9

Prove the tap and the pipeline agree

sort -u wordlist.txt | tee unique.txt | wc -l && wc -l unique.txt

The count downstream and the count in the file agree. tee copies bytes and changes nothing, which is what makes it safe to drop into a pipeline you are debugging.

Show output
6
6 unique.txt

Write the same stream to several files at once

head -3 wordlist.txt | tee copy-a.txt copy-b.txt

Every filename argument gets its own complete copy. There is no flag for this - extra files are just extra arguments.

Show output
banana
apple
Cherry

Confirm every copy is identical

head -3 wordlist.txt | tee copy-a.txt copy-b.txt > /dev/null && tail -n +1 copy-a.txt copy-b.txt

tail -n +1 on several files prints each with a header, which is a quick way to show two files side by side without running diff.

Show output
==> copy-a.txt <==
banana
apple
Cherry

==> copy-b.txt <==
banana
apple
Cherry

Use tee with no filenames at all

tee < wordlist.txt | head -3

With no file arguments tee is simply cat: stdin to stdout, nothing captured. Occasionally useful when a script builds the argument list and it comes out empty.

Show output
banana
apple
Cherry

Overwriting and appending

tee overwrites by default

printf 'old\n' > notes.txt && printf 'new\n' | tee notes.txt && cat notes.txt

Like >, tee truncates the file before writing. The output appears twice here because tee printed it once and cat read it back.

Show output
new
new

Append instead, with -a

printf 'first run\n' | tee build.log && printf 'second run\n' | tee -a build.log && cat build.log

-a (--append) is tee's equivalent of >>. The two tee calls print as they go, then cat shows the accumulated file.

Show output
first run
second run
first run
second run

-a creates the file if it is not there yet

printf 'a\nb\n' | tee -a fresh.log && cat fresh.log

No need to touch the file first. -a only means "do not truncate", not "the file must already exist".

Show output
a
b
a
b

Writing files your shell cannot

The reason most people meet tee. A redirect is performed by the shell, and the shell is still you even when the command after sudo is not.

The problem: a redirect into a file you do not own

echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.conf

Fails with Permission denied as an ordinary user. Verified in the sandbox: nothing is written and the shell reports the failure before echo ever runs.

The trap: sudo does not fix it

sudo echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.conf

Exactly the same Permission denied, which is the surprising part. sudo elevates echo, but the > is carried out by your shell before sudo is involved at all - and your shell has no more right to write /etc than it did a moment ago. Verified in the sandbox: the file is still not created.

The fix: pipe into sudo tee

echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf

Now the process doing the writing is the one that was elevated. tee echoes what it wrote, which is why you see the line twice in a terminal - once from tee, and it is the only copy.

Show output
vm.swappiness=10

Silence the echo with > /dev/null

echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf > /dev/null && cat /etc/sysctl.d/99-swappiness.conf

The idiomatic form. Without > /dev/null every line you write is repeated back at you, which is noise on a multi-line file. The cat here just proves the write landed.

Show output
vm.swappiness=10

Write a whole config file from a heredoc

sudo tee /etc/sysctl.d/99-swappiness.conf > /dev/null <<'EOF'
# Reduce swap pressure on a database host.
vm.swappiness=10
vm.vfs_cache_pressure=50
EOF
cat /etc/sysctl.d/99-swappiness.conf

The pattern for creating a root-owned config file in one step. Quoting the delimiter as 'EOF' stops the shell expanding $ and backticks inside the block, which matters for anything containing a variable you meant literally.

Show output
# Reduce swap pressure on a database host.
vm.swappiness=10
vm.vfs_cache_pressure=50

Check who ends up owning the file

echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf > /dev/null && stat -c '%U %G %a %s' /etc/sysctl.d/99-swappiness.conf

Owned by root, mode 644, because tee created it as the elevated user. stat -c is used here rather than ls -l because it prints no timestamp - see chmod for what the mode means and chown for changing the two names in front of it.

Show output
root root 644 17

Append to a root-owned file

echo 'vm.vfs_cache_pressure=50' | sudo tee -a /etc/sysctl.d/99-swappiness.conf > /dev/null && cat /etc/sysctl.d/99-swappiness.conf

sudo tee -a is the elevated >>. The same trap applies: sudo echo x >> /etc/file fails for exactly the reason the plain redirect did.

Show output
vm.vfs_cache_pressure=50

Watching a pipeline go past

Dropping tee between two stages is the cheapest way to find out which stage broke, because it captures the intermediate result without changing it.

Count matches and keep them

grep ' 200 ' access.log | tee matched.log | wc -l

The count comes back on screen while matched.log holds the lines themselves, so you can look at them without re-running grep.

Show output
7

Capture a full ranking while showing only the top of it

sort -k9 -rn access.log | tee ranked.log | head -2

head closes the pipe after two lines, but tee has already written every line it saw to the file. The full ranking is on disk, the interesting part is on screen.

Show output
192.0.2.44 - - [13/Aug/2026:09:20:33] "GET /dashboard HTTP/1.1" 200 4021
192.0.2.44 - - [13/Aug/2026:09:16:04] "GET /dashboard HTTP/1.1" 200 4021

See the data and the summary at the same time

sort -u wordlist.txt | tee /dev/stderr | wc -l

Writing to /dev/stderr puts the stream on your terminal while stdout carries on to wc. Nothing is captured to disk, which is what you want when you only need to look once.

Show output
Cherry
Elderberry
apple
banana
cherry
date
6

Fan a stream into another command, not a file

sort -u wordlist.txt | tee >(wc -l > count.txt) > /dev/null

>(...) is bash process substitution: it looks like a filename to tee, but it is a pipe into the command inside. Handy for running two analyses over one stream. The two branches finish independently, so read count.txt in a later step rather than immediately.

Exit status, errors, and one way to lose your file

tee hides the exit status of the command before it

false | tee out.txt; echo "exit $?"

$? is tee's status, not false's, because the shell reports the last command in a pipeline. A pipeline that failed halfway therefore looks like a success.

Show output
exit 0

Get the real status with pipefail

set -o pipefail; false | tee out.txt; echo "exit $?"

set -o pipefail makes a pipeline report the first non-zero status in it. Worth setting in any script that pipes through tee and then checks whether the work succeeded.

Show output
exit 1

tee will not create a missing directory

echo hi | tee nodir/file.txt

The error goes to stderr and tee still writes the stream to stdout, then exits 1. It creates files, never directories - mkdir -p first if the path might not exist.

Show output
tee: nodir/file.txt: No such file or directory
hi

What tee does when a write fails

echo hi | tee /dev/full; echo "exit $?"

/dev/full is a device that is always out of space, which is how you test this without filling a disk. tee reports the failing file by name and exits non-zero; --output-error tunes whether it also gives up on the remaining ones.

Show output
hi
tee: /dev/full: No space left on device
exit 1

Never tee into the file you are reading

sort -n big.txt | tee big.txt | wc -l

tee truncates its output file as it starts, while the command on the left is still reading the same file, so the input is destroyed underneath it. Measured in the sandbox on a 200,000-line file: this reported 0 lines and left the file empty, and the cat version of the same mistake kept 44,176 lines on one run - the amount that survives is a race, which is why it can look harmless on a small file. Write to a temporary file and mv it into place instead.

Discard the copy entirely

echo hi | tee /dev/null

Writing to /dev/null keeps the stream flowing to stdout and captures nothing. Occasionally the right answer when a script's filename variable is legitimately empty.

Show output
hi

Chain tee more than once

seq 1 5 | tee a.txt | tee b.txt | tail -2

Each tee taps the stream and passes it on, so both files get the whole thing. One tee a.txt b.txt is the clearer way to write this, but chained taps turn up in pipelines that were built up a stage at a time.

Show output
4
5

Ignore Ctrl-C while writing

printf 'deploy\n' | tee -i deploy.log && cat deploy.log

-i (--ignore-interrupts) makes tee ignore SIGINT, so a Ctrl-C aimed at a long-running command upstream does not also kill the thing recording its output. The log survives the interrupt with everything written up to that point.

Show output
deploy
deploy