How Shell Expansion and Globbing Actually Work
Bash rewrites typed commands through an ordered sequence of expansions before running them. The exact order explains most quoting and globbing surprises.
The command you type is rarely the command that actually runs — before execution, the shell performs a specific, ordered sequence of expansions and substitutions, rewriting your input into its final form.
Why the order of expansion actually matters
Bash performs expansions in a defined sequence: brace expansion, tilde expansion, parameter/variable expansion, command substitution, arithmetic expansion, word splitting, and finally filename (glob) expansion — in that order. Getting the mental model of this sequence right explains behavior that otherwise looks inconsistent, like why quoting a variable changes whether word splitting happens to its expanded value.
Brace expansion: pure text generation, no filesystem involved
echo file{1,2,3}.txt
# expands to: file1.txt file2.txt file3.txt
Brace expansion happens purely as text manipulation, before the shell ever checks the filesystem — file{1,2,3}.txt expands to those exact strings whether or not any of those files actually exist.
Parameter expansion: more than just $variable
echo ${name:-default} # use "default" if $name is unset or empty
echo ${name#prefix} # remove shortest matching prefix
echo ${#name} # string length
Beyond simple variable substitution, parameter expansion supports default values, prefix/suffix removal, and string length — a surprisingly capable text-manipulation toolkit that’s easy to overlook if you only ever use $variable in its simplest form.
Command substitution: running a command for its output
echo "Today is $(date +%A)"
$(...) runs the enclosed command and substitutes its standard output in place — the older backtick syntax (`date +%A`) does the same thing but nests and reads far less clearly for anything beyond a trivial case.
Word splitting: the step that quoting protects against
After expansion, the shell splits the resulting text on whitespace (or the characters in $IFS) into separate words — this is exactly the step that turns an unquoted $variable containing spaces into multiple separate arguments, rather than the one argument you likely intended.
Filename expansion (globbing): the final step, and the only one touching the filesystem
ls *.txt
ls file?.txt
ls file[0-9].txt
Globbing happens last, and it’s the only expansion step that actually checks the filesystem — * matches any sequence of characters, ? matches exactly one character, and [...] matches any single character within the specified set, all resolved against files that actually exist in the relevant directory.
Why an unmatched glob pattern doesn’t always error
echo *.xyz
# if no .xyz files exist, this prints the literal string "*.xyz"
By default, an unmatched glob pattern is passed through literally rather than causing an error or expanding to nothing — a frequent source of confusion in scripts that assume a glob will either match real files or disappear entirely, when the actual default behavior is neither.
Why understanding this sequence prevents a whole class of scripting bugs
Bugs involving “why did my variable turn into multiple arguments,” “why didn’t my wildcard match what I expected,” or “why does quoting change this command’s behavior” almost always trace back to a step in this expansion sequence behaving exactly as specified, just not as the script’s author expected — knowing the actual order (and which steps touch the filesystem versus which are pure text manipulation) turns these from mysterious bugs into predictable, fixable behavior.
Extended globbing: patterns beyond *, ?, and […]
shopt -s extglob
ls !(*.tmp) # everything except files ending in .tmp
ls +(file1|file2).txt
Bash’s basic glob operators only go so far. shopt -s extglob enables a richer, regex-adjacent pattern set borrowed from the Korn shell: @(pattern-list) matches exactly one of the alternatives, !(pattern-list) matches anything except the alternatives, +(pattern-list) matches one or more repetitions, and *(pattern-list) matches zero or more. These remain glob patterns evaluated only against real filenames, not a general regular-expression engine — but they close a real gap between plain wildcards and needing an external find or grep invocation for moderately complex filename filtering.
Three shopt options that change what “no match” means
shopt -s nullglob # unmatched glob expands to nothing, not the literal pattern
shopt -s failglob # unmatched glob is a script error
shopt -s dotglob # * also matches dotfiles, not just visible files
The earlier default — an unmatched glob passed through literally — is a deliberate choice that surprises many scripts. nullglob changes an unmatched *.xyz into zero words instead of the literal string *.xyz, which matters enormously inside for f in *.xyz; do ...; done: without nullglob, that loop body runs once with the literal, nonexistent pattern as $f when no .xyz files exist. failglob goes further and treats an unmatched pattern as an outright error. dotglob extends * to also match filenames beginning with a dot, which POSIX globbing excludes by default specifically to keep a command like rm * from ever touching hidden configuration files by accident.
Zsh’s globbing goes considerably further
Zsh’s default globbing already exceeds Bash’s without any extra setting: **/ recurses through subdirectories (ls **/*.log finds every .log file at any depth), and glob qualifiers in parentheses filter matches by metadata — *(.) for regular files only, *(/) for directories only, *(.om[1]) for the single most recently modified regular file. Bash gained a comparable recursive ** only with shopt -s globstar (added in Bash 4.0), and even then without Zsh’s qualifier syntax; matching Zsh’s metadata-based filtering in Bash generally means reaching for find instead.
Arithmetic expansion is its own step in the sequence
echo $(( 2 + 3 * 4 )) # 14
count=$(( count + 1 ))
$(( ... )) is evaluated as a distinct step in the same expansion sequence, using C-like integer arithmetic — no external expr or bc process required for anything that fits the shell’s integer type. Because it is pure text-to-number-to-text substitution with no filesystem interaction, arithmetic expansion can be nested inside earlier expansion types, including inside a brace-expansion range: echo file{1..$(( 2 + 3 ))}.txt is valid precisely because arithmetic expansion resolves before that result is handed to brace expansion.
Tilde expansion: the step easy to forget exists at all
echo ~ # current user's home directory
echo ~otheruser # otheruser's home directory, looked up via the password database
Tilde expansion happens early, alongside brace expansion, and resolves ~ to $HOME for the current user or to the looked-up home directory of a named user — the second form is why ~otheruser/file can work even for an account you never explicitly configured a path for. Because it happens before word splitting, a ~ appearing inside a quoted string is not expanded, a common source of “why didn’t my tilde work” confusion when a path has been quoted defensively.
Why brace expansion runs before globbing, not after
Because Bash performs brace expansion first and filename expansion last, echo file{1,2}.txt is guaranteed to attempt matching against file1.txt and file2.txt specifically — the shell never treats {1,2} as a glob-style pattern to search the filesystem for, since brace expansion has already replaced it with literal text several steps earlier in the sequence.
Related:
- History Expansion and Search: How !!, Ctrl-R, and Shell History Files Actually Work
- Shell Scripting Pitfalls: Quoting, Word Splitting, and Why $var Isn’t Always Safe
Sources: