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.
Assignment has no spaces around =, and reading uses $. Variables are strings unless declared otherwise; there is no type system.
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 textVariables 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.
| Form | Variables expanded | Globs expanded | Word splitting |
|---|---|---|---|
| unquoted $var | yes | yes | yes |
| "double quotes" | yes | no | no |
| 'single quotes' | no | no | no |
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.
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 |
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 missingBash 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.
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 generates strings before anything else runs; it is not a glob and needs no matching files.
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"; doneGlobs match existing filenames: * any string, ? one character, [abc] a set. Two options are worth enabling in scripts:
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"; donelangs=("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"; doneAlways expand arrays as "${arr[@]}" — with quotes and @ — to keep elements with spaces intact.
count = 3 with spaces: bash runs a command named count.$var in rm, cp or [ ], which breaks on spaces and on empty values.$(( 7 / 2 )) to be 3.5.${var-default} when you meant ${var:-default}; without the colon an empty string counts as set.nullglob the loop runs once with the literal pattern.What does `echo "${path##*/}"` print when `path=/usr/local/bin/node`?
name=value (no spaces); read with "$name"; double-quote every expansion by default.:-), required values (:?), trimming (#, %), replacement (//) and case (^^, ,,).$(( )) is integer arithmetic; use bc or awk for decimals.nullglob and globstar in scripts.Next lesson: Bash Conditionals and Loops — branch on tests and repeat work over files, lines and numbers.