Bash & the POSIX Shell Module 12 – Processes, jobs and signals
Subshells and the execution environment
A subshell copies the parent shell's variables, functions and directory, runs apart and cannot change the parent: learn when Bash creates one and what survives.
What you will learn
- Explain what a subshell inherits from its parent and what it cannot change
- Choose between parentheses and braces for grouping commands
- Predict which constructs create subshells and what variables survive them
Before you start
What you’ll learn · Before you start
What you will learn
- Explain what a subshell inherits from its parent and what it cannot change
- Choose between parentheses and braces for grouping commands
- Predict which constructs create subshells and what variables survive them
Before you start
On this page
A script that changes a variable inside ( x=99 ) and prints it afterwards still sees the old value: x stayed 1.
The parentheses ran a subshell: A copy of the shell that runs commands apart from it, as Bash does for each command of a pipeline. It starts with the shell's variables, and every change it makes to them is lost when it exits. More in “Reading files line by line with read”, a copy of the shell that starts with all the parent’s
variables and cannot change any of them.
You will see what a subshell copies and what it throws away, learn to pick between ( ... ) and { ...; } for
grouping, recognise the constructs that create subshells without spelling them out, and trace the special variables
that tell shells apart.
Parentheses run a subshell; braces run in the current shell
Wrapping commands in parentheses makes Bash fork a new process, copy the shell’s state into it and run the commands
there. Every change the copy makes, a variable assignment, a cd, a shopt, ends when the copy ends. Braces are
different: they group commands without a new process, so every change stays.
The session below sets x=1, changes it inside parentheses and inside braces, and shows what survived:
"""Subshells: ( ) vs { } and what survives each."""
from session import run, write
run("""
# Set a variable and see it
x=1
echo "x is $x"
# Change x inside parentheses (a subshell)
( x=99; echo "inside ( ): x is $x" )
echo "after ( ): x is $x"
# Change x inside braces (same shell)
{ x=42; echo "inside { }: x is $x"; }
echo "after { }: x is $x"
# cd inside a subshell does not change the parent
pwd
( cd /tmp; echo "inside ( ): pwd is $(pwd)" )
pwd
# cd inside braces changes the current shell
{ cd /tmp; echo "inside { }: pwd is $(pwd)"; }
pwd
cd ~/demo
# BASH_SUBSHELL shows the nesting depth
echo "main shell: BASH_SUBSHELL=$BASH_SUBSHELL"
( echo "one level: BASH_SUBSHELL=$BASH_SUBSHELL" )
( ( echo "two levels: BASH_SUBSHELL=$BASH_SUBSHELL" ) )
""") Output
# Set a variable and see it
$ x=1
$ echo "x is $x"
x is 1
# Change x inside parentheses (a subshell)
$ ( x=99; echo "inside ( ): x is $x" )
inside ( ): x is 99
$ echo "after ( ): x is $x"
after ( ): x is 1
# Change x inside braces (same shell)
$ { x=42; echo "inside { }: x is $x"; }
inside { }: x is 42
$ echo "after { }: x is $x"
after { }: x is 42
# cd inside a subshell does not change the parent
$ pwd
~/demo
$ ( cd /tmp; echo "inside ( ): pwd is $(pwd)" )
inside ( ): pwd is /tmp
$ pwd
~/demo
# cd inside braces changes the current shell
$ { cd /tmp; echo "inside { }: pwd is $(pwd)"; }
inside { }: pwd is /tmp
$ pwd
/tmp
$ cd ~/demo
# BASH_SUBSHELL shows the nesting depth
$ echo "main shell: BASH_SUBSHELL=$BASH_SUBSHELL"
main shell: BASH_SUBSHELL=0
$ ( echo "one level: BASH_SUBSHELL=$BASH_SUBSHELL" )
one level: BASH_SUBSHELL=1
$ ( ( echo "two levels: BASH_SUBSHELL=$BASH_SUBSHELL" ) )
two levels: BASH_SUBSHELL=2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 subshell_session.py
Inside ( x=99; ... ) the value was 99, but after the parentheses closed x was back to 1. The braces printed 42
both inside and after, because the assignment happened in the same shell. The same goes for cd: the subshell moved
to /tmp and came back to ~/demo, while the braces really moved the shell and it stayed in /tmp until the
explicit cd ~/demo.
Step through a subshell’s life and watch what crosses back:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Parent starts with x=1. The shell has a variable x with the value 1 and the working directory ~/demo.
- Parentheses make a copy. ( x=99; cd /tmp ) forks a subshell. It starts as a copy of the parent, with the same x=1 and the same directory ~/demo.
- The subshell changes x and cd. Inside the subshell: x=99 and cd /tmp. The copy's x is now 99 and its directory is /tmp. The parent's x is still 1 and its directory is still ~/demo.
- The subshell exits. The subshell ends with exit status 0. Its variables and directory were never the parent's, so they are gone when it exits. Only the status crosses back.
- The parent is unchanged. echo "$x" prints 1 and pwd prints ~/demo. Nothing the subshell changed reached the parent. This is why ( cd build && make ) leaves the caller's directory unchanged.
- Braces keep the changes. With { x=99; cd /tmp; } the commands run in the parent itself, so x becomes 99 and the directory becomes /tmp. Braces group commands without a subshell.
Only two things cross back from a subshell: the bytes it wrote to standard output and its exit status: The number from 0 to 255 every command ends with, kept by the shell in $?. 0 means success; Bash uses 126 for found but not executable and 127 for not found. More in “Your first script: shebang, chmod and running it”. The Bash Reference Manual states the rule in its section on the command execution environment: The state a shell or program runs in: its open files, current directory, umask, traps, variables, functions and options. A subshell copies most of it; a new program inherits only the exported environment.: what a subshell changes “cannot affect the shell’s execution environment”.
Use parentheses to protect the caller
( cd build && make ) runs the build in another directory and leaves the caller’s directory unchanged, without
remembering to cd back afterwards.
Braces need a semicolon or newline before the closing brace
The syntax is easy to slip on. Parentheses are operators, recognised as separate tokens even without spaces, so
(echo a) parses correctly. Braces are reserved words: they need whitespace around them and a semicolon or newline
before the }, so {echo a} is a syntax error and { echo a; } is correct.
| Form | Runs where | Syntax |
|---|---|---|
( list ) |
a subshell | no semicolon needed |
{ list; } |
the current shell | semicolon or newline before } |
Use braces when you need the side effects to stay, such as a function body or a group whose output goes to one file:
{ head -n1; tail -n1; } < data.csv reads the first and last lines from one open file. Use parentheses when the side
effects must not leak, such as a temporary cd or a variable you want to throw away.
Checkpoint
Check: Parentheses vs braces
Pipelines, command substitution and background jobs all create subshells
A subshell is not only ( ... ). The Bash manual lists every place one is made:
- Each stage of a pipeline, except possibly the last one when lastpipe: A Bash option, turned on with shopt -s lastpipe, that runs the last command of a pipeline in the current shell when job control is off, as it is in scripts, so a while loop at the end of a pipeline keeps the variables it sets. More in “Shell scripting interview questions, explained” is on and job control is off.
- A command substitution: $(command), or the older backquoted form: Bash runs the command in a subshell and puts its standard output in place of the substitution, with every trailing newline removed. More in “Command and process substitution”
$(...). - process substitution: <(command) or >(command): Bash starts the command and puts a file name such as /dev/fd/63 in its place. Reading that file gives the command's output; writing to it feeds the command's input. More in “Command and process substitution”
<(...)and>(...). - A command followed by
&(a background job). - A coprocess started with
coproc.
The practical consequence is the same in all of them: a variable set inside is gone when the construct ends. The
reading files line by line lesson showed a counter that stayed 0 after
piping into a while loop, and the returning values from functions
lesson showed a function that could not keep its changes when captured with $(...). This lesson is the reason behind
both.
The session below shows what happens when a script pipes into a while loop, and what BASH_SUBSHELL says:
"""Implicit subshells: pipelines, $(...), <(...) and &."""
from session import run, write
# Write a script that shows pipeline subshells
write("pipe-count.sh", """\
#!/usr/bin/env bash
# Each pipeline stage (except the last with lastpipe) runs in a subshell
count=0
echo -e "one\\ntwo\\nthree" | while read -r line; do
count=$((count + 1))
echo "inside loop: count=$count, BASH_SUBSHELL=$BASH_SUBSHELL"
done
echo "after loop: count=$count"
""")
run("""
# Show the script
cat pipe-count.sh
# Run it - the count stays 0 because the while runs in a subshell
bash pipe-count.sh
# A variable inside $(...) does not survive either
outer=10
result=$( inner=20; echo "inner is $inner" )
echo "result is: $result"
echo "inner is: ${inner:-unset}"
echo "outer is: $outer"
# A subshell sees all the parent's variables (even unexported)
secret=42
( echo "subshell sees secret: $secret" )
# But bash -c only sees exported ones
echo "bash -c sees secret: $(bash -c 'echo "${secret:-unset}"')"
export secret
echo "after export: $(bash -c 'echo "${secret:-unset}"')"
""") Output
# Show the script
$ cat pipe-count.sh
#!/usr/bin/env bash
# Each pipeline stage (except the last with lastpipe) runs in a subshell
count=0
echo -e "one\ntwo\nthree" | while read -r line; do
count=$((count + 1))
echo "inside loop: count=$count, BASH_SUBSHELL=$BASH_SUBSHELL"
done
echo "after loop: count=$count"
# Run it - the count stays 0 because the while runs in a subshell
$ bash pipe-count.sh
inside loop: count=1, BASH_SUBSHELL=1
inside loop: count=2, BASH_SUBSHELL=1
inside loop: count=3, BASH_SUBSHELL=1
after loop: count=0
# A variable inside $(...) does not survive either
$ outer=10
$ result=$( inner=20; echo "inner is $inner" )
$ echo "result is: $result"
result is: inner is 20
$ echo "inner is: ${inner:-unset}"
inner is: unset
$ echo "outer is: $outer"
outer is: 10
# A subshell sees all the parent's variables (even unexported)
$ secret=42
$ ( echo "subshell sees secret: $secret" )
subshell sees secret: 42
# But bash -c only sees exported ones
$ echo "bash -c sees secret: $(bash -c 'echo "${secret:-unset}"')"
bash -c sees secret: unset
$ export secret
$ echo "after export: $(bash -c 'echo "${secret:-unset}"')"
after export: 42
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 implicit_session.py
The loop ran three times, and BASH_SUBSHELL inside it was 1. After the loop, count was 0 because the increments
happened in a copy. The same pattern explains the $( inner=20; ... ) capture: inner was unset afterwards. The
reading files line by line lesson gives three fixes: feeding the loop with
done < file, with done < <(command), or enabling lastpipe in a script.
Key idea
Every pipeline stage, every command substitution, every background job and every process substitution runs in a subshell, so a variable it sets is gone when the construct ends.
Checkpoint
Check: Implicit subshells
A subshell sees every variable; a new program sees only exported ones
A subshell starts as a copy of the shell, so it has every variable, whether exported or not. A new program such as
bash -c or sh -c starts from scratch and receives only the environment variable: A variable that has been exported, so every program the shell starts receives a copy of it in its environment, the list of name=value pairs a process begins with. More in “Variables, export and the environment”, the
exported variables.
The session above showed the difference: ( echo "$secret" ) saw the value because the subshell was a copy, while
bash -c 'echo "${secret:-unset}"' saw nothing until export secret added it to the environment. The same four
readings, side by side:
After secret=42 |
( echo "$secret" ) prints |
bash -c 'echo "$secret"' prints |
|---|---|---|
before export |
42 |
nothing |
after export secret |
42 |
42 |
This is why library functions work in subshells without re-sourcing the file, and why a helper script that needs a variable must have it exported.
Confusing a subshell with a new shell
Wrong: assuming bash script.sh can read your shell’s unexported variables. A child process inherits only the
environment, and only export name puts a variable there. Right: export what the child needs, or pass it as an
argument.
Checkpoint
Check: Subshell vs new program
exit inside a subshell leaves only the subshell
When a script runs exit 1, the script ends. When a subshell runs exit 1, only the subshell ends; the parent
continues and sees the exit status in $?. This matters for error handling: a library function that calls exit
inside a $(...) capture does not end the caller’s script. The script goes on, often with an empty variable where it
expected a value.
"""Exit inside a subshell leaves only the subshell; exec replaces the shell."""
from session import run, write
# Write a script showing exit in subshell
write("exit-sub.sh", """\
#!/usr/bin/env bash
echo "before subshell"
( echo "inside, about to exit 3"; exit 3 )
echo "after subshell, status was $?"
echo "script continues"
""")
# Write a script showing exec replaces the shell
write("exec-demo.sh", """\
#!/usr/bin/env bash
echo "before exec"
exec echo "this line ran via exec"
echo "this line never runs"
""")
run("""
# exit inside ( ) leaves only the subshell
cat exit-sub.sh
bash exit-sub.sh
# exec CMD replaces the shell entirely
cat exec-demo.sh
bash exec-demo.sh
# exec with only redirections changes the current shell's descriptors
exec 3> logfile.txt
echo "first line" >&3
echo "second line" >&3
exec 3>&-
cat logfile.txt
rm logfile.txt
""") Output
# exit inside ( ) leaves only the subshell $ cat exit-sub.sh #!/usr/bin/env bash echo "before subshell" ( echo "inside, about to exit 3"; exit 3 ) echo "after subshell, status was $?" echo "script continues" $ bash exit-sub.sh before subshell inside, about to exit 3 after subshell, status was 3 script continues # exec CMD replaces the shell entirely $ cat exec-demo.sh #!/usr/bin/env bash echo "before exec" exec echo "this line ran via exec" echo "this line never runs" $ bash exec-demo.sh before exec this line ran via exec # exec with only redirections changes the current shell's descriptors $ exec 3> logfile.txt $ echo "first line" >&3 $ echo "second line" >&3 $ exec 3>&- $ cat logfile.txt first line second line $ rm logfile.txt
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 exit_exec_session.py
The subshell printed “about to exit 3” and ended. The script printed “after subshell, status was 3” and continued.
This is the mechanism behind the interview question below: a die function that calls exit inside a command
substitution ends only that substitution, and the caller runs on with whatever was printed before the exit.
The exec example shows the opposite: exec echo ... replaced the shell with echo, so the next line never ran.
exec with only redirections, such as exec 3> file, changes the shell’s own file descriptors and is covered in
the file descriptors and exec lesson.
Checkpoint
Check: Exit inside a subshell
Three special variables tell processes apart
The positional and special parameters lesson introduced $$ and
BASHPID. Their difference matters inside subshells:
| Variable | What it holds |
|---|---|
$$ |
The process ID: A number the kernel gives to each running process, unique at any moment and reused after the process ends. Bash keeps it in $$ and BASHPID. of the main shell, unchanged inside subshells |
BASHPID |
The process ID of the process running now |
BASH_SUBSHELL |
How many subshells deep the current command is (0, 1, 2 …) |
$$ is useful when every part of one script must agree on one name: myscript.$$ is the same name in the main shell
and in its subshells. For a file only this run should read, mktemp is the safer way to get a name. BASHPID tells
processes apart: inside a subshell it differs from $$. BASH_SUBSHELL counts the depth without exposing a PID.
The first session showed BASH_SUBSHELL rising from 0 in the main shell to 1 and 2 inside nested parentheses. POSIX
specifies $$, and says it keeps the value of the current shell inside a subshell, but it specifies neither BASHPID
nor BASH_SUBSHELL, and dash has neither. A script that must run under /bin/sh relies on the logic of where
subshells are created rather than on these names.
BASHPID arrived in Bash 4.0
macOS ships /bin/bash 3.2.57, which has $$ and BASH_SUBSHELL but not BASHPID. A script that needs BASHPID
should test the version in BASH_VERSINFO first, or run a newer Bash installed beside the system one.
Checkpoint
Check: Telling processes apart: $$, BASHPID, BASH_SUBSHELL
Practise: predict what survives
Write a short script in three parts and predict every line it prints before you run it. First, put three lines in a
file, set count=0, feed the file into a while read loop with a pipe and echo count after done. Second, feed the
same loop with done < file instead and echo count again. Third, end with ( cd /tmp ); pwd followed by
{ cd /tmp; }; pwd. Then run it and compare the two counts and the two directories with what you expected:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what does a subshell copy from its parent, and what can it change there?
A subshell starts as a copy of the parent shell. It has the parent’s variables (exported and unexported), functions,
aliases, shell options, the current directory, open file descriptors and the process ID in $$. It cannot change
anything in the parent: a variable assignment, a cd, a shopt or an unset inside the subshell is gone when the
subshell exits. Only two things cross back to the parent: the bytes the subshell wrote to standard output (if the
parent captured them with $(...)) and its exit status. Parentheses ( list ) make a subshell explicitly, but
pipelines, command substitutions, process substitutions, background jobs and coprocesses all create one implicitly.
Two harder questions follow, about a library function that calls exit inside a capture and about worker processes
that share a temporary file name. Name the one command you would run first in each case, then read its first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A shared library has
die() { echo "FATAL: $*" >&2; exit 1; }. A script callssettings=$(load_settings)whereload_settingscallsdiewhen the file is missing. The script runs to completion with empty settings and deploys with defaults. What went wrong and how do you fix it?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do ( list ) and { list; } differ?
( list ) runs in a subshell, so every variable, cd and option change is lost when it ends. { list; } runs in the current shell and keeps every change.
-
Why does { echo hi } fail as a command group?
The closing brace is a reserved word that Bash recognises only where a command could start, so it needs a semicolon or a newline before it: { echo hi; }.
-
Which constructs run their commands in a subshell without any parentheses?
Each stage of a pipeline, except the last one when lastpipe is on and job control is off; a command substitution; a process substitution; a background job started with &; and a coprocess.
-
Which variables does a subshell see, and which does a new program such as bash -c see?
A subshell starts as a copy of the parent and sees every variable, exported or not. A new program receives only the exported ones.
-
What does exit do inside ( ... )?
It ends only the subshell. The parent carries on and finds the subshell's exit status in $?.
-
How do $$, BASHPID and BASH_SUBSHELL tell processes apart?
$$ is the main shell's process ID, the same inside subshells; BASHPID is the ID of the process running now; and BASH_SUBSHELL counts how deeply subshells are nested.
Key takeaways
( list )runs in a subshell: every variable,cdand option change is lost when it ends;{ list; }runs in the current shell and keeps every change.- A subshell is created implicitly by a pipeline stage (except the last with
lastpipe), a command substitution$(...), a process substitution<(...)or>(...), a background job&and a coprocess. - A subshell starts with all the parent’s variables, but a new program such as
bash -csees only exported ones. exitinside a subshell leaves only the subshell; the parent continues and reads the exit status in$?.$$is the main shell’s PID even inside subshells,BASHPIDis the current process andBASH_SUBSHELLcounts the nesting depth.
Terms in this lesson
- execution environment
- The state a shell or program runs in: its open files, current directory, umask, traps, variables, functions and options. A subshell copies most of it; a new program inherits only the exported environment.
- process ID
- A number the kernel gives to each running process, unique at any moment and reused after the process ends. Bash keeps it in $$ and BASHPID.
References
- Bash Reference Manual: Command Execution Environment (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Command Grouping (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Bash Variables (BASHPID, BASH_SUBSHELL) (Chet Ramey (GNU Bash maintainer))
- POSIX.1-2024: Shell Execution Environment (The Open Group and IEEE)
- POSIX.1-2024: Special Parameters (The Open Group and IEEE)
Report a problem with this lesson
Kept only in this browser. Your Learn progress