Skip to content

Syntax and Variables

Bash has no types, no declarations you must write, and almost no punctuation — which makes it feel easy right up until a filename contains a space. This page covers assignment, the expansion rules that rewrite your command line, and the quoting discipline that keeps it correct.

Before bash runs a simple command, it rewrites the line in a fixed order. Knowing the order explains almost every surprise:

  1. Brace expansionfile{1,2}.txtfile1.txt file2.txt
  2. Tilde expansion~/home/you
  3. Parameter expansion$var, ${var:-default}
  4. Command substitution$(date)
  5. Arithmetic expansion$(( 2 + 2 ))
  6. Word splitting — the results of 3–5 are split on IFS (space, tab, newline)
  7. Pathname expansion (globbing)*.log → matching filenames
  8. Quote removal — the quotes themselves are stripped

Steps 6 and 7 are the dangerous ones, and they are exactly what double quotes disable. Note that word splitting happens after variable expansion — so bash splits the value of your variable, not your source code.

Assignment is a single word: name, =, value. No spaces around =.

Terminal window
name="Ada"
count=42
path=/usr/local/bin # quotes optional when there is no whitespace or glob
Terminal window
name = "Ada" # WRONG: runs the command `name` with args `=` and `Ada`
name= "Ada" # WRONG: runs `Ada` with name set to empty in its environment

Names may contain letters, digits and underscores, and may not start with a digit. Everything is a string; count=42 stores the three-character text 42 and bash converts it to a number only in arithmetic contexts.

Read a variable with $name. Use ${name} when the name would otherwise run into following characters:

Terminal window
file=report
echo "$file.csv" # => report.csv (`.` cannot be part of a name)
echo "$file_final" # => (empty) bash looked for a variable named file_final
echo "${file}_final" # => report_final braces end the name explicitly

${ } is not decoration — it is the syntax that enables everything in parameter expansion below.

These are different states, and several expansions distinguish them:

Terminal window
unset a # a does not exist
b="" # b exists and is empty
echo "${a-A}" # => A (a is unset)
echo "${b-B}" # => "" (b is set, though empty)
echo "${b:-B}" # => B (the colon also treats empty as "missing")

declare (or its synonym typeset) sets attributes on a variable:

Terminal window
declare -i n=5 # integer: arithmetic evaluated on assignment
n+=3 ; echo "$n" # => 8 (numeric add, not string concat)
declare -r VERSION=1.2 # readonly; further assignment is an error
declare -a list # indexed array
declare -A map # associative array (bash 4.0+)
declare -x API_URL=... # export to child processes (same as `export`)
declare -l lower # store lowercased (bash 4.0+); -u for uppercase
declare -p n # print the declaration: declare -i n="8"

declare inside a function makes the variable local by default, which is a frequent source of confusion — use declare -g if you really want a global.

Three modes, three behaviours.

Form Variable expansion Word splitting / globbing Use for
bare yes yes almost never
"double" yes no almost always
'single' no no literal text, regexes, awk/sed programs
Terminal window
f="my file.txt"
rm $f # runs: rm "my" "file.txt" -> two wrong filenames
rm "$f" # runs: rm "my file.txt" -> correct

The rule that covers 95% of cases: quote every variable expansion and every command substitution unless you have a specific reason not to.

Single quotes are absolute — nothing inside is special, and you cannot escape a single quote inside single quotes. To include one, close, escape, reopen:

Terminal window
echo 'it'\''s fine' # => it's fine
echo "it's fine" # easier when the text has no $ or backticks

