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.
The pipeline every command goes through
Section titled “The pipeline every command goes through”Before bash runs a simple command, it rewrites the line in a fixed order. Knowing the order explains almost every surprise:
- Brace expansion —
file{1,2}.txt→file1.txt file2.txt - Tilde expansion —
~→/home/you - Parameter expansion —
$var,${var:-default} - Command substitution —
$(date) - Arithmetic expansion —
$(( 2 + 2 )) - Word splitting — the results of 3–5 are split on
IFS(space, tab, newline) - Pathname expansion (globbing) —
*.log→ matching filenames - 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.
Variables
Section titled “Variables”Assignment is a single word: name, =, value. No spaces around =.
name="Ada"count=42path=/usr/local/bin # quotes optional when there is no whitespace or globname = "Ada" # WRONG: runs the command `name` with args `=` and `Ada`name= "Ada" # WRONG: runs `Ada` with name set to empty in its environmentNames 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:
file=reportecho "$file.csv" # => report.csv (`.` cannot be part of a name)echo "$file_final" # => (empty) bash looked for a variable named file_finalecho "${file}_final" # => report_final braces end the name explicitly${ } is not decoration — it is the syntax that enables everything in
parameter expansion below.
Unset vs empty
Section titled “Unset vs empty”These are different states, and several expansions distinguish them:
unset a # a does not existb="" # b exists and is emptyecho "${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 and attributes
Section titled “declare and attributes”declare (or its synonym typeset) sets attributes on a variable:
declare -i n=5 # integer: arithmetic evaluated on assignmentn+=3 ; echo "$n" # => 8 (numeric add, not string concat)declare -r VERSION=1.2 # readonly; further assignment is an errordeclare -a list # indexed arraydeclare -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 uppercasedeclare -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.
Quoting: the number one bug source
Section titled “Quoting: the number one bug source”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 |
f="my file.txt"rm $f # runs: rm "my" "file.txt" -> two wrong filenamesrm "$f" # runs: rm "my file.txt" -> correctThe 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:
echo 'it'\''s fine' # => it's fineecho "it's fine" # easier when the text has no $ or backticksInside double quotes, only $, `, \, and (in history-enabled interactive shells) ! keep
special meaning. Backslash escapes only those characters:
echo "cost: \$5 and \\ a backslash" # => cost: $5 and \ a backslashecho "a * b" # => a * b (no globbing inside quotes)ANSI-C quoting
Section titled “ANSI-C quoting”$'...' interprets backslash escapes like C:
printf '%s' $'line1\nline2' # a real newlineIFS=$'\n' # the standard way to set IFS to a newlineecho $'\t indented' # a real tabecho $'\x41 é' # => A éThe one place bare is right
Section titled “The one place bare is right”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 substitution
Section titled “Command substitution”$(command) runs a command and substitutes its standard output.
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$?).
# preserve output containing spaces and newlinescontents="$(cat notes.txt)"
# nesting works cleanly with $( ), unlike backticksecho "$(basename "$(dirname "$PWD")")"To capture output including trailing newlines, append a sentinel:
out=$(printf 'a\n\n\n'; printf X) # protect the newlinesout=${out%X} # then remove the sentinelArithmetic
Section titled “Arithmetic”Bash does integer arithmetic only. There is no floating point — use awk, bc -l, or python3
for that.
i=5echo $(( i + 1 )) # => 6 ($ optional on names inside (( )))echo $(( 10 / 3 )) # => 3 integer division truncatesecho $(( 10 % 3 )) # => 1echo $(( 2 ** 10 )) # => 1024(( ... )) is the statement form: it evaluates and sets an exit status but produces no output.
(( count++ ))(( total += price ))if (( x > 10 && y < 5 )); then echo "in range"; fiThe 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:
echo $(( 0x1f )) # => 31 hexecho $(( 010 )) # => 8 leading zero means octalecho $(( 10#010 )) # => 10 force base 10 (important for date parts like "08")echo $(( 2#1011 )) # => 11 binaryn=7; echo $(( n > 5 ? 100 : 200 )) # => 100 ternary workslet 'x = 2 + 2' and the deprecated expr do the same job worse. Prefer (( )) and $(( )).
Parameter expansion
Section titled “Parameter expansion”The ${...} forms are bash’s string library. They are fast (no subprocess) and worth memorizing.
Defaults and errors
Section titled “Defaults and errors”${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)port="${PORT:-8080}" # config with a fallback: "${HOME:?HOME must be set}" # hard requirement; `:` is a no-op commandflags="${VERBOSE:+--verbose}" # add a flag only when the var is setecho "hello ${1:-stranger}" # safe under `set -u`Length
Section titled “Length”s="hello"echo "${#s}" # => 5 characters (locale-aware)arr=(a b c)echo "${#arr[@]}" # => 3 number of elementsecho "${#arr[0]}" # => 1 length of element 0Substrings
Section titled “Substrings”s="abcdefgh"echo "${s:2}" # => cdefgh from index 2 to end (0-based)echo "${s:2:3}" # => cde 3 characters from index 2echo "${s: -3}" # => fgh last 3 — note the space before -3echo "${s:(-3):2}" # => fg parentheses work tooTrimming with patterns
Section titled “Trimming with patterns”These use glob patterns, not regexes. # trims from the front, % from the back; doubling the
character makes the match greedy.
path="/var/log/nginx/access.log.1"echo "${path##*/}" # => access.log.1 longest match from front (basename)echo "${path#*/}" # => var/log/... shortest match from frontecho "${path%/*}" # => /var/log/nginx (dirname)echo "${path%%.*}" # => /var/log/nginx/access
f="archive.tar.gz"echo "${f%.gz}" # => archive.tar strip one known suffixecho "${f%%.*}" # => archive strip everything from the first dotecho "${f##*.}" # => gz the extensionMnemonic: on a US keyboard # is left of $ and % is right of it — # cuts the left, % cuts the
right.
Search and replace
Section titled “Search and replace”s="one two two three"echo "${s/two/2}" # => one 2 two three first match onlyecho "${s//two/2}" # => one 2 2 three all matchesecho "${s/#one/1}" # => 1 two two three anchored to the startecho "${s/%three/3}" # => one two two 3 anchored to the endecho "${s//two/}" # => one three delete all matchespath=${PATH//:/$'\n'} # turn PATH into one entry per lineThe pattern is a glob, so ${file//\*/x} replaces literal asterisks and ${s//[aeiou]/} deletes
vowels.
Case conversion (bash 4.0+)
Section titled “Case conversion (bash 4.0+)”s="hello world"echo "${s^}" # => Hello world uppercase first characterecho "${s^^}" # => HELLO WORLD uppercase allu="ABC"echo "${u,}" # => aBC lowercase firstecho "${u,,}" # => abc lowercase allecho "${s^^[aeiou]}" # => hEllO wOrld only characters matching the patternIndirection and listing
Section titled “Indirection and listing”name="HOME"echo "${!name}" # => /home/you value of the variable *named* by $nameecho "${!BASH_@}" # names of all variables starting with BASH_Quoting for reuse (bash 4.4+)
Section titled “Quoting for reuse (bash 4.4+)”${var@Q} prints the value quoted so it can be re-parsed by the shell — invaluable for logging and
for building commands:
v="a b'c"echo "${v@Q}" # => 'a b'\''c'Arrays
Section titled “Arrays”Indexed arrays
Section titled “Indexed arrays”fruits=(apple banana "dragon fruit")fruits+=(cherry) # appendfruits[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 countecho "${!fruits[@]}" # => 0 1 2 3 10 the indicesecho "${fruits[@]:1:2}" # => banana "dragon fruit" sliceunset 'fruits[1]' # quote it, or the glob may match a fileThe single most important array rule:
for f in "${fruits[@]}"; do echo "[$f]"; done # correct: one word per elementfor 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:
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:
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):
mapfile -t lines < notes.txt # -t strips the trailing newline from each lineecho "${#lines[@]} lines"Associative arrays (bash 4.0+)
Section titled “Associative arrays (bash 4.0+)”They must be declared before use, or bash treats subscripts as arithmetic.
declare -A colorcolor[apple]=redcolor["dragon fruit"]=pinkcolor=( [lime]=green [plum]=purple ) # bulk assignment
echo "${color[apple]}" # => redecho "${!color[@]}" # all keysecho "${color[@]}" # all valuesecho "${#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]'Special variables
Section titled “Special variables”| 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[*]}":
# with arguments: one "two three"printf '[%s]\n' "$@" # => [one] [two three]printf '[%s]\n' "$*" # => [one two three]printf '[%s]\n' $@ # => [one] [two] [three] <- splitting damageForwarding arguments to another command is always "$@":
#!/usr/bin/env bashexec docker run --rm -it myimage "$@"Finding a script’s own directory — a common need, and this is the reliable form:
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:
token=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 32)Key points
Section titled “Key points”- No spaces around
=; every value is a string until arithmetic says otherwise. - Double-quote every
$varand$(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 tobasename,dirname,sed, andtr. "${arr[@]}"and"$@"preserve element boundaries; the[*]and unquoted forms do not.