Most surprising Bash bugs are one of two things going wrong: an expansion that was not quoted when it should have been, or a quoted expansion that was quoted when it should not have been. Both are mistakes about context, because the same characters mean different things depending on whether the result is being split into words, globbed, or treated as a single value. This reference is organised around that idea rather than around syntax.

Bash performs four expansions in a fixed order: tilde, parameter, command, arithmetic, then word splitting, then pathname expansion. Quoting suppresses word splitting and globbing, which is why "$var" and $var differ. The default rule that survives everything else is quote every expansion unless you specifically want splitting, with two exceptions worth memorising: "$@" for positional parameters and $(printf '%sn' ...) for arrays you need split.

Key takeaways:

  • Unquoted expansions are subject to word splitting and globbing. That is the source of most “why did my filename become seven arguments” bugs.
  • ${var:-default} and ${var-default} differ on null versus unset. Knowing which one you want is half of using them correctly.
  • "$@" and "$*" are not interchangeable. "$@" preserves argument boundaries; "$*" does not.
  • Inside (( )) and $(()), a leading zero means octal. $((08))
  • [[ ]] does not word split or glob. [ ] does. Use [[ ]].
  • Run ShellCheck. Most of this is machine-checkable, and the tool is right far more often than you are.

The four expansion contexts

Bash expands, in order: tilde, parameter, command, arithmetic. After that come word splitting and pathname expansion. The last two are the ones quoting controls, and they are why quoting matters.

ExpansionSyntax
Tilde~, ~/dir, ~user
Parameter$name, ${name}, ${name:-x}
Command$(command)
Arithmetic$(( expression ))
Process substitution>(command), <(command)
Use $( ) rather than backticks. It nests, it reads correctly, and it does not have the backslash surprises.

Quoting

