Linux & Bash · Part I · Module 9 · Quoting and Shell Expansion
Quoting Inside Quoting
Sooner or later the text you need to quote contains quotes of its own — a message with an apostrophe, a command sent to another shell, a pattern with both kinds in it. The usual response is to add backslashes until something works. There are three constructions that are right the first time.
Intermediate18 min readLinuxPart IFundamentalsQuotingExpansion
Part I · Module 9 · Chapter 9.4 · Quoting Inside Quoting
Chapter 9.3 deferred this twice: a single quote cannot appear inside single quotes, and a command built in a string does not work. Both are this chapter.
1. Quotes Do Not Nest — They Toggle
The mental model that causes the trouble is thinking of quotes as brackets, with an inside and an outside. They are not. A quote character switches a mode on, and the next matching one switches it off.
So this is not a string containing a quoted part:
$ echo 'it'\''s here'
it's hereIt is three pieces of text, joined because nothing separates them:
| Piece | What it is |
|---|---|
'it' | a single-quoted string — it |
\' | a backslash-escaped quote, outside any quoting — ' |
's here' | another single-quoted string — s here |
Adjacent pieces with no space between them become one word, so the command receives it's here as a single argument.
The alternative, when the text has apostrophes but no dollar signs, is simply to use the other quote:
$ echo "it's here"
it's hereSwitching quote style is almost always better than escaping. Reach for '\'' only when you are inside single quotes for a reason — because the text also contains $ or backticks that must not expand.
2. A Double Quote Inside Double Quotes
This one does escape, because double quotes have a backslash:
$ echo "she said \"no\""
she said "no"Or switch style again:
$ echo 'she said "no"'
she said "no"And when both kinds appear, you concatenate:
$ echo 'it'\''s a "test"'
it's a "test"Pieces: 'it' + \' + 's a "test"'. The double quotes needed nothing, because inside single quotes they are ordinary characters.
3. Let the Shell Tell You
Guessing is unnecessary. printf %q prints a value quoted so that pasting it back reproduces it exactly:
$ v="it's a \"test\" file.txt"
$ printf 'raw : %s\n' "$v"
raw : it's a "test" file.txt
$ printf '%%q : %q\n' "$v"
%q : it\'s\ a\ \"test\"\ file.txtIt is deliberately ugly — every character it escaped is one that would otherwise have meant something. Use it to check your reasoning, and to generate a safe form when a script has to build one.
set -x does the same for whole commands, as Chapter 9.3 showed.
4. Heredocs: Settle It Once for a Whole Block
For anything longer than a line, per-character quoting is the wrong tool. A heredoc feeds a block of text to a command, and one choice at the top decides expansion for all of it.
$ cat <<EOF
unquoted delimiter: $v and $(echo sub) and $HOME
EOF
unquoted delimiter: world and sub and /home/engineer$ cat <<"EOF"
quoted delimiter: $v and $(echo sub) and $HOME
EOF
quoted delimiter: $v and $(echo sub) and $HOMEThe delimiter decides:
| Written | Behaves like |
|---|---|
<<EOF | double quotes — variables and command output expand |
<<"EOF" or <<'EOF' | single quotes — everything literal |
Inside the body you can write both kinds of quote freely, as many as you like, with no escaping at all. That is the whole appeal.
cat <<'EOF' > check.sh
#!/bin/bash
# $1 and "quotes" and 'apostrophes' all survive untouched
if [ -z "$1" ]; then
echo "usage: $0 <name>"
exit 1
fi
echo "it's fine: $1"
EOFWriting that with echo lines would be an exercise in escaping. With a quoted delimiter there is nothing to escape.
Indenting a heredoc
<<- strips leading tabs from the body so a heredoc can be indented along with the code around it:
$ cat <<-EOF
tab-indented line
space-indented line
EOF
tab-indented line
space-indented lineThe tab-indented line came out flush; the space-indented one kept its spaces. Tabs only — spaces are never stripped, which is a reliable source of confusion in an editor that converts one to the other.
A here-string, for one line
$ IFS=: read -r a b <<< "x:y"
$ echo "a=[$a] b=[$b]"
a=[x] b=[y]<<< feeds a single string as input. It pairs naturally with read from Chapter 8.6, and unlike a pipe it does not create a subshell — so variables set by that read survive, which was exactly the trap in Chapter 8.6 §5.
5. The Two-Level Problem
The hardest case is sending a command to a second shell — bash -c, ssh, docker exec, sudo sh -c. The text is expanded twice: once by your shell as it builds the argument, and again by the remote shell as it runs it.
Here are four ways to run ls -d on a path with a space in it. All four were measured.
1 — Interpolate it. Broken.
$ d="/tmp/m9v/my dir"
$ bash -c "ls -d $d"
ls: /tmp/m9v/my: No such file or directory
ls: dir: No such file or directoryYour shell substituted the value, producing the text ls -d /tmp/m9v/my dir. The inner shell then split that at the space, because by then the quotes you were thinking of never existed.
2 — Escape the inner quotes. Works, fragile.
$ bash -c "ls -d \"$d\""
/tmp/m9v/my dirThe escaped quotes survive into the inner command line, so the inner shell sees ls -d "/tmp/my dir". Correct — but it breaks the moment the value itself contains a double quote, and it gets rapidly worse with a third level.
3 — Let the shell quote it for you. Works, generic.
$ bash -c "ls -d $(printf %q "$d")"
/tmp/m9v/my dirprintf %q produces a form that survives one round of shell parsing, whatever the value contains.
4 — Pass it as a parameter. Works, and cannot be got wrong.
$ bash -c 'ls -d "$1"' _ "$d"
/tmp/m9v/my dir6. Do Not Reach for eval
eval takes a string and runs it as a command, which makes it look like the answer to every problem in this chapter. It is almost always the wrong tool.
cmd="ls -d 'my dir'"
eval "$cmd" # worksIt works because eval re-runs the entire pipeline on the text, which is exactly why it is dangerous: every character in that string is now code. A value containing ; or $(…) is executed, not quoted. If any part of the string came from a filename, a log line, or a person, you have handed them your shell.
Almost everything people use eval for has a direct, safe replacement:
Instead of eval for | Use |
|---|---|
| Holding a command to run | An array — cmd=(ls -d "my dir"); "${cmd[@]}" |
| Building a variable name | An associative array (Chapter 19.6) |
| Passing a command to another shell | The positional-parameter form from §5 |
| Doing arithmetic on a string | $(( )) from Chapter 8.5 |
There are legitimate uses — eval "$(ssh-agent)" is one, because the output is trusted and must affect the current shell — and they are rare enough to be worth a comment each time.
7. Worked: Generating a Script
A script that writes another script is where every technique here comes together.
#!/bin/bash
set -euo pipefail
run_name="${1:?usage: make_runner.sh <run-name>}"
out_dir="$HOME/runs/$run_name"
mkdir -p "$out_dir"
# Quoted delimiter: the body is a SCRIPT, so nothing in it should expand now.
cat <<'SCRIPT' > "$out_dir/run.sh"
#!/bin/bash
set -euo pipefail
seed="${1:?usage: run.sh <seed>}"
echo "it's starting with seed $seed"
./simulator --seed "$seed" --log "run_$seed.log"
SCRIPT
# Unquoted delimiter: this one IS data, and the values must be filled in.
cat <<CONFIG > "$out_dir/config.txt"
run name : $run_name
created : $(date +%F' '%T)
directory : $out_dir
CONFIG
chmod +x "$out_dir/run.sh"
echo "wrote $out_dir/run.sh and $out_dir/config.txt"The two heredocs sit four lines apart and differ by two quote characters, and that difference is the entire point of the chapter:
<<'SCRIPT'— every$1,$seedand apostrophe is written literally into the generated file, to be expanded when that script runs.<<CONFIG—$run_nameand$(date …)are expanded now, because the config is a record of this moment.
Get them the wrong way round and run.sh is generated with its variables already blanked out, which produces a confusing failure a long way from its cause.
Practice
1 — Take the idiom apart. Run each piece and watch them join:
echo 'it'
echo \'
echo 's here'
echo 'it'\''s here'2 — Switch style instead.
echo "it's here"
echo 'she said "no"'
echo "she said \"no\""
echo 'it'\''s a "test"'3 — Ask the shell.
for s in "plain" "two words" "it's" 'a"b' 'x;y' 'p$q'; do printf '%-10s -> %q\n' "$s" "$s"; done4 — Compare the two heredoc forms.
v=world
cat <<EOF
$v and $(echo sub)
EOF
cat <<'EOF'
$v and $(echo sub)
EOF5 — Prove <<- strips tabs and not spaces. Reproduce §4's example, being careful that your editor inserts real tabs.
6 — Prove a here-string avoids the subshell.
echo "a:b" | { IFS=: read -r x y; echo "piped: x=$x"; }
IFS=: read -r x y <<< "a:b"; echo "string: x=$x"7 — Run all four two-level approaches.
mkdir -p "/tmp/nq/my dir"
d="/tmp/nq/my dir"
bash -c "ls -d $d"
bash -c "ls -d \"$d\""
bash -c "ls -d $(printf %q "$d")"
bash -c 'ls -d "$1"' _ "$d"8 — Make the heredoc mistake on purpose. Generate a script with an unquoted delimiter and look at what ends up in the file. Then fix it.
9 — Clean up. rm -rf /tmp/nq
Check Your Understanding
1. Break 'it'\''s' into its pieces and say what each contributes.
2. You need to echo don't — give two ways and say which you would prefer.
3. You are generating a script with a heredoc and its variables come out empty. What went wrong?
4. What does <<- strip, and what does it not?
5. bash -c "grep $pattern $file" fails when the pattern contains a space. Give the fix you would actually use and say why.
6. Why is eval "$cmd" risky when cmd was built from a file name?
8. Remember
- Quotes toggle a mode; they do not nest. A quoted string ends at the next matching quote, always.
'\''is close, escape, reopen — three pieces joined because nothing separates them.- Switching quote style beats escaping. Use
"it's"rather than the idiom unless the single quotes are load-bearing. - A double quote inside double quotes can be escaped with a backslash; a single quote inside single quotes cannot, by any means.
printf %qshows a safely quoted form, andset -xshows the finished command.- A heredoc settles expansion once for a whole block.
<<EOFexpands;<<'EOF'does not. - Quote the delimiter when the body is a script; leave it bare when the body is data with values filled in.
<<-strips leading tabs only, never spaces.<<<feeds one line and creates no subshell, unlike a pipe.- Crossing a shell boundary, pass values as arguments:
bash -c 'cmd "$1"' _ "$value". Nothing to quote, no levels to count. - Avoid
eval. An array holds a command safely;evalturns every character of a string into code — Chapter 19.5 measures both halves of that, including a value that executes.
Quoting is now complete as a mechanism. The next chapter returns to two of the pipeline's earlier stages — braces and the tilde — which are useful, frequently confused with file-name matching, and different from it in one decisive way.
Where this fits
Part of the Linux & Bash curriculum.
