latency.lab
Module 01

Words, Quoting & Expansion

Level: foundationTime: ~60 minPrereq: Module 00

By the end you can

If you only ever properly learn one thing about bash, make it this module. Quoting is not style. It is the difference between a script that works and a script that deletes the wrong directory when someone puts a space in a filename. Nearly every shell bug you will ever hit — and a large share of the shell-related CVEs ever filed — is a quoting bug wearing a disguise.

The shell is a word-splitting machine

A command line is not a string that gets passed to a program. It is a list of words, and bash decides where the boundaries are. Programs receive an array of arguments; the shell's job is to build that array. Everything in this module is about controlling how it does that.

the core demo
file="my report.txt"

rm $file      # TWO words: rm "my" "report.txt"  -- deletes the wrong things
rm "$file"    # ONE word:  rm "my report.txt"    -- correct

Read that until it is boring. The variable holds one value in both cases. What differs is whether bash was allowed to split it after substituting it.

The expansion order, precisely

When bash processes a word it performs these steps in this order:

  1. Brace expansion — {a,b} becomes a b
  2. Tilde expansion — ~ becomes your home directory
  3. Parameter expansion — $var becomes its value
  4. Command substitution — $(cmd) becomes cmd's output
  5. Arithmetic expansion — $((1+1)) becomes 2
  6. Word splitting — the result is chopped on IFS characters
  7. Filename expansion (globbing) — *.txt becomes matching filenames
The whole module in one sentence

Steps 6 and 7 happen after your variable's value has been pasted in, and double quotes are how you switch them off. That is the entire mechanism.

Two consequences people find surprising, both of which follow directly from the ordering:

  • Brace expansion is first, so it cannot use variables: {1..$n} does not work, because $n has not been substituted yet when braces are processed.
  • Globbing is last and operates on the result of substitution, so a variable containing * will expand to filenames when used unquoted. If a filename can contain an asterisk, an unquoted variable is a landmine.

IFS: the thing doing the splitting

IFS (Internal Field Separator) is the variable holding the characters bash splits on. Its default is space, tab, and newline. Change it and you change what "a word" means:

ifs.sh
line="root:x:0:0:root:/root:/bin/bash"

IFS=':' read -r user pass uid gid gecos home shell <<< "$line"
echo "$user"   # root
echo "$shell"  # /bin/bash

Setting IFS on the same line as the command scopes the change to that one command only — a very useful idiom, and much safer than assigning it globally and forgetting to restore it.

The three kinds of quoting

FormSubstitution?Splitting / globbing?Use for
'single'No — everything literalNoLiteral text, regexes, awk programs
"double"Yes — $var, $(cmd) still expandNoAlmost everything else
bareYesYes — split and globbedAlmost nothing, deliberately

Inside single quotes there is no escape mechanism at all — you cannot backslash-escape a single quote. To include one, end the string, add an escaped quote, and start again: 'it'\''s'. It looks absurd and it is the standard idiom.

"$@" versus "$*" versus $@

This is the highest-value distinction in the language, and it is worth memorising outright:

args.sh
show() { printf '[%s]' "$@"; echo; }

set -- "one two" "three"       # two arguments

show "$@"   # [one two][three]      <- correct: each arg preserved
show $@     # [one][two][three]     <- split on whitespace, now three args
show "$*"   # [one two three]       <- joined into ONE arg
  • "$@" expands to each argument as a separate, intact word. This is what you want, essentially always.
  • "$*" joins all arguments into a single word, separated by IFS's first character. Useful when you deliberately want one string.
  • Unquoted $@ or $* re-splits and globs every argument. Almost always a bug.

The same distinction applies verbatim to arrays in Module 07: "${arr[@]}" versus "${arr[*]}".

Where quoting actually bites

bugs.sh
# 1. Command substitution splits too
files=$(ls)              # any filename with a space is now several words

