Command Grouping and Subshells
Shell scripts can face problems when variables or other changes made inside pipelines do not survive after the pipeline finishes. These issues happen because pipeline stages often run in separate subshells. A subshell isolates changes, while { } grouping runs commands in the current shell. Understanding this difference helps explain what survives and what is lost. It also shows when grouping or subshells can be used to control shell state safely.
( ) — Subshells
Parentheses run a group of commands in a subshell — a new child shell process, forked from the current one:
(
cd /tmp
echo "Inside subshell: $(pwd)"
)
echo "Outside: $(pwd)"Inside subshell: /tmp
Outside: /your/current/working/dirThe cd inside the parentheses changed the subshell’s working directory, but the outer shell’s directory is completely untouched once the subshell exits. This is exactly the same isolation principle from Chapter 1’s discussion of running a script as a separate process — a subshell is a lighter-weight version of the same idea, forked without also exec-ing a different program.
{ } — Command Grouping In The Current Shell
Curly braces group commands together too, but without creating a new process — everything runs directly in your current shell:
{
cd /tmp
echo "Inside group: $(pwd)"
}
echo "Outside: $(pwd)"Inside group: /tmp
Outside: /tmpThis time, the cd genuinely changed the outer shell’s directory — there was no subshell boundary to contain it. { } is purely a syntactic grouping, not a process boundary.
Syntax Differences Worth Noting
( ) and { } look similar but have different parsing rules:
( subshell contents )needs no special spacing or trailing punctuation — parentheses are their own token.{ grouped contents; }requires a space after the opening{and a semicolon or newline before the closing}. Without the space, Bash tries to parse{commandas one literal word; without the trailing;or newline, it can’t tell where your last command ends and the closing brace begins.
{ echo "This works"; }{echo "This fails"}bash: {echo: command not foundVariable Scope: What Persists, What Doesn’t
This is the practical heart of the distinction. A subshell gets its own copy of the current shell’s variables at the moment it’s forked — changes made inside never propagate back out:
count=0
(
count=100
echo "Inside subshell: $count"
)
echo "Outside: $count"Inside subshell: 100
Outside: 0{ }, having no process boundary at all, behaves exactly like writing the same commands without any grouping — every variable change is visible immediately in the current shell, both during and after:
count=0
{
count=100
echo "Inside group: $count"
}
echo "Outside: $count"Inside group: 100
Outside: 100Exit Status Of A Group
Both forms report the exit status of their last command, usable with if, &&, ||, and $? exactly as covered previous chapters:
(
echo "doing work"
false
)
echo "Group exit status: $?"doing work
Group exit status: 1Environment Inheritance Into Subshells
Here’s a distinction worth drawing explicitly against Chapter 3’s fork-and-exec discussion of running an external script: a subshell created with ( ) is a fork without an exec of a different program — it’s still running Bash, so it inherits a full copy of the parent shell’s variables, not just the exported ones:
local_only="visible without export"
(
echo "$local_only"
)visible without exportCompare this against running the same variable through an actual separate script (a real fork-and-exec, covered before in the course) — there, only exported variables would be visible at all. A subshell is a much lighter boundary: it sees everything the parent shell could see at the moment it was created, but nothing it does can write back to that parent.
When Else Bash Creates Subshells
Now the full picture, tying together several earlier chapters’ behavior:
- Each stage of a pipeline runs in its own subshell — this is exactly why
command | while read line; do var=$line; donelosesvarthe moment the pipeline finishes, a problem first flagged in Chapter 8. - Command substitution,
$(...), runs its contents in a subshell too — any variable set inside a$(...)is gone once the substitution completes, which is worth knowing even though command substitution’s whole purpose is normally just to capture output, not side effects. - Background jobs (
command &, covered in a later chapter) also run in a subshell. - Process substitution’s command itself (the part inside
<(...)) also runs in a subshell — but critically, the loop or command reading from it does not, which is exactly why the< <(...)pattern from Chapter 12 avoids the variable-loss problem a plain pipe causes.
Best Practices
- Use
{ }when a group of commands needs to affect the current shell — setting a variable that must survive, orcd-ing somewhere the rest of the script should follow. - Use
( )when you specifically want isolation — a temporarycdfor a self-contained block of work, or grouping commands whose side effects genuinely shouldn’t leak into the rest of the script. - Use
{ cmd1; cmd2; } > fileto send multiple commands’ output to one redirection target without the overhead (or variable isolation) of a subshell — this is one of{ }’s most common practical uses. - Remember pipeline stages are subshells. If a loop fed by a pipe needs to set a variable your script relies on afterward, that’s the signal to reach for process substitution instead, as covered fully in the next section’s Shell-Safety Considerations.
Shell-Safety Considerations
Here is the pipeline variable-loss bug from Chapter 8, finally explained end to end, with the fix from Chapter 12 shown alongside the reason it actually works.
Naive (broken) version — a counter incremented inside a while read loop fed by a pipe:
count=0
printf 'one\ntwo\nthree\n' | while IFS= read -r line; do
(( count++ ))
done
echo "Counted: $count"Counted: 0The loop ran three full iterations — read genuinely saw all three lines — but because it was the receiving end of a pipe, the entire while loop executed inside a subshell. (( count++ )) incremented a count that only ever existed inside that subshell’s private copy; the moment the pipeline finished, that copy was discarded, and the outer shell’s count was never touched.
Corrected version — process substitution, so the loop itself never leaves the current shell:
count=0
while IFS= read -r line; do
(( count++ ))
done < <(printf 'one\ntwo\nthree\n')
echo "Counted: $count"Counted: 3The command being fed into the loop (printf ...) still runs in its own subshell, exactly as noted above — but the while loop reading from it is connected via a redirection (<), not a pipe, so the loop itself is never forked. count is incremented directly in the current shell, and the final value is correct.
Hands-On: Subshells, Grouping, And Fixing The Pipeline Counter Bug
1. Set up a working directory.
mkdir -p ~/shell-course/ch14
cd ~/shell-course/ch142. Compare cd persistence between ( ) and { }.
echo "Start: $(pwd)"
(
cd /tmp
echo "Inside (): $(pwd)"
)
echo "After (): $(pwd)"
{
cd /tmp
echo "Inside {}: $(pwd)"
}
echo "After {}: $(pwd)"
cd ~/shell-course/ch143. Confirm a subshell sees non-exported variables.
local_only="visible without export"
(
echo "Inside subshell: $local_only"
)4. Check exit status propagation from a group.
(
echo "doing work"
false
)
echo "Exit status: $?"5. Send two commands’ combined output to one file using { }.
{
echo "First line"
echo "Second line"
} > combined.log
cat combined.log6. Reproduce the pipeline counter-loss bug, then fix it.
echo "--- broken: pipe ---"
count=0
printf 'one\ntwo\nthree\n' | while IFS= read -r line; do
(( count++ ))
done
echo "Counted: $count"
echo "--- fixed: process substitution ---"
count=0
while IFS= read -r line; do
(( count++ ))
done < <(printf 'one\ntwo\nthree\n')
echo "Counted: $count"7. Clean up.
cd ~
rm -rf ~/shell-course/ch14You’ve now watched ( ) isolate its changes and { } let them through, confirmed a subshell’s broader inheritance compared to a fully separate script process, and — most importantly — fully closed the loop on the pipeline variable-loss problem this course has referenced since Chapter 8, with a complete, correct explanation of why it happens and why process substitution fixes it. The next chapter covers exit status and command chaining in more depth — &&, ||, ;, and the common mistakes that come from mixing them carelessly.