Quoting, word splitting and globbing

The expansion order behind most script bugs, and the habits that prevent them.

Beginner25 min · lesson 7 of 15
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (1 files, 1 KB): bo-quoting.tar.gz. Unpack it with tar -xzf bo-quoting.tar.gz, which creates bo-quoting/. SHA-256: c0e81fdad6191b5a391885733fbf04d5e5ff092f794890f8a8cb8cfc9a2b09dd

Earlier lessons asked you to put double quotes round every expansion, to write -- before file names, and to pass file names NUL-separated. This lesson explains why those habits work. You will see the steps Bash applies to a command line before the command starts, in the order the Bash manual defines, and watch each step change the arguments a command receives. Then you will pass a script's arguments on without damaging them, stop a file named -rf from turning into an option, move file names between commands safely, and split a string on purpose when splitting is what you want. The examples run in ~/bash-ops/quoting.

What happens to a line before the command runs

A command never sees the line you typed. Bash first turns the line into a list of words, and the program receives that list as its arguments. To see the list, use a tiny script that prints how many arguments it got and each one in brackets:

args.sh
#!/usr/bin/env bash
# args.sh: print how many arguments arrived, then each one in brackets.
printf '%s argument(s)\n' "$#"
for arg in "$@"; do
printf '[%s]\n' "$arg"
done
deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ chmod +x args.sh ./args.sh "q3 report.log" web01
2 argument(s) [q3 report.log] [web01]

Two arguments. The double quotes kept q3 report.log together as one word, and the quote characters themselves were removed before the script started: args.sh never saw them. Create two files for the next steps (ls shows the spaced name in single quotes so you can see where it starts and ends; the quotes are not part of the name):

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ touch app.log 'q3 report.log' ls
app.log args.sh 'q3 report.log'

Between reading the line and starting the command, Bash applies its expansions, always in the same order:

The order of expansions (Bash manual, Shell Expansions)
1Brace expansion
app-{web,db}.conf becomes two words; it works on the text alone
2Tilde, parameter, arithmetic, command substitution
~, $name, $((n + 1)) and $(cmd), left to right; process substitution happens here too
3Word splitting
the unquoted results of step 2 are cut at spaces, tabs and newlines (the characters in IFS)
4Pathname expansion
unquoted *, ? and [...] in any word are replaced by the matching file names
5Quote removal
the quote characters you typed are removed; the command receives the words
Inside double quotes only the $ expansions of step 2 happen, and their results are neither split nor globbed. Inside single quotes nothing happens.

Brace expansion comes first and works on text alone, before any variable has been read. That is why a range cannot take its end from a variable:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ ./args.sh app-{web,db}.conf n=3 ./args.sh {1..$n}
2 argument(s) [app-web.conf] [app-db.conf] 1 argument(s) [{1..3}]

app-{web,db}.conf became two words. {1..$n} did not become 1 2 3: when brace expansion ran, $n was still the two characters $n, so there was no range to expand. Step 2 replaced $n with 3 afterwards, too late. Brace ranges only work with values written out (numbers, or single letters as in {a..e}). Tilde expansion, in step 2, happens only when ~ is unquoted at the start of a word:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ ./args.sh ~/notes "~/notes"
2 argument(s) [/home/deploy/notes] [~/notes]

In quotes, ~/notes stayed literal. Write "$HOME/notes" when the path needs quotes. Word splitting, step 3, cuts only the unquoted results of step 2, never text you typed:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ name="q3 report.log" ./args.sh $name ./args.sh "$name"
2 argument(s) [q3] [report.log] 1 argument(s) [q3 report.log]

That is the whole reason for every "$var" in this course. Splitting has a second effect that is easy to miss: an unquoted expansion whose value is empty produces no word at all.

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ empty= ./args.sh $empty end ./args.sh "$empty" end
1 argument(s) [end] 2 argument(s) [] [end]

Unquoted, $empty vanished and end became the first argument, so a command that expects a file name first reads the wrong thing as its file. This is how [ -n $path ] answered "yes" for an empty path in "Exit status and tests". Quoted, the empty value stays an argument, an empty one. Pathname expansion, step 4, runs on the words that splitting produced, so a pattern that arrives inside a value is expanded as if you had typed it:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ pattern='*.log' ./args.sh $pattern ./args.sh "$pattern"
2 argument(s) [app.log] [q3 report.log] 1 argument(s) [*.log]

The names that pathname expansion produces are not split again, so q3 report.log arrived whole even here. Quoted, the pattern was passed on as text, which is what find -name '*.log' needs. The results of an expansion are also never read again as shell syntax. Quote characters inside a value are ordinary characters, and so is a ;:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ value='"q3 report.log"' ./args.sh $value line='hello; echo INJECTED' ./args.sh $line
2 argument(s) ["q3] [report.log"] 3 argument(s) [hello;] [echo] [INJECTED]

Quotes stored inside a variable protect nothing; only the quotes you type around the expansion do. And the ; in line did not end a command. Splitting can only add arguments, never run a second command. Extra arguments are harmful enough (the -rf file below is exactly that), but running text as a command needs something that parses it again, such as eval or bash -c, which is why neither may ever receive text from outside the script.

A few places are not argument lists and are not split: the right-hand side of an assignment, the word after case, and everything inside [[ ]].

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ name="q3 report.log" copy=$name ./args.sh "$copy"
1 argument(s) [q3 report.log]

copy=$name kept all three spaces. Quotes in those places do no harm, though, and one habit ("quote every expansion") is easier to keep than a list of exceptions.

"$@" and $*: passing arguments on

Inside a script, $@ and $* both stand for all of its arguments. They differ when quoted, and only one form passes arguments on unchanged. set -- sets the arguments at the prompt the way a script receives them; the second one here is a lone *:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ set -- 'q3 report.log' '*' ./args.sh "$@" ./args.sh $* ./args.sh "$*"
2 argument(s) [q3 report.log] [*] 5 argument(s) [q3] [report.log] [app.log] [args.sh] [q3 report.log] 1 argument(s) [q3 report.log *]

"$@" produced the two original arguments, exactly. Unquoted $* (unquoted $@ behaves the same) went through splitting and pathname expansion: q3 report.log fell apart and * became every name in the directory. "$*" joined all the arguments into one word, separated by spaces (strictly, by the first character of IFS). Use "$@" whenever you pass arguments on to another command, and "$*" only to put them into a message, as in printf 'called with: %s\n' "$*".

A file name that looks like an option

