Skip to content

Quoting and Expansion

Bash rewrites every command line before running it: it expands braces, tildes, variables, command substitutions and arithmetic, splits the result into words, and replaces globs with file names. Quoting controls which of those steps happen, and missing quotes are the most common source of shell script bugs.

Track: Core · Interview weight: Med


Must-Know Facts

Fact Value Verify with
Single quotes Everything literal; no expansion at all echo '$HOME'
Double quotes Expand $var, $(cmd), $((expr)); prevent word splitting and globbing echo "$HOME"
Backslash Escapes one character echo \$HOME
Expansion order Brace, tilde, parameter/command/arithmetic, word splitting, pathname (glob), quote removal set -x
Globs Expanded by the shell, not by the command echo *
Unmatched glob Left as literal text unless nullglob is set echo *.none
Word splitting Unquoted expansions split on IFS (space, tab, newline) set -x
Always quote "$var", "$(cmd)", "$@" bash -x script.sh
End of options -- stops option parsing, for names starting with - rm -- -rf
Command substitution $(cmd) nests cleanly; backticks are the legacy form echo "$(date)"

Quote Types

name=world
echo 'single: $name $(date +%Y) \n'
echo "double: $name $(date +%Y) \$HOME"
echo unquoted:   $name    spaced

Output:

single: $name $(date +%Y) \n
double: world 2026 $HOME
unquoted: world spaced

Unquoted, the extra spaces disappear: word splitting turns the line into separate arguments, and echo joins them with one space.


Word Splitting and Filenames with Spaces

touch "report 2026.txt" notes.md
for f in $(ls); do echo "[$f]"; done
for f in *; do echo "[$f]"; done
file="report 2026.txt"
ls -l $file
ls -l "$file"

Output:

[notes.md]
[report]
[2026.txt]
[notes.md]
[report 2026.txt]
ls: cannot access 'report': No such file or directory
ls: cannot access '2026.txt': No such file or directory
-rw-rw-r-- 1 laborant laborant 0 Sep 16 13:42 report 2026.txt

$(ls) is split on spaces; the glob * produces one word per file. set -x before the command shows the split arguments (+ ls -l report 2026.txt).

Never loop over the output of ls

for f in $(ls) breaks on spaces and on glob characters in names. Use for f in *, or find ... -print0 | xargs -0 for recursive work.

Arrays keep elements intact only with "${arr[@]}":

args=("one two" three)
printf '[%s]\n' "${args[@]}"
printf '[%s]\n' ${args[@]}
printf '[%s]\n' "${args[*]}"

Output:

[one two]
[three]
[one]
[two]
[three]
[one two three]

The same rule applies to script arguments: "$@" passes them unchanged, $* and unquoted $@ re-split them.


Globs

echo *.md *.log
shopt -s nullglob; echo "nullglob: [" *.log "]"; shopt -u nullglob

Output:

notes.md *.log
nullglob: [ ]
Pattern Matches
* Any string, excluding a leading .
? One character
[abc], [0-9], [!a] One character from a set, or not in it
** Any depth, with shopt -s globstar

A file whose name starts with - becomes an option after glob expansion:

touch ./-l
ls *
rm ./-l

Output:

-rw-rw-r-- 1 laborant laborant    0 Sep 16 13:42 notes.md
-rw-rw-r-- 1 laborant laborant    0 Sep 16 13:42 notes.md.bak
-rw-rw-r-- 1 laborant laborant    0 Sep 16 13:42 report 2026.txt

proj:
total 12
# ... (trimmed)

