| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10 |
| Applies to | Linux distributions with Bash installed |
| Privilege | Normal user; examples create files in the current directory |
| Scope | Bash process substitution with <(command) and >(command): file-like paths, /dev/fd or FIFO implementation, comparison with pipes and command substitution, while read in the current shell, diff, tee fan-out, exit status, and portability. Does not cover named custom file descriptors, full pipeline/pipefail reference, or heredocs. |
| Related guides | Shell scripting course hub |
Bash process substitution exposes a command's input or output through a file-like pathname. <(command) lets another command read the output as if it were a file, while >(command) lets another command write into a process.
For example, diff <(sort file1) <(sort file2) compares two command outputs without manually creating and cleaning up temporary data files. Process substitution is also useful with while read loops and tee, but it is Bash-specific and its asynchronous behavior matters when exit status or completion must be tracked.
What Is Process Substitution?
Process substitution is a Bash (and ksh/zsh) feature — not POSIX sh. It is useful when a command expects a pathname or multiple independent input sources, but the data you want to provide comes from other commands.
| Form | What Bash gives the surrounding command |
|---|---|
<(list) |
A pathname whose contents are the stdout of list |
>(list) |
A pathname you can write to; Bash forwards that data to stdin of list |
The substituted process runs asynchronously. Bash connects it through a FIFO or a /dev/fd descriptor, depending on the system. There must be no space between < or > and ( — < (cmd) is a syntax error, not process substitution.
<(command): Use Output as a File
<(command) expands to a pathname such as /dev/fd/63. Commands that accept file operands, such as diff, comm, paste, grep, and wc, can read from <(command) directly.
Compare two sorted lists without manually creating temporary data files:
printf 'b\na\nc\n' > a.txt
printf 'c\nb\na\n' > b.txt
diff <(sort a.txt) <(sort b.txt)An empty diff means the sorted contents match. Bash runs both sort commands asynchronously and gives diff a separate file-like pathname for each output.
Inspect the generated pathname (the number varies by run and shell):
bash -c 'printf "path=%s\n" <(printf hello)'path=/dev/fd/63Do not hard-code /dev/fd/63 in scripts. Treat the expansion as an opaque handle Bash creates for one command line.
>(command): Send Output to a Process
>(command) gives you a pathname to write to. Data written there is piped into the substituted command's stdin.
Send a line through tee into a file without a named pipe on disk:
rm -f recv.log
printf 'stream data\n' > >(tee recv.log)stream datatee receives the data through its stdin and writes a copy to recv.log while also printing it to stdout. Because the process substitution runs asynchronously, do not rely on recv.log being complete immediately after the surrounding command finishes.
Use >(command) when the surrounding command needs an output pathname, but you want data written to that pathname to become another command's stdin.
How /dev/fd or FIFOs Make It Work
GNU Bash documents process substitution as connecting the substituted list to a FIFO or a file descriptor under /dev/fd. From your script's perspective, the expansion is just a pathname string.
That is why diff <(cmd1) <(cmd2) works: diff receives two pathname arguments and reads from them like files. The exact mechanism is system-dependent; Bash may use named pipes or /dev/fd, so scripts should treat the generated pathname as an opaque, temporary interface rather than depend on its exact form.
Process Substitution vs Pipe
A pipe connects stdout of one command directly to stdin of the next. Process substitution exposes a path instead.
| Mechanism | Best when |
|---|---|
Pipe cmd1 | cmd2 |
One stream flows straight into the next program |
<(cmd) |
A command needs a file argument, or you want redirection instead of a pipeline |
>(cmd) |
A command needs to write to a path that feeds another process |
diff is a classic use case because it normally compares two file operands, while a pipe provides only one stdin stream. Process substitution gives diff two separate file-like pathnames without manually creating and cleaning up temporary data files. By default, Bash executes pipeline elements in separate subshell environments, which is why variables changed inside cmd | while read ... usually disappear afterward — see Bash pipes for pipefail and PIPESTATUS. Process substitution avoids a subshell for the reader when you use redirection (< <(cmd)) instead of a pipe.
Process Substitution vs Command Substitution
Command substitution captures stdout into a string with $(command). Process substitution exposes stdout as a file-like path with <(command).
Both can feed wc -l, but they behave differently behind the scenes:
printf 'line1\nline2\n' > sample.txtCount lines from a captured string (here-string):
wc -l <<< "$(cat sample.txt)"2Count lines from process substitution (streaming file descriptor):
wc -l < <(cat sample.txt)2Use $(command) when you need the text in a variable or as part of a string. Use <(command) when a tool should read from a file and you want streaming input — or when trailing-newline rules from command substitution would get in the way.
| Approach | Data handling |
|---|---|
$(command) |
Captures the command's output as shell text before use |
<(command) |
Streams data through a FIFO or file descriptor to the reader |
| Temp file | Stores data on disk |
Prefer process substitution for large streaming output when the consumer can read incrementally.
Keep a while read Loop in the Current Shell
A common pipeline pitfall: because pipeline elements run in subshells by default, variables set inside cmd | while read usually disappear afterward.
count=0
printf 'one\ntwo\nthree\n' | while IFS= read -r line; do count=$((count + 1)); done
printf 'pipe count=%s\n' "$count"pipe count=0Process substitution keeps the loop in the current shell because the input arrives through redirection, not a pipe:
count=0
while IFS= read -r line; do count=$((count + 1)); done < <(printf 'one\ntwo\nthree\n')
printf 'procsub count=%s\n' "$count"procsub count=3What does < <(command) mean?
The two < characters belong to different constructs. <(command) is process substitution: Bash runs command asynchronously and produces a file-like pathname such as /dev/fd/63. The separate < before it is normal input redirection.
Conceptually, done < <(command) becomes something like done < /dev/fd/63. The loop therefore reads the substituted command's output through stdin while the loop itself remains in the current shell.
The same pattern is covered in read a file line by line. For loading all lines into an array at once, mapfile with < <(command) is often simpler.
When each incoming line becomes a lookup key rather than an ordered
item, the Bash associative-array guide uses
the same done < <(command) shape to consume sorted map keys.
Compare Two Command Outputs with diff
Sorting before diff is a practical pattern when order differs but content should match:
diff <(sort a.txt) <(sort b.txt)
echo "diff exit=$?"diff exit=0When the files differ, diff prints the differences and exits with status 1. The exit status you care about in scripts is usually diff's status, not the substituted commands' statuses — see the exit-status section below.
Fan Out with tee and >(...)
tee can write to multiple destinations. Process substitution lets one destination be another command:
rm -f payload.gz sha.txt
printf 'payload data\n' | tee >(gzip -c >payload.gz) >(sha256sum >sha.txt)payload datatee prints the line to the terminal while also sending a copy to each >(...) branch. This demonstrates fan-out, but the two consumers run asynchronously. Do not immediately depend on payload.gz or sha.txt unless you add explicit synchronization outside process substitution — the tee pipeline can finish before gzip or sha256sum has finished writing those files.
Exit Status and Asynchronous Behavior
Process substitution does not turn the substituted command's exit status into the main command's exit status automatically.
When one substituted command fails but diff still produces output, you see diff's status — not a direct report of the inner failure:
diff <(false) <(printf 'x\n') 2>/dev/null
echo "main exit=$?"0a1
> x
main exit=1Here main exit=1 comes from diff, not from false. Check substituted commands explicitly when their success matters — run critical commands outside process substitution, or restructure the workflow when you need reliable status from each branch.
Process-substitution commands run asynchronously, and $? normally reports the status of the surrounding command, not the commands inside <(...) or >(...). A plain wait is not a general solution for tracking multiple process substitutions. If individual completion or exit status matters, use a design with explicit background jobs, a normal pipeline with pipefail/PIPESTATUS, or another synchronization mechanism.
Portability to sh
Process substitution is a Bash extension — POSIX sh does not define <(list) or >(list). A script may appear to work when sh happens to invoke a shell that supports the extension, so do not use runtime success as proof of POSIX compatibility.
If the script uses <(...) or >(...), declare Bash explicitly with #!/usr/bin/env bash or #!/bin/bash. For portability notes beyond Bash, see Bash vs sh. When sh compatibility is required, use a temporary file or a pipe redesign instead.
Common Syntax and Lifecycle Mistakes
| Symptom / trap | Likely cause | Fix |
|---|---|---|
syntax error near ( |
Space before ( — < (cmd) |
Write <(cmd) or >(cmd) with no space |
Works in Bash but fails under sh |
Process substitution is not POSIX | Run with Bash shebang or avoid <()/>() |
Loop counter stays zero after while read |
Pipeline subshell | Use done < <(command) instead of | while read |
Assumed inner command's $? is the script's $? |
Substituted process runs asynchronously | Check the main command's $?; restructure when inner status matters |
Hard-coded /dev/fd/63 breaks |
FD number is not stable | Use <(cmd) expansion; never embed the path literally |
>(gzip …) output missing or incomplete |
Async consumer still running; bare wait may not sync all branches |
Do not read output files immediately; use explicit jobs or another coordination pattern |
Summary
Bash process substitution exposes command I/O through pathnames: <(command) for file-like input to tools such as diff, and >(command) for file-like output into another process. It fills the gap between pipes (stream to stream) and $(command) (capture as text) without requiring you to manage temporary data files yourself.
Use < <(command) when a while read loop must update variables in the current shell. Use tee >(cmd1) >(cmd2) to fan out streams, but remember substituted processes run asynchronously — their exit statuses are not automatically your script's exit status, and a bare wait does not reliably synchronize multiple branches.
Process substitution requires Bash (or ksh/zsh), not POSIX sh. Pair it with Bash redirection when you need stderr merged or log files alongside file-like paths.
References
- GNU Bash manual — Process Substitution
- GNU Bash manual — Pipelines
- GNU Bash manual — Job Control Builtins
Frequently Asked Questions
Why can't there be a space before (?
< and > are redirection operators. Process substitution requires the token <( or >( as a single unit. A space — < (cmd) — is parsed as input redirection followed by a subshell, which is invalid in that position.
Is process substitution POSIX?
No. It is defined by Bash and also available in ksh and zsh. POSIX sh and dash reject <(…).
Does process substitution run asynchronously?
Yes. Bash runs the substituted command asynchronously and connects it through a FIFO or /dev/fd while the surrounding command executes.
How do I get its exit status?
The exit status of the main command (for example diff or tee) is what $? reports immediately afterward. To capture a substituted command's status, run it outside process substitution, start it as an explicit background job you can wait on, or inspect PIPESTATUS when the design uses a normal pipeline. A bare wait after multiple >(...) branches does not reliably report every consumer's status.
When should I use it instead of a pipe?
Use process substitution when a command needs a file operand (diff, comm, wc), when a while read loop must stay in the current shell (< <(cmd)), or when you want streaming input without storing $(cmd) output in a variable. Use a plain pipe when stdout of one command should flow directly into the next program's stdin.

