Bash Variables, Quoting and Expansions

Intermediate
13 min

Bash Variables, Quoting and Expansions

Most bugs in shell scripts come from a single cause: the author did not know exactly what bash does to a line before running it. Bash performs a fixed sequence of expansions — variables, arithmetic, braces, globs, word splitting — and quoting controls which of them happen. This lesson makes that sequence explicit, shows the parameter-expansion operators that replace sed and basename for simple string work, and covers arithmetic. After it you will be able to predict what any line of bash will do.

Variables

Assignment has no spaces around =, and reading uses $. Variables are strings unless declared otherwise; there is no type system.

bash
name="Ada" count=3 dir=$HOME/projects # no quotes needed on the right side of = for a single word echo "$name has $count items" echo "${name}_backup" # braces separate the name from following text

Variables set in a script are local to that process. export VAR makes them visible to child processes; the environment variables lesson covered when that matters.

Quoting Rules

| Form | Variables expanded | Globs expanded | Word splitting | |---|---|---|---| | unquoted $var | yes | yes | yes | | "double quotes" | yes | no | no | | 'single quotes' | no | no | no |

bash
msg="hello world" echo $msg # hello world (split into two words, re-joined by echo) echo "$msg" # hello world (one argument, spaces preserved) path="My Documents" ls $path # ls: cannot access 'My': No such file ls "$path" # correct echo 'Price: $5' # Price: $5 echo "Price: \$5" # Price: $5 (escape inside double quotes)

The rule that prevents nearly every quoting bug: double-quote every variable reference unless you specifically want splitting or globbing. "$@" for all arguments, "$file" for paths, "$(command)" for substitutions.

Parameter Expansion

Bash can transform a variable's value while reading it. These operators are faster than spawning sed or cut and work in any POSIX shell (the first four) or bash (the rest).

| Expression | Result | |---|---| | ${var:-default} | default if var is unset or empty | | ${var:=default} | same, and assigns it | | ${var:?message} | abort with message if unset | | ${var:+alt} | alt if var is set, otherwise empty | | ${#var} | length in characters | | ${var#pattern} / ${var##pattern} | remove shortest / longest matching prefix | | ${var%pattern} / ${var%%pattern} | remove shortest / longest matching suffix | | ${var/old/new} / ${var//old/new} | replace first / all occurrences | | ${var:2:5} | substring from offset 2, length 5 | | ${var^^} / ${var,,} | uppercase / lowercase |

bash
file="/var/log/app/server.log" echo "${file##*/}" # server.log (basename) echo "${file%/*}" # /var/log/app (dirname) echo "${file%.*}" # /var/log/app/server echo "${file##*.}" # log (extension) env="${NODE_ENV:-development}" : "${DATABASE_URL:?DATABASE_URL must be set}" # exits with the message if missing

Arithmetic

Bash does integer arithmetic inside $(( )). Variables need no $ inside it, and the usual operators apply, including ** for power and % for modulo. For decimals use bc or awk.

bash
a=17; b=5 echo $(( a + b )) # 22 echo $(( a / b )) # 3 (integer division) echo $(( a % b )) # 2 echo $(( 2 ** 10 )) # 1024 (( a++ )) # increment (( total = a * b )) # assign if (( a > b )); then echo "a is larger"; fi echo "scale=2; 17 / 5" | bc # 3.40 awk 'BEGIN { printf "%.2f\n", 17 / 5 }'

(( )) without the $ is a command whose exit status is 0 when the expression is non-zero, which makes it a natural numeric test in if and while.

Brace Expansion and Globbing

Brace expansion generates strings before anything else runs; it is not a glob and needs no matching files.

bash
echo file{1,2,3}.txt # file1.txt file2.txt file3.txt echo {a..e} # a b c d e echo {01..10} # zero-padded 01 02 ... 10 mkdir -p app/{src,tests,docs} cp config.yml{,.bak} # cp config.yml config.yml.bak for i in {1..5}; do echo "run $i"; done

Globs match existing filenames: * any string, ? one character, [abc] a set. Two options are worth enabling in scripts:

bash
shopt -s nullglob # a pattern with no matches expands to nothing instead of itself shopt -s globstar # ** matches directories recursively for f in **/*.log; do gzip "$f"; done

Arrays

bash
langs=("bash" "python" "go") langs+=("rust") echo "${langs[0]}" # bash echo "${langs[@]}" # all elements, each as a separate word echo "${#langs[@]}" # 4 for l in "${langs[@]}"; do echo "$l"; done

Always expand arrays as "${arr[@]}" — with quotes and @ — to keep elements with spaces intact.

Common Mistakes

  • count = 3 with spaces: bash runs a command named count.
  • Unquoted $var in rm, cp or [ ], which breaks on spaces and on empty values.
  • Expecting $(( 7 / 2 )) to be 3.5.
  • Using ${var-default} when you meant ${var:-default}; without the colon an empty string counts as set.
  • Relying on a glob matching nothing to produce an empty loop; without nullglob the loop runs once with the literal pattern.
Quick Quiz
Question 1 of 3

What does `echo "${path##*/}"` print when `path=/usr/local/bin/node`?

Key Takeaways

  • Assign with name=value (no spaces); read with "$name"; double-quote every expansion by default.
  • Single quotes are literal; double quotes expand variables but suppress globbing and word splitting.
  • Parameter expansion handles defaults (:-), required values (:?), trimming (#, %), replacement (//) and case (^^, ,,).
  • $(( )) is integer arithmetic; use bc or awk for decimals.
  • Brace expansion generates strings, globs match files; enable nullglob and globstar in scripts.

Next lesson: Bash Conditionals and Loops — branch on tests and repeat work over files, lines and numbers.

Bash Variables, Quoting and Expansions - Linux & Command Line | CodeYourCraft | CodeYourCraft