Quoting and word splitting
The bug class behind most script incidents.
A restaurant kitchen never cooks straight from the handwritten ticket. A prep cook reads it, then lines up separate labeled bowls, one ingredient per bowl, and the chef works only from those bowls. Your shell does the same thing. Before `rm`, `cp`, or `rsync` ever starts, the shell reads your line and chops it into a list of separate words called arguments, and the program receives only that finished list. Quoting is how you decide where the chops land.
Get it wrong and the shell tears one filename into several arguments, or hands a command a list of targets you never named. A lot of the worst one-liners in operations history are not clever exploits. They are a missing pair of double quotes around a variable.
Four words, defined once. A *variable* is named storage (`file='report.txt'`). *Expansion* is the shell swapping `$file` for its value before the command runs. *Word splitting* is the shell cutting that expanded text into separate arguments wherever it finds whitespace. *Globbing* (also called pathname expansion) turns a pattern like `*.log` into the list of filenames it matches. Here is the trap: word splitting and globbing happen automatically to every unquoted expansion. That default is the whole reason this lesson exists.
How the shell chops your line
Every command line runs through a fixed assembly line, and the order matters. First the shell expands parameters (`$var`), command substitutions (`$(...)`), and arithmetic (`$((...))`). Then it word-splits the unquoted results. Then it globs any pattern characters that survived. Then it strips the quote characters themselves. Only then does it hand the finished argument list to the program. The one detail that saves you: splitting and globbing touch the results of expansion, never text sitting inside quotes. `"$file"` rides the whole way as a single, uncuttable word, whatever it holds.
You can watch the cut happen. `printf '[%s]\n'` prints each argument it receives inside brackets on its own line, so the bracket count tells you how many words the shell made.
Brackets you can count. Unquoted, the space inside the name became a cut, and one file turned into two arguments. `"$file"` came through whole. Now watch the same split in front of a command that acts on whatever it is given.
That error is the lucky outcome. `rm` complained only because nothing named `quarterly` or `report.txt` was sitting there. Had those files existed, `rm` would have deleted both, exited 0, and left nothing in the logs to say the wrong things were gone. The characters the shell cuts on live in a variable called IFS (Internal Field Separator, the shell's own list of separator characters), which defaults to space, tab, and newline. A Unix filename can hold any byte except `/` and the all-zeros NUL byte, spaces and newlines included. So no clever IFS value makes unquoted expansion safe against every name a directory can hold. Double-quoting is the general fix.
That exit code is the part most people miss. A split filename that matches nothing fails loudly, the way you just saw. A split filename that matches real files does its damage in silence and returns success. Never read a zero exit code as proof that your quoting was correct. A clean exit is exactly what the dangerous case produces.
Three kinds of quotes, and the one everyone forgets
The rules are short enough to keep in your head. Double quotes allow expansion but switch off splitting and globbing, so `"$file"` is one argument, always. Single quotes are fully literal, with no expansion at all, which makes them right for regular expressions and awk programs where you want `$` and `*` handed to that tool untouched. Unquoted is the only mode where the shell splits, and inside a script that is almost never what you meant.
One expansion is worth memorizing on its own. `"$@"`, with the double quotes, expands to every argument your script was given, each preserved as its own separate word. It is the way, effectively the only correct way, to forward your script's arguments to another command. Its lookalikes all corrupt anything containing a space: `$*` and a bare unquoted `$@` re-split, and `"$*"` mashes every argument into one.
#!/usr/bin/env bashecho '--- "$@" keeps each argument whole ---'printf '[%s]\n' "$@"echo '--- $* re-splits on whitespace ---'printf '[%s]\n' $*
Build argument lists with arrays, not strings
Remember the labeled bowls from the kitchen. An array is that same idea in bash: one slot per item, and the shell never re-chops whatever is already sitting in a slot. When the flags you pass depend on something decided at runtime, an optional `--dry-run`, or a directory whose name has a space in it, build them in an array, which is bash's real list type. Append with `name+=(...)`, expand with `"${name[@]}"`, and every element lands as exactly one argument regardless of its contents. The shortcut people reach for, gluing flags into one space-separated string and expanding it unquoted, quietly puts splitting and globbing back on data you only half control.
#!/usr/bin/env bashset -euo pipefailopts=(--archive --verbose --compress --delete)if [[ -n "${DRY_RUN:-}" ]]; thenopts+=(--dry-run)fisrc='/srv/app data' # the space is deliberatersync "${opts[@]}" "$src/" [email protected]:/srv/backups/app/
You can confirm the array is right before you let it drive a real transfer. Replace `rsync` with `printf` and print the exact argument list the script would build, then count the brackets.
A filename that pretends to be a flag
A command's option parser works like a doorman reading a guest list. Anyone whose name starts with a dash, he assumes, is staff carrying instructions, so he acts on them instead of showing them to a seat. Quoting guarantees a value arrives as a single argument. It does nothing to stop that argument from posing as an option. Any directory other people can write to, an upload folder, `/tmp`, a shared build workspace, can hold a file named literally `-rf` or `--delete`. When a glob expands, that name falls into your command's argument list, the parser sees the leading dash, and it treats the file as a flag. This is a genuine attack class, not a party trick. Filenames like `--checkpoint=1` paired with `--checkpoint-action=exec=sh` have turned a routine `tar` backup into code execution, and a cron job (a task the system runs automatically on a schedule) that sweeps a shared directory is a favorite place to leave them.
Someone dropped a file named `-rf`. Now you clean the directory the way you have a thousand times before.
The glob sorted `-rf` to the front, `rm` read it as its recursive-and-force flag, and `archive` and `report.pdf` were gone. The planted file, counted as a flag rather than a target, came through untouched. Two fixes exist, and one of them belongs on every destructive command like this. Put `--` after the last real option. It tells the command that everything after it is a filename, so `rm -- *` treats `-rf` as the operand it actually is. That marker is part of the POSIX (Portable Operating System Interface, the standard that defines how Unix tools behave) argument conventions, and nearly every tool respects it, `grep -r -- "$pattern" .` and `chown root: -- "$dir"` included. The other fix is to anchor the glob to a path, so no expanded name can begin with a dash: `rm ./*` yields `./-rf`, which can only be read as a filename. The path form even covers the rare tool that ignores `--`.
When you actually want splitting
Sometimes chopping a string apart is the actual goal. Slicing a loaf into even portions is honest work when you reach for a bread knife. Doing it by tearing at the loaf with both hands is not. So when you have a configuration line with several fields, or a list you need to walk one item at a time, cut it deliberately with a tool built for the job, instead of leaning on the unquoted default and hoping. `read -ra parts <<< "$line"` splits one string into an array, and you can set IFS for that single command to choose the delimiter. `mapfile -t lines < file` reads a whole file into an array, one line per element, with no splitting or globbing at all. For filenames, drop the text processing entirely: `find . -type f -print0` ends each name with a NUL byte, the one byte a name can never contain, and `xargs -0 rm --` reads them the same way, so spaces, newlines, and glob characters all survive the trip.
Standing defaults for anything you ship: double-quote every expansion, `"$var"`, `"$(cmd)"`, `"${arr[@]}"`, without stopping to debate whether a value 'could' hold a space; write `--` before the file operands of destructive commands; and prefer `printf '%s\n'` to `echo` for arbitrary data, because `echo` mishandles values that start with `-` or contain backslashes. You do not have to police this by eye. ShellCheck (a static analyzer for shell scripts) flags every unquoted expansion as SC2086, which turns the habit into something a machine enforces, and a later lesson wires that check into your CI (continuous integration) pipeline so it runs on every change.
Point it at your scripts today and clear what it finds: `find . -name '*.sh' -exec shellcheck {} +`. Every SC2086 you fix is one more filename that cannot quietly become an argument you never typed.