Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Bash Variables: Quoting, Scope, and the Rules That Prevent Disasters

Sean

Platform Writer

Aug 10, 2026
8 min read

A Bash variable is assigned with name=value and read with $name. No spaces around the equals signname = value tries to run a command called name. And you should almost always write "$name" with quotes, because unquoted expansion is the source of most shell scripting bugs that involve a filename with a space in it.

Bash Variables: Quoting, Scope, and the Rules That Prevent Disasters

Bash variables look simple and have a surprising number of sharp edges: no types, no declarations, scope that leaks by default, and expansion rules that silently split your data. Here is the set that matters, in roughly the order things go wrong.

Table of contents

Assignment, and the whitespace rule

name="Alice"       # correct
count=42           # correct

name = "Alice"     # WRONG: runs the command `name` with args = and Alice
name= "Alice"      # WRONG: runs `Alice` with name set to empty
name ="Alice"      # WRONG: runs `name` with argument =Alice

The parser treats whitespace as an argument separator, so any space around = turns an assignment into a command invocation. This is the single most common beginner error and the message — name: command not found — does not obviously point at it.

Reading is straightforward, and braces disambiguate:

echo "$name"
echo "${name}"              # identical
echo "${name}_backup"       # braces required here
echo "$name_backup"         # looks for a variable called name_backup

# Command substitution
files=$(ls -1 | wc -l)
today=$(date +%F)

Use $( ) rather than backticks. Backticks do not nest, and the escaping rules inside them are genuinely awkward.

Quoting, which is the whole ballgame

If you take one thing from this page: quote your variable expansions. Unquoted, Bash performs word splitting and glob expansion on the result, which is almost never what you want.

file="my report.txt"

rm $file      # runs: rm my report.txt   -- two files, both wrong
rm "$file"    # runs: rm 'my report.txt' -- correct

pattern="*.txt"
echo $pattern     # expands the glob: a.txt b.txt c.txt
echo "$pattern"   # prints literally: *.txt

The failure is worse when the variable is empty. rm -rf $DIR/ with DIR unset becomes rm -rf /, which is the origin of a genuinely famous class of incident.