Inside double quotes, only $, `, \, and (in history-enabled interactive shells) ! keep special meaning. Backslash escapes only those characters:

Terminal window
echo "cost: \$5 and \\ a backslash" # => cost: $5 and \ a backslash
echo "a * b" # => a * b (no globbing inside quotes)

$'...' interprets backslash escapes like C:

Terminal window
printf '%s' $'line1\nline2' # a real newline
IFS=$'\n' # the standard way to set IFS to a newline
echo $'\t indented' # a real tab
echo $'\x41 é' # => A é

Inside [[ ]] and (( )) bash does not word-split, so quoting is optional there (though still harmless). And when you want a variable to become multiple arguments — rare, and better done with an array — bare is the mechanism.

$(command) runs a command and substitutes its standard output.

Terminal window
today=$(date +%F)
echo "Report for $today"
files=$(ls -1 /etc | wc -l)
echo "$files files"

Details that matter:

  • All trailing newlines are stripped. Interior newlines are kept.
  • It runs in a subshell, so variable assignments inside are lost.
  • Quote the result ("$(...)") or it is word-split and globbed like any other expansion.
  • The exit status of the substitution is the command’s status — but it is discarded if it is part of an assignment (x=$(false) succeeds; the assignment itself is what sets $?).
Terminal window
# preserve output containing spaces and newlines
contents="$(cat notes.txt)"
# nesting works cleanly with $( ), unlike backticks
echo "$(basename "$(dirname "$PWD")")"

To capture output including trailing newlines, append a sentinel:

Terminal window
out=$(printf 'a\n\n\n'; printf X) # protect the newlines
out=${out%X} # then remove the sentinel

Bash does integer arithmetic only. There is no floating point — use awk, bc -l, or python3 for that.

Terminal window
i=5
echo $(( i + 1 )) # => 6 ($ optional on names inside (( )))
echo $(( 10 / 3 )) # => 3 integer division truncates
echo $(( 10 % 3 )) # => 1
echo $(( 2 ** 10 )) # => 1024

(( ... )) is the statement form: it evaluates and sets an exit status but produces no output.

Terminal window
(( count++ ))
(( total += price ))
if (( x > 10 && y < 5 )); then echo "in range"; fi

The exit status rule is inverted relative to C intuition: (( expr )) returns 0 (success) when the value is non-zero, and 1 when the value is zero.

Number bases and other niceties:

Terminal window
echo $(( 0x1f )) # => 31 hex
echo $(( 010 )) # => 8 leading zero means octal
echo $(( 10#010 )) # => 10 force base 10 (important for date parts like "08")
echo $(( 2#1011 )) # => 11 binary
n=7; echo $(( n > 5 ? 100 : 200 )) # => 100 ternary works

let 'x = 2 + 2' and the deprecated expr do the same job worse. Prefer (( )) and $(( )).

The ${...} forms are bash’s string library. They are fast (no subprocess) and worth memorizing.

Terminal window
${var:-default} # use default if var is unset or empty; var unchanged
${var-default} # use default only if var is *unset*
${var:=default} # use default AND assign it to var
${var:?message} # error out with message on stderr if unset/empty
${var:+alt} # use alt only if var IS set and non-empty (else nothing)
Terminal window
port="${PORT:-8080}" # config with a fallback
: "${HOME:?HOME must be set}" # hard requirement; `:` is a no-op command
flags="${VERBOSE:+--verbose}" # add a flag only when the var is set
echo "hello ${1:-stranger}" # safe under `set -u`
Terminal window
s="hello"
echo "${#s}" # => 5 characters (locale-aware)
arr=(a b c)
echo "${#arr[@]}" # => 3 number of elements
echo "${#arr[0]}" # => 1 length of element 0
Terminal window
s="abcdefgh"
echo "${s:2}" # => cdefgh from index 2 to end (0-based)
echo "${s:2:3}" # => cde 3 characters from index 2
echo "${s: -3}" # => fgh last 3 — note the space before -3
echo "${s:(-3):2}" # => fg parentheses work too

These use glob patterns, not regexes. # trims from the front, % from the back; doubling the character makes the match greedy.

Terminal window
path="/var/log/nginx/access.log.1"
echo "${path##*/}" # => access.log.1 longest match from front (basename)
echo "${path#*/}" # => var/log/... shortest match from front
echo "${path%/*}" # => /var/log/nginx (dirname)
echo "${path%%.*}" # => /var/log/nginx/access
f="archive.tar.gz"
echo "${f%.gz}" # => archive.tar strip one known suffix
echo "${f%%.*}" # => archive strip everything from the first dot
echo "${f##*.}" # => gz the extension

Mnemonic: on a US keyboard # is left of $ and % is right of it — # cuts the left, % cuts the right.

Terminal window
s="one two two three"
echo "${s/two/2}" # => one 2 two three first match only
echo "${s//two/2}" # => one 2 2 three all matches
echo "${s/#one/1}" # => 1 two two three anchored to the start
echo "${s/%three/3}" # => one two two 3 anchored to the end
echo "${s//two/}" # => one three delete all matches
path=${PATH//:/$'\n'} # turn PATH into one entry per line

The pattern is a glob, so ${file//\*/x} replaces literal asterisks and ${s//[aeiou]/} deletes vowels.

Terminal window
s="hello world"
echo "${s^}" # => Hello world uppercase first character
echo "${s^^}" # => HELLO WORLD uppercase all
u="ABC"
echo "${u,}" # => aBC lowercase first
echo "${u,,}" # => abc lowercase all
echo "${s^^[aeiou]}" # => hEllO wOrld only characters matching the pattern
Terminal window
name="HOME"
echo "${!name}" # => /home/you value of the variable *named* by $name
echo "${!BASH_@}" # names of all variables starting with BASH_

${var@Q} prints the value quoted so it can be re-parsed by the shell — invaluable for logging and for building commands:

Terminal window
v="a b'c"
echo "${v@Q}" # => 'a b'\''c'
Terminal window
fruits=(apple banana "dragon fruit")
fruits+=(cherry) # append
fruits[10]="sparse" # arrays are sparse; indices need not be contiguous
echo "${fruits[0]}" # => apple (0-based; braces are REQUIRED)
echo "${fruits[-1]}" # => sparse last element (bash 4.3+)
echo "${#fruits[@]}" # => 5 element count
echo "${!fruits[@]}" # => 0 1 2 3 10 the indices
echo "${fruits[@]:1:2}" # => banana "dragon fruit" slice
unset 'fruits[1]' # quote it, or the glob may match a file

The single most important array rule:

Terminal window
for f in "${fruits[@]}"; do echo "[$f]"; done # correct: one word per element
for f in ${fruits[@]}; do echo "[$f]"; done # WRONG: splits "dragon fruit"
for f in "${fruits[*]}"; do echo "[$f]"; done # WRONG: one single joined string

"${arr[@]}" expands to one quoted word per element. "${arr[*]}" joins all elements with the first character of IFS into a single word — occasionally useful:

Terminal window
ids=(1 2 3)
( IFS=, ; echo "${ids[*]}" ) # => 1,2,3 (subshell keeps IFS change local)

Arrays are the correct way to build a command with optional arguments:

Terminal window
args=(--color=auto)
[[ -n "${VERBOSE:-}" ]] && args+=(--verbose)
[[ -n "${PATTERN:-}" ]] && args+=(--include="$PATTERN")
grep "${args[@]}" "$needle" "$file"

Read lines into an array with mapfile (bash 4.0+, also spelled readarray):

Terminal window
mapfile -t lines < notes.txt # -t strips the trailing newline from each line
echo "${#lines[@]} lines"

They must be declared before use, or bash treats subscripts as arithmetic.

Terminal window
declare -A color
color[apple]=red
color["dragon fruit"]=pink
color=( [lime]=green [plum]=purple ) # bulk assignment
echo "${color[apple]}" # => red
echo "${!color[@]}" # all keys
echo "${color[@]}" # all values
echo "${#color[@]}" # number of entries
for k in "${!color[@]}"; do
printf '%s -> %s\n' "$k" "${color[$k]}"
done
[[ -v color[lime] ]] && echo "lime exists" # -v tests "is set" (bash 4.2+)
unset 'color[plum]'
Variable Meaning
$? Exit status of the last foreground command (0 = success)
$0 Name the script was invoked as
$1$9, ${10} Positional parameters; braces required past 9
$# Number of positional parameters
"$@" All arguments, each as its own word — what you almost always want
"$*" All arguments joined into one word with IFS’s first char
$$ PID of the current shell (not of a subshell — see $BASHPID)
$! PID of the most recent background command
$_ Last argument of the previous command
$- Current option flags
$LINENO Current line number
$RANDOM A new pseudo-random integer 0–32767 on each reference
$SECONDS Seconds since the shell started
$BASH_SOURCE Array of source filenames; ${BASH_SOURCE[0]} is the current file
$PIPESTATUS Array of exit statuses from the last pipeline

"$@" versus "$*" is the same distinction as "${arr[@]}" versus "${arr[*]}":

Terminal window
# with arguments: one "two three"
printf '[%s]\n' "$@" # => [one] [two three]
printf '[%s]\n' "$*" # => [one two three]
printf '[%s]\n' $@ # => [one] [two] [three] <- splitting damage

Forwarding arguments to another command is always "$@":

#!/usr/bin/env bash
exec docker run --rm -it myimage "$@"

Finding a script’s own directory — a common need, and this is the reliable form:

Terminal window
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)

${BASH_SOURCE[0]} is used rather than $0 because it is correct even when the file is sourced.

$RANDOM and $$ are convenient but not secure. For anything that must be unguessable, read from /dev/urandom:

Terminal window
token=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 32)
  • No spaces around =; every value is a string until arithmetic says otherwise.
  • Double-quote every $var and $(cmd) — this prevents word splitting and globbing, which is where most bash bugs come from.
  • $( ) over backticks; it nests and it is readable.
  • $(( )) does integer math; (( )) is the statement form with an inverted exit status.
  • Parameter expansion (${v:-x}, ${v##*/}, ${v//a/b}, ${v,,}) replaces most calls to basename, dirname, sed, and tr.
  • "${arr[@]}" and "$@" preserve element boundaries; the [*] and unquoted forms do not.