Skip to content

Lesson 2 of 9 · 14m

Quoting and Word Splitting

Why unquoted $var bites, "$@" vs "$*", [ vs [[, and the injection angle.

A backup script that worked in testing deletes the wrong files in production because one directory name contained a space. This is the single most common real-world Bash bug — not race conditions, not permissions, just an unquoted variable. Quoting is not cosmetic; it is the difference between one argument and several.

What unquoted expansion actually does

When Bash sees $var without quotes, it performs two operations you didn't ask for:

  1. Word splitting: the value is split on IFS (space, tab, newline) into separate arguments.
  2. Glob expansion: *, ?, and [...] in the result are expanded against the filesystem.

So this:

file="Q3 report.csv"
cp $file /backups/

runs cp Q3 report.csv /backups/ — three arguments, one failure. And this is worse:

rm -rf $TARGET_DIR

If TARGET_DIR is " / " or empty, you've got a different bug report than you intended.

The fix is one character class: "$var". Double quotes preserve the value as exactly one argument, no splitting, no globbing. Unquoted $var is almost never what you want — make "$var" the default and justify every exception.

"$@" versus "$*"

Passing arguments through to another command:

run_backup() {
  /usr/local/bin/backup-tool "$@"   # each arg arrives intact
}
  • "$@" — each positional parameter becomes a separate word. "a b" "c" stays two args.
  • "$*" — joins all args into one word with IFS[0]. Almost never right.
  • $@ unquoted — splitting and globbing. Never.

The [ ] versus [[ ]] trap

x=""
[ -z $x ] && echo empty    # WRONG: [ -z ] is a one-arg test, always true
[ -z "$x" ] && echo empty  # correct

[[ ]] doesn't word-split its left-hand side, so [[ $x = y ]] is safe — but [[ ]] is a Bash extension, not POSIX. Both work if you quote; prefer [[ ]] inside Bash scripts for the pattern matching ([[ $f == *.log ]]) and the saner operator behavior.

Globs that turn into filenames

for f in /var/log/*.log; do
  ...
done

When nothing matches, f becomes the literal string /var/log/*.log — a filename that doesn't exist. Guard it:

shopt -s nullglob        # unmatched globs expand to nothing
for f in /var/log/*.log; do ...

or check [[ -e $f ]] inside the loop.

Security angle

Unquoted expansion of untrusted input is an injection vector. If any part of your script consumes user-controlled data — a filename from a request, a webhook payload — quote it everywhere, and never interpolate it into a command string you pass to sh -c.

For output, echo interprets -n, -e, and backslashes inconsistently across implementations. For untrusted or arbitrary data:

printf '%s\n' "$untrusted"

Try this

touch "my file.txt"
for f in $(ls my*.txt); do echo "got: $f"; done   # splits
for f in my*.txt; do echo "got: $f"; done        # correct

Pitfalls

  • IFS manipulation is real but rare — you almost never want to change it. If you do, restore it in a trap.
  • read without -r eats backslashes. Always read -r unless you specifically want backslash escapes.
  • Command substitution $(cat file) strips trailing newlines — matters for exact byte comparisons.