shell · difficulty ◆◆
tee — write a stream to disk without stopping it
The T-junction of Unix: nothing stops flowing downstream, and everything gets written down.
`sudo echo 10 >> /etc/sysctl.conf` dies with 'Permission denied'. `echo 10 | sudo tee -a /etc/sysctl.conf` works. Same user, same sudo, same file — the only thing that changed is which process opens it.
$ teeWhat it does, and why it is a fitting and not a command
tee reads standard input, writes every byte to standard output, and writes the same bytes to each file operand. That is the entire program. In a pipeline it is a T-junction: the left side keeps feeding the next stage, and a copy branches off to disk. Without it your only options are `cmd > file` (output gone, nothing continues) or `cmd | less` (read once, save nothing). POSIX standardised tee decades ago, so it exists in GNU coreutils, BSD, busybox and every shell environment you will ever ssh into, with -a and -i as the only classic flags. It is buffered in the ordinary way — tee does not read the whole stream first, it copies chunks as they arrive, which is why it works on a tar stream or a `tail -f` that never ends.
The three ways tee bites: truncation, SIGPIPE, and permission
Truncation is the one people learn by losing data. `tee FILE` opens FILE with O_TRUNC, so the first byte of the new stream destroys the old file, and if the producer dies halfway you keep the partial wreck. Append is a deliberate choice: -a. SIGPIPE is subtler. `seq 100000 | tee y.txt | head -1` looks right and is not: head exits after one line, tee's stdout becomes a broken pipe, tee gets SIGPIPE and dies, and y.txt holds however much tee had already copied. On this box that was 1,859 lines on one run and 14,139 on another — never a stable number, because the size depends on how fast head was scheduled. Filter first, tee second, view last. Permission is the third: the shell performs `>>` redirection, so `sudo echo x >> file` runs echo as root and then tries to open the file as you. tee inverts that — it is the process that opens the file, so `... | sudo tee -a file` puts the privileged open where it belongs.
Two patterns worth memorising: split stderr, and write privileged files
Splitting streams is where tee earns its keep on a terminal. `cmd > >(tee out.log) 2> >(tee err.log >&2)` runs tee in the background for each descriptor: stdout still reaches your screen, stderr still reaches your screen in the right order, and both are captured separately instead of interleaved in one log. And the privileged write has exactly one correct shape — `printf '%s\n' 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf` — with the usual refinement `... | sudo tee /etc/sysctl.conf >/dev/null` when you do not want the line echoed back a second time. Remember that tee prints what it stores. That echo is not noise; it is the confirmation that the bytes made it into the file.
Example
$ df -h | tee disk-usage.txt | tail -4tmpfs 5.0M 0 5.0M 0% /run/lock
/dev/nvme0n1p2 2.0G 220M 1.6G 13% /boot
/dev/nvme0n1p1 1.1G 6.2M 1.1G 1% /boot/efi
tmpfs 1.6G 56K 1.6G 1% /run/user/1000Real output from this host. The terminal shows the last four mounts; disk-usage.txt holds all nine — `wc -l < disk-usage.txt` prints 9. That is the whole idea in one line: tail is the viewfinder, tee is the recorder. Without tee you would re-run df and lose the first capture.
$ echo "level=info boot ok" | tee boot.log
echo "level=warn disk 95%" | tee boot.log
echo "level=error io timeout" | tee -a boot.log
cat boot.loglevel=info boot ok
level=warn disk 95%
level=error io timeout
level=warn disk 95%
level=error io timeoutEvery line above is real, and the point is the second half: only two lines survived in the file. Plain `tee boot.log` truncates, so the third command's output is all that is left of the first two. `wc -l < boot.log` prints 2. Use -a the moment a file is meant to accumulate; use bare tee only when you want a fresh copy each run.
$ journalctl -u docker.service -n 200 --no-pager | grep -iE 'healthcheck|only one connection' | tee ~/docker-health.log | tail -2Oct 07 07:07:32 nuc dockerd[1297]: time="2026-10-07T07:07:32.775642553+02:00" level=warning msg="healthcheck failed" actualDuration="226.78µs" error="Unavailable: connection error: desc = \"transport: Error while dialing: only one connection allowed\"" timeout=15s
Oct 07 07:07:37 nuc dockerd[1297]: time="2026-10-07T07:07:37.775840042+02:00" level=error msg="healthcheck failed fatally" error="session healthcheck failed fatally: Unavailable: connection error: desc = \"transport: Error while dialing: only one connection allowed\""Filter first, tee second, view last — that order is what keeps the file complete. Real triage from this box: 75 matching lines and 20,087 bytes written to ~/docker-health.log, while the terminal shows only the newest warning and the fatal error that followed it five seconds later. Put tail before tee and you get a truncated file instead.
$ echo "vm.swappiness=10" >> /etc/sysctl.conf
echo $?bash: line 1: /etc/sysctl.conf: Permission denied
1The classic. The shell opens /etc/sysctl.conf for append before echo ever runs, so wrapping it in sudo changes nothing — sudo would only fix the echo. tee opens the file itself, so `echo "vm.swappiness=10" | sudo tee -a /etc/sysctl.conf` puts the privileged open in the right process. The failing redirect writes nothing at all, which is the one mercy here.
$ for i in $(seq 1 2000); do echo "compiled module_$i.ts"; done | tee build.log build.copy.log | tail -2
wc -l build.log build.copy.logcompiled module_1999.ts
compiled module_2000.ts
2000 build.log
2000 build.copy.log
4000 totalOne stream, two destinations, both complete — `cmp build.log build.copy.log` exits 0. This is how you keep an immutable raw log and a working copy you can edit or truncate later: `... | tee raw.log work.log`. tee takes any number of file operands and writes each byte to all of them in the same pass.
$ ls -l /etc/hosts /no/such/path > >(tee out.log) 2> >(tee err.log >&2)
cat out.log
cat err.logls: cannot access '/no/such/path': No such file or directory
-rw-r--r-- 1 root root 218 Jan 6 2026 /etc/hosts
-rw-r--r-- 1 root root 218 Jan 6 2026 /etc/hosts
ls: cannot access '/no/such/path': No such file or directoryReal output. Process substitution runs each tee in the background, so stdout still reaches the terminal and stderr still reaches it in the right place, while each stream is captured on its own. Compare that with `cmd > all.log 2>&1`, where the two streams are interleaved and you can no longer grep for errors without also matching success lines.
Common flags
- -a, --append
- Append to the files instead of overwriting them. The only safe choice for logs, and the flag that turns tee into a usable logger.
- -i, --ignore-interrupts
- Ignore SIGINT. The files survive a Ctrl-C that kills the rest of the pipeline — useful when the left side is doing expensive work you do not want to redo.
- -p
- Run in a pipe-appropriate mode (default warn-nopipe): diagnose write errors on non-pipe outputs but only exit once all outputs are broken pipes, so a consumer closing early no longer kills tee mid-stream.
- --output-error=MODE
- warn | warn-nopipe | exit | exit-nopipe — what to do when a write fails. The default is to exit immediately on a broken pipe and merely diagnose errors on regular files.
- --output-error=exit-nopipe
- Ignore stdout going away but exit hard if a file write fails — the strict setting for backup jobs where a short file must not pass silently.
- - (as a file operand)
- Historic shorthand, now only partly true. On this box GNU tee 9.4 still creates a real file named '-' (confirmed with ls -A), so write /dev/stdout if that is what you mean.
- --version
- Prints the coreutils release, e.g. `tee (GNU coreutils) 9.4` here. Worth knowing: the 9.x series changed tee's pipe behaviour twice.
History
The manual page called it a pipe fitting
Version 7 Unix documented the command as `tee - pipe fitting`, and then explained that 'Tee transcribes the standard input to the standard output and makes copies in the files', with exactly two options: -i ignores interrupts, -a appends. V7 tee took only a list of files; with none it read stdin and wrote stdout, so the command was effectively a pass-through. The name is borrowed from plumbing, and the borrowing is imprecise in a way the manuals never hid: a real tee fitting divides one flow into two, while the command duplicates the stream into every output it is given — nothing is split, everything is copied.
Four decades of tiny, load-bearing patches
coreutils keeps changing tee, which is odd for a program that only copies bytes. 6.6 (2006-11-22) made it exit on SIGPIPE 'as POSIX requires', telling anyone who wanted the old behaviour to run `(trap '' PIPE; tee)`. 8.24 (2015-07-03) added --output-error to control what happens when a pipe or a file write fails. 9.2 (2023-03-20) added -p and taught tee to recognise when all remaining outputs have become broken pipes and to cope with non-blocking outputs — previously it could truncate data going to a telnet or mpirun session and print 'Resource temporarily unavailable'. Then 9.11 (2026-04-20) shipped two regressions and 9.12 (2026-09-14) fixed both: tee could loop forever after writing all its output when a write returned EAGAIN, and it treated short writes as errors. The humblest binary in the toolbox is still live code.
Fun facts
Pros & cons
pros
- + The only tool that lets a stream be persisted without stopping it — nothing has to be buffered to disk and re-read, and nothing has to be re-run
- + Writes any number of destinations in the same pass, so `tee raw.log work.log` gives you an untouched original plus something you can edit
- + The portable answer to privileged redirects: `... | sudo tee -a /etc/x` is the idiom every ops runbook uses, and it works in busybox too
cons
- − No atomicity and no error handling by default: it truncates its targets immediately, ignores write errors on non-pipe outputs, and leaves a partial file behind when the producer dies
- − Its exit status describes tee, not the pipeline — a failed command upstream passes unnoticed unless you check PIPESTATUS
- − Every byte is written twice with no rate limiting, so piping a multi-gigabyte stream through tee to a slow disk can throttle or stall the producer
Takeaways
- 1When a redirect is refused, move the open into tee: `echo x | sudo tee -a /etc/sysctl.conf` — never `sudo echo x >> file`, because the shell does the redirect, not sudo
- 2Filter first, tee second, view last: `cmd | grep x | tee log | tail` keeps the file whole, while tail in front of tee SIGPIPEs the source and truncates it
- 3Plain `tee FILE` truncates before the first byte lands — use -a for anything that accumulates, and remember the partial file is what you keep if the pipeline dies
- 4Read `${PIPESTATUS[@]}` (bash) or `$pipestatus` (zsh) after a tee pipeline; tee exiting 0 tells you nothing about whether the command feeding it failed
- 5tee into two files for a raw plus a working copy — `... | tee raw.log work.log` — instead of copying a large log by hand afterwards