Skip to content
Shell & TerminalDeep Dive Published Updated 5 min readViews unavailable

Command Substitution vs Process Substitution

$(command) captures text; <(command) exposes a live stream as a path-like argument. Buffering, exit-status visibility, and portability differ sharply.

Command substitution and process substitution look similar, but they solve different interface problems. One turns a command’s output into shell text. The other connects a command’s output or input to a filename-shaped endpoint.

Command substitution produces a word

kernel=$(uname -r)
printf 'kernel=%s\n' "$kernel"

The shell runs uname -r, captures its standard output, removes trailing newline characters, and substitutes the remaining text. Quoting "$kernel" prevents later word splitting and glob expansion.

Because the result is stored as text, command substitution is a poor fit for arbitrary binary data: shell variables cannot represent embedded NUL bytes. Capturing very large output also means retaining it before the consumer can use it.

Process substitution produces an endpoint

diff <(sort old.txt) <(sort new.txt)

Bash, Zsh, and Ksh can replace each <(...) expression with a path that diff opens. Behind that path is commonly a pipe exposed through /dev/fd, or sometimes a named pipe. The producer and consumer can run concurrently, and the data is streamed instead of becoming one shell variable.

The reverse form supplies a writable destination:

tee >(gzip >output.gz) >(sha256sum >output.sha256) >/dev/null

Each >(...) becomes an endpoint to which tee writes; the enclosed command reads that data on standard input.

The practical decision rule

Use command substitution when the output is small textual metadata that belongs in an argument or variable: a version, path, identifier, or date. Use process substitution when a command insists on filenames but the data naturally comes from or goes to another process.

Process substitution is not specified by POSIX sh, so a script whose shebang is #!/bin/sh must not depend on it. A temporary file or explicit FIFO is the portable alternative. Even in Bash, failures inside process substitutions can be less visible than ordinary foreground-command failures; capture statuses explicitly when correctness depends on every branch succeeding.

Finally, neither form is a quoting exemption. Quote command-substitution results, and quote ordinary variables used inside either substitution. The syntax controls data transport, not the safety of later expansion.

Where process substitution actually came from

Process substitution predates Bash by several years. David Korn’s KornShell at Bell Labs first documented the feature in the 1986 ksh86 release, built specifically around systems that exposed /dev/fd/N as openable file descriptors. Bash and Zsh later adopted the same <(...) and >(...) syntax, which is why the construct now feels like a generic shell feature rather than a Korn shell invention. POSIX never standardized it, which is precisely why a script under a #!/bin/sh shebang cannot assume it exists.

The subshell problem process substitution actually solves

A frequent bug looks like this:

count=0
grep -c error logfile | while read -r n; do
  count=$n
done
echo "$count"   # prints 0, not the value read inside the loop

Because the right-hand side of a pipeline commonly runs in a subshell, the count set inside the while loop vanishes once that subshell exits — the parent shell’s count was never touched. Rewriting the loop with process substitution keeps the loop itself in the current shell:

count=0
while read -r n; do
  count=$n
done < <(grep -c error logfile)
echo "$count"   # prints the actual value

<(grep -c error logfile) still supplies a filename-like endpoint, but redirecting it with < into the loop means the loop body never forks — only the producer inside the parentheses does. This is the single most common practical reason to reach for process substitution instead of a pipeline.

Exit status used to be effectively unrecoverable

Before Bash 4.4, there was no supported way to retrieve the exit status of the command running inside a process substitution from the shell that started it — that command’s failure was invisible to $?, which reflected only whatever consumed the substitution’s endpoint. Bash 4.4, released in 2016, changed this: a process substitution now sets $!, the same variable used for backgrounded jobs, so a subsequent wait "$!" can retrieve its real exit status. Scripts that must still run under older Bash versions cannot check that status directly and typically route around the gap with a temporary file or an explicit status-signaling mechanism inside the substituted command.

Where the endpoint actually comes from

On Linux, <(...) typically becomes a path like /dev/fd/63, backed by an anonymous pipe the kernel exposes through /proc/self/fd. On systems without that exact mechanism, Bash falls back to a named pipe (FIFO) created in a temporary location. Either way, the consuming program just calls open() on an ordinary-looking path — it does not need to know it is reading a live pipe rather than a regular file, which is the interface trick that lets programs like diff, which insist on two file arguments, work against dynamically generated data instead.

Command substitution strips more than you’d expect

Command substitution does not remove just one trailing newline from a command’s output — it strips every trailing newline. x=$(printf 'a\n\n\n') leaves $x equal to a, not a followed by two blank lines, which surprises anyone expecting the shell to preserve exactly what a command printed. Process substitution has no equivalent behavior to worry about, since nothing is being captured into a string; the stream reaches its consumer exactly as produced, newlines included.

A quieter optimization: $(< file)

Bash, Ksh, and Zsh all recognize a special case of command substitution: $(< file) reads a file’s contents directly, applying the same trailing-newline-stripping as $(cat file), but without actually forking and executing cat. It is a shell built-in shortcut for an extremely common pattern rather than a separate feature — functionally identical, just implemented without the extra process. POSIX sh does not guarantee this exact form, so a script requiring strict portability should still write $(cat file) and accept the one additional fork.

Zsh’s third form: a real temporary file

Zsh adds a form beyond Bash’s <(...) and >(...): =(...), which writes the command’s output to a genuine temporary file and substitutes that file’s path instead of a pipe. This matters for programs that seek() within their input rather than reading it as a stream — a plain <(...) can fail or behave oddly against those, while =(...) supplies an actual, seekable, if short-lived, file. Bash and Ksh provide no equivalent; a manual mktemp remains the portable substitute.

Related:

Sources:

Comments