The Linux essentials lesson "Quoting, wildcards, history and aliases" showed this problem with a file named -l: once the glob has expanded, ls * reads the name as an option, and -- or ./* fixes it. Quoting cannot help, because the name already arrives as exactly one argument; the trouble is that it starts with -. In a script that deletes files, the same mistake destroys data. To create such a file for the demo, put a path in front of the name (drop/-rf), because touch -rf would read it as options too. Here drop stands for a directory other people can write to, such as an upload area; drop2 and drop3 are copies for trying the fixes:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ mkdir -p drop/archive touch drop/archive/app.tar drop/report.pdf drop/-rf cp -a drop drop2 cp -a drop drop3 ls -A drop
-rf archive report.pdf

Someone has left a file named -rf there, and a routine cleanup runs rm *. Do experiments like this only in a scratch directory. The parentheses run the commands in a subshell, a copy of your shell, so the cd inside ends with it and your shell stays in ~/bash-ops/quoting for the next step. cd drop || exit 1 leaves the subshell if the directory is missing, so rm * can never run in the wrong place:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ ( cd drop || exit 1 printf '[%s]\n' * rm * echo "status: $?" ls -A )
[-rf] [archive] [report.pdf] status: 0 -rf

The printf line shows the words rm received: -rf archive report.pdf. rm took -rf as its recursive and force options, deleted the archive directory with everything in it and report.pdf, and exited 0. The only survivor is -rf itself, which rm never treated as a file. Its position is not what makes this work: GNU rm, like most GNU tools, accepts options anywhere among its arguments. There are two fixes. The first is --, which marks the end of the options: every argument after it is an operand (a file name here), whatever it looks like. This is a POSIX convention and nearly every command follows it:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ ( cd drop2 || exit 1 rm -- * echo "status: $?" ls -A )
rm: cannot remove 'archive': Is a directory status: 1 archive

This time rm removed -rf and report.pdf as files and refused archive, because deleting a directory needs -r and nobody asked for it. That error and status 1 are the correct outcome. The other fix from the Linux lesson, rm ./*, gives the same result in drop3, because every word then starts with ./. That form also protects commands for which -- is not enough: find reads any argument that starts with - as the start of its search expression, even after --, so a starting directory named -old must be given as ./-old (the lesson "Command-line options" uses this). Put -- before the file operands of every command whose paths come from a variable or a glob, as in rm -- "$file" or cp -- "$src" "$dst". A path that starts with a directory, such as "$dir"/* with dir=/srv/uploads, is safe for the same reason as ./*.

Lists of file names: xargs and NUL separators

When file names travel from one command to another as text, the separator decides whether they arrive intact. "Loops and reading input safely" read find -print0 output with read -d ''. The other common consumer is xargs, which reads items from standard input and runs a command with them as arguments. By default it splits its input at blanks and newlines, and it treats quote characters as quoting. Three names test that:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ mkdir names touch "names/it's.log" 'names/a b.log' names/c.log
$ find names -name '*.log' | sort | xargs wc -l echo "status: $?"
xargs: unmatched single quote; by default quotes are special to xargs unless you use the -0 option wc: names/a: No such file or directory wc: b.log: No such file or directory 0 names/c.log 0 total status: 123

names/a b.log became names/a and b.log, neither of which exists, and when xargs reached the apostrophe in it's.log it stopped reading with unmatched single quote. Only c.log was counted, and status 123 is how GNU xargs reports that a command it ran failed. find -print0 and xargs -0 agree on NUL as the separator, the one byte a file name cannot contain:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ find names -name '*.log' -print0 | sort -z | xargs -0 wc -l
0 names/a b.log 0 names/c.log 0 names/it's.log 0 total

When you do not need a pipe at all, find can run the command itself. With -exec COMMAND {} + it puts as many names as fit where {} is, with no text in between to parse:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ find names -name '*.log' -exec wc -l {} +
0 names/c.log 0 names/it's.log 0 names/a b.log 0 total

The order is the order in which find met the names in the directory. Every name begins with names/, the starting point you gave find, so none can start with a dash; with find . they begin with ./, which is the ./* fix again.

When you want splitting, ask for it

Sometimes a string really is a list: PATH holds directories separated by colons, and a setting may hold several host names separated by spaces. Split it on purpose with read -a, which stores the words of one line in an array (Bash-only; the next lesson covers arrays), and set IFS for that one command to choose the separator, as "Variables, quotes and command output" showed for NAME=value command:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ IFS=: read -ra dirs <<< "$PATH" printf "[%s]\n" "${dirs[@]}"
[/usr/local/sbin] [/usr/local/bin] [/usr/sbin] [/usr/bin] [/sbin] [/bin] [/usr/games] [/usr/local/games] [/snap/bin]

Each directory of PATH became one element. read splits at the IFS characters (and trims them from the ends of the line), but it never expands * or any other pattern, and that is the difference from an unquoted variable:

deploy@web01:~/bash-ops/quoting · Ubuntu 26.04 LTS
$ hosts='web01 db01 *' for h in $hosts; do printf '[%s]\n' "$h"; done read -ra list <<< "$hosts" printf '[%s]\n' "${list[@]}"
[web01] [db01] [app.log] [args.sh] [drop] [drop2] [drop3] [names] [q3 report.log] [web01] [db01] [*]

The unquoted $hosts split correctly and then its * expanded into every name in the directory, so the loop ran nine times for three hosts. read -ra produced exactly the three words, * included. When you want a string split, use read -ra for words and mapfile for lines, and keep file names NUL-separated. Leaving a variable unquoted gives you splitting and pathname expansion together, and you almost never want both.

Try this

In a new directory, make a directory spool holding a.tmp, b c.tmp, -v.tmp and keep.txt (create the dash name as spool/-v.tmp). Write purge-tmp.sh, which deletes the .tmp files in the directory named by its argument, first the obvious way: cd "$1" || exit 2, then rm *.tmp. Make it executable and check:

1. ./purge-tmp.sh spool then echo "status: $?": expect rm: invalid option -- '.', GNU rm's hint Try 'rm ./-v.tmp' to remove the file '-v.tmp'., status 1 and all four files still there. Work out from the glob which argument rm read as options.

2. Fix it with rm -- *.tmp (and cd -- "$1" for a directory name that starts with a dash). Expect status 0 and only keep.txt left. ShellCheck, from the lesson "Check your script", reports the first version as SC2035 and suggests the same two fixes.

3. Run it once more: expect rm: cannot remove '*.tmp': No such file or directory and status 1. That is the unmatched glob from "Loops and reading input safely"; decide whether an empty directory should be an error for this job, and guard the rm if not.

Takeaway

Quote every expansion so a value stays one argument, and put -- or a ./ prefix before file operands so no argument can become an option. When you want a string split, split it on purpose with read -ra or NUL separators, never by leaving a variable unquoted.

Quick check
01A script sets days=7 and runs for d in {1..$days}; do ...; done. The loop body runs once, with d set to {1..7}. Why?
Incorrect — A range has no spaces ({1..3} expanded in the lesson). With spaces the braces would not be treated as a range at all.
Incorrect — Quoting changes nothing here: brace expansion still runs before $days is expanded, so it sees text, not a number.
Correct — Brace expansion is step 1 and works on text alone; $days became 7 only in step 2, when the braces were already done.
Incorrect — Brace expansion applies to every word, including a for list: for d in {1..7} gives seven passes.
02An upload directory holds files named -rf and a.txt and a subdirectory logs. A cleanup job runs rm -f "$dir"/* with dir=/srv/uploads. Can the -rf file make rm delete logs?
Incorrect — Sorting is not the issue, and here the word is /srv/uploads/-rf, which does not start with a dash.
Incorrect — The glob result is not split; pathname expansion produces whole names, each prefixed with the directory.
Incorrect — -f only suppresses prompts and errors about missing files; rm has no way to tell where an argument came from.
Correct — Like ./*, a glob that starts with a directory cannot produce an argument that begins with -.
03A script reads a host name from a file into host and runs ping -c1 $host. The file has been edited to contain web01; reboot. What does the shell do?
Incorrect — The result of an expansion is never parsed again, so the ; in the value is not a command separator.
Correct — Word splitting turned the value into two words. That is argument injection, which is harmful enough, but no second command runs.
Incorrect — Syntax is checked on the line you typed, before expansion. The value is only data, so there is nothing to reject.
Incorrect — Quote removal only removes quote characters that were typed in the line, never characters that came out of a variable.

Related