Your bash script works fine in testing. Then someone names a file “my file.txt” and everything breaks. The loop doesn’t iterate properly. The file operation runs twice. You’re tearing your hair out wondering why.
Word splitting is the culprit. It’s bash’s default behavior when you don’t quote variables. I’ve seen production scripts fail because of this. Here’s the thing: it’s avoidable with one habit: always quote your variables.
What Is Word Splitting?
When you use an unquoted variable, bash splits it on whitespace (and other characters defined in IFS — the Internal Field Separator):
#!/bin/bash
file="my file.txt"echo $file # Word splitting happens here$ bash word_split.shmy file.txtWait, that looks fine. Here’s where it breaks:
#!/bin/bash
file="my file.txt"ls -l $file # Word splitting happens$ bash word_split_broken.shls: cannot access 'my': No such file or directoryls: cannot access 'file.txt': No such file or directoryBash split the variable on the space and passed two arguments to ls: my and file.txt. But the actual file is my file.txt.
The fix is quoting:
#!/bin/bash
file="my file.txt"ls -l "$file" # Quoted, no splittingNow it works.
Why This Happens: IFS
The IFS variable defines word splitting characters:
$ echo "$IFS" | od -c \t \nThat’s space, tab, newline. By default, unquoted variables split on any of these.
You can change IFS:
#!/bin/bash
IFS=':'result="one:two:three"echo $result # Still splits, but only on colonsBut even then, the default behavior catches everyone. The fix is simple: quote.
The Loop Disaster
Unquoted variables in loops are particularly dangerous:
#!/bin/bash
files="file1.txt file2.txt file3.txt"
# WRONGfor file in $files; do echo "Processing: $file"done$ bash loop_broken.shProcessing: file1.txtProcessing: file2.txtProcessing: file3.txtOkay, that worked. Now with spaces:
#!/bin/bash
files="my file1.txt my file2.txt my file3.txt"
# WRONGfor file in $files; do echo "Processing: $file"done$ bash loop_broken2.shProcessing: myProcessing: file1.txtProcessing: myProcessing: file2.txtProcessing: myProcessing: file3.txtChaos. The loop iterates 6 times instead of 3.
The solution is to use arrays:
#!/bin/bash
files=("my file1.txt" "my file2.txt" "my file3.txt")
# RIGHTfor file in "${files[@]}"; do echo "Processing: $file"done$ bash loop_fixed.shProcessing: my file1.txtProcessing: my file2.txtProcessing: my file3.txtOr if you’re reading from a command:
#!/bin/bash
# Using mapfile to read lines safelymapfile -t files < <(find . -name "*.txt")
for file in "${files[@]}"; do echo "Processing: $file"doneThis reads into an array, one line per element, with newlines stripped.
The Glob Expansion Problem
Word splitting and glob expansion are different, but related:
#!/bin/bash
pattern="*.txt"
# WRONGfor file in $pattern; do echo "File: $file"doneIf you have files a.txt and b.txt, this echoes:
File: a.txtFile: b.txtBut if no .txt files exist:
File: *.txtYou get the literal string. With quoting:
#!/bin/bash
pattern="*.txt"
# RIGHTfor file in $pattern; do # Unquoted here allows glob expansion echo "File: $file"doneIf no files match, $pattern is untouched and glob expansion fails (which is what you want). It’s subtle, but you need glob expansion for * to work, so don’t quote the glob. Just quote everything else.
The Rule: Always Quote Variables
Here’s the pattern:
#!/bin/bash
myvar="some value"
# ALWAYS quote when expanding variables:echo "$myvar"ls -l "$myvar"grep "something" "$myvar"if [ "$myvar" = "test" ]; then echo "Matched"fi
# Exception: glob patterns (let them expand)for file in *.txt; do echo "$file"done
# Exception: word splitting is intentionalIFS=':' read -r -a parts <<< "$PATH"for part in "${parts[@]}"; do echo "$part"doneRule of thumb: Quote every variable expansion unless you explicitly want word splitting.
Real-World Example: File Processing Script
#!/bin/bashset -euo pipefail
directory="$1"
# WRONG - breaks with spaces and special charsfor file in $directory/*; do echo "Processing: $file"done
# RIGHT - quoted correctlyfor file in "$directory"/*; do echo "Processing: $file"done
# Even better - explicit arraymapfile -t files < <(find "$directory" -type f)for file in "${files[@]}"; do echo "Processing: $file"doneCheck for Unquoted Variables
Find potential word-splitting issues:
$ grep -n '\$[A-Za-z_][A-Za-z0-9_]*[^"]' your_script.sh | head -10This finds unquoted variable expansions. Review each one. Quote if it’s not an intentional glob.
The One Thing
Next time you write a loop:
# NEVERfor item in $variable; do ...done
# ALWAYSfor item in "$variable"; do ...done
# Or use arraysfor item in "${array[@]}"; do ...doneThat habit will save you from the 3 AM “why is the script running things twice” call. Quote variables. Always.