Shell Expansion in Depth
You’ve been using several kinds of expansion since early in this course without a formal name for what was happening — $(date), $((count + 1)), ${1^^} in the last chapter. This chapter names and formalizes every expansion Bash performs, in the actual order it performs them, and introduces a few you haven’t seen yet: brace expansion, tilde expansion, the full range of parameter expansion, and process substitution — a genuinely useful tool for exactly the kind of subshell and word-splitting problems this course has been flagging since Chapter 8.
The Order Of Expansions
Bash performs expansion in a fixed sequence, and the order matters — a later stage operates on the output of an earlier one, not on your original text:
flowchart TD
A["Brace expansion"] --> B["Tilde expansion"]
B --> C["Parameter expansion,<br/>arithmetic expansion,<br/>command substitution<br/>(left to right, one pass)"]
C --> D["Word splitting"]
D --> E["Pathname expansion"]
E --> F["Quote removal"]
Brace expansion happens first and purely textually — it has no idea what files exist or what variables are set. Word splitting and pathname expansion happen after variables and command substitutions have already produced their values — which is exactly why quoting (Chapter 11) has to happen at the point of expansion, not before: there’s nothing to quote until the value actually exists.
Brace Expansion
{...} generates multiple literal strings from one compact pattern, entirely at the text level, before anything else happens:
echo file{1,2,3}.txtfile1.txt file2.txt file3.txtecho {1..5}
echo {01..10}
echo {a..e}1 2 3 4 5
01 02 03 04 05 06 07 08 09 10
a b c d eBrace expansion happens whether or not any matching files exist — unlike pathname expansion (globbing), it’s not checking the filesystem at all, just generating text combinations.
It’s commonly used to create several related paths in one line:
mkdir -p project/{src,tests,docs}Tilde Expansion
~ expands to your home directory ($HOME); ~username expands to that specific user’s home directory:
echo ~
echo ~root/home/you
/root~+ expands to the current directory (equivalent to $PWD), and ~- expands to the previous directory ($OLDPWD, the directory you were in before your last cd).
Parameter Expansion: Defaults
Beyond plain $variable, Bash’s parameter expansion syntax handles unset-or-empty variables directly, without a separate if check:
| Form | Behavior |
|---|---|
${var:-default} | Use default if var is unset or empty — doesn’t change var itself |
${var:=default} | Same, but also assigns default to var |
${var:?message} | Print message and exit with an error if var is unset or empty |
${var:+altvalue} | Use altvalue only if var is set and non-empty — the inverse of :- |
unset log_level
echo "Level: ${log_level:-info}"
echo "log_level is still: [${log_level:-unset}]"
: "${log_level:=info}"
echo "Now log_level is actually set to: $log_level"Level: info
log_level is still: [unset]
Now log_level is actually set to: infoThe : before ${log_level:=info} there is the : builtin, which does nothing except successfully evaluate its arguments — it’s a common idiom (no-op) specifically for triggering :=’s assignment side effect without needing to print or use the result immediately.
Parameter Expansion: Substrings And Length
text="Hello, World"
echo "${#text}"
echo "${text:0:5}"
echo "${text:7}"
echo "${text: -5}"12
Hello
World
World${#text} is length, in characters. ${text:offset:length} extracts a substring — omit length to take everything to the end. A negative offset counts from the end of the string, but note the required space before the - (${text: -5}, not ${text:-5}) — without that space, Bash parses it as the :-default form covered above instead of a substring offset.
Parameter Expansion: Search And Replace
These are genuinely useful for everyday path and string manipulation, without reaching for an external tool:
| Form | Behavior |
|---|---|
${var#pattern} | Remove the shortest match of pattern from the front |
${var##pattern} | Remove the longest match of pattern from the front |
${var%pattern} | Remove the shortest match of pattern from the end |
${var%%pattern} | Remove the longest match of pattern from the end |
${var/pattern/replacement} | Replace the first match |
${var//pattern/replacement} | Replace all matches |
path="/home/you/reports/2026/summary.txt"
echo "${path##*/}"
echo "${path%/*}"
echo "${path%.txt}"
echo "${path//\//_}"summary.txt
/home/you/reports/2026
/home/you/reports/2026/summary
_home_you_reports_2026_summary.txt${path##*/} strips everything up through the last /, giving you the equivalent of basename. ${path%/*} strips everything from the last / onward, giving you dirname. ${path%.txt} strips a known suffix. ${path//\//_} replaces every / with _ — the pattern itself needed the / escaped, since / is also the delimiter syntax for this form.
Parameter Expansion: Case Modification
word="Hello"
echo "${word^^}"
echo "${word,,}"
echo "${word^}"HELLO
hello
Hello^^ uppercases everything, ,, lowercases everything, and the single-character forms (^, ,) affect only the first character — this is exactly the ${1^^} form used without explanation back in the Functions chapter.
Command Substitution
$(...) runs a command and substitutes its standard output in place, with trailing newlines stripped:
today=$(date +%Y-%m-%d)
echo "Today is $today"Today is 2026-08-23You’ll also encounter the older backtick form, `command`, which does the same thing but nests awkwardly (nested backticks require escaping) and is harder to read at a glance. This course uses $(...) exclusively — prefer it in your own scripts too.
Note
Command substitution strips trailing newlines from the captured output, but preserves newlines in the middle of multi-line output. If a command’s output ends with several blank lines, all of them are removed — this rarely matters, but is worth knowing if you’re ever capturing output where trailing blank lines were meaningful.
Arithmetic Expansion
$((...)) evaluates an arithmetic expression and substitutes the numeric result — you’ve used this since Chapter 8’s while loop counters:
a=7
b=2
echo "$((a + b))"
echo "$((a / b))"
echo "$((a % b))"9
3
1Note $((a / b)) is 3, not 3.5 — Bash arithmetic is integer-only. Division truncates toward zero, with no floating-point support at all. For anything requiring actual decimal precision, you need an external tool (such as bc or awk); Bash’s own arithmetic simply doesn’t have the capability, and silently truncating instead of erroring is worth remembering as a real source of subtly wrong results if you forget it.
Process Substitution
<(command) and >(command) let a command’s output (or input) be treated like a file, without creating a real temporary file on disk:
diff <(sort file1.txt) <(sort file2.txt)Here, <(sort file1.txt) behaves like a filename diff can read from — Bash sets it up as a special path pointing at that command’s output stream — letting you compare two commands’ output directly, without manually creating and cleaning up temp files for each one.
This solves a real problem flagged back in Chapter 8: piping a command into while read runs that while loop in a subshell, so any variables it sets are lost the moment the loop ends. Process substitution avoids that entirely, because it’s not a pipe — the while loop stays in your current shell:
count=0
while IFS= read -r line; do
(( count++ ))
done < <(printf 'one\ntwo\nthree\n')
echo "Counted $count lines"Counted 3 linesWritten as a pipe instead (printf '...' | while IFS= read -r line; do (( count++ )); done), count would still be 0 after the loop — the increments would have happened inside a subshell that vanished the instant the pipeline finished. Feeding the same loop via < <(...) keeps everything in the current shell, so count comes out correct.
Pathname Expansion (Recap)
Chapter 11 already covered pathname expansion’s dangerous side — an unquoted variable containing *, ?, or [...] silently expanding against real filenames. Formally, it’s the last expansion stage before quote removal: after word splitting divides unquoted text into words, each resulting word is checked against the filesystem and replaced with matching filenames if it contains unescaped glob characters. Nothing new to add here beyond what Chapter 11 already demonstrated — it’s included in this chapter’s ordering diagram simply because this is where it fits in Bash’s actual expansion sequence.
Best Practices
- Prefer
$(...)over backticks for command substitution — always, in every script in this course. - Remember arithmetic expansion is integer-only. Don’t assume
$((a / b))gives a precise result; reach for an external tool when decimal precision actually matters. - Use
${var:-default}for read-only fallback values,${var:=default}when you also want the variable actually set, and pick deliberately between them rather than defaulting to one out of habit. - Reach for process substitution (
< <(...)) instead of a pipe whenever a loop needs to set variables that must still be visible after the loop finishes. - Use parameter expansion’s search-and-replace forms for simple path and string manipulation before reaching for an external tool —
${path##*/}and${path%/*}cover most everyday basename/dirname needs without spawning a separate process.
Shell-Safety Considerations
Command substitution is just another unquoted expansion when left unquoted — and it’s subject to exactly the same word-splitting and pathname-expansion dangers as any variable, including the classic broken-loop pattern from Chapter 8, now with $(find ...) in place of $(ls).
Naive (broken) version:
mkdir -p /tmp/shell-course-demo
cd /tmp/shell-course-demo
touch "my file.txt" other.txt
for f in $(find . -maxdepth 1 -type f); do
echo "Found: $f"
doneFound: ./my
Found: file.txt
Found: ./other.txtTwo real files, three iterations — find’s own output was correct, but the unquoted $(...) around it let word splitting tear "./my file.txt" apart at the space, exactly as $(ls) did back in Chapter 8.
Corrected version — combine while IFS= read -r from Chapter 8 with process substitution from this chapter, rather than looping over the unquoted command substitution directly:
while IFS= read -r f; do
echo "Found: $f"
done < <(find . -maxdepth 1 -type f)Found: ./my file.txt
Found: ./other.txtTwo files, two iterations, filenames fully intact — find’s output is read line by line through read, which splits only on newlines, not on every space, and process substitution keeps the whole loop running in the current shell rather than a throwaway subshell. This combined pattern — while IFS= read -r ...; done < <(command) — is the standard, safe way to loop over another command’s output in Bash, and directly replaces both the $(ls) mistake from Chapter 8 and the $(find ...) version shown here.
Hands-On: Expansions, In Order
1. Set up a working directory.
mkdir -p ~/shell-course/ch12
cd ~/shell-course/ch122. Try brace and tilde expansion.
mkdir -p project/{src,tests,docs}
ls project
echo ~
echo ~+3. Work through parameter expansion’s default forms.
unset greeting
echo "${greeting:-Hello}"
echo "[${greeting:-unset}]"
: "${greeting:=Hello}"
echo "$greeting"4. Practice substring extraction and search-and-replace.
path="/var/log/app/2026-error.log"
echo "${path##*/}"
echo "${path%/*}"
echo "${path%.log}"
echo "${path//-/_}"5. Confirm arithmetic expansion’s integer truncation.
echo "$((7 / 2))"
echo "$((7 % 2))"6. Compare two files with process substitution.
printf 'apple\nbanana\ncherry\n' > list-a.txt
printf 'banana\ncherry\ndate\n' > list-b.txt
diff <(sort list-a.txt) <(sort list-b.txt)7. Reproduce the $(find ...) word-splitting bug, then fix it with process substitution.
mkdir -p /tmp/shell-course-demo
cd /tmp/shell-course-demo
touch "my file.txt" other.txt
echo "--- broken ---"
for f in $(find . -maxdepth 1 -type f); do
echo "Found: $f"
done
echo "--- fixed ---"
while IFS= read -r f; do
echo "Found: $f"
done < <(find . -maxdepth 1 -type f)8. Clean up.
cd ~
rm -rf ~/shell-course/ch12
rm -rf /tmp/shell-course-demoYou’ve now worked through every expansion Bash performs, in the order it performs them, and combined process substitution with the safe-reading pattern from Chapter 8 to fix a word-splitting bug you’d otherwise hit constantly when looping over another command’s output. The next chapter covers read in full — prompts, silent input, timeouts, and reading into multiple variables or arrays at once.