Bash Variables: Parameter Expansion, Arithmetic and Arrays
Bash variables in depth: unset versus empty, default values, parameter expansion, arithmetic traps, special variables, arrays and associative arrays.
Bash has one data type: the string. A variable holds text, Bash's arithmetic reads that text as an integer when you ask it to, and arrays are lists or maps of strings. That simplicity makes variables easy to start with and easy to misuse: an empty value and a missing one look the same, 08 is not a valid number, and an unquoted variable can turn into several arguments.
This part goes through variables in depth: assignment and scope, the difference between unset and empty, default values, the parameter expansions that replace sed and basename for most string work, arithmetic, special variables, indexed and associative arrays, and reading input with read.
Prerequisites
- Part 2, especially quoting and the expansion order.
- The practice directory in
~/bash-practice.
The Linux guide's scripting part introduces variables, quoting and export briefly; this part assumes you have seen a variable before.
Assign, Inspect and Scope
Assignment is name=value with no spaces around =. Names use letters, digits and underscores and cannot start with a digit. By convention, environment variables and constants are UPPER_CASE; use lower_case for your own variables so you do not overwrite something like PATH or HOME by accident.
Variables are local to the current shell until you export them. Child processes get a copy of the exported ones, and nothing a child does can change the parent's variables:
Note that a script cannot set variables in the shell that started it. To load settings into your current shell, source a file instead of running it (Part 7).
Unset, Empty and Default Values
A variable can be unset (it does not exist), empty (it exists and holds no text), or set to a value. Plain $name shows nothing in both of the first two cases. The default-value expansions tell them apart, and the colon decides whether "empty" counts as missing:
| Expansion | Result |
|---|---|
${v:-default} |
default if v is unset or empty, else $v |
${v-default} |
default only if v is unset |
${v:=default} |
Like :-, and also assigns default to v |
${v:?message} |
Exit the script with message if v is unset or empty |
${v:+alt} |
alt if v is set and not empty, else nothing |
${v:?} is the right tool for values a script cannot work without, and ${v:+...} builds optional arguments:
#!/usr/bin/env bash
# deploy.sh: refuse to run without a target
target="${TARGET:?set TARGET to staging or production}"
echo "deploying to $target"
The second example leaves ${verbose:+--verbose} unquoted on purpose: when it is empty, it should disappear instead of becoming an empty argument.
To test whether a variable is set at all, use [[ -v name ]] (Bash 4.2+). It works under set -u, where reading an unset variable is an error:
More patterns are in check if a variable is set in Bash.
Parameter Expansion for Strings
Parameter expansion does most string work inside Bash, without starting sed, cut, basename or tr. That is shorter and, in a loop, many times faster (Part 9 measures it).
The memory aid: # is to the left of $ on a US keyboard and removes from the left, % is to the right and removes from the right. A space is needed in ${path: -5}; ${path:-5} would be the default-value expansion.
Replacement and case conversion:
${var@Q} quotes a value so it can be read back by Bash, which is useful for log messages that must show hidden characters. See convert a string to lowercase, check if a string contains a substring and concatenate strings for more.
The & Change in Bash 5.2
Bash 5.2 turned on a new option, patsub_replacement: a & in the replacement text of ${var/pattern/replacement} now stands for the matched text, as in sed. Scripts written for older versions that put a literal & in a replacement changed behaviour:
Bash 5.2 replaced the match (the word and with its spaces) with a space, the match itself and another space, so the & vanished and the word stayed. A backslash fixes it in 5.2 ("${s/ and / \& }"), but inside double quotes older versions print the backslash too. For a script that must behave the same on every version, put shopt -u patsub_replacement 2>/dev/null || true near the top; older Bash versions do not know the option, hence the 2>/dev/null || true.
Indirect References
${!name} expands the variable whose name is stored in name, and ${!prefix@} lists variable names that start with prefix:
For anything more than reading a value, namerefs (declare -n, in Part 6) are clearer.
Variable Attributes With declare
declare (and local inside functions) can attach attributes to a variable:
| Option | Effect |
|---|---|
-r |
Read-only (the same as readonly) |
-x |
Exported (the same as export) |
-i |
Integer: every assignment is evaluated as arithmetic |
-l / -u |
Convert the value to lowercase / uppercase on every assignment |
-a / -A |
Indexed array / associative array |
-n |
Nameref: the variable is another name for a different variable |
-g |
Inside a function: create a global variable |
declare -i looks convenient but hides errors: abc is read as the name of an unset variable, which is 0. Prefer plain variables with explicit $(( )) arithmetic and validate input instead (Part 8).
Arithmetic
Bash does 64-bit integer arithmetic in $(( )), which expands to the result, and (( )), a command whose exit status is 0 when the result is non-zero. Inside both, you can use variable names without $.
Two traps catch everyone eventually.
Leading zeros mean octal. Values taken from dates, times or file names often have them:
(( )) returns failure when the result is 0. (( count++ )) evaluates to the old value of count, so the first increment from 0 "fails", and a script with set -e stops there:
Write count=$(( count + 1 )) instead. (( ++count )) happens to work when counting up from 0, but any (( )) whose result is 0 fails the same way, for example (( remaining -= 1 )) reaching zero. The assignment form never fails. Part 10 collects the other set -e surprises.
let "x = 1 + 2" and expr 1 + 2 are older ways to do arithmetic, and $[ 1 + 2 ] is a deprecated form that still works. Use $(( )) and (( )) in new scripts.
Special Variables
| Variable | Contains |
|---|---|
$? |
Exit status of the last command |
$$ |
Process ID of the shell (in a subshell, still the parent's PID) |
$BASHPID |
Process ID of the current Bash process, subshells included |
$! |
Process ID of the last background job |
$0, $1 ... $#, $@ |
Script name and arguments (Part 8) |
$RANDOM |
A number from 0 to 32767; not for passwords or tokens |
$SRANDOM |
A 32-bit number from the system's random generator (Bash 5.1+) |
$SECONDS |
Seconds since the shell started; assign 0 to start a timer |
$EPOCHSECONDS, $EPOCHREALTIME |
Unix time, in seconds and with microseconds (Bash 5.0+) |
$LINENO |
The current line number in the script |
$PIPESTATUS |
An array with the exit status of each command in the last pipeline |
$HOSTNAME, $PWD, $OLDPWD |
The host name, the current and the previous directory |
printf '%(...)T' formats a time with strftime codes inside Bash, so you do not need to call date in a loop. See get the current time in seconds since the epoch and format a date as YYYY-MM-DD.
Indexed Arrays
An array holds a list of strings, indexed from 0. Arrays are the right way to keep file names, command arguments or any list whose items may contain spaces.
Always write "${servers[@]}" with the quotes: it expands to one word per element, so "db 1" stays one item. "${servers[*]}" joins everything into one string, which is only useful for display.
Removing an element with unset leaves a gap; the other indices do not move:
Quote the argument to unset ('servers[1]'): unquoted, servers[1] is a glob that matches a file called servers1 if one exists. Two more classic slips: $servers is only the first element, and $servers[1] is the first element followed by the text [1]. Always use braces with arrays.
Associative Arrays
An associative array maps string keys to values (Bash 4.0+). It must be declared with declare -A. Without that, Bash creates an indexed array and quietly turns every key into index 0:
The first command stored only 5432, at index 0: both web and db were evaluated as arithmetic, where an unset name is 0.
A common use is counting. This reads the access log and counts requests per status code, entirely in Bash:
#!/usr/bin/env bash
# status-count.sh: count requests per HTTP status in an access log
declare -A count=()
# fields: ip - - [date zone] "method path proto" status size ...
while read -r _ _ _ _ _ _ _ _ status _ || [[ -n $status ]]; do
[[ $status =~ ^[0-9]{3}$ ]] || continue # skip lines in another format
count[$status]=$(( ${count[$status]:-0} + 1 ))
done < "${1:-logs/access.log}"
for status in "${!count[@]}"; do
printf '%s %3d\n' "$status" "${count[$status]}"
done | sort
read splits each line on spaces; _ is a throwaway name for the fields the script does not need. The status code is the ninth field. As in Part 3, || [[ -n $status ]] keeps a last line that has no newline; at the real end of the file, read empties its variables, so the loop stops. Part 9 does the same job with awk and compares the speed.
Split a String Into an Array
To split a string on a delimiter, use read -a with IFS set for that one command, or mapfile for lines. Do not write arr=($string): it splits on every space and also expands wildcards:
<<< is a here string: it feeds the text, plus a newline, to the command's standard input. Split a string by a delimiter shows more variations.
Read Input With read
read reads one line from standard input into variables. The options you will use:
| Option | Effect |
|---|---|
-r |
Keep backslashes as they are (always use it) |
-p text |
Show a prompt, but only when the input is a terminal |
-s |
Do not echo the input (for passwords) |
-t sec |
Give up after a timeout; the exit status is then greater than 128 |
-n N |
Return after N characters instead of a whole line |
-a name |
Split the line into an array |
-d char |
Read up to char instead of a newline (-d '' for null bytes) |
The prompt did not appear in the last example because the input was not a terminal; in an interactive terminal you would see Environment [staging]: and could type an answer. Scripts that ask questions should still work when nobody is there to answer: give every question a default, and offer a command-line option so automation never has to answer prompts. Prompt for yes, no or cancel has a complete example.
Common Mistakes
- Spaces around
=.name = valueruns a command calledname. - Unquoted expansions. Use
"$var"and"${array[@]}". - Treating empty and unset as the same when it matters. Use
${v-...}versus${v:-...}, or[[ -v v ]]. ${s/x/&}written for Bash 5.1 or older. In 5.2+,&inserts the match; escape it.- Numbers with leading zeros in arithmetic. Use
10#$n. (( count++ ))underset -e. Usecount=$(( count + 1 )).- Forgetting
declare -A. The keys silently become index 0. arr=($string)to split. Useread -r -aormapfile -t.
Summary
- Variables are strings;
declare -pshows exactly what one holds. ${v:-default},${v:?message}and${v:+alt}handle missing values;[[ -v name ]]tests whether a variable is set.#,##,%,%%,/,//,^^and,,cover most string work without external commands.- Arithmetic is 64-bit integer only; watch for octal and for
(( ))returning failure on 0. - Use indexed arrays for lists and
declare -Afor maps, always expanded as"${name[@]}".
Next: Control Structures, where [[ ]] and [ ] part ways and loops meet subshells.
Keep Learning
Tags
Found an issue?