opscape

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"
output
/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"
output
.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
outputexit 1
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

count.sh
#!/usr/bin/env bash
set -u
filename=notes.txt
wc -l "$filname"
echo "never printed"
bash count.sh; echo "exit status: $?"
explain
output
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
outputexit 1
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"
output
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

info.sh
#!/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
explain
output
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"
output
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