← back to latest

Compare the output of two commands without writing a temp file

diff <(sort packages-web01.txt) <(sort packages-web02.txt)

anatomy

diff
Compare two files line by line and report what changed. The catch is in the interface: diff takes filenames, not streams. You can pipe one command into it by passing a dash, but there is no second dash to give, so two pieces of live output have nowhere to go.
<(sort packages-web01.txt)
Process substitution. Bash runs sort in the background, connects its output to a pipe, and substitutes the pipe's pathname into the command line. What diff receives on argv is the literal string /dev/fd/63, so from its point of view it was handed an ordinary file. The sort matters as much as the substitution: diff compares line positions, so two lists holding identical entries in different orders read as a wall of changes until you order them first.
<(sort packages-web02.txt)
A second substitution, running its own sort against its own pipe on its own descriptor. Both processes start before diff does and stream in parallel, so the comparison begins while the sorts are still producing. Bash numbers these from the top of the descriptor table downward, so a command using three of them gets /dev/fd/63, /dev/fd/62, and /dev/fd/61.

Sample output

$ diff <(sort packages-web01.txt) <(sort packages-web02.txt)
1a2
> docker
5d5
< postgres

$ diff packages-web01.txt packages-web02.txt
2,3c2
< redis
< postgres
---
> git
4a4
> docker
7c7
< git
---
> redis

The first run gives you the answer: web02 has docker and is missing postgres, and nothing else differs. The second run is the same two files compared without sorting, and it reports four hunks of pure noise, because the packages were recorded in install order rather than alphabetically. Neither machine changed between the two commands. Only the ordering did.

When you would reach for it

Two machines behave differently and you want to know what is actually installed on one and not the other. Two config dumps, two API responses, two directory listings, two git show outputs from different commits. The shape is always the same: the thing you want to compare is the output of a command, not a file sitting on disk, and the tool you want to compare it with insists on filenames. Process substitution closes that gap without leaving /tmp/a.txt and /tmp/b.txt behind for you to forget about.

Gotchas

  • /bin/sh rejects the syntax outright. The feature is not in POSIX, so dash, the default /bin/sh on Debian and Ubuntu, answers with dash: 1: Syntax error: "(" unexpected, and busybox ash, the default shell on Alpine, rejects it too. The error names a parenthesis, not a missing feature, which sends people looking for a typo. A script that works when you run it interactively and fails under cron or CI is usually this: change the shebang to #!/usr/bin/env bash and it works again.
  • A failure inside the substitution is invisible, and it can look like success. diff <(cat gone-a.txt) <(cat gone-b.txt) prints two “No such file or directory” errors on stderr and then exits 0, because both pipes were empty and empty matches empty. The exit status you get belongs to diff, never to the commands inside the parentheses. set -euo pipefail does not catch it either, since neither substitution is part of the pipeline bash is watching. Confirm the inputs exist before you trust a clean comparison.
  • What you get is a pipe, not a file, and tools that seek will say so. unzip -l <(cat archive.zip) reports End-of-central-directory signature not found, because unzip needs to jump to the end of the archive and a pipe only moves forward. Anything that reads front to back is fine; anything that needs random access wants a real file. The same limit explains why you cannot save the path for later: f=<(sort packages-web01.txt) completes, but the descriptor closes with the assignment, and reading $f on the next line gives /dev/fd/63: No such file or directory.
  • The output form >(cmd) runs asynchronously and the shell does not wait for it. printf 'a\nb\nc\n' > >(sleep 1; wc -l > count.txt) returns to your prompt immediately, and a script that reads count.txt on the very next line finds nothing there. Bash sets $! to the substituted process, so wait $! after the command holds until it has finished writing.

Variants

$ comm -13 <(sort packages-web01.txt) <(sort packages-web02.txt)

Print only what the second list has and the first does not, with no diff markup to strip. comm reads three columns, lines unique to file one, unique to file two, and common to both, and the digits switch columns off. It also requires sorted input and will warn but still answer wrongly if you forget

$ diff <(git show HEAD~2:auth.js) auth.js

Compare a file as it looked two commits ago against what is in your working tree right now, including changes you have not staged. Mixing a substitution and a real filename in one command is allowed, since both arrive as paths

$ sort server.log | tee >(gzip > sorted.log.gz) | wc -l

The output direction: tee writes one copy into a gzip process that never touches an intermediate file, while the other copy carries on down the pipeline. Add wait before reading sorted.log.gz in a script

lineage

Process substitution came out of the Korn shell rather than the Bourne lineage that bash otherwise inherits, and by most accounts David Korn added it once there was a way to name an open file descriptor as a path. That way was /dev/fd, a directory that appeared in the Research Unix line at Bell Labs and spread through the BSDs, where each entry is a process's own open descriptor dressed up as a filename. The syntax was never folded into POSIX, which is the real reason it is a bash feature and not a shell feature, and why a script that opens with the wrong shebang line rejects it. Bash keeps a fallback for systems with no /dev/fd at all: it creates a named pipe in a temporary directory and passes that path instead, which is the same trick performed by hand.