Bash scripting guide · part 3 of 32
Variables
Assigning and reading shell variables, the no-spaces rule, braces, unset vs empty, scope, readonly, and the special variables bash sets for you.
A shell variable is a name holding a string. You assign with name=value and read with
$name. There is no declaration step and, unless you ask for one, no type: count=10 stores
the two characters 1 and 0, and only arithmetic contexts treat them as a number.
Assignment: no spaces around =
The shell splits a command line into words before it knows what they mean. name=value with
no spaces is one word in the shape of an assignment. Add spaces and it becomes a command
named name with arguments, which is the first error almost everyone hits.
Spaces around = run a command instead
greeting = "hello"
greeting= "hello"
greeting="hello"
echo "$greeting"
/bin/bash: line 1: greeting: command not found /bin/bash: line 2: hello: command not found hello
greeting = "hello" tries to run a command called greeting. greeting= "hello" is
subtler: it sets greeting to the empty string for one command, and that command is
hello, which does not exist either. Only the third line is an assignment.
Names may contain letters, digits and underscores and cannot start with a digit. They are
case-sensitive. A useful convention: lowercase for your script's own variables, UPPERCASE for
environment variables and shell settings (PATH, HOME, IFS), so you never clobber one by
accident. A script that sets PATH=/tmp/out because it wanted an output path will stop finding
every command.
The value is one word, so values with spaces need quotes: msg="disk full". Without them,
msg=disk full assigns disk for the duration of a command named full. Quotes on the right
side are otherwise optional for a plain variable copy (b=$a does not split), but
quoting every value is a habit that never hurts (see Quoting).
Reading: $name and ${name}
$name and ${name} are the same thing. Braces are needed when the next character could be
part of the name, and for all the operators covered in Parameter expansion.
Braces mark where the name ends
file=report
echo "$file_2026.csv"
echo "${file}_2026.csv"
.csv report_2026.csv
In the first echo, bash reads the longest valid name, file_2026, which is unset and
expands to nothing, leaving .csv. Braces end the name explicitly. A dot cannot be part of
a name, so "$file.csv" would have worked without them.
Unset is not the same as empty
A variable can be unset (it does not exist) or set to the empty string. Plain $x expands
to nothing in both cases, which hides typos: $fiel is silently empty. set -u turns a
reference to an unset variable into an error, and ${x+set} tells the two states apart.
Tell unset and empty apart
empty=""
unset missing
echo "empty is [${empty}], ${empty+set}"
echo "missing is [${missing}], ${missing-unset}"
declare -p empty missing
empty is [], set missing is [], unset declare -- empty="" /bin/bash: line 5: declare: missing: not found
Both expand to nothing, but ${var+word} gives word only when the variable exists, and
${var-word} gives word only when it does not. declare -p prints empty with its value
and fails on missing, setting status 1, because there is nothing to print.
Catch typos with set -u
#!/usr/bin/env bash
set -u
filename=notes.txt
wc -l "$filname"
echo "never printed"
bash count.sh; echo "exit status: $?"
count.sh: line 4: filname: unbound variable exit status: 1
Without set -u, the misspelled $filname would be empty and wc would have
complained about a file named '' or, unquoted, read standard input. With it,
bash stops at the unset name and the script exits with status 1. This is one third of the usual
set -euo pipefail header (Strict mode and its caveats).
Integers, read-only values and attributes
declare (also spelled typeset) attaches attributes to a variable. -i makes every
assignment an arithmetic evaluation, -r makes it read-only, -l and -u fold case on
assignment. readonly NAME=value is the POSIX spelling of declare -r. declare -p prints a
variable with its attributes, the most honest way to see what a variable really contains.
Integer, case-folding and read-only attributes
declare -i n=7
n+=3
s=7
s+=3
declare -u shout=quiet
readonly limit=5
declare -p n s shout limit
limit=10
declare -i n="10" declare -- s="73" declare -u shout="QUIET" declare -r limit="5" /bin/bash: line 8: limit: readonly variable
+= appends for strings but adds for an integer variable, so n is 10 while s is 73.
-u stored QUIET. Assigning to a read-only variable is an error; in a script it aborts that
command with status 1 and the old value stays.
Prefer $(( )) over declare -i in practice: the arithmetic is visible where it happens,
rather than depending on an attribute set fifty lines earlier (Arithmetic).
Scope: global unless you say local
Every variable in a script is global by default, including the ones assigned inside functions.
A function that uses i as a loop counter will overwrite the caller's i. Declare function
variables with local; bash scopes them dynamically, meaning they are visible to the function
and to anything it calls, and they disappear when it returns.
Without local, a function overwrites the caller's variable
count=100
leaky() { count=0; }
tidy() { local count=0; count=5; }
tidy; echo "after tidy: $count"
leaky; echo "after leaky: $count"
after tidy: 100 after leaky: 0
tidy changed only its own count. leaky assigned the global one. Make local the first
line of every function that needs scratch variables.
Variables are also invisible to programs you start unless you export them; that and the
NAME=value command prefix form are covered in Environment variables.
Special variables
Bash maintains a set of read-only-ish variables you will use constantly:
| Variable | Holds |
|---|---|
$? |
exit status of the last command |
$$ |
process ID of the shell (the main shell, even inside a subshell) |
$! |
process ID of the last background job |
$0 |
name of the script or shell |
$1 … $9, ${10} |
positional parameters (arguments) |
$# |
number of arguments |
"$@" |
all arguments, each as a separate word |
$RANDOM |
a random integer from 0 to 32767 |
$SECONDS |
seconds since the shell started |
$LINENO |
current line number in the script |
Special variables in a running script
#!/usr/bin/env bash
echo "script: $0, $# args, first: $1"
for arg in "$@"; do echo " arg: $arg"; done
false
echo "status of false: $?, line $LINENO"
sleep 1 &
echo "background pid is a number: $(( $! > 0 ))"
bash info.sh one "two words" three
script: info.sh, 3 args, first: one arg: one arg: two words arg: three status of false: 1, line 5 background pid is a number: 1
$# counts three arguments because the quoted "two words" is one. "$@" gives them back
exactly as passed. $? is read immediately after false, before any other command
replaces it. $! holds the PID of the backgrounded sleep.
$? is overwritten by every command, including echo and [. If you need it twice, save it
first: rc=$?. More on arguments in Script arguments.
Removing a variable
unset name deletes it, which is different from name=; after unset, ${name+set} is
empty again and an exported name stops being passed to children. unset -f name removes a
function of that name instead.
Unset a variable
tmpdir=/tmp/work
echo "before: ${tmpdir:-<unset>}"
unset tmpdir
echo "after: ${tmpdir:-<unset>}"
[[ -v tmpdir ]] || echo "tmpdir no longer exists"
before: /tmp/work after: <unset> tmpdir no longer exists
[[ -v name ]] (bash 4.2 and later) is true when the variable is set, even to an empty
string. Note that it takes the bare name, no $.
Related
declareCreate variables with attributes (array, associative array, integer, read-only, exported), or show how variables are defined.localDeclare variables inside a function that disappear when it returns, leaving any global of the same name untouched.unsetRemove shell variables, array elements or functions, so they no longer exist rather than being empty.exportMark shell variables (or bash functions) to be passed to every program the shell starts.setTurn shell options such as -e, -u, -x and pipefail on or off, and replace the positional parameters.- Environment variablesHow export passes variables to child processes, the one-command NAME=value prefix, loading .env files, and why a child cannot change its parent.
- Parameter expansionThe ${...} operators for defaults, lengths, substrings, trimming prefixes and suffixes, search and replace, and case conversion, without calling sed or cut.
- QuotingSingle quotes, double quotes, backslashes and $'...', what each protects, and the unquoted-variable bugs they prevent, each shown failing and then fixed.
- ArithmeticInteger math with $(( )) and (( )), the exit status of (( )) that breaks set -e, leading-zero octal surprises, and what to use for decimals.
- Strict mode and its caveatsWhat set -euo pipefail actually does, the cases where -e silently does nothing, the traps in -u and pipefail, and a balanced way to use it.
- Script argumentsPositional parameters $1 to ${10}, $# and $0, why "$@" is almost always right and "$*" rarely, shift, getopts, and ending options with --.