skip to content

In bash, `diff <(sort a.txt) <(sort b.txt)` compares the output of two commands even though `diff` only accepts filenames. What does `<(sort a.txt)` expand to, and what is running behind that word?

level: juniorimportance: should knowfreq 42%

answer

  1. gives a command something to open
  2. runs alongside, not before
  3. a path appears where a word was
  4. /dev/fd/63 is the read end of a pipe

basics

~20 s

Process substitution runs sort a.txt as a separate process and expands to a filename, typically /dev/fd/63, that reads from that process's output. diff opens that path like an ordinary file, so the two sorts stream straight into it.

solid answer

~40 s

`<(cmd)` is process substitution: bash creates a pipe, starts `cmd` asynchronously with its stdout on the write end, and replaces the whole word with a *filename* naming the read end — on Linux and macOS a path like `/dev/fd/63`. `diff` therefore receives two ordinary-looking path arguments, opens them, and reads both sorts as they run. Nothing is captured into a variable and nothing is written to disk. The mirror form `>(cmd)` gives you a filename you write *to*, feeding that command's stdin — `tee >(gzip -9 > out.gz) >(sha256sum > out.sha)` fans one stream out to two consumers. Two caveats come with it: it is a bashism that `/bin/sh` on Debian or Ubuntu will not parse, and the path is a pipe, so the consumer gets one forward-only pass over the data.

go deeper

for a junior

Be ready to say that <(cmd) hands a command a filename to open rather than a blob of text, and to offer diff <(sort a) <(sort b) as the everyday example.

for a middle

Explain the plumbing: bash forks the command onto a pipe and substitutes /dev/fd/N naming the read end, so producer and consumer run concurrently and nothing is buffered whole.

for a senior

Raise the operational limits before this ships: it is bash-only, the path is a one-pass pipe, the substituted command's failure is silent, and the name means nothing to a remote or descriptor-stripping process.

for a principal

Own the trade-off explicitly. Process substitution removes temp-file lifecycle bugs from glue scripts but pins them to bash and hides producer failures, so decide where your team accepts that and where a script should graduate to a real program.

## What the shell actually substitutes Command substitution captures text. Process substitution does not — it manufactures a *name*. When bash parses `<(sort a.txt)` it does three things before the outer command ever starts: 1. creates a pipe; 2. forks a process running `sort a.txt` with its standard output attached to the write end; 3. replaces the word `<(sort a.txt)` with a filename that refers to the read end. You can see the filename directly, because `echo` prints its arguments without opening them: ```bash $ echo <(true) /dev/fd/63 ``` That is the whole trick. A program that insists on file arguments is handed something that looks like a file and behaves like a stream. ## Two directions `<(cmd)` yields a filename you *read* from, so the substituted command is the producer. `>(cmd)` yields a filename you *write* to, so the substituted command is the consumer and everything written to the path arrives on its stdin: ```bash tee >(gzip -9 > out.gz) >(sha256sum > out.sha) > /dev/null ``` Both forms are single words in the command line, so they can appear anywhere a filename argument or a redirection target can. ## Where the name comes from On Linux and macOS the name is `/dev/fd/N`, a directory the system provides that lists the calling process's own open descriptors (on Linux `/dev/fd` is a symlink to `/proc/self/fd`). Bash keeps the descriptor — in practice a high-numbered one such as 63 — open across the fork and exec, so the process that becomes `diff` inherits it and `/dev/fd/63` resolves to the pipe. Where a system provides no `/dev/fd`, bash falls back to creating a real named pipe (FIFO) in a temporary directory and passing that path instead. Either way, no data lands on disk: a FIFO is a rendezvous point between two processes, not a file with contents. ## Concurrent, not sequential A common wrong mental model is "bash runs `sort`, collects the result, then calls `diff`". Producer and consumer run at the same time, joined by a pipe with a bounded buffer (64 KiB on Linux). Nothing has to be buffered whole, so `diff <(sort huge1.txt) <(sort huge2.txt)` keeps memory flat regardless of file size, and the two sorts overlap in time. If the consumer exits early the descriptor closes and the producer is killed by SIGPIPE; if the consumer never opens the path at all, the producer simply blocks once the pipe fills. ## What it is good for The canonical uses are the two- or three-file tools that have no stdin mode for their second argument: ```bash diff <(ssh host1 rpm -qa | sort) <(ssh host2 rpm -qa | sort) comm -13 <(sort -u installed.txt) <(sort -u wanted.txt) ``` The `comm` line prints the entries present only in `wanted.txt`. The same idea covers `join`, `paste`, and any tool with a `--config`-style option that must be given a path when the content is generated on the fly. Writing direction covers fan-out with `tee`, and feeding a compound command's standard input. ## The limits worth knowing early - **It is not POSIX.** A script with `#!/bin/sh` on a system where `/bin/sh` is dash fails to parse the line at all — the failure is a syntax error, not a runtime one, so it can hit code paths you never reach in testing. - **One pass, forward only.** The path names a pipe, so a program that wants to seek or re-read cannot use it. - **The status is lost.** `$?` after the command reflects the outer command, never the substituted one — if `sort a.txt` cannot open the file, `diff` just sees an empty stream. - **The path is local and per-process.** `ssh host wc -l <(date)` expands `<(date)` on your machine and asks the remote shell to open a path that means nothing there. None of these make it a bad tool; they make it a tool for glue scripts you know will run under bash, where the alternative is a temporary file you then have to remember to delete.

  • Does `<(cmd)` ever write a temporary file to disk?
    Not on a system with `/dev/fd` — the name is just a path to an already-open pipe, so the data never touches a filesystem. Where `/dev/fd` is unavailable bash falls back to creating a real named pipe in a temporary directory, but a FIFO holds no contents either; it is only a rendezvous point, and bash removes it once the processes are connected.
  • What happens to the substituted command if the consumer never opens the filename?
    It still runs. With nobody reading, it blocks once the pipe buffer fills — 64 KiB on Linux — because the read descriptor is held open in the consumer's descriptor table. When the consumer exits and that descriptor closes, the producer gets SIGPIPE and dies. Nothing is written to disk in either case.
  • Why does `ssh host wc -l <(date)` not do what it looks like it does?
    Expansion happens entirely in your local shell: bash starts `date` locally and substitutes a local `/dev/fd/63` path, then sends the literal string `wc -l /dev/fd/63` to the remote host. The remote shell opens *its own* descriptor 63, which is unrelated, so you get an error or nonsense output rather than the local date.

saying these in an interview costs you the question

  • Says <(cmd) pastes the command's output text into the line
  • Claims bash writes the output to a temporary file first
  • Thinks the substituted command finishes before diff starts
  • Assumes the same line works in a /bin/sh script
  • Believes the output arrives on diff's standard input

context