Shell Scripting Pitfalls: Quoting, Word Splitting, and Why $var Isn't Always Safe
An unquoted variable works in testing, then silently breaks the first time its value contains a space — the single most common shell scripting bug.
An unquoted variable reference is arguably the single most common source of real, production-breaking shell scripting bugs — it works fine through casual testing, then fails the first time the variable’s actual value contains something the script’s author didn’t anticipate.
The core problem: word splitting
file="my document.txt"
rm $file
# attempts to remove two separate files: "my" and "document.txt"
Without quotes, $file’s expanded value is subject to word splitting — the shell breaks it into separate words on whitespace before passing it as arguments, meaning a single variable holding a filename with a space becomes two separate arguments instead of one.
The direct fix: quote every variable expansion
rm "$file"
# correctly treats the entire value as one argument
Double-quoting a variable reference disables word splitting (and glob expansion) for that expansion specifically, while still allowing variable and command substitution inside the quotes — this single habit prevents the large majority of variable-related shell scripting bugs.
Why this specific bug is so easy to miss in testing
Test values chosen during development (test.txt, myfile) rarely contain spaces, special characters, or glob metacharacters — the bug only surfaces once the script runs against real-world data (a user’s actual filename, an argument passed from another program) that happens to contain a space or wildcard character the developer’s test cases never included.
Single quotes vs. double quotes: a genuinely different behavior
name="World"
echo "Hello, $name" # Hello, World (double quotes: expansion happens)
echo 'Hello, $name' # Hello, $name (single quotes: no expansion at all)
Double quotes still allow variable and command substitution inside them; single quotes suppress all expansion, treating everything inside literally — using the wrong one is a common, specific mistake distinct from the “forgot to quote at all” problem.
Why [ -z $var ] is a specific, well-known trap
[ -z "$var" ] # correct: safe even if $var is unset or contains spaces
[ -z $var ] # broken if $var is unset — becomes [ -z ] , a syntax error
If $var is unset or empty, the unquoted version can collapse into invalid syntax for the test command entirely — a bug that only manifests for specific, easy-to-forget-to-test input states (empty or unset variables), rather than failing consistently regardless of input.
What shellcheck actually catches, and why it’s worth running
ShellCheck is a static analysis tool specifically built to catch this entire category of shell scripting pitfalls — unquoted expansions, common globbing mistakes, and dozens of other well-known failure patterns — before a script ever actually runs against real data where the bug would otherwise surface.
Why “it worked when I tested it” isn’t the same as “it’s correct”
Shell scripting bugs in this category are specifically dangerous because they’re input-dependent — a script can run correctly across dozens of successful executions and then fail (or, worse, silently do the wrong thing, like deleting the wrong file) the first time it encounters an input value with a space, a leading dash, or a glob character the developer’s testing never happened to include.
The practical habit that prevents almost all of these
Quoting every variable expansion by default — not just the ones that “seem like” they might contain spaces — is the single highest-value habit in shell scripting specifically because you generally can’t be certain in advance which variable will eventually hold an unexpected value; consistent quoting removes the need to guess correctly every time.
“$@” vs. “$*”: both expand every positional parameter, but not the same way
set -- "one two" three
for arg in "$@"; do echo "[$arg]"; done
# [one two]
# [three]
for arg in "$*"; do echo "[$arg]"; done
# [one two three]
Quoted "$@" expands to each positional parameter as its own separate word, preserving whatever whitespace was originally inside any single argument — almost always what a script forwarding its own arguments to another command actually wants. Quoted "$*" instead joins every positional parameter into a single word, separated by the first character of $IFS (a space, by default) — collapsing several distinct arguments into one. Using "$*" where "$@" was intended is a specific, well-documented mistake distinct from the general “forgot to quote” problem, because both forms here are quoted correctly; they just mean genuinely different things.
IFS controls more than you’d expect
IFS=,
read -r a b c <<< "one,two,three"
echo "$b" # two
$IFS (Internal Field Separator) determines both where word splitting breaks unquoted expansions and where read splits an input line into separate variables. Changing it is a legitimate technique for parsing delimited data like CSV lines, but a script that changes IFS permanently rather than scoping the change — via a subshell, a saved-and-restored value, or a per-command assignment — risks corrupting every later word-splitting operation in the same shell that assumed the default whitespace behavior.
printf ‘%q’: asking the shell to quote something for you
name="a file with spaces & stuff.txt"
printf '%q\n' "$name"
# a\ file\ with\ spaces\ \&\ stuff.txt
Bash’s printf supports a %q conversion that outputs its argument quoted in a form safe to reuse as shell input — useful specifically when a script needs to generate another shell command as text, for logging, for a generated script, or for eval, rather than just passing a value directly as an argument. It answers “how do I safely quote an arbitrary string for the shell” without hand-rolling escaping logic, which is easy to get subtly wrong for strings that themselves contain quote characters.
Unquoted variables are also a security boundary, not just a correctness one
An unquoted variable flowing into a command isn’t only a word-splitting correctness bug — when that variable’s value can be influenced by something outside the script’s control, such as a filename from an untrusted upload directory or a value passed through several layers of automation, unquoted expansion becomes a genuine command-injection surface. A crafted value like ; rm -rf ~, or one that begins with a dash to smuggle in an unintended flag, can change what a command actually does, not just how many arguments it receives. The fix is identical to the correctness fix — quote the expansion — but the stakes are categorically different once the value’s source is untrusted rather than merely another developer’s own test data.
Quoting inside double quotes: what still expands
Double quotes suppress word splitting and glob expansion, but they do not suppress variable expansion, command substitution, or arithmetic expansion happening inside them — "$name has $(( count + 1 )) items" still substitutes both. Only single quotes suppress everything, which is why switching from double to single quotes to “quote more safely” can silently break a string that actually depended on an expansion happening inside it.
Related:
- How Shell Expansion and Globbing Actually Work
- How to Debug Shell Scripts With set -x and ShellCheck
Sources: