| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10 |
| Applies to | Linux distributions with Bash installed |
| Privilege | Normal user; examples use /tmp/bash-subshell-lab |
| Scope | Bash execution environments: ( list ) subshell groups, { list; } current-shell groups, group redirection, pipeline, command-substitution and process-substitution scope, lastpipe, BASH_SUBSHELL, BASHPID, executing vs sourcing scripts, and external processes vs Bash subshell rules. Does not cover full trap inheritance or set -e in grouped commands. |
| Related guides | Bash pipes Shell scripting course hub |
A Bash subshell runs commands in an isolated shell environment. Changes such as cd, variable assignments, and shell options made inside the subshell normally disappear when it finishes.
The most common way to create one is with parentheses:
( commands )Curly braces look similar, but { commands; } run in the current shell instead. That difference explains many Bash problems where a variable or directory change unexpectedly disappears.
What Bash Means by a Subshell
A Bash subshell is a separate execution environment for a list of shell commands. Commands inside it can change variables, the working directory, or shell options, but those changes normally disappear when the subshell ends.
Two grouping forms look similar but behave differently:
( list )runslistin a subshell environment.{ list; }runslistin the current shell.
Both groups return the exit status of their command list. The difference is whether assignments and cd survive after the closing ) or }.
Parentheses ( ): Isolated Group
Parentheses are shell operators. Bash runs the commands inside in a subshell, so the parent shell keeps its own variables and working directory.
Save your starting directory, then run cd inside parentheses:
mkdir -p /tmp/bash-subshell-lab
cd /tmp/bash-subshell-lab
(cd /tmp; pwd)
pwd/tmp
/tmp/bash-subshell-labThe subshell printed /tmp, but the parent shell's working directory stayed in /tmp/bash-subshell-lab.
The same isolation applies to shell variables:
x=1
(x=2; echo "inside x=$x")
echo "parent x=$x"inside x=2
parent x=1x=2 existed only inside the subshell. The parent x remained 1.
Check the Exit Status of a Subshell
A parenthesized group returns the exit status of its command list. Capture it with $? immediately after the subshell finishes:
mkdir -p /tmp/bash-subshell-lab/logs
printf '2026-09-10 ERROR fail\n' > /tmp/bash-subshell-lab/logs/app.log
( grep ERROR /tmp/bash-subshell-lab/logs/app.log )
status=$?
printf 'status=%s\n' "$status"2026-09-10 ERROR fail
status=0When the subshell command fails, the parent shell receives that non-zero status:
( cd /does-not-exist 2>/dev/null )
printf 'status=%s\n' "$?"status=1The parent shell receives the exit status of the subshell's command list, so ( … ) can be used directly in if, &&, and || conditions.
Braces { }: Current-Shell Group
Braces form a reserved-word command group. Bash runs the list in the current shell, so variable assignments and cd persist after the group.
Brace syntax is stricter because { and } are reserved words rather than operators. Separate { from the command list, and end the list with ; or a newline before }.
Valid shape:
{ command1; command2; }Run the same cd test with braces:
cd /tmp/bash-subshell-lab
{ cd /tmp; pwd; }
pwd/tmp
/tmpAfter the brace group, the parent shell's working directory is /tmp. If several commands need to be grouped while keeping the directory change in the current shell, a brace group such as { cd /tmp; some-command; } can be used.
Variable assignments persist the same way:
y=1
{ y=2; echo "inside y=$y"; }
echo "parent y=$y"inside y=2
parent y=2() vs {} — Decision Table
| Need | Use | Why |
|---|---|---|
| Isolate temporary shell-state changes | ( list ) |
Variable, option, and directory changes disappear when the subshell exits |
| Apply several commands in the current shell | { list; } |
Assignments and cd remain visible afterward |
| Redirect stdout for multiple commands at once | Either ( … ) > file or { …; } > file |
Redirection attaches to the whole group |
| Keep brace group syntax valid | { cmd1; cmd2; } |
Braces require ; or newline before } |
When you need to group several commands for one redirection while isolating their side effects, parentheses are useful. When the block must update caller state, braces or a plain sequence without grouping is the right tool.
( ) vs $( ) in Bash
Parentheses group commands in a subshell while leaving their stdout on the normal output stream. Command substitution also uses a subshell, but captures its stdout and substitutes that text into the surrounding command.
Compare grouping with capture from the lab directory:
cd /tmp/bash-subshell-lab
( cd /tmp; pwd )
dir=$(cd /tmp; pwd)
printf 'dir=%s\n' "$dir"/tmp
dir=/tmp( … ) printed /tmp to stdout. $( … ) ran the same commands in a subshell environment but assigned the captured path to dir. Neither cd changed the parent working directory.
| Syntax | Purpose | Output |
|---|---|---|
( commands ) |
Run a grouped list in a subshell | Goes to normal stdout |
$(commands) |
Run commands and substitute their stdout | Captured into surrounding command or expression |
{ commands; } |
Group commands in the current shell | Goes to normal stdout |
Variable and Directory Scope
Parentheses are especially useful when several commands need a temporary working directory because you do not need to save and restore $PWD.
Run a short search from a subdirectory, then confirm the parent directory is unchanged:
mkdir -p /tmp/bash-subshell-lab/logs
printf '2026-09-10 ERROR fail\n' > /tmp/bash-subshell-lab/logs/app.log
cd /tmp/bash-subshell-lab
(
cd logs || exit
grep ERROR app.log
)
pwd2026-09-10 ERROR fail
/tmp/bash-subshell-labgrep ran inside the subshell after cd logs. pwd still shows /tmp/bash-subshell-lab because that directory change did not survive.
Brace groups fit when the script genuinely needs the new directory for everything that follows. Put the remaining logic inside the same { …; } block, or accept that the whole script's cwd changed.
Redirect an Entire Command Group
Redirection operators can apply to a whole group instead of a single command. Both grouping forms accept one redirect for every command in the list. See Bash redirection for descriptor details; here the point is group scope.
Send two echo lines to one file from a parenthesized group:
cd /tmp/bash-subshell-lab
rm -f group.log
(echo line1; echo line2) > group.log
cat group.logline1
line2The same pattern works with braces:
rm -f group2.log
{ echo brace1; echo brace2; } > group2.log
cat group2.logbrace1
brace2Both redirects captured stdout from every command in the group.
Where Bash Creates Subshell Environments Automatically
You do not need parentheses to enter a subshell. Bash also creates subshell environments for several other constructs:
- Standard command substitution —
$( list )and legacy backticks run the command list in a subshell environment. Bash 5.3 also has current-shell command-substitution forms; see the dedicated command-substitution guide. - Pipeline elements — Bash normally executes each command in a multi-command pipeline in a separate process and subshell environment. With
lastpipe, the final command can run in the current shell when job control is disabled. - Asynchronous commands — commands run with
&execute asynchronously in a subshell environment. - Process substitution —
<( list )and>( list )runlistasynchronously in a separate process; changes made inside that command do not update the parent shell. Process substitution is a Bash feature and is not part of basic POSIXsh. - Explicit
( list )— the grouping operator from the sections above.
Command substitution is a common surprise. Assignments inside the substitution do not reach the parent shell:
z=1
out=$(z=2; echo "z=$z")
echo "out=$out parent z=$z"out=z=2 parent z=1The capture shows z=2 from inside the subshell, but the parent's z stayed 1. Bash command substitution covers quoting, nesting, exit status, and Bash 5.3 current-shell forms for $(…).
Background execution also isolates shell-state changes. A brace group normally runs in the current shell, but & makes the asynchronous group execute in a subshell environment:
x=0
{ sleep 0.1; x=1; } &
wait
echo "async parent x=$x"async parent x=0The background group could set x=1 internally, but its assignment could not change the parent's x.
The Pipeline Variable-Loss Problem
Pipelines are the other everyday subshell trap. Bash normally executes each command in a multi-command pipeline in a separate process and subshell environment, so a while read loop on the right side of a pipe cannot update a counter in the parent shell.
count=0
printf 'a\nb\nc\n' | while read -r line; do count=$((count + 1)); done
echo "count=$count"count=0The loop incremented count inside the pipeline's subshell. The parent variable never changed. Read a file line by line in Bash shows process-substitution patterns that keep the loop in the current shell.
lastpipe — Advanced Exception
With shopt -s lastpipe and job control disabled, Bash runs the last pipeline command in the current shell instead of its normal subshell environment. That is the main exception to the pipeline rule above.
bash -c 'shopt -s lastpipe; count=0; printf "a\nb\n" | while read -r line; do count=$((count+1)); done; echo count=$count'count=2lastpipe works only when the option is enabled and job control is not active. For Bash scripts, process substitution such as while read …; done < <(command) is often clearer when the loop must modify variables in the current shell.
External Processes vs Bash Subshells
An external command such as grep normally runs as a child process, but that does not make it a Bash subshell. Here, "subshell" means Bash is executing shell code in an isolated copy of the shell environment.
This distinction matters most for shell variables, builtins, functions, options, and cd. Changes to that shell state cannot flow back from a subshell to its parent.
Running a Script vs Sourcing It
Running a script normally executes it in a separate process. bash script.sh specifically starts another Bash process, while sourcing runs the file's commands in the current shell.
Create a small script in the lab directory:
cat > /tmp/bash-subshell-lab/change.sh <<'EOF'
cd /tmp
value=changed
EOFRun it with a separate Bash process:
cd /tmp/bash-subshell-lab
value=before
bash /tmp/bash-subshell-lab/change.sh
printf 'after bash: value=%s pwd=%s\n' "$value" "$PWD"after bash: value=before pwd=/tmp/bash-subshell-labThe script changed its own process, not the caller's shell.
Source the same file in the current shell:
cd /tmp/bash-subshell-lab
value=before
source /tmp/bash-subshell-lab/change.sh
printf 'after source: value=%s pwd=%s\n' "$value" "$PWD"after source: value=changed pwd=/tmpcd and value persisted because source ran the commands here. See Bash source vs execute a script for when to use each approach.
| Method | Where commands run | Changes affect current shell? |
|---|---|---|
bash script.sh |
New Bash process | No |
./script.sh (executable script with shebang) |
Separate process via its interpreter | No |
source script.sh / . script.sh |
Current shell | Yes |
Inspect Nesting with BASH_SUBSHELL
BASH_SUBSHELL is a special Bash variable that indicates the current subshell nesting level. At the top level it is normally 0, and it increases as Bash enters nested subshell environments. It is useful when you want to confirm whether Bash is currently executing inside a nested subshell.
echo "top=$BASH_SUBSHELL"
(echo "parens=$BASH_SUBSHELL"; (echo "nested=$BASH_SUBSHELL"))
{ echo "braces=$BASH_SUBSHELL"; }top=0
parens=1
nested=2
braces=0Parentheses increased the nesting level. The brace group stayed at 0 because it ran in the current shell.
If you want to inspect the process ID as well, use BASHPID. $$ normally continues to expand to the PID of the original shell inside ( … ), while BASHPID reflects the current Bash process:
printf 'parent: $$=%s BASHPID=%s\n' "$$" "$BASHPID"
(
printf 'subshell: $$=%s BASHPID=%s\n' "$$" "$BASHPID"
)parent: $$=143476 BASHPID=143476
subshell: $$=143476 BASHPID=143490$$ still identifies the original shell, while BASHPID shows the Bash process executing the subshell.
Practical Uses for Intentional Isolation
Use ( … ) when a block needs temporary shell-state changes that should not affect the parent shell:
- try a different working directory without moving the script
- set several temporary variables or shell options for a block of commands (for example
( set +x; commands ))
Use { …; } when several commands need to be grouped without isolating their changes:
- run several builtins or assignments and leave the results in the caller's scope
- share one redirection or here-document across multiple commands
Functions called normally run in the current shell. Running a function in a pipeline, command substitution, asynchronous context, or explicit ( … ) subshell can instead isolate its changes from the caller. See Bash functions for how local variables and dynamic scope interact with these groups.
Common Misconceptions
| Misconception | Reality |
|---|---|
{ } is a subshell |
Braces run in the current shell; only ( ) creates a subshell group |
| Every command starts a subshell | External programs are child processes; Bash subshell rules apply to shell lists and specific constructs |
cd inside () changes the script directory |
Parent directory is unchanged after the subshell exits |
| Pipelines always share variables | Each pipeline command normally runs in a separate process and subshell environment unless lastpipe applies to the final stage |
$() is "just text replacement" |
Command substitution runs commands in a subshell; side effects do not persist |
echo $$ proves no subshell formed |
$$ often stays the parent PID inside ( … ); compare BASHPID instead |
Running script.sh can change my current shell |
A separately executed script cannot change its caller's variables or cwd; source script.sh runs commands in the current shell |
Both ( list ) and { list; } return the exit status of their command list.
Summary
Bash subshells are about execution environment, not just parentheses syntax. The ( list ) operator runs commands in an isolated environment: cd, variable assignments, exports, and shell-option changes made inside ( … ) do not alter the parent shell. The { list; } reserved-word group runs in the current shell, which is why the list must end with ; or a newline before }.
Bash also creates isolated environments for command substitution, pipeline stages, process substitution, and asynchronous commands.
This explains why variables set inside a while read loop fed by a pipe disappear afterward, and why assignments inside $(…) do not reach the parent shell. Use BASH_SUBSHELL and BASHPID to inspect nesting and process identity, ( … ) when you want to isolate temporary shell-state changes, and { …; } or process substitution when the current shell must keep the changes.
When an external program runs, think child process. The key question is whether Bash is running the commands in the current shell or an isolated environment. That distinction explains why variables, directory changes, and shell settings sometimes disappear after a pipeline, command substitution, or parenthesized group.
References
- GNU Bash Manual — Command Grouping
- GNU Bash Manual — Command Execution Environment
- GNU Bash Manual — Command Substitution
- GNU Bash Manual — Process Substitution
- GNU Bash Manual — Pipelines
- GNU Bash Manual — Bash Variables (
BASHPID,BASH_SUBSHELL)
Frequently Asked Questions
Is { } a subshell?
No. Braces run the command list in the current shell. Only ( ) creates a subshell group in the sense this article uses. Variable assignments and cd inside { …; } persist after the group.
Why did my variable disappear after a pipe?
Each command in a pipeline normally runs in its own execution environment. A while read loop on the right side of | updates variables inside that environment, not in the parent shell. Use process substitution or lastpipe when the loop must modify caller state.
Does $() run in a subshell?
Yes. Standard command substitution runs the nested commands in a subshell. Output is captured as text, but assignments and cd inside the substitution do not affect the parent shell.
Does cd inside () affect the parent?
No. The subshell can change its own working directory, but when the subshell exits the parent shell keeps its previous directory. Use { cd …; …; } when the directory change must remain in effect.
What does lastpipe change?
With shopt -s lastpipe and job control disabled, Bash runs the last pipeline command in the current shell instead of its normal subshell environment. That can let a final while read loop update parent variables. Process substitution is usually clearer in scripts.

