the most common mistakes and how to avoid them
Quoting in Bash is the most underestimated topic in shell programming. Word splitting and globbing break scripts in ways that are invisible while you write them, yet they show up reliably in production data with spaces, special characters and wildcards. This article covers all the common quoting mistakes and shows the correct alternatives.
Table of Contents
- 1. Quoting in Bash: basics and expansion order
- 2. Word splitting: the silent data destroyer
- 3. Globbing: when variables turn into wildcards
- 4. Double quotes: when and why
- 5. Single quotes and the $'...' syntax
- 6. Quoting and arrays: the safe alternative
- 7. Quoting in subshells and command substitution
- 8. Here documents and here strings
- 9. Quoting mistakes compared
- 10. Summary
- 11. FAQ
1. Quoting in Bash: basics and expansion order
Quoting in Bash controls which shell expansions are performed on a string. Bash processes every command through a fixed sequence of expansions: first brace expansion, then tilde expansion, then parameter and variable expansion, then arithmetic expansion, then command substitution, then word splitting, and finally pathname expansion (globbing). Quoting interrupts this chain at specific points: double quotes disable word splitting and globbing but still allow parameter expansion. Single quotes disable all expansions completely.
The misunderstanding behind most quoting mistakes in Bash is this: variables are not expanded when they are assigned, but when they are used. That means a variable holding a filename with a space gets split into two or more words when used unquoted, regardless of how it was assigned. Quoting in Bash is not optional, it is a fundamental part of the language semantics. Every Bash variable used in a context where spaces, tabs or special characters might appear must be wrapped in double quotes.
ShellCheck has a dedicated code, SC2086, for unquoted variables. It is the single most common ShellCheck warning, an indicator of just how widespread this quoting mistake is in real shell scripts. The ShellCheck documentation for SC2086 explains precisely in which contexts missing quotes cause problems and when they are genuinely unnecessary.
2. Word splitting: the silent data destroyer
Word splitting is the mechanism by which Bash breaks an unquoted variable into multiple words when it appears in a command position. The separator is defined by the $IFS variable (Internal Field Separator). By default, $IFS contains space, tab and newline. If a variable holds a filename like my document.txt and is used unquoted, Bash sees two arguments: my and document.txt. Ignoring this behavior of quoting in Bash leads to bugs that never show up locally, because test data rarely contains spaces.
The classic case: a script iterates over files with for f in $(ls). In a directory with no spaces in filenames this appears to work fine. In a directory containing my project.tar.gz the loop receives three elements: my, project.tar.gz, treated as separate files. That is the core of the word splitting problem in quoting in Bash. The safe alternative is always find -print0 combined with read -r -d '' into an array, or direct glob expansion with the loop variable quoted.
#!/usr/bin/env bash
set -euo pipefail
# === WORD SPLITTING EXAMPLES ===
# WRONG: SC2086 - unquoted variable, word splitting happens
filename="my project (v2).txt"
ls -la $filename # Bash sees: ls -la my project (v2).txt - 4 args!
cp $filename /backup/ # Fails or copies wrong files
# RIGHT: quoted variable - treated as single argument
ls -la "$filename"
cp "$filename" /backup/
# WRONG: for f in $(ls) - word splitting + loses exit code
for f in $(ls /var/backups/); do
echo "$f" # Splits filenames with spaces
done
# RIGHT: glob expansion - shell handles it natively
for f in /var/backups/*; do
[[ -f "$f" ]] || continue
echo "$f" # Each filename is a single word, properly quoted
done
# RIGHT alternative: null-delimited find for complex filters
while IFS= read -r -d '' f; do
echo "$f"
done < <(find /var/backups -maxdepth 1 -type f -print0)
# WRONG: IFS manipulation without reset - affects all subsequent word splitting
IFS=":"
read -r user pass uid gid rest < /etc/passwd # OK here
echo $rest # SC2086 - but also: IFS is still ":" - side effects!
# RIGHT: local IFS scope for read
while IFS=: read -r user pass uid gid gecos home shell; do
printf '%s -> %s\n' "$user" "$home"
done < /etc/passwd
# IFS is unchanged outside the while loop
3. Globbing: when variables turn into wildcards
Globbing is the expansion of wildcards such as *, ? and [...] into matching filenames. Like word splitting, globbing only applies to unquoted strings. The problem with quoting in Bash is this: if a variable holds a value like *.txt and is used unquoted, Bash expands the value to every matching filename in the current directory, or, if no file matches, either leaves the string unchanged or throws an error (depending on the nullglob option).
A subtle globbing mistake occurs when filenames themselves contain wildcard characters, which is rare but possible. A filename such as report[2026].pdf contains a bracket glob pattern. If that filename appears unquoted in a command, Bash tries to expand it as a glob pattern. Without a matching file it stays unchanged (with nullglob unset), but the behavior is undefined and can lead to security issues. Quoting in Bash prevents globbing entirely: inside "$filename", wildcards are just ordinary characters.
4. Double quotes: when and why
Double quotes are the single most important quoting technique in Bash. They disable word splitting and globbing, but still allow parameter expansion ($variable), command substitution ($(command)) and arithmetic expansion ($((expression))). The rule of thumb: every variable substituted into a command belongs in double quotes, unless word splitting or globbing is explicitly desired. That case is rare and should always be commented.
The most common exception in quoting in Bash, where double quotes are actually unnecessary, is arithmetic contexts such as $(( variable + 1 )) and the right-hand side of [[ ... =~ regex ]], where the pattern is treated as a regex when unquoted and as a literal string when quoted. Every other context, argument lists, conditional expressions in [ ] (single brackets), here strings, case patterns, requires quotes whenever the variable might contain special characters.
#!/usr/bin/env bash
set -euo pipefail
# === DOUBLE QUOTE EXAMPLES ===
dir="/path/with spaces/data"
file="report [2026].pdf"
# WRONG: globbing and word splitting in mkdir
mkdir $dir # SC2086 - tries to create "/path/with" and "spaces/data"
# RIGHT: quoted - creates the full path with spaces
mkdir -p "$dir"
# WRONG: unquoted in [ ] - word splitting causes syntax error or wrong result
if [ -f $file ]; then # SC2086 - [ sees extra arguments ]
echo "exists"
fi
# RIGHT: use [[ ]] (no word splitting) or quote in [ ]
if [[ -f "$file" ]]; then
echo "exists"
fi
# WRONG: command substitution result unquoted
output=$(find . -name "*.log")
for f in $output; do # SC2086 - word splits the find output!
echo "$f"
done
# RIGHT: never store find output in a string - use an array
declare -a logs=()
while IFS= read -r -d '' f; do
logs+=("$f")
done < <(find . -name "*.log" -print0)
for f in "${logs[@]}"; do
echo "$f"
done
# Regex: pattern must NOT be quoted to be treated as regex
version="2.4.8-p4"
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
echo "Valid version format"
fi
# Literal string comparison: quote the pattern
search_term="hello world"
if [[ "$version" == "$search_term" ]]; then
echo "Match"
fi
5. Single quotes and the $'...' syntax
Single quotes are the strongest form of quoting in Bash: every character between single quotes is treated literally. No variable expansion, no command substitution, no backslash interpretation, not even a backslash can escape a single quote inside single quotes. That makes single quotes ideal for strings that need no variable expansion and contain special characters: regular expressions, awk programs, sed expressions.
The $'...' syntax is an extension of quoting in Bash that understands escape sequences but performs no variable expansion. $'\n' is a newline character, $'\t' a tab, $'\e' the escape character (for ANSI codes), $'\0' the null byte. This syntax is especially useful for IFS definitions (IFS=$'\n\t'), ANSI color codes (RED=$'\e[31m') and strings with control characters that would otherwise be hard to represent. The advantage over printf expansion: no subshell fork.
6. Quoting and arrays: the safe alternative
Arrays are the structural answer to the word splitting problem in quoting in Bash. Instead of separating multiple values inside a single string with spaces, an array stores each value as an isolated element. Bash's array builtin cleanly separates the concepts of "a list of values" and "a string representation": elements can contain any character, including spaces, tabs and even newlines. As long as the array is expanded correctly, always with "${array[@]}" in double quotes and @ rather than *, every element is a single, safe word.
The subtle difference in quoting in Bash between @ and * for arrays: inside double quotes, "${array[@]}" expands to one separately quoted element per item, while "${array[*]}" joins all elements into a single string using the first character of $IFS (space by default). ${array[*]} without quotes behaves like ${array[@]} without quotes, both are subject to word splitting. For loops, function arguments and command arguments, always use "${array[@]}".
#!/usr/bin/env bash
set -euo pipefail
# === ARRAY QUOTING ===
# Simulating filenames with special characters
declare -a files=(
"normal.txt"
"file with spaces.log"
"report [2026].pdf"
"backup$(date +%Y).tar.gz" # No expansion in single-quoted assignment!
)
# Correct: single-quoted to prevent expansion during assignment
declare -a files2=(
'normal.txt'
'file with spaces.log'
'report [2026].pdf'
)
# WRONG: ${array[*]} without quotes - word splits on IFS
for f in ${files2[*]}; do # SC2048/SC2068
echo "$f" # "file with spaces.log" becomes 3 loop iterations
done
# RIGHT: "${array[@]}" - each element is one word
for f in "${files2[@]}"; do
echo "$f" # "file with spaces.log" is one element
done
# WRONG: passing array as string to function
process() {
local items="$1" # Only receives first element or first word!
for i in $items; do echo "$i"; done
}
process "${files2[*]}" # SC2048 - string, not array
# RIGHT: pass array elements as separate arguments
process_all() {
for item in "$@"; do
echo "Processing: $item"
done
}
process_all "${files2[@]}" # Each element is a separate argument
# Array slicing - quoting matters here too
batch=("${files2[@]:0:2}") # First 2 elements, properly quoted
printf 'Batch element: %s\n' "${batch[@]}"
7. Quoting in subshells and command substitution
Command substitution with $(command) is a common source of quoting mistakes in Bash. The result of a command substitution is itself a string, and, if left unquoted, is subject to word splitting and globbing. files=$(find . -name "*.log") is not an array, it is a newline-separated string. Any further use without quotes leads to word splitting on the newlines. The pattern local result=$(some_command) has an additional problem: if some_command fails, the exit code is lost, because local always returns exit code 0 (ShellCheck SC2155).
Inside double quotes, further double quotes can be used within command substitutions: "$(command "argument with spaces")". This is correct and portable. The outer quotes protect the result of the command substitution from word splitting, the inner quotes form a separate quoting context for the inner command's argument. This nested quoting in Bash is confusing at first, but its syntax is unambiguously defined.
8. Here documents and here strings
Here documents (<<EOF ... EOF) are a special form of quoting in Bash. Standard here documents allow variable expansion inside the block, they behave like double-quoted strings. To prevent variable expansion, the delimiter word is quoted: <<'EOF' or <<"EOF" (both equivalent for single quoting). With <<-EOF, leading tabs (not spaces) at the start of a line are stripped, which allows indented here documents inside functions.
Here strings (<<< "$variable") are a compact alternative to echo "$variable" | command for quoting in Bash. They avoid a subshell and are therefore more portable and faster. Important: the string in a here string follows the same quoting rules as everywhere else: <<< $variable without quotes leads to word splitting. <<< "$variable" passes the full value as a single string. For multi-line strings containing variables, a here document is the cleaner choice.
9. Quoting mistakes compared
The table below summarizes the most common quoting in Bash mistakes, the corresponding ShellCheck code and the correct alternative. Use it as a quick reference during code review.
| Wrong pattern | ShellCheck | Problem | Fix |
|---|---|---|---|
| cp $file /dest | SC2086 | Word splitting on spaces | cp "$file" /dest |
| for f in $(ls) | SC2045 | Splitting + globbing on output | for f in ./* |
| local x=$(cmd) | SC2155 | Exit code of cmd lost | local x; x="$(cmd)" |
| "${array[*]}" | SC2048 | Everything one string, not an array | "${array[@]}" |
| [ $var = "x" ] | SC2086 | Syntax error when $var has a space | [[ "$var" == "x" ]] |
The most common quoting mistakes in Bash boil down to a handful of patterns: SC2086 (unquoted variables), SC2155 (local declaration combined with command substitution) and SC2048 (wrong array expansion character). Anyone running ShellCheck as a CI gate catches all three automatically. For code reviews, manual checking is still needed for regex contexts, where deliberately leaving the pattern unquoted is desired, and for arrays passed as arguments.
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Shell scripts without hidden quoting bugs?
We audit existing Bash scripts for quoting mistakes, word splitting issues and unsafe variable expansion, and replace fragile patterns with correct, tested alternatives.
ShellCheck analysis
Full ShellCheck scan with prioritization and a fix plan for every quoting warning
Quoting refactoring
Replace string-based file lists with safe arrays, fix word splitting issues for good
CI integration
ShellCheck as a mandatory gate for every shell change in GitHub Actions or GitLab CI
10. Summary
Quoting in Bash is not optional, it is the foundation of safe shell scripts. Word splitting and globbing break scripts in ways that stay invisible during development with simple test data, yet show up reliably in production data with spaces, special characters and wildcards. The three most important rules: variables in double quotes ("$variable"), arrays expanded with "${array[@]}", and command substitution kept separate from local declaration (local x; x="$(cmd)").
ShellCheck finds the most common quoting mistakes automatically. Integrating it as a CI gate ensures that new scripts never reach production with unquoted variables. Manual review is still required for contexts where deliberately leaving a value unquoted, such as regex patterns in [[...=~...]], is intentional and needs to be clearly commented. With ShellCheck, the quoting systematics described here, and consistent use of arrays instead of string hacks, quoting in Bash becomes a solvable challenge rather than an impossible one.
Quoting in Bash: the essentials at a glance
Double quotes
Always around variables: "$var". Disables word splitting and globbing, still allows parameter expansion and command substitution.
Arrays instead of strings
Store file lists as arrays, never as strings. Always "${array[@]}", never "${array[*]}" for iteration.
Command substitution
local x; x="$(cmd)" separates declaration from assignment, so exit codes are caught by set -e (SC2155).
ShellCheck
SC2086 (unquoted), SC2155 (local+command), SC2048 (array-*). Integrate as a CI gate, fix warnings or suppress them with a reason.