Linux & Bash · Part I · Module 11 · Exit Status and Command Chaining
Grouping, Subshells and What Survives
Two ways to treat several commands as one unit, differing in a single respect that changes everything downstream: one of them starts a new shell and the other does not. That difference decides whether a directory change, a variable, an option or an exit survives past the closing bracket.
Intermediate18 min readLinuxPart IFundamentalsExit StatusBash
Part I · Module 11 · Chapter 11.4 · Grouping, Subshells and What Survives
Chapter 11.3 used || { echo "..."; exit 1; } as the standard guard and ( … ) in half its Practice blocks, promising an explanation. Several earlier chapters did the same. This is it.
1. One Unit, Two Ways
Both of these treat a set of commands as a single thing:
( cd build; make )
{ cd build; make; }They look interchangeable. They differ in exactly one respect, and it is the one that matters:
Measured:
$ cd /tmp/demo
$ ( cd build; echo " inside ( ): $(pwd)" ); echo " after ( ): $(pwd)"
inside ( ): /tmp/demo/build
after ( ): /tmp/demo
$ cd /tmp/demo
$ { cd build; echo " inside { }: $(pwd)"; }; echo " after { }: $(pwd)"
inside { }: /tmp/demo/build
after { }: /tmp/demo/buildSame two commands. After the parentheses you are back where you started; after the braces you have moved.
2. The Brace Syntax Is Fussy
Parentheses behave like punctuation anywhere. Braces are reserved words, which means the shell has to recognise them as separate words — and that imposes two requirements people trip over constantly.
$ { echo ok; }
ok
$ {echo bad; }
bash: syntax error near unexpected token `}'
$ { echo bad }
bash: syntax error: unexpected end of fileYou need a space after {, because {echo is read as one word. And you need a ; or a newline before }, because otherwise } is read as an argument to echo and the group is never closed — which is why the second error complains about the end of the file rather than about the brace.
{ echo a; echo b; } # on one line: space after {, ; before }
{
echo a
echo b
} # across lines: the newline does the jobParentheses need neither:
$ (echo ok)
ok3. What a Subshell Contains
A subshell is a child process, so Chapter 7.4's inheritance rules apply exactly. It receives a copy of everything and returns nothing but its exit status.
Variables:
$ v=outer
$ ( v=inner; echo " inside: $v" ); echo " after: $v"
inside: inner
after: outerShell options — which is why Chapter 9.6 suggested experimenting inside parentheses:
$ shopt -u nullglob
$ ( shopt -s nullglob; shopt nullglob )
nullglob on
$ shopt nullglob
nullglob offThe working directory, as §1 showed. And the exit status does come back:
$ ( exit 5 ); echo " after ( ): still alive, status $?"
after ( ): still alive, status 5exit inside a subshell ends the subshell, not your shell. Inside braces it ends everything:
$ bash -c '{ echo " in group"; exit 5; }; echo " NOT REACHED"'
in group
$ echo " that shell exited with $?"
that shell exited with 5That difference is worth holding onto: ( exit 1 ) is harmless and { exit 1; } is not, which makes parentheses the safe way to try something that might end badly.
4. Redirecting a Whole Group
This is what braces are genuinely for, and it is the most common legitimate use of either construct.
$ { echo a; echo b; } > out.txt
$ cat out.txt
a
bOne redirect, applied to everything in the group. Without it you would write > on every line and the second would have to be >>:
echo a > out.txt
echo b >> out.txtWhich is noisier, and wrong the moment somebody reorders the lines.
Parentheses redirect the same way:
$ ( echo c; echo d ) > out2.txtPrefer braces for this. There is no reason to start a process just to redirect, and a subshell adds a fork for nothing.
The pattern turns up most in building a report:
{
echo "generated : $(date +%F' '%T)"
echo "host : $(hostname -s)"
echo "run : $run_name"
echo
echo "passed : $pass"
echo "failed : $fail"
} > "$report"Eight lines, one redirect, and the whole block can be moved or wrapped in a conditional without touching the redirection. (The > operator itself is Module 12.)
5. When to Use Which
The cd sandbox
The single most useful application, and the proper answer to Chapter 11.2's hazard:
( cd "$build_dir" && make ) || { echo "build failed" >&2; exit 1; }The cd applies only inside the parentheses. The rest of the script continues in the directory it was already in, and you do not have to remember to cd back — which matters, because a cd - after a command that failed will not run.
Compare the three approaches:
cd build; make; cd .. # breaks if cd or make fails
cd build && make; cd .. # better, but the cd .. still fires blindly
( cd build && make ) # nothing to undoThe third has no cleanup step at all, because there is nothing to clean up.
Grouping for a conditional
Chapter 11.3 used this shape repeatedly:
cd "$d" || { echo "cannot enter $d" >&2; exit 1; }Braces, not parentheses — deliberately. exit must end the script, and inside parentheses it would only end the subshell, leaving the script running with its precondition unmet. Getting that backwards produces a guard that prints its message and then carries on regardless.
cd "$d" || ( echo "cannot enter $d" >&2; exit 1 ) # BROKEN — the script continues
cd "$d" || { echo "cannot enter $d" >&2; exit 1; } # correctRunning a block in parallel
Because a subshell is a process, it can be backgrounded (Chapter 7.5):
( cd run_1 && ./simulate.sh ) > run_1.log 2>&1 &
( cd run_2 && ./simulate.sh ) > run_2.log 2>&1 &
( cd run_3 && ./simulate.sh ) > run_3.log 2>&1 &
wait
echo "all three finished"Three simulations at once, each in its own directory, with no interference — and the parent shell's directory untouched throughout. Braces could not do this; backgrounding a group is not a thing.
6. The Subshells You Did Not Write
Parentheses are the explicit form. Several constructs start a subshell without one, and recognising them explains behaviour from four earlier chapters.
| Construct | Why there is a subshell |
|---|---|
$( … ) | Command substitution — Chapter 8.4 |
`…` | The older spelling, same thing |
cmd1 | cmd2 | Every stage of a pipeline |
( … ) & | A backgrounded group |
cmd & | Any backgrounded command |
The pipeline one is the expensive surprise, and Chapter 8.6 measured it:
$ n=0; cat data.txt | while read -r l; do n=$(( n + 1 )); done; echo "after pipe: n=[$n]"
after pipe: n=[0]
$ n=0; while read -r l; do n=$(( n + 1 )); done < data.txt; echo "after redirect: n=[$n]"
after redirect: n=[3]The while loop was the right-hand side of a pipe, so it ran in a subshell, incremented its copy of n, and discarded it. Redirecting instead of piping removes the subshell and the counter survives.
And Chapter 8.4's script-directory idiom is a subshell used on purpose:
script_dir=$(cd "$(dirname "$0")" && pwd)The cd happens inside the substitution's subshell, so your working directory is untouched while the absolute path still comes back. That is the whole reason the idiom is safe.
7. Putting It Together
A regression runner using every construct from the module:
#!/bin/bash
set -uo pipefail
runs_root="${1:?usage: $0 <runs-root>}"
report="$runs_root/summary.txt"
[ -d "$runs_root" ] || { echo "no such directory: $runs_root" >&2; exit 2; }
pass=0
fail=0
# A subshell per run: each gets its own directory, none affects the others
# or us, and all three can be in flight at once.
for d in "$runs_root"/run_*; do
( cd "$d" && ./simulate.sh ) > "$d/sim.log" 2>&1 &
done
wait
# No subshell here — the counters must survive, so this reads from a glob
# rather than a pipe.
for d in "$runs_root"/run_*; do
if grep -q 'TEST PASSED' "$d/sim.log" 2>/dev/null; then
pass=$(( pass + 1 ))
else
fail=$(( fail + 1 ))
echo "FAILED: ${d##*/}"
fi
done
# Braces, so one redirect covers the whole report.
{
echo "generated : $(date +%F' '%T)"
echo "root : $runs_root"
echo "passed : $pass"
echo "failed : $fail"
} > "$report"
echo "wrote $report"
[ "$fail" -eq 0 ] || exit 1Five decisions worth naming:
( cd "$d" && … ) &— parentheses because each run needs its own directory and none of them should change the script's; backgrounded because a subshell is a process.|| { …; exit 2; }— braces, becauseexitmust end the script.- The counting loop reads a glob, not a pipe, so
passandfailsurvive. { … } > "$report"— braces for the redirect, no process started.- The final
exit 1gives the script the meaningful status Chapter 11.1 argued for.
Practice
mkdir -p /tmp/grp/build && cd /tmp/grp1 — See the directory difference.
cd /tmp/grp; ( cd build; echo " inside ( ): $(pwd)" ); echo " after ( ): $(pwd)"
cd /tmp/grp; { cd build; echo " inside { }: $(pwd)"; }; echo " after { }: $(pwd)"
cd /tmp/grp2 — Meet both syntax errors.
{ echo ok; }
{echo bad; }
{ echo bad }Press Ctrl-C if the third leaves you at a continuation prompt.
3 — Scope a variable and an option.
v=outer; ( v=inner; echo " inside: $v" ); echo " after: $v"
shopt -u nullglob
( shopt -s nullglob; shopt nullglob )
shopt nullglob4 — Compare the two exits.
( echo " in subshell"; exit 5 ); echo " shell alive, status $?"
bash -c '{ echo " in group"; exit 5; }; echo " NOT REACHED"'; echo " that shell: $?"5 — Redirect a group.
{ echo a; echo b; } > out.txt; cat out.txt
( echo c; echo d ) > out2.txt; cat out2.txt6 — Build the cd sandbox.
cd /tmp/grp
( cd build && pwd ) && echo " back in $(pwd)"
( cd nowhere && pwd ) || echo " failed inside, and we are still in $(pwd)"7 — Catch the broken guard.
( cd /nonexistent || ( echo " subshell guard"; exit 1 ); echo " STILL RAN — the guard did not stop it" )
( cd /nonexistent || { echo " brace guard"; exit 1; }; echo " NOT REACHED" )8 — Lose a counter to a pipe, then rescue it.
printf 'a\nb\nc\n' > three.txt
n=0; cat three.txt | while read -r l; do n=$(( n + 1 )); done; echo " piped: $n"
n=0; while read -r l; do n=$(( n + 1 )); done < three.txt; echo " redirect: $n"9 — Background three subshells.
for i in 1 2 3; do ( sleep "$i"; echo " run $i done" ) & done
wait
echo " all finished"10 — Clean up. cd /tmp && rm -rf /tmp/grp
Check Your Understanding
1. What is the one difference between ( … ) and { … }, and what follows from it?
2. Why does { echo a; echo b } fail?
3. ( cd build && make ) — where are you afterwards, and why is that useful?
4. cd "$d" || ( echo "failed" >&2; exit 1 ) prints the message and the script carries on. Why, and what is the fix?
5. A while loop counts lines and the total is zero afterwards. What is the likely cause?
6. You want eight lines written to one file. Which construct, and why not the other?
8. Remember
( … )starts a subshell;{ … }groups in the current shell. Everything else follows.- Nothing escapes a subshell — not a directory change, a variable, a shell option, or anything else. Only the exit status comes back.
- Everything escapes a brace group, because there was never a boundary.
- Braces are fussy: a space after
{, and a;or newline before}. They are reserved words, not punctuation. ( exit 1 )is harmless;{ exit 1; }ends your shell.- Use parentheses when you want the changes thrown away, braces for everything else.
( cd "$dir" && make )is the clean directory sandbox — nocd -to remember and nothing to undo if it fails.- A guard that must stop the script needs braces.
|| ( …; exit 1 )prints its message and carries on. { …; } > fileis the right way to redirect a block. Parentheses fork for nothing.- A subshell is a process, so it can be backgrounded — which is how you run several directory-scoped jobs at once.
- Several constructs make a subshell without parentheses:
$( … ), every stage of a pipeline, and anything backgrounded. - A counter that is zero after a loop means a pipe. Redirect, or use process substitution — Chapter 17.7 measures this one, including the
exitthat does not exit. $$does not change inside a subshell, and$BASHPID— which does — needs Bash 4.
That completes Module 11. You can now read the number every command returns, chain commands so that a failure stops the sequence rather than being ignored, recognise the one chaining shape that looks like a conditional and is not, and group commands with a deliberate choice about whether their effects survive.
Module 12 takes the last structural piece of the shell: the three streams every process is born with, the operators that redirect them to and from files, and the pipe — the construct that turns a handful of small tools into a toolkit, and the one that has been quietly creating subshells behind your back since Chapter 8.6.
Where this fits
Part of the Linux & Bash curriculum.