# 2. Empty variables VANISH when unquoted
name=""
[ -n $name ]             # becomes: [ -n ]  -- which is TRUE. Bug.
[ -n "$name" ]           # becomes: [ -n "" ] -- correctly false

# 3. A leading dash makes your value look like a flag
user_input="-rf"
rm "$user_input"         # quoted, and STILL parsed as a flag
rm -- "$user_input"      # -- ends option parsing. Now it is a filename.

# 4. Globs inside variables expand on use
pattern="*.txt"
echo $pattern            # lists your .txt files
echo "$pattern"          # prints the literal string *.txt

Case 3 deserves emphasis because quoting alone does not save you. Quoting controls word splitting; it does not stop a program from interpreting a leading dash as an option. Any time a value could begin with -, terminate the option list with --. This is a real attack surface, not a theoretical one.

The rule

The habit to build

Quote every expansion, every time, unless you have a specific reason not to. Not "when it might contain a space" — always, because you will be wrong about which values can contain spaces. The two legitimate reasons to leave something unquoted are: you genuinely want globbing, or you genuinely want splitting. Both are rare, and both deserve a comment saying so. Run shellcheck; SC2086 is exactly this warning and it is right far more often than you are.

Takeaways

  • The shell builds an argument list. Quoting is how you control where the word boundaries land.
  • Word splitting and globbing happen after substitution — which is precisely what double quotes suppress.
  • "$@" preserves each argument; "$*" joins them into one; unquoted re-splits both. Use "$@".
  • Unquoted empty variables disappear entirely, silently changing the meaning of a test.
  • Quoting does not stop a leading dash being read as a flag — use -- to end option parsing.

Try it: survive hostile arguments

The graders here pass arguments containing spaces, glob characters, empty strings and leading dashes — the exact inputs that break unquoted code. A naive implementation passes the easy test and fails the rest, which is precisely the point.

Live

Loading starter code…

Checkpoint · Quoting & Expansion

5 questions · pass at 70%

Q01
A variable holds a filename with a space: f="my report.txt". Why does rm $f try to delete two files?
WhyThe value is one string in both cases; what changes is whether bash is permitted to split it after substitution. Substitution and splitting are separate ordered steps, and double quotes disable the splitting step (and globbing). This ordering is the mechanism behind essentially every quoting bug.
Q02
You want a wrapper function to pass its arguments through to another command completely unchanged, including any that contain spaces. Which is correct?
Why"$@" is special-cased by the shell: it expands to one word per argument, each preserved exactly. "$*" joins everything into a single argument. Unquoted $@ and $* both re-split on whitespace and then glob, so an argument containing a space becomes two and an argument containing * becomes a list of filenames.
Q03
Why is [ -n $name ] true even when name is the empty string?
WhyThis is the subtlest case in the module: an unquoted empty expansion produces zero words, not one empty word. The test command then sees a completely different expression than the one you wrote. Quoting gives you [ -n "" ], which is correctly false. It's a good example of why 'quote when it might have spaces' is the wrong rule — this value has no spaces at all.
Q04
A filename comes from user input and could begin with a dash. You correctly write rm "$input". Are you safe?
WhyQuoting and option parsing are two different layers. The shell hands rm a clean single argument; rm then inspects it and sees what looks like a flag. The -- convention is honoured by essentially all GNU and POSIX tools and is the correct fix. (Prefixing with ./ also works for paths specifically, but -- is the general answer and applies to non-path arguments too.)
Q05
Why does for i in {1..$n} fail to produce a range?
WhyThis falls straight out of the expansion order: brace expansion is step 1, parameter expansion is step 3. When bash processes the braces, it sees {1..$n}, which is not a valid range, so it leaves the text alone. The usual fixes are an arithmetic for loop — for ((i=1; i<=n; i++)) — or seq. It's a good demonstration that the ordering is not trivia; it predicts real behaviour.

Finished Module 01?

Passing the quiz marks it complete automatically. Or mark it yourself.