bash · level 1

Variables & Quoting

Single vs double quotes, ${var} braces, and parameter expansion that replaces half your sed scripts.

150 XP

Variables & Quoting

Bash quoting is a tiny syntax with enormous consequences. Get it wrong and your script silently corrupts filenames, mangles user input, or runs the wrong command. Get it right and you can delete most of your sed/cut/awk glue.

The four flavours of quote

Bash has four ways to surround a string. They are not interchangeable.

Quote Variable expansion ($var) Command substitution ($(...)) Backslash escapes Whitespace preserved
'literal' no no no (except \\') yes
"weak quoted" yes yes yes (\\$, \\\``, \", \\`) yes
$'ANSI-C' no no yes (\\n, \\t, \\xNN) yes
bare word yes yes yes NO — splits on IFS

The two you'll use 95% of the time are single and double. The rule:

  • Quote everything by default. "$var" is the safe form. Bare $var re-splits on IFS, breaking any value containing whitespace.
  • Use single quotes when you don't need expansion. Cleaner, fewer surprises.
name="Sam Bailey"
echo $name      # Sam Bailey  (works by accident — IFS swallows the split)
mv $name dest   # ❌ tries to mv "Sam" "Bailey" "dest"
mv "$name" dest # ✅ one source filename

${var} braces — when and why

The braces in ${var} are mandatory exactly when bash would otherwise be confused about where the variable name ends:

name=Sam
echo "$name_log"    # ❌ looks for var "name_log"  — empty!
echo "${name}_log"  # ✅ var "name" + literal "_log"  → "Sam_log"

If your variable touches an alphanumeric, an underscore, or another expansion, brace it. ShellCheck flags the unbraced cases.

Braces are also the gateway to parameter expansion — the bash feature that replaces 80% of your inline sed.

Parameter expansion

The most underused power in shell. Inside ${...} you can transform the value mid-expansion:

file="report.2024-03-15.txt"

${file%.txt}          # report.2024-03-15        — strip shortest suffix matching .txt
${file%.*}            # report.2024-03-15        — strip shortest suffix matching .anything
${file%%.*}           # report                   — strip LONGEST suffix matching .anything
${file#report.}       # 2024-03-15.txt           — strip shortest prefix
${file##*.}           # txt                      — strip LONGEST prefix → extension
${file##*/}           # report.2024-03-15.txt    — basename
${file%/*}            # (no slash → unchanged)   — dirname (with caveats)
${file//-/_}          # report.2024_03_15.txt    — global replace - with _
${file/2024/2025}     # report.2025-03-15.txt    — replace FIRST match only
${file:0:6}           # report                   — substring offset:length
${file:7}             # 2024-03-15.txt           — substring offset, to end
${#file}              # 23                       — length in bytes

Memorise the four operators: #/## for prefixes, %/%% for suffixes, //// for replace, :offset:length for slicing. Single = first match (or shortest); double = all (or longest).

Defaults and existence checks

${var:-default}       # use 'default' if var is unset OR empty
${var-default}        # use 'default' only if var is unset (empty stays empty)
${var:=default}       # like :- but ALSO ASSIGN default to var
${var:+alternate}     # use 'alternate' if var IS set and non-empty
${var:?error msg}     # die with 'error msg' if var is unset/empty

The : variants treat empty as unset. Without :, only truly-unset triggers the default. The distinction matters when an empty string is meaningful:

LOG_LEVEL=""                          # empty == "default off"
echo "${LOG_LEVEL:-info}"             # info     — empty triggers default
echo "${LOG_LEVEL-info}"              # (empty)  — empty is set

Indirection: ${!var}

Resolve a variable named by another variable:

name="HOME"
echo "${!name}"     # /Users/sam — value of $HOME

Useful for config-driven scripts; rare otherwise. If you reach for it more than once, you want an associative array (next lesson).

ANSI-C quoting

$'...' lets you write escape sequences in a string literal — useful for IFS:

IFS=$'\n\t'        # newline + tab only — safer default than space-tab-newline
printf '%s\n' $'first\nsecond'

This is the only quoting form that interprets \n, \t, etc. inside the literal. "\n" is the two characters backslash-n.

When "$@" differs from "$*"

Two ways to refer to "all positional arguments" — one is correct, the other is almost never what you want.

forward() {
  inner_command "$@"   # ✅ each arg is its own word — preserves caller's quoting
}

forward() {
  inner_command "$*"   # ❌ all args concatenated with first char of IFS
}

The plain $@ (without quotes) re-splits on IFS, which is the same trap as bare $var. Always write "$@".

Common bugs and fixes

Unquoted glob. for f in *.log; do ... done works — the for-loop handles word splitting safely. But cp *.log dest/ fails when there are no matches: *.log stays literal, and you cp '*.log' dest/. Either set shopt -s nullglob or check first.

$( ) vs backticks. $( ) nests cleanly, doesn't need backslash gymnastics, and is preferred everywhere. Backticks (`) are vestigial; use them only when targeting a strict-POSIX shell that lacks $( ) (rare).

$IFS munged in scripts. A library function that does IFS=$'\n' and forgets to restore it changes the splitting behaviour for every command that follows. Save and restore: oldIFS=$IFS; IFS=$'\n'; ...; IFS=$oldIFS.

ASCII vs UTF-8 length. ${#var} returns bytes, not characters. ${#hello} is 5 but ${#héllo} is 6 in UTF-8 because é is two bytes. There's no built-in for character count.

bash vs zsh — quoting differences

zsh inherits most of bash's quoting, plus a few wins:

  • ${(s/sep/)var} — split a string on a separator into words, native syntax. In bash you'd use IFS games or read -a.
  • ${(j/sep/)array} — join an array with a separator. Bash needs a loop or printf.
  • ${(P)var} — indirect reference, equivalent to bash's ${!var}.
  • ${(L)var} / ${(U)var} — lowercase / uppercase the value (bash 4+ has ${var,,} / ${var^^}).
  • No-quote tolerance. zsh does NOT word-split unquoted variables by default — mv $name dest works for name="Sam Bailey". This is safer but means scripts written for zsh often break in bash. Test both.

When you write portable scripts, target bash. When you scripts for your interactive zsh, you can lean on these niceties.

ShellCheck — the safety net

If you write any non-trivial bash, shellcheck script.sh is non-negotiable. It catches:

  • Unquoted variable expansions that would break on filenames with spaces.
  • cd $foo && patterns where the cd silently fails.
  • [ ] quirks where [[ ]] would be safer.
  • $( cat file ) where < file is enough.

Treat ShellCheck warnings the way you'd treat TypeScript errors: zero tolerance.

Tools in the wild

3 tools
  • ShellCheckfree tier

    Static analyser that flags every unquoted variable. The single biggest quality win for shell scripts.

    cli
  • explainshellfree tier

    Paste any shell line; it annotates each token. Brilliant for decoding cryptic ${} expansions.

    service
  • shfmtfree tier

    Opinionated bash formatter — normalises quoting style and brace placement so reviews focus on logic, not whitespace.

    cli