FormBehaviour
'literal'Nothing is expanded. No variables, no globs, no escapes. The correct choice for any pattern or string you do not want interpreted
"double"Variables and command substitution expand. Globs do not. A backslash keeps its meaning only before $, `, ", and newline
$'ansi-c'Escapes are interpreted, but variables are not expanded. The readable way to write control characters
$"locale"Like double quotes, with translation applied. Rarely needed
name='Ada Lovelace'
echo '$name'          # literally  $name
echo "$name"          # Ada Lovelace
printf '%sn' "$name"

# ANSI-C quoting: escapes work, variables do not expand
printf '%sn' $'linetonenlinettwo'
printf '%sn' $'x41x42x43'      # ABC
printf '%sn' $'u00e9'            # é

The $' ' form is worth preferring to echo -e. echo behaviour varies between shells and can be disabled by a built-in flag, whereas $'...' and printf '%sn' do exactly what they say.

Word splitting and globbing

After expansion, an unquoted result is split into words on the characters in IFS — by default space, tab and newline — and each resulting word is then globbed:

file="report final.txt"
rm $file        # TWO arguments: rm report final.txt  -> wrong
rm "$file"      # ONE argument: rm 'report final.txt'  -> right

pattern='*.log'
ls $pattern     # expands to the matching files
ls "$pattern"   # a single literal argument named *.log

That is the bug in miniature: two words where you meant one, or a glob where you meant a filename. It is also why rm -rf $dir/ is a genuinely dangerous construct and rm -rf "$dir"/ is not.

Parameter expansion

Defaults: the null versus unset distinction

FormSubstitutes when variable is…Assigns?
${v:-def}unset or emptyno
${v-def}unset onlyno
${v:=def}unset or emptyyes
${v=def}unset onlyyes
${v:?message}—errors out if unset or empty
${v?message}—errors out if unset
${v:+alt}—uses alt if set and non-empty
${v+alt}—uses alt if set
Colon means “or empty”. No colon means “unset only”.
# Empty is acceptable here, unset is not
: "${API_TOKEN:?API_TOKEN is not set}"

# Configurable timeout with a default
timeout=${TIMEOUT:-30}

# Optional flag: use --verbose only when DEBUG is non-empty
args=()
[ -n "$DEBUG" ] && args+=(--verbose)

# Set it once and use it, avoiding repeated checks
: "${LOG_DIR:=/var/log/app}"

${v:?message} is the most valuable form in the list. Under set -u, an unset variable aborts with a bare “unbound variable”, whereas :?message tells the reader exactly which variable is missing and why. Use it on every externally supplied value at the top of a script.

Substrings

path="/var/log/app/output.log"

echo "${path##*/}"     # output.log   longest suffix removed (basename)
echo "${path%/*}"      # /var/log/app  shortest suffix removed (dirname)
echo "${path%%.*}"     # /var/log     longest prefix removed (extension)

# Length
echo "${#path}"        # 28

# Substring: ${var:offset:length}, offset is zero-based
echo "${path:0:4}"     # /var
echo "${path: -4}"     # .log   (note the space, or it reads as an arithmetic expression)

The # and % forms come in pairs: one # removes the shortest match, two remove the longest. Same for %. This is why ${var##*/} reliably yields a basename for a path that contains slashes — the longest trailing match gets everything after the last one.

Replacement

s="a-b-c-d"

echo "${s/-/_}"       # a_b-c-d     first occurrence
echo "${s//-/_}"      # a_b_c_d     all occurrences
echo "${s/#a/A}"      # A-b-c-d     anchored at the start
echo "${s/%d/D}"      # a-b-c-D     anchored at the end
echo "${s/[-]/}"       # abcd        delete every dash (replacement omitted)
echo "${s//[a-c]/_}"  # _-_-_-d     pattern is a glob, so character classes work

The pattern is a glob pattern, not a regular expression. If you need real regex, this is not it, and reaching for sed is often the clearer choice anyway.

Case, indirect, and name enumeration

shout="${name^^}"        # uppercase   (Bash 4+)
quiet="${name,,}"        # lowercase

target="HOME"
echo "${!target}"         # indirect: the value of $HOME

# All variable names starting with a prefix
echo "${!PREFIX_@}"
for v in "${!PREFIX_@}"; do
  printf '%s=%sn' "$v" "${!v}"
done

Indirect expansion is a legitimate technique and also a way to write code nobody can follow two years later. Use it where you genuinely need indirection, such as an allowlist of variable names in a config parser, and nowhere else.

Positional parameters: the distinction that bites

# "$@" preserves each argument as a separate word
# "$*" joins them into one word with the first character of IFS

printf 'arg: [%s]n' "$@"     # one line per argument
printf 'all: [%s]n' "$*"     # everything in a single string
Arguments"$@""$*"
a "b c"2 args: a, b c1 arg: a b c
Quoted, both stay one argument each when there is only one. The difference appears as soon as an argument contains a space.

The rule is short: always "$@", never "$*", unless you specifically want a single joined string. And "$@" beats bare $@, because the quoted form stops word splitting and globbing of argument values.

# Reading lines safely: -r stops backslash escapes, IFS= stops trimming
while IFS= read -r line; do
  printf '%sn' "$line"
done < file.txt

# Handling both a trailing newline and a final line with no newline
while IFS= read -r line || [ -n "$line" ]; do
  process "$line"
done < file.txt

That last pattern — || [ -n "$line" ] — is the correct way to process a file whose final line may lack a trailing newline, which is the classic off-by-one-line bug in Bash loops.

Arrays

files=("report 1.txt" "report 2.txt" "notes.md")

echo "${#files[@]}"          # 3          count
echo "${files[0]}"           # report 1.txt
echo "${files[@]}"           # all three, each a separate word when unquoted
echo "${files[*]}"           # all three joined

# Safe iteration: quoted, so spaces survive
for f in "${files[@]}"; do
  process "$f"
done

# Append and build
files+=("extra.txt")
cmd=(git log --oneline)
cmd+=(--since "2 weeks ago")
"${cmd[@]}"

Build commands as an array and invoke "${cmd[@]}". That avoids quoting problems entirely and is the single most useful habit for writing anything non-trivial in Bash, there is no word splitting or globbing to defend against.

Arithmetic expansion

echo $((2 + 3 * 4))        # 14
echo $((10 / 3))            # 3    integer division
echo $((7 % 3))             # 1
echo $((2 ** 10))           # 1024

echo $((0x1F))              # 31   hex
echo $((2#1010))            # 10   binary
echo $((10#08))             # ERROR: leading zero means octal, and 8 is not an octal digit

That last line is a real trap in scripts that do arithmetic on values which might arrive with a leading zero. $((value)) where value is 08 fails. If your input might be zero-padded, use the explicit base form $((10#$value)).

Arithmetic contexts treat bare names as variables, so $((count + 1)) reads $count without the dollar sign. This is convenient and mildly confusing the first time.

[[ ]] instead of [ ]

# POSIX test: does word splitting and globbing
[ "$name" = "a b" ]

# Bash conditional: does neither
[[ "$name" == "a b" ]]

[[ -f "$path" ]]              # file exists
[[ -d "$path" ]]              # directory
[[ -n "$value" ]]             # non-empty
[[ -z "$value" ]]             # empty
[[ $count -gt 10 ]]           # numeric comparison
[[ "$name" == *.txt ]]        # glob match, no glob expansion happens
[[ "$line" =~ ^[0-9]+$ ]]     # regex match; RHS is a regex, not a glob

Inside [[ =~ ]], the right-hand side is an unquoted regular expression, and quoting it turns it into a literal string. That inversion surprises everyone once. Store the pattern in a variable and leave the variable unquoted.

set options worth having

set -euo pipefail
IFS=$'nt'
OptionEffectCaveat
-eExit on a command returning non-zeroDoes not trigger in every context — notably the left side of &&, ||, if and !. Frequently misunderstood
-uError on an unset variableGood. Combine with ${v:?message} for useful error text
-o pipefailA pipeline fails if any stage fails, not just the lastCatches cases where grep in a pipeline is hiding an upstream failure
IFS=$'nt'Removes space from the default field separatorA deliberate opinionated choice. Makes read and unquoted splitting stricter

set -e is the one to be careful with. It is not “abort on any error”; it is a set of specific contexts. If you rely on it, understand that a command in a conditional does not trigger it, which is exactly where you would want it to.

The default posture

  • Quote every expansion. "$var", "$(cmd)", "${arr[@]}".
  • Use single quotes for patterns — globs, regexes, sed and awk scripts — unless you genuinely need interpolation inside them.
  • Prefer printf '%sn' to echo.
  • Build command lines as arrays and run "${cmd[@]}".
  • Use [[ ]] for tests.
  • Run ShellCheck. It catches unquoted expansions, and it is right far more often than intuition.

The remaining Bash scripts in this site’s own tooling are a reasonable worked example: auditing IAM access keys with Bash and automating EC2 backups with the AWS CLI both run under strict mode and use quoted expansions throughout, because in a script a stray word split is a failed backup rather than a confusing message.

References

Related Tools

Try these companion tools on DevStackIO Tools: