Skip to main content
RunBook Academy

LinuxXXXV · Shell Scripting for SysadminsVariables

Variables and quoting - the foundation of safe shell scripts

Intermediate⏱ ~10 minbash

What you'll learn

  • Use variables safely
  • Quote every variable expansion
  • Recognise common quoting pitfalls
  • Use special variables correctly

Prerequisites

Verified against Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL 9.x · Rocky Linux 9.x · AlmaLinux 9.x · Linux kernel 6.1 LTS / 6.6 LTS · systemd 255+ · OpenSSH 8.7p1 (RHEL 9) / 9.6p1 (Ubuntu 24.04) · nftables 1.0.x · chrony 4.x · Pacemaker 2.1.x · Corosync 3.1.x · 2026-08-09

Not yet marked complete on this device.

Variables and quoting are the foundation of safe shell scripts. Almost every shell bug is a variable or quoting bug. This lesson covers the rules.

Variable basics

NAME="alice"
echo "$NAME"     # alice
echo ${NAME}     # alice (with braces)

Variables are case-sensitive. Convention: lowercase for variables, uppercase for constants and environment variables.

Special variables

VariableMeaning
$0Script name
$1, $2, …Positional parameters
$#Number of positional parameters
$@All positional parameters (each quoted separately)
$*All positional parameters (single string)
$?Exit status of last command
$$PID of current shell
$!PID of last background job
$-Current shell options

Use $@ for arguments, $? for exit status, $$ for the PID.

Quoting rules

  • Double quotes ("..."): allow variable expansion and command substitution.
  • Single quotes ('...'): literal; no expansion.
  • No quotes: subject to word splitting and globbing.
FILE="my file.txt"
cat $FILE         # WRONG: word-split, becomes "cat my file.txt"
cat "$FILE"       # CORRECT: "cat my file.txt"

Always quote variable expansions. The only exceptions are specific cases where you want word splitting (rare).

Common pitfalls

# Word splitting: a scalar cannot be iterated safely at all
LIST="a b c"
for arg in $LIST; do echo "$arg"; done      # splits on IFS - breaks on spaces
for arg in "$LIST"; do echo "$arg"; done    # ALSO WRONG - runs exactly once
read -r -a items <<<"$LIST"                 # scalar -> array, explicitly
for arg in "${items[@]}"; do echo "$arg"; done   # RIGHT

# Unquoted: glob expansion
[ -f $FILE ]      # WRONG: glob expands
[ -f "$FILE" ]    # RIGHT

# Remote execution: quoting locally does not protect the remote shell
dir="/srv/my data"
ssh user@host "rm -rf $dir"                      # WRONG: remote shell re-splits it
ssh user@host "rm -rf -- $(printf '%q' "$dir")"  # RIGHT: quoted for both shells

Three traps in that block are worth spelling out.

Quoting does not fix a for-loop. for arg in "$LIST" does not iterate the words - it iterates a single item that happens to be the whole string, so the body runs exactly once. shellcheck flags it as SC2066. The fix is not a quote, it is a different data type: build an array.

"$arr" is element zero, never the whole array. If LIST really is an array, "$LIST" expands to ${LIST[0]} and silently discards the rest. Always "${arr[@]}".

A command sent over SSH is parsed twice. The local quotes decide what string is transmitted; the remote login shell then parses that string again. Spaces, $, backticks and ; in a variable are live metacharacters on the far end. printf '%q' produces a form that survives the second parse.

Arrays

# Define
NAMES=("alice" "bob" "carol")
NAMES+=("dave")     # append

# Iterate
for name in "${NAMES[@]}"; do
    echo "$name"
done

# Access
echo "${NAMES[0]}"     # alice
echo "${NAMES[@]}"     # all elements
echo "${#NAMES[@]}"    # count

"${NAMES[@]}" (quoted with @) expands each element separately - safe.

The @ and the quotes are both load-bearing:

NAMES=("alice" "bob smith" "carol")

for n in "${NAMES[@]}"; do echo "[$n]"; done   # RIGHT: 3 iterations
# [alice] [bob smith] [carol]

for n in ${NAMES[@]}; do echo "[$n]"; done     # WRONG: unquoted, word-split
# [alice] [bob] [smith] [carol]

for n in "${NAMES[*]}"; do echo "[$n]"; done   # WRONG: joins into one word
# [alice bob smith carol]

Same rule as "$@" versus "$*". Reach for "${array[@]}" every time; the other two forms are for the rare case where you actually want splitting or a single joined string.

Default values

${VAR:-default}     # use default if VAR is unset or empty
${VAR:=default}     # set default if VAR is unset or empty
${VAR:?error}       # error if VAR is unset or empty
${VAR:+alt}         # use alt if VAR is set and non-empty

Command substitution

DATE=$(date)
COUNT=$(ls | wc -l)

Modern bash: $(command) is preferred over backticks.

Knowledge check

Knowledge check · 3 questions

  1. Q1. What is the difference between "$@" and "$*"?

  2. Q2. Unquoted variable expansion is safe in modern bash.

  3. Q3. Which of the following are valid bash quoting patterns? Select all that apply.

Passing score: 75%. Answers are checked in this browser.