A Bash variable is assigned with name=value and read with $name. No spaces around the equals sign — name = 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 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
- Quoting, which is the whole ballgame
- Parameter expansion: defaults, trimming, and substitution
- Environment variables and scope
- Arrays, and integer arithmetic
- The safety header every script should have
- How this fits the rest of the stack
- FAQ
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 therm -rf $DIR/class of disaster.-o pipefail— a pipeline fails if any stage fails, not just the last one. Without it,false | truesucceeds.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:
- Bash Append to File:
>>,tee -a, and Heredoc - How to Run .sh File in Linux: bash, chmod, and shebang
- Bash Array: Declaring, Looping, and the Quoting That Breaks Everything
- Services on RunxBuild
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.