# Defensive: fail loudly if the variable is unset
: "${DIR:?DIR must be set}"
rm -rf "${DIR:?}"/*

The distinction between quote types:

  • Double quotes — expand variables and command substitutions, prevent word splitting and globbing.
  • Single quotes — expand nothing, entirely literal. Not even backslash escapes.
  • No quotes — everything expands, including splitting and globbing. Use deliberately or not at all.

The one place you want splitting is expanding an array of arguments, and even there "${array[@]}" is the quoted form that does the right thing.

Parameter expansion: defaults, trimming, and substitution

Bash has a compact syntax for the operations people usually reach for sed or an if block to do.

# Defaults
${var:-default}    # use default if var is unset or empty
${var-default}     # use default only if var is UNSET (empty is kept)
${var:=default}    # use default AND assign it
${var:?message}    # error and exit if unset or empty
${var:+alt}        # use alt only if var IS set

# Length
${#var}            # character count

# Substrings
${var:7}           # from index 7 to end
${var:0:3}         # first three characters
${var: -4}         # last four (the space before - is required)
path="/var/log/nginx/access.log"

${path##*/}        # access.log     -- longest match from front, like basename
${path%/*}         # /var/log/nginx -- shortest match from back, like dirname
${path##*.}        # log            -- extension
${path%.*}         # /var/log/nginx/access -- without extension

name="deploy-prod-v2"
${name/-/_}        # deploy_prod-v2  -- first occurrence
${name//-/_}       # deploy_prod_v2  -- all occurrences
${name^^}          # DEPLOY-PROD-V2  -- uppercase
${name,,}          # deploy-prod-v2  -- lowercase

${var:-default} is the most useful of these by a wide margin, and it is how nearly every well-written script handles optional configuration:

PORT="${PORT:-3000}"
LOG_LEVEL="${LOG_LEVEL:-info}"
WORKERS="${WORKERS:-4}"

Environment variables and scope

A plain assignment creates a shell variable, visible to the current shell and nothing else. export promotes it to an environment variable, which child processes inherit.

name="Alice"          # shell variable
export name           # now in the environment
export name="Alice"   # both in one step

# Set for one command only, without persisting
DEBUG=1 ./myscript.sh

# Inspect
printenv PATH
export -p | grep name
unset name

Exported variables flow downward only. A child process cannot modify its parent’s environment, which is why a script that sets variables must be sourced rather than executed if you want them to persist:

./setup.sh       # runs in a subshell; its variables vanish
source setup.sh  # runs in the current shell; variables persist
. setup.sh       # identical to source

Variables are global to the script by default, including inside functions. Use local in functions or you will clobber outer state:

process() {
  local file="$1"        # scoped to this function
  local count=0
  # ...
}

Forgetting local inside a function is a genuinely nasty bug class, because it works fine until a variable name collides and then produces behaviour that looks impossible.

Arrays, and integer arithmetic

# Indexed arrays
files=("a.txt" "b file.txt" "c.txt")
echo "${files[0]}"        # a.txt
echo "${files[@]}"        # all elements
echo "${#files[@]}"       # count: 3
files+=("d.txt")          # append

# Iterate SAFELY -- the quotes matter
for f in "${files[@]}"; do
  echo "processing: $f"
done

# Associative arrays -- must be declared
declare -A config
config[host]="localhost"
config[port]=5432
for key in "${!config[@]}"; do
  echo "$key = ${config[$key]}"
done

"${files[@]}" quoted expands to one word per element; ${files[*]} joins them into a single string. Using the wrong one on filenames with spaces produces exactly the bug you were trying to avoid.

# Arithmetic
count=5
(( count++ ))
(( total = count * 2 ))
echo $(( count + 10 ))

# declare -i makes a variable integer-typed
declare -i counter=0
counter+=5        # 5, arithmetic

# Without declare -i, += concatenates strings
plain=0
plain+=5          # "05", not 5

That last pair catches people. Bash has no types by default, so += means string concatenation unless you asked for integers.

The safety header every script should have

Four lines that turn silent failures into loud ones:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
  • -e — exit on any command failure rather than continuing with bad state.
  • -u — treat unset variables as an error. This alone prevents the rm -rf $DIR/ class of disaster.
  • -o pipefail — a pipeline fails if any stage fails, not just the last one. Without it, false | true succeeds.
  • IFS=$'\n\t' — remove space from the field separator, so word splitting no longer breaks on filenames with spaces.

set -e has known edge cases — it does not fire inside conditionals or on the left of && — so it is a strong default rather than a guarantee. Check exit codes explicitly where correctness matters.

Run ShellCheck over anything you intend to keep. It catches unquoted expansions, missing local, and the string-versus-integer confusion above, and it is the highest-value tool in shell scripting by a distance.

shellcheck deploy.sh

# Or in CI
docker run --rm -v "$PWD:/mnt" koalaman/shellcheck:stable ./*.sh

One last point that belongs here: secrets do not belong in scripts. Read them from the environment, injected by whatever runs the script. On RunxBuild that is the service’s environment variables — see the services documentation — which keeps credentials out of your repository and lets them rotate without a commit.

How this fits the rest of the stack

No spaces around the equals sign, quote every expansion, use ${var:-default} for optional configuration, and local inside functions. Put set -euo pipefail at the top of anything you will run unattended and let ShellCheck catch the rest. If you are moving deploy scripts onto a platform that injects configuration as environment variables, the RunxBuild hosting calculator shows what the services cost by line item.

Useful related references:

FAQ

Why does name = value fail in Bash?

Bash treats spaces as argument separators, so name = value is parsed as running a command called name with the arguments = and value. Assignment requires no whitespace around the equals sign.

Should I always quote Bash variables?

Almost always. Unquoted expansion performs word splitting and glob expansion, so a filename containing a space becomes two arguments. The main exception is when you deliberately want splitting, and even then quoted array expansion is usually correct.

What is the difference between $var and ${var}?

Nothing on their own. Braces are required when the variable name is followed by characters that could be part of a name, such as ${name}_backup, which would otherwise look for a variable called name_backup.

Why do my variables disappear after running a script?

Executing a script runs it in a subshell, and a child process cannot modify its parent’s environment. Use source script.sh to run it in the current shell if you want the variables to persist.

What does set -euo pipefail do?

It exits on command failure, treats unset variables as errors, and makes a pipeline fail if any stage fails rather than only the last. Together they turn silent misbehaviour into an immediate, visible failure.

#bash variables#shell scripting#parameter expansion#quoting#environment variables