Bash · lesson 3 of 22
Bash Variables
Master variable assignment, quoting rules, environment variables, export, and special variables like $0, $1, $#, $@, and $$.
By SkillByExample · Updated
Every example below is a complete script. The outputs were captured by running each one
with bash 5.2 on Ubuntu 24.04 as a user named alice.
Variable Assignment
Bash assignment has no spaces around =. A space turns the line into a command invocation.
name="Alice"
age=30
echo "name=$name age=$age"
name = "Bob" # spaces turn this into a command called "name"
echo "name is still: $name"
$ bash assign.sh
name=Alice age=30
assign.sh: line 5: name: command not found
name is still: Alice
Bash read line 5 as “run the program name with the arguments = and Bob”. No such
program exists, so it printed an error and carried on. The variable was never touched.
Also note the script did not stop: a failed command does not end a Bash script unless you
ask for that (see the error-handling lesson on set -e).
By convention, UPPERCASE names are for environment variables and constants, and lowercase names are for a script’s own variables. Names are case-sensitive.
Quoting and Word Splitting
Put a $ in front of the name to read a variable. Whether you put the result in quotes
changes what the command receives.
filename="my file.txt"
printf '[%s]\n' $filename # unquoted: split into two words
echo "---"
printf '[%s]\n' "$filename" # quoted: one word
$ bash splitting.sh
[my]
[file.txt]
---
[my file.txt]
printf prints each argument it receives in brackets, so it shows exactly what the command
received. Without quotes, Bash split the value on whitespace and passed two arguments. Run
rm $filename and it would try to delete two files, my and file.txt, neither of
which is the file you meant. Quote every expansion unless you specifically want
splitting.
Brace Syntax ${}
You need braces when text follows a variable name directly. Without them, Bash can’t tell where the name ends.
prefix="pre"
echo "1: $prefixfix" # looks up a variable named "prefixfix"
echo "2: ${prefix}fix" # braces end the name after "prefix"
$ bash braces.sh
1:
2: prefix
Line 1 is empty, not an error. An unset variable expands to nothing by default, so the typo
fails silently. (set -u, covered in the error-handling lesson, turns this into a hard
error.) Braces also unlock parameter expansion: defaults, substrings and search-and-replace.
Quoting Rules
Double quotes expand $variables and $(commands). Single quotes keep every character
literally.
name="World"
echo "Hello, $name"
echo 'Hello, $name'
echo "Home is $(cd ~ && pwd)"
echo 'Home is $(cd ~ && pwd)'
echo "Backticks: `whoami`"
$ bash quotes.sh
Hello, World
Hello, $name
Home is /home/alice
Home is $(cd ~ && pwd)
Backticks: alice
Backticks are the old spelling of $(...). They still work, but they can’t nest cleanly,
so prefer $(...). Use single quotes when you need a literal $ or backslash, as in regex
patterns and awk programs.
Environment Variables
The shell hands environment variables to every program it starts. That is why git,
curl and docker read their configuration from them. Common ones:
| Variable | Holds |
|---|---|
HOME | your home directory |
USER | your login name |
PATH | colon-separated directories searched for commands |
PWD / OLDPWD | current and previous directory (cd - uses OLDPWD) |
SHELL | your login shell |
env and printenv list the exported variables. set also lists the shell’s own
variables and functions.
export
export marks a variable so that child processes inherit it.
DB_PASS="secret"
bash -c 'echo "child sees: [$DB_PASS]"'
export DB_PASS
bash -c 'echo "child sees: [$DB_PASS]"'
DB_PASS="changed" # already exported, so the new value flows too
bash -c 'echo "child sees: [$DB_PASS]"'
$ bash export.sh
child sees: []
child sees: [secret]
child sees: [changed]
Each bash -c is a new process. Before the export, that process started without the
variable. Once a name is exported it stays exported, so later assignments reach later
children too. You don’t need to export again after changing the value.
unset and readonly
unset removes a variable. readonly stops later assignments from changing it.
name="Alice"
unset name
echo "after unset: [${name}]"
readonly MAX_RETRIES=3
MAX_RETRIES=5
echo "MAX_RETRIES=$MAX_RETRIES"
$ bash readonly.sh
after unset: []
readonly.sh: line 6: MAX_RETRIES: readonly variable
MAX_RETRIES=3
Many people expect the last line never to print. readonly protects the value, but in
a normal script the failed assignment is just an error message, and execution continues
with the original 3. To make it fatal, add set -e (see Practice task 2). declare -r NAME=value is an equivalent spelling of readonly.
Special Variables
Bash sets a handful of variables that describe the script and its arguments.
echo "script: $0"
echo "first: $1"
echo "second: $2"
echo "count: $#"
for a in "$@"; do echo " @ -> [$a]"; done
for a in "$*"; do echo " * -> [$a]"; done
false
echo "exit code of false: $?"
$ bash args.sh alpha "two words" gamma
script: args.sh
first: alpha
second: two words
count: 3
@ -> [alpha]
@ -> [two words]
@ -> [gamma]
* -> [alpha two words gamma]
exit code of false: 1
"$@" kept all three arguments separate, including two words. "$*" joined them into a
single string. That is why "$@" is the right way to forward arguments to another
command. $? holds the exit code of the last command: 0 means success and anything else
means failure. The other special variables are $$ (this shell’s PID, often used for
unique temp filenames, though mktemp is safer), $! (the PID of the last background job)
and $- (the current shell option flags).
Default Values
Parameter expansion can supply a fallback, assign a default, or stop the script when a value is missing.
name="${1:-anonymous}"
logfile="${LOG_FILE:-/tmp/app.log}"
echo "name=$name logfile=$logfile"
: "${CONFIG_DIR:=/etc/myapp}"
echo "CONFIG_DIR=$CONFIG_DIR"
db="${DATABASE_URL:?DATABASE_URL must be set}"
echo "db=$db"
$ bash defaults.sh
name=anonymous logfile=/tmp/app.log
CONFIG_DIR=/etc/myapp
defaults.sh: line 8: DATABASE_URL: DATABASE_URL must be set
:- supplies a fallback without changing the variable. := also assigns it, and the :
command in front is a no-op that just triggers the expansion. :? is different from
everything else in this lesson because it does stop the script. The last echo never
ran, and the script exited with status 1. That makes it the idiomatic guard for required
configuration.
Variable Scope
Variables are global by default, even when they are assigned inside a function. local
limits a variable to the function it is declared in.
greeting="global"
counter=0
say_hello() {
local greeting="local"
counter=$((counter + 1)) # no local: modifies the global
echo "inside: greeting=$greeting counter=$counter"
}
say_hello
echo "outside: greeting=$greeting counter=$counter"
$ bash scope.sh
inside: greeting=local counter=1
outside: greeting=global counter=1
greeting was declared local, so the outer value survived. counter was not, so the
function’s increment changed the script-wide variable. In any function longer than a few
lines, declare its variables local so it can’t overwrite the caller’s.
Practice
-
Replace the two
printflines insplitting.shwithforloops that count how many times they run, once over$filenameand once over"$filename". Predict the counts first.Solution
filename="my file.txt" n=0; for w in $filename; do n=$((n+1)); done; echo "unquoted loop ran $n times" n=0; for w in "$filename"; do n=$((n+1)); done; echo "quoted loop ran $n times"$ bash p1.sh unquoted loop ran 2 times quoted loop ran 1 times -
Make the
readonlyviolation stop the script, so the finalechonever runs.Solution
Add
set -eat the top. The failed assignment returns a non-zero status, andset -eexits on it:set -e readonly MAX_RETRIES=3 MAX_RETRIES=5 echo "MAX_RETRIES=$MAX_RETRIES"$ bash p2.sh p2.sh: line 3: MAX_RETRIES: readonly variableThe script exits with status 1 and prints nothing else.
-
Run
defaults.shso that every line prints: the namebob, the database URLpostgres://db/app, andLOG_FILEset but empty. Which log file does it pick, and why?Solution
$ DATABASE_URL=postgres://db/app LOG_FILE= bash defaults.sh bob name=bob logfile=/tmp/app.log CONFIG_DIR=/etc/myapp db=postgres://db/appIt still picks
/tmp/app.log. The colon in:-means “unset or empty”. With plain${LOG_FILE-/tmp/app.log}(no colon), the empty value would win andlogfilewould be blank. -
In
scope.sh, makecounterlocal inside the function, then call it twice. Predict all three printed values.Solution
counter=0 bump() { local counter=$((counter + 1)) echo "inside: counter=$counter" } bump bump echo "outside: counter=$counter"$ bash p4.sh inside: counter=1 inside: counter=1 outside: counter=0The right-hand side still reads the global
0, so each call computes1into a new local copy that is thrown away when the function returns. -
A child process changes an exported variable. Does the parent see the change?
Solution
export GREETING="hi" bash -c 'GREETING="changed in child"; echo "child: $GREETING"' echo "parent: $GREETING"$ bash p5.sh child: changed in child parent: hiNo. The environment is copied into the child when it starts, and it never flows back to the parent. That is why
cdinside a script can’t change your terminal’s directory.