The subshell is a child shell process that inherits the parent's environment but not its variables, and the process substitution is the mechanism that connects a command's output to a file descriptor without a temporary file. The two mechanisms look similar but have fundamentally different effects on variable scope, and the difference is the source of the bug that every bash developer encounters. This article covers the subshell mechanics, the process substitution syntax, the variable scope implications, and the practical patterns.
The subshell mechanics and the variable scope
The subshell is created by parentheses, by a pipeline segment or by command substitution, and the subshell inherits a copy of the parent's variables but not the parent's unsaved state. The variable set inside a subshell is invisible outside:
RESULT=$(echo hello | grep hello | wc -l)
echo $RESULT
The pipeline creates a subshell for each segment, and the RESULT captures the output of the subshell. But the variables set inside the pipeline's segments do not persist in the parent.
The process substitution syntax
Process substitution, the <(command) and >(command) syntax, connects a command's output to a file descriptor without a temporary file:
diff <(sort file1) <(sort file2)
which compares the sorted versions of two files without creating sorted temp files. The process substitution creates a named pipe or a /dev/fd path, and the command reads from it as a file.
The variable scope implications and the workarounds
The variable that the while read loop sets inside a pipe is invisible after the loop:
COUNT=0
cat file | while read line; do COUNT=$((COUNT+1)); done
echo $COUNT
COUNT is zero because the while loop ran in a subshell created by the pipe. The fix is process substitution:
COUNT=0
while read line; do COUNT=$((COUNT+1)); done < <(cat file)
echo $COUNT
The < <(...) feeds the file to the while loop without a subshell, and the variable persists. This is the pattern that every bash script that counts or aggregates needs, and the pattern is the fix for the bug that every bash developer encounters at least once.
The practical patterns
The patterns that use process substitution correctly are the ones where the variable scope matters: the counting loop, the aggregation loop, and the configuration parsing that sets variables. The patterns that use subshells deliberately are the ones where the variable isolation is desired: the variable that the subshell modifies without affecting the parent, the temporary state that the subshell contains.
The subshell and the process substitution are the two bash mechanisms that create child contexts. The subshell isolates variables, the process substitution preserves them. The difference is the source of the bug every bash developer encounters.
The subshell creation methods
The subshell is created by three syntaxes: parentheses, command substitution and pipeline. Each creates a child process that inherits the parent's environment but not the unsaved state.
The process substitution syntax
The <(command) syntax connects a command output to a file descriptor:
diff <(sort file1) <(sort file2)
The while read loop and the variable scope fix
The variables set in a while loop reading from a pipe are invisible after the loop. The fix is process substitution:
COUNT=0
while read line; do COUNT=$((COUNT+1)); done < <(grep ERROR file)
echo $COUNT
The here-string
The <<< syntax provides single-string input:
read -r LINE <<< hello world
The closing summary
The subshell isolates variables, the process substitution preserves them. The script that uses each for its intended purpose works correctly.
The subshell mechanics and the variable scope in detail
The subshell is a child bash process that inherits the parent's exported variables and the current directory, but the child has its own variable space: the variables set inside the subshell are local to the child and disappear when the child exits. The inheritance is one-directional: the parent's exported variables are visible in the child, but the child's variable changes are invisible to the parent.
The subshell creation by the pipeline is the most common and the most surprising: every segment of a pipeline runs in its own subshell, and the while read loop that is part of a pipeline runs in a subshell where the variable changes are lost. The pipeline that loses the variable:
cat /etc/passwd | while IFS=: read user uid rest; do
if [ $uid -ge 1000 ]; then
ADMIN_USERS="$ADMIN_USERS $user"
fi
done
echo $ADMIN_USERS
The ADMIN_USERS is empty after the loop because the loop ran in a subshell created by the pipe. The fix is the process substitution:
while IFS=: read user uid rest; do
if [ $uid -ge 1000 ]; then
ADMIN_USERS="$ADMIN_USERS $user"
fi
done < <(cat /etc/passwd)
echo $ADMIN_USERS
The < <(...) feeds the file to the loop in the current shell, and the ADMIN_USERS persists after the loop. The process substitution is the fix that preserves the variable scope, and the fix is the one that every bash script that aggregates data in a loop needs.
The command substitution and the exit code capture
The command substitution creates a subshell for the command execution, and the exit code of the command is available through the special variable $? immediately after the substitution:
OUTPUT=$(command_that_might_fail)
if [ $? -ne 0 ]; then
echo "command failed with output: $OUTPUT"
fi
The $? check after the substitution captures the command's exit code, and the OUTPUT variable captures the command's output. The two captures together provide both the result and the status, and the combination is the pattern that the production scripts use for the command execution that might fail.
The process substitution and the /dev/fd mechanism
The process substitution creates a named pipe or a /dev/fd file descriptor path, and the path is what the consuming command reads. The /dev/fd/N is a symlink to the pipe's file descriptor, and the reading process opens the path and reads from the pipe as if it were a file. The mechanism is the same one that the heredoc article described, the mechanism that connects the process output to the process input without a temp file.
The process substitution on the systems that support /dev/fd works transparently, and the systems that do not support it use named pipes in /tmp, which the bash creates and manages behind the scenes. The portability is the reason that the process substitution requires bash rather than POSIX sh, and the bash requirement is satisfied on every modern Linux distribution.
The nested subshells and the variable propagation
The nested subshells propagate the variables from the outer to the inner: the inner subshell inherits the outer subshell's exported variables, and the propagation is one-directional from the outer to the inner. The nested subshell pattern:
(export VAR=inner; (echo $VAR))
echo $VAR
The inner subshell sees VAR because the outer subshell exported it, and the parent does not see VAR because the subshell's export does not propagate upward. The one-directional propagation is the rule, and the rule is the reason that the variables set inside the subshells are lost.
The background processes and the variable scope
The background processes created with the & operator are subshells by nature, and the variables set in the background process are invisible to the parent. The background process that sets a variable and the parent that reads the variable after the wait command:
result=""
long_running_function &
BG_PID=$!
wait $BG_PID
result=$?
echo "exit code: $result"
The wait command blocks until the background process exits, and the $? after the wait contains the background process's exit code. The exit code is the communication that the background process can provide to the parent, and the output can be captured through a temp file or a named pipe.
The scope management patterns in production scripts
The production bash scripts manage the variable scope through the patterns that prevent the silent data loss: the process substitution for the loops that set variables, the explicit output capture for the command execution, the temp file for the complex data exchange between the parent and the child, and the exit code for the simple status communication.
The anti-pattern that the scope management prevents is the script that appears to work because the developer tested the happy path, and the happy path does not exercise the variable scope bug. The bug manifests when the error path is exercised, and the error path is the one where the variable that the loop set is needed for the error reporting, the variable that is silently empty because the loop ran in a subshell.
The discipline that prevents the scope bug is the shellcheck static analysis, the tool that the error handling article recommended, and the shellcheck that flags the variable set in a subshell and used outside is the check that catches the bug before the runtime.
The closing perspective on the scope in bash
The variable scope in bash is one-directional from the parent to the child, and the direction is the design of the Unix process model: the child inherits the parent's environment, but the child's changes to its own copy do not affect the parent. The copy is the mechanism, and the mechanism is the same one that every process on the system uses: the fork that copies the process state, the exec that replaces the program, and the isolation that prevents the child from corrupting the parent.
The bash script author who understands the scope model writes scripts that work correctly the first time, because the author knows where the variables live and where they do not. The author who does not understand the scope model writes scripts that work sometimes, and the sometimes is the worst kind of bug to diagnose. The understanding is the skill that this article teaches, and the skill is the one that the production bash script needs.
The coproc as the persistent two-way channel
The coproc keyword creates a coprocess, a background process with a two-way pipe that the parent can read from and write to by name. Unlike a plain background process whose output must be captured all at once, the coproc allows an interactive dialogue with the child:
coproc BC { bc -l; }
echo "2^10" >&${BC[1]}
read -u ${BC[0]} RESULT
echo "2^10 = $RESULT"
The coproc creates the named file descriptors, the BC array holding the read and write ends of the pipe, and the parent writes a request to the child's stdin and reads the reply from the child's stdout. The coprocess pattern is the one that the calculator, the database client and the interpreter embedding use: the child that answers multiple queries without a restart per query, the persistent channel that the command substitution cannot provide because each substitution starts and finishes a fresh child.
The limitation of the coproc is the single coprocess per shell, the bash restriction that the newer versions relaxed but that the portable scripts respect. The second coproc in the same shell overrides the first, and the scripts that need multiple persistent children use named pipes or the process substitution pairs instead, the mechanisms that the earlier sections described.
The scope rule as the fork consequence
The subshell scope rule is not an arbitrary bash decision but the direct consequence of the fork syscall that creates every subshell: the child process receives a copy of the parent's memory, the copy that includes the variable space, and every modification the child makes happens to its private copy. When the child exits, its entire memory is discarded, the variables included, and the parent never sees the child's modifications. The command substitution captures only what the child writes to stdout, the single channel that crosses the process boundary, and the exit status that the kernel propagates through the wait call. These two channels, the captured stdout and the exit status, are the complete communication surface between the subshell and its parent, and the bash script author who understands this surface designs the data flow accordingly: the output through stdout, the status through the exit code, and everything else through an explicit temp file when the data is too complex for the two channels.
The understanding of the fork consequence makes the scope behavior predictable rather than surprising: the variable set in the pipe segment dies with the segment, the variable set in the command substitution dies with the substitution, and the only data that survives the process boundary is what was explicitly passed through the designed channels. The discipline that this understanding produces is the discipline of explicit data flow, the script where every value that crosses a scope boundary does so through a named channel, and the explicit flow is the property that makes the script debuggable when the implicit flow would make it a mystery.