Why this matters
You have already met the symptoms: a variable set inside a while loop that is empty after the loop, a cd inside $( ) that did not stick, an exit that did not end the script. They all come from one question: is this code running in the current shell process, or in a forked copy? This lesson makes that question answerable at a glance, and adds the opposite tool, exec, which runs a program without forking at all, and which also lets you rewire a script's own file descriptors.
Subshells: ( ... )
Parentheses run a list of commands in a subshell: bash forks, the child runs the commands, and the parent waits.
$ x=1; echo "parent $BASHPID"
parent 1
$ ( x=2; echo "child $BASHPID sees x=$x"; cd /tmp; pwd )
child 87 sees x=2
/tmp
$ echo "x=$x, pwd=$PWD"
x=1, pwd=/work
The child starts as a copy of the parent: all variables (exported or not), functions, options, the current directory and open file descriptors. Traps are reset, except ignored signals. Whatever the child changes is thrown away when it exits. Its exit status (the status of its last command, or its exit N) becomes the status of the whole ( ... ).
Uses:
( cd "$src" && tar cf - . ) | ( cd "$dest" && tar xf - ) # copy a tree, no cd leak
( set -e; step1; step2 ) # strict mode for a block only
( export LC_ALL=C; sort big.txt ) # temporary environment
For the last one, LC_ALL=C sort big.txt is simpler: an assignment before a command applies to that command only.
Where bash creates subshells implicitly
| Construct | Why |
|---|---|
( list ) | explicitly |
$( command ) and `command` | output is captured from a child |
| each stage of a pipeline | the stages must run at the same time |
command & | runs while the parent continues |
<( command ), >( command ) | process substitution |
The pipeline row explains the most famous surprise:
count=0
printf 'a\nb\nc\n' | while read -r line; do
count=$((count + 1))
done
echo "$count" # prints 0
The while loop is a pipeline stage, so it ran in a subshell and incremented a copy. Fixes: feed the loop with a redirection instead (done < file, or done < <(command)), so it runs in the current shell; or shopt -s lastpipe, which (in scripts, where job control is off) runs the last stage of a pipeline in the current shell.
And exit inside a subshell exits only the subshell:
check() { [ -f "$1" ] || exit 1; }
result=$(check config; echo ok) # exit ends the $( ) child; the script continues
Groups: { ...; }
Braces group commands in the current shell: no fork, all changes stick.
{ echo "header"; date; cat body.txt; } > report.txt # one redirection for three commands
[ -r "$f" ] || { echo "cannot read $f" >&2; exit 1; } # several commands after ||
Syntax rules, because { and } are reserved words, recognized only as separate words at the start of a command: there must be a space after {, and the last command needs a ; or newline before }. {echo hi} is a command named {echo; { echo hi } waits for more input because } was taken as an argument to echo.
( list ) | { list; } | |
|---|---|---|
| process | forked child | current shell |
variable, cd, exit effects | lost / exit only the child | kept / exit the script |
| cost | a fork | none |
| typical use | isolation | one redirection, or several commands after && or an or-list |
Note that a group becomes a subshell anyway if it is part of a pipeline or run with &.
exec, form 1: replace the shell with a program
exec command args skips the fork: bash calls execve directly, so the shell process becomes the program, keeping its PID, its file descriptors and its environment.
without exec: bash (PID 40) ─fork─▶ child (41) ─exec─▶ server bash waits, idle
with exec: bash (PID 40) ──────────────── exec ───▶ server (PID 40)
Nothing after a successful exec in a script ever runs. This is the standard ending of wrapper scripts, such as container entrypoints:
#!/usr/bin/env bash
set -euo pipefail
export APP_ENV=production
cd /srv/app
[ -f .env ] && . ./.env
exec ./server --port 8080 "$@"
Why it matters: the server now is the process the container runtime or systemd started, so it receives their SIGTERM directly (an intermediate bash would receive it instead and, while waiting for a foreground child, would not forward it), its exit status is reported directly, and there is no idle bash using memory.
If the program cannot be executed, a non-interactive shell exits with status 126 or 127 and an error such as exec: ./server: not found.
exec, form 2: redirect the shell's own file descriptors
exec with only redirections and no command does not replace anything. It applies the redirections to the current shell permanently, so every later command inherits them.
exec > build.log 2>&1 # from here on, all output of the script goes to the log
echo "this goes to build.log"
File descriptors are just small numbers indexing the process's table of open files (module 5). exec can open, copy and close any of them:
| Syntax | Meaning |
|---|---|
exec 3> file | open file for writing as fd 3 (truncate) |
exec 3>> file | open for appending as fd 3 |
exec 3< file | open for reading as fd 3 |
exec 3>&1 | make fd 3 a copy of fd 1 (wherever it points now) |
exec 1>&3 | make fd 1 a copy of fd 3 |
exec 3>&- | close fd 3 |
Saving and restoring stdout, step by step:
fd 1 fd 3
start terminal -
exec 3>&1 terminal terminal (a saved copy)
exec > run.log run.log terminal
echo "step 1" → run.log
exec 1>&3 3>&- terminal - (restored, spare closed)
Reading a file through a numbered descriptor lets a loop read two inputs at once, or read from a file while still reading the keyboard on stdin:
exec 3< hosts.txt
while IFS= read -r host <&3; do
read -r -p "Reboot $host? " answer # from the terminal, fd 0
[ "$answer" = y ] && echo "would reboot $host"
done
exec 3<&-
You can watch descriptors open and close in /proc/$$/fd (lesson 1): run exec 3> /tmp/x; ls -l /proc/$$/fd; exec 3>&- in the sandbox shell.
Warning: Typing
exec > filein your interactive shell sends all output, including what you type next, into the file and your terminal seems dead. Typeexec > /dev/ttyblind to get it back. Andexec bashorexec lsin an interactive shell replaces your shell: whenlsfinishes, the session ends.
source versus running
The same distinction explains source (or .): bash setup.sh runs the script in a new bash process, so its variables and cd vanish; source setup.sh reads and runs the file in the current shell, so they stick. That is why environment setup files (~/.bashrc, a Python venv/bin/activate) are sourced, never executed.
Common mistakes
- Variables lost after
cmd | while read. The loop ran in a subshell. Usedone < <(cmd)orshopt -s lastpipe. exitinside$( )or a pipeline only ends that subshell.{echo hi}or{ echo hi }: missing space or missing;. You getbash: {echo: command not foundor a prompt waiting for more input.- Code after
exec program: it never runs. Put cleanup before the exec, or do not exec. - Forgetting to close extra descriptors. A child that inherits an open write end of a pipe can keep a reader waiting for end-of-file forever. Close fds you opened (
exec 3>&-). - Running a setup script instead of sourcing it, then wondering why nothing changed.
Exercises
Two quizzes on what runs where, then two scripts: one keeps its working directory by visiting directories in subshells, the other rewires its own stdout with exec and restores it.
Recap
( list )runs in a forked subshell: changes are lost,exitends only the subshell.$( ), pipeline stages,&and process substitution are implicit subshells;lastpipeanddone < <(cmd)keep loops in the current shell.{ list; }groups in the current shell; mind the space and the;.exec programreplaces the shell (same PID, fds, environment); use it to end wrapper scripts.execwith only redirections rewires the current shell:exec > log 2>&1,exec 3< file,exec 3>&1,exec 3>&-.source fileruns a file in the current shell;bash filein a new process.
Going further: help exec, help source, and shopt lastpipe. Read the "COMMAND EXECUTION ENVIRONMENT" section of man bash (once installed) for the exact list of what a subshell inherits.