ls * received -l as its first argument and switched to long format. ls -- * or ls ./* avoids it; rm -- -rf and rm ./-rf remove a file literally named -rf.


Brace, Tilde and Arithmetic Expansion

Brace expansion happens first and does not look at the filesystem.

echo {a,b,c}.conf
echo web{01..03}
echo {01..10..3}
mkdir -p proj/{src,docs,tests}; ls proj
cp notes.md{,.bak}
echo ~ ~root
echo "~ is not expanded in quotes"
echo "$(( 7 / 2 )) $(( 7 % 2 )) $(( 2 ** 10 ))"

Output:

a.conf b.conf c.conf
web01 web02 web03
01 04 07 10
docs
src
tests
/home/laborant /root
~ is not expanded in quotes
3 1 1024

Bash arithmetic is integer only; use bc or awk for decimals.


Parameter Expansion

path=/var/log/nginx/access.log.1
echo "${path##*/}"
echo "${path%/*}"
echo "${path%.*}"
echo "${path/log/LOG}"
echo "${path//log/LOG}"
echo "${#path}"
unset port; echo "${port:-8080} [$port]"
echo "${port:=9090} [$port]"
echo "${missing:?must be set}"

Output:

access.log.1
/var/log/nginx
/var/log/nginx/access.log
/var/LOG/nginx/access.log.1
/var/LOG/nginx/access.LOG.1
27
8080 []
9090 [9090]
/tmp/q.sh: line 39: missing: must be set
Form Result
${v##*/} / ${v%/*} Basename / dirname without a subprocess
${v%.*} / ${v##*.} Strip / keep the last extension
${v/a/b} / ${v//a/b} Replace first / all
${v:-x} x if unset or empty; v unchanged
${v:=x} Same, and assigns x to v
${v:?msg} Exits a script with msg if unset or empty
${#v} Length

Fail fast on missing configuration

: "${DB_HOST:?DB_HOST is required}" at the top of a script stops it before it runs with an empty host.


Common Errors

ls: cannot access 'report': No such file or directory

Cause: an unquoted variable holding a name with spaces was split into several arguments.

Fix: quote it: ls -l "$file".


Interview Checkpoints

L1: What is the difference between single and double quotes?

Say first: single quotes keep everything literal; double quotes still expand variables, command substitution and arithmetic, but stop word splitting and globbing.

Proof: echo '$HOME' versus echo "$HOME".

Follow-up: How do you put a single quote inside a single-quoted string? ('it'\''s'.)

L1: Who expands *.log, the shell or ls?

Say first: the shell; ls receives a list of file names, or the literal *.log when nothing matches.

Proof: echo *.log; set -x; ls *.log.

Follow-up: Why does find . -name *.log sometimes fail? (The shell expands the unquoted pattern first.)

L2: Rename every .txt file in a directory to .bak, including names with spaces.

Say first: loop over a glob and use parameter expansion, with quotes.

Proof:

for f in *.txt; do mv -- "$f" "${f%.txt}.bak"; done

Follow-up: What happens if no .txt file exists, and how does nullglob change that?

L2: Create directories app/dev, app/staging and app/prod in one command.

Say first: brace expansion.

Proof: mkdir -p app/{dev,staging,prod}

Follow-up: Why does mkdir -p "app/{dev,prod}" create one oddly named directory?

L2: Delete a file named -rf.

Say first: end option parsing or give a path.

Proof: rm -- -rf or rm ./-rf

Follow-up: What did rm -rf do when typed without --? (Nothing: no operands.)

L3: A backup script works in testing but copies nothing for some customers.

Say first: look for unquoted variables and names with spaces.

Proof: bash -x backup.sh shows cp /data/Acme Corp /backup split into two arguments.

Follow-up: Which linter catches this before it ships? (shellcheck, warning SC2086.)

L4: In what order does bash expand a command line, and why does it matter?

Say first: brace, tilde, then parameter, command and arithmetic expansion left to right, then word splitting, pathname expansion and quote removal.

Proof: x='*'; echo $x lists files because the glob in the variable is expanded after substitution; echo "$x" prints *.

Don't say: "Variables are expanded last."


Captured on Rocky Linux 10.2 and Ubuntu 24.04.4 LTS (iximiuz Labs microVMs, kernel 6.1.167), 2026-09.