Skip to content

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/dir

The 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: /tmp

This 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 {command as 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 found

Variable 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: 100

Exit 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: 1

Environment 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 export

Compare 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; done loses var the 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, or cd-ing somewhere the rest of the script should follow.
  • Use ( ) when you specifically want isolation — a temporary cd for 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; } > file to 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: 0

The 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: 3

The 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/ch14

2. 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/ch14

3. 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.log

6. 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/ch14

You’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.

Last updated on