The Parsing Order
Bash applies expansions in a very specific order. Each step transforms the command line before handing it to the next. Most "why did Bash do that?" moments come from expecting a later step to see something an earlier step already consumed — or vice versa.
Once you internalize the order, a lot of otherwise-confusing behavior becomes predictable. You can look at any line and mentally walk through what Bash will actually run.
Every command line passes through 8 expansion steps in a fixed order. A transformation that happens in step 5 cannot see the input of step 2. Most "weird" Bash behavior is a step-ordering surprise.
The eight steps
In order:
The order is fixed. Every command line goes through it in sequence.
Why the order matters
Consider:
dir="my docs"
cd $dir/*.txt
Walk the steps:
1. Brace expansion: no {} — unchanged
2. Tilde expansion: no ~ — unchanged
3. Parameter expansion: $dir → "my docs"
Line: cd my docs/*.txt
4. Command substitution: no $() — unchanged
5. Arithmetic: no $(( )) — unchanged
6. Process substitution: none — unchanged
7. Word splitting: split on IFS → cd, my, docs/*.txt
8. Pathname expansion: docs/*.txt doesn't exist — left literal
Final words: cd my docs/*.txt
Execute: cd gets three arguments
Result: cd is called with three arguments and errors out.
Now quote the variable:
cd "$dir/*.txt"
3. Parameter expansion: "$dir" → "my docs"
Line: cd "my docs/*.txt"
7. Word splitting: skipped (quoted)
8. Pathname expansion: skipped (quoted)
Execute: cd gets one argument: literal string "my docs/*.txt"
Also wrong — no file matches the literal. What you actually wanted:
cd "$dir"/*.txt
Here the quoting wraps $dir only; the *.txt is outside the quotes, so globbing happens on that portion.
Quotes bind to the characters they enclose, not to whole arguments. "$dir"/*.txt is "quote the $dir part, leave the */.txt unquoted." Mixing quoted and unquoted in a single word is common and correct.
The step that surprises people: brace expansion is first
Brace expansion runs before everything else. So:
prefix="item"
echo {$prefix-a,$prefix-b}
# Output: {item-a,item-b}
Wait — that is not what we wanted. The issue: brace expansion runs first, before $prefix is evaluated. Bash sees {$prefix-a,$prefix-b} as a literal since the braces contain $ which does not trigger expansion yet. It gives up and leaves the line untouched.
Actually a better example:
prefix="item"
echo ${prefix}-{a,b,c}
# Output: item-a item-b item-c
Here brace expansion happens first: ${prefix}-{a,b,c} → ${prefix}-a ${prefix}-b ${prefix}-c. Then parameter expansion resolves each ${prefix}. Then they print.
The rule: brace expansion is purely textual and happens first.
Brace expansion patterns
Often useful:
# Lists
echo {foo,bar,baz}
# foo bar baz
# Numeric ranges
echo {1..5}
# 1 2 3 4 5
# Padded ranges
echo {01..10}
# 01 02 03 04 05 06 07 08 09 10
# Step
echo {0..20..5}
# 0 5 10 15 20
# Letter ranges
echo {a..e}
# a b c d e
# Concatenations
echo {a,b}_{1,2}
# a_1 a_2 b_1 b_2
# The mkdir idiom
mkdir -p project/{src,test,docs}
# Creates project/src, project/test, project/docs
mkdir -p foo/{src,tests,docs} is the idiomatic way to create a set of subdirectories in one command. Uses brace expansion, not globbing — no files need to exist.
Tilde expansion — second, limited scope
Tilde expansion happens at the start of a word and in a few specific contexts:
cd ~/projects # ~ at the start of a word — expands to $HOME
echo ~ # $HOME
echo ~user # user's home directory
echo ~+ # $PWD
echo ~- # $OLDPWD (last directory you were in)
PATH=~/bin:$PATH # After `:` in PATH assignment, ~ also expands
Note: ~ only expands at the start of a word. foo~bar is literal; ~bar (at the start) is user bar's home directory.
Parameter expansion — the workhorse
Covered in depth in a later lesson. Quick reference:
${var} # value of var
${var:-default} # value, or 'default' if unset/empty
${var:=default} # value, or assign 'default' and use it
${var:?error} # value, or print 'error' and exit if unset
${var:+alt} # 'alt' if var is set, else empty
${#var} # length of var
${var#prefix} # strip shortest prefix match
${var%suffix} # strip shortest suffix match
${var//old/new} # replace all 'old' with 'new'
${var^^} # uppercase
${var,,} # lowercase
All of these happen in step 3 — before word splitting and globbing. The result is still subject to both unless quoted.
Command substitution and arithmetic (steps 4-5)
Command substitution:
now=$(date +%s) # $ + paren — runs command
timestamp=`date +%s` # backticks — legacy
Arithmetic:
total=$((a + b * 2)) # evaluates as arithmetic
length=$((${#str} + 1)) # nested — length of str plus 1
Both run inside Bash's expansion pipeline. The result is substituted and then subject to the remaining steps (word splitting, globbing).
Process substitution — the weird one
Step 6. Converts a command into a file:
diff <(ls dir1) <(ls dir2)
# Compares the outputs of two ls commands
Under the hood: Bash opens a pipe to each <(...) command and replaces the expression with a path like /dev/fd/63. diff reads them as if they were files.
Same with >(...) for output:
echo hello > >(tee logfile)
Sends echo's output to a process (which tees it to logfile and stdout).
Process substitution is how you do "fork-less" pipeline-like stuff without running into the subshell-eats-variables problem.
Word splitting — step 7 — the usual suspect
Word splitting is step 7. It runs on the output of all previous steps (except where quoting disabled it).
value="foo bar baz"
echo $value # unquoted: 3 words
# foo bar baz
echo "$value" # quoted: 1 word
# foo bar baz (same visible output, but 1 arg to echo)
This is the step we covered in lesson 1.1. Quoting blocks it.
Where word splitting applies
- Unquoted
$var - Unquoted
$(cmd) - Unquoted
${var:-default}
It does not apply:
- Inside
"..."or'...' - In variable assignments:
x=$value(no word splitting; the full string is assigned) - In
[[ ... ]]conditionals (we'll cover this in Module 3) - In
casepatterns
The assignment case matters. x=$value works without quotes because no word splitting happens. But cmd $value does word-split. This asymmetry is confusing — "I didn't need quotes on the assignment, why do I need them on the command?" — but it is the rule.
Pathname expansion — step 8 — globbing
The last step. Any word that contains *, ?, or [abc] is matched against the filesystem. Each match becomes a separate word.
ls *.txt # might expand to: ls a.txt b.txt c.txt
ls ?.txt # might expand to: ls a.txt
ls [abc].txt # might expand to: ls a.txt b.txt c.txt
A few options control globbing behavior:
shopt -s nullglob # unmatched globs expand to nothing (default: leave literal)
shopt -s dotglob # * matches hidden files too
shopt -s globstar # ** matches recursively
shopt -s nocaseglob # case-insensitive
Most production scripts want at least nullglob:
shopt -s nullglob
for file in *.txt; do
process "$file"
done
# Without nullglob, if no .txt files exist, the loop runs once with
# $file literally equal to "*.txt" — which is almost never what you want.
Enable nullglob at the top of any script that iterates over globs. It fixes the zero-matches case and has almost no downsides.
Walk-through: a bug you can now diagnose
user="alice smith"
echo Hello, $user's files: *
Expected: Hello, alice smith's files: file1 file2 file3
Actual output depends on the current directory, but it is definitely not what was expected. Let's walk the steps:
Input: echo Hello, $user's files: *
Step 1 (brace): no change
Step 2 (tilde): no change
Step 3 (parameter): $user → alice smith
Line: echo Hello, alice smith's files: *
Step 4 (cmd sub): no change
Step 5 (arithmetic): no change
Step 6 (proc sub): no change
Step 7 (word split): Now things go wrong.
Hello, alice smith's files: *
splits on IFS to:
[echo] [Hello,] [alice] [smith's] [files:] [*]
Step 8 (pathname): * expands to every file in the current directory.
Final call: echo Hello, alice smith's files: a.txt b.txt c.txt — with six or more args depending on cwd.
Fix:
echo "Hello, $user's files: *"
Now $user is inside quotes → no word splitting on it; * is inside quotes → no globbing.
Assignments — the one place with different rules
Bash has a special carve-out: variable assignments do not undergo word splitting or globbing, even unquoted.
path=/tmp/my dir # ERROR — "dir" is treated as a separate command
path="/tmp/my dir" # Works — quotes needed because of the space
pattern=*.txt # Works — no globbing on assignment, stored as literal "*.txt"
value=$(cmd) # Works — no word splitting on the result when assigning
This is why you sometimes see unquoted assignments that "look wrong" but work. The language has a specific exemption for assignments.
# Both work
count=$((a + b))
count="$((a + b))"
# But when you use it, quoting matters
echo $count # subject to word splitting
echo "$count" # not
Special cases that skip word splitting and globbing
Several contexts do not apply the final two steps even on unquoted expansions:
- Variable assignment:
x=$y(already covered) [[ ... ]]:[[ $var == "pattern" ]]— no word splitting on$varinside[[]]case:case $var in— samehere-strings:cmd <<< $var— no word splitting- Array subscript:
arr[$i]=value— no word splitting on$i
This list explains why the advice "quote everything" has exceptions — some contexts already behave as if quoted.
When in doubt, quote. The contexts where quoting is unnecessary are few, and quoting never hurts correctness.
Testing your mental model
Useful set flags for understanding what Bash does:
set -x # trace every command after expansion
echo $var *.txt
# + echo alice smith a.txt b.txt c.txt
# ^ that's what Bash actually runs after all expansions
Seeing the post-expansion form with -x is the fastest way to confirm your mental model. We will cover this in depth in the debugging lesson.
Quiz
You have: files=(a.txt b.txt); then: ls $files. What happens?
What to take away
- Bash applies 8 expansion steps in a fixed order. Memorize them: brace, tilde, parameter, command substitution, arithmetic, process substitution, word splitting, pathname expansion.
- Brace expansion is first — purely textual, independent of variables.
- Parameter, command, and arithmetic substitution happen in the middle. Their results feed into word splitting and globbing.
- Word splitting (step 7) only applies to unquoted expansions.
- Pathname expansion (step 8) is the last step. Quoted words skip it.
- Variable assignments,
[[ ]],case, here-strings, and array subscripts do not apply word splitting or globbing. - Use
set -xto see the post-expansion form of commands you run. It is the definitive test of your mental model.
Next module: parameter expansion in depth — the ${var:-default}-style forms every senior engineer uses daily.