Skip to content
VLSI Mentor

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ echo 'it'\''s here'
it's here

It is three pieces of text, joined because nothing separates them:

PieceWhat 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ echo "it's here"
it's here

Switching 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ echo "she said \"no\""
she said "no"

Or switch style again:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ echo 'she said "no"'
she said "no"

And when both kinds appear, you concatenate:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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.txt

It 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat <<EOF
unquoted delimiter: $v and $(echo sub) and $HOME
EOF
unquoted delimiter: world and sub and /home/engineer
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat <<"EOF"
quoted delimiter:   $v and $(echo sub) and $HOME
EOF
quoted delimiter:   $v and $(echo sub) and $HOME

The delimiter decides:

WrittenBehaves like
<<EOFdouble 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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"
EOF

Writing 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat <<-EOF
	tab-indented line
    space-indented line
	EOF
tab-indented line
    space-indented line

The 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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 directory

Your 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ bash -c "ls -d \"$d\""
/tmp/m9v/my dir

The 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ bash -c "ls -d $(printf %q "$d")"
/tmp/m9v/my dir

printf %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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ bash -c 'ls -d "$1"' _ "$d"
/tmp/m9v/my dir

6. 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cmd="ls -d 'my dir'"
eval "$cmd"          # works

It 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 forUse
Holding a command to runAn array — cmd=(ls -d "my dir"); "${cmd[@]}"
Building a variable nameAn associative array (Chapter 19.6)
Passing a command to another shellThe 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/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, $seed and apostrophe is written literally into the generated file, to be expanded when that script runs.
  • <<CONFIG — $run_name and $(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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
echo 'it'
echo \'
echo 's here'
echo 'it'\''s here'

2 — Switch style instead.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
echo "it's here"
echo 'she said "no"'
echo "she said \"no\""
echo 'it'\''s a "test"'

3 — Ask the shell.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
for s in "plain" "two words" "it's" 'a"b' 'x;y' 'p$q'; do printf '%-10s -> %q\n' "$s" "$s"; done

4 — Compare the two heredoc forms.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
v=world
cat <<EOF
$v and $(echo sub)
EOF
cat <<'EOF'
$v and $(echo sub)
EOF

5 — 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 %q shows a safely quoted form, and set -x shows the finished command.
  • A heredoc settles expansion once for a whole block. <<EOF expands; <<'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; eval turns 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.