Skip to main content
Bash · lesson 3 of 22
Bash beginner 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:

VariableHolds
HOMEyour home directory
USERyour login name
PATHcolon-separated directories searched for commands
PWD / OLDPWDcurrent and previous directory (cd - uses OLDPWD)
SHELLyour 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

  1. Replace the two printf lines in splitting.sh with for loops that count how many times they run, once over $filename and 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
  2. Make the readonly violation stop the script, so the final echo never runs.

    Solution

    Add set -e at the top. The failed assignment returns a non-zero status, and set -e exits 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 variable

    The script exits with status 1 and prints nothing else.

  3. Run defaults.sh so that every line prints: the name bob, the database URL postgres://db/app, and LOG_FILE set 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/app

    It 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 and logfile would be blank.

  4. In scope.sh, make counter local 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=0

    The right-hand side still reads the global 0, so each call computes 1 into a new local copy that is thrown away when the function returns.

  5. 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: hi

    No. The environment is copied into the child when it starts, and it never flows back to the parent. That is why cd inside a script can’t change your terminal’s directory.

Frequently Asked Questions

Why can't I have spaces around the = sign when assigning variables?
Bash parses NAME = value as a command named NAME with arguments = and value. The assignment syntax NAME=value has no spaces — this is one of bash's strictest rules.
What is the difference between $@ and $*?
Both expand to all positional parameters. The key difference is quoting: "$@" expands each argument as a separate word (preserving spaces), while "$*" joins all arguments with the first character of IFS (usually a space).
When should I use export?
Use export when you want a variable to be visible to child processes (subshells, commands you run). Variables without export exist only in the current shell session.
Does assigning to a readonly variable stop a Bash script?
Not by default. In a normal non-interactive bash script the assignment fails with a 'readonly variable' error, the value stays unchanged, and the script keeps running. Add set -e (or run bash in POSIX mode) if you want that failure to abort the script.