Variables and Expansion
Problem statement
Write a small deploy summary script that reads ENVIRONMENT and REPLICAS from the environment with safe defaults, builds a release name, does some maths, and shows why quotes matter. Variables are the first thing every script uses, and most shell bugs are quoting or expansion bugs.
The script defines a summary function and runs it twice: once with nothing set, and once as ENVIRONMENT=prod REPLICAS=5 summary.
- Print the release name
shop-<environment>-<date>and the total pods (replicas × 3 regions) for both runs. - Show that an unquoted variable holding
my report.txtturns into two words, and a quoted one stays one. - Show the difference between single and double quotes.
- Show that a child process only sees a variable after
export. - Cut the path
/var/log/nginx/access.loginto its file name, its folder, and a.gzname.
Expected output:
== defaults ==release: shop-dev-20261001, replicas: 2, total pods in 3 regions: 6== set for one command ==release: shop-prod-20261001, replicas: 5, total pods in 3 regions: 15== unquoted vs quoted ==2 argument(s)1 argument(s)== single vs double quotes ==single: $appdouble: shop== export ==child sees: nothingchild sees: blue== cutting a path ==file: access.logfolder: /var/log/nginxgzip: /var/log/nginx/access.gzHints
${ENVIRONMENT:-dev} means "the value of ENVIRONMENT, or dev if it is unset or empty". $(( replicas * 3 )) does whole-number maths.Approach
Optimal: Defaults, quotes and expansions
Covers: VAR=value, "$VAR", ${VAR:-default}, ${VAR:?message}, $(command), $(( maths )), single vs double quotes, export, VAR=x command, ${path##*/}, ${path%/*}, ${var%.ext}, strict mode.
Strict mode, used on every page in this section. The second line, set -euo pipefail, makes bash stop on mistakes instead of carrying on:
| Option | Means |
|---|---|
-e |
exit as soon as a command fails (with some exceptions, see Handle Command Failures) |
-u |
treat an unset variable as an error, instead of silently using empty text |
-o pipefail |
a pipeline fails if any command in it fails, not just the last one |
Put it right after the shebang line #!/usr/bin/env bash in every script you write.
Setting and reading a variable. name=value sets it, with no spaces around =. With spaces, name = value runs a command called name. $name or ${name} reads it. The braces are needed when text follows, as in ${app}-prod.
Always quote when you read. Bash splits an unquoted $var into words at spaces and expands * into file names. So a file called my report.txt becomes two arguments, my and report.txt:
"$var" keeps it as one argument. The rule is simple: put double quotes around every $var and $(...), unless you know you need splitting.
Defaults and required values.
| Form | Gives |
|---|---|
${VAR:-dev} |
the value, or dev if unset or empty |
${VAR:=dev} |
the same, and also sets VAR to dev |
${VAR:?is required} |
the value, or stop the script with that message |
${VAR:-} |
the value, or empty; safe under set -u |
Running commands and maths. $(date +%F) runs the command and puts its output in place (command substitution). $(( replicas * 3 )) does whole-number maths; inside, you can drop the $ on variable names.
Single vs double quotes. Inside 'single quotes' nothing is expanded: '$app' is the four characters $app. Inside "double quotes", $var, $(...) and $((...)) are expanded, but spaces and * are not touched.
Shell variables vs environment variables. A normal variable lives only in the current shell. export copies it into the environment, which child processes (scripts and programs you start) receive:
color=blue"}}:::purple --> C1["child without export
sees nothing"]:::red S --> E(["export color"]):::yellow E --> C2["child after export
sees blue"]:::green classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px
VAR=value command sets a variable just for that one command, which is how the second summary run gets prod and 5.
Cutting strings without extra tools.
| Form | On /var/log/nginx/access.log |
|---|---|
${path##*/} |
access.log (remove up to the last /) |
${path%/*} |
/var/log/nginx (remove from the last /) |
${path%.log}.gz |
/var/log/nginx/access.gz |
${#path} |
its length |
Walking through the code. The # Setup: line only unsets the two variables so the first run really uses the defaults, so skip past it.
summaryreads both variables with defaults, builds the release name from a fixed date, and prints the totals.count_argsprints how many arguments it received, unquoted and quoted.- The quote lines print the same variable both ways.
bash -cstarts a child shell; it seescoloronly afterexport color.- The last three lines cut the path.
Edge cases. ${VAR:-x} also replaces an empty value; ${VAR-x} (no colon) replaces only an unset one. Bash maths is whole numbers only: $(( 7 / 2 )) is 3; use awk for decimals.
#!/usr/bin/env bash
set -euo pipefail
# Setup: start with both variables unset, so the first run uses the defaults
unset ENVIRONMENT REPLICAS
summary() {
local env_name=${ENVIRONMENT:-dev}
local replicas=${REPLICAS:-2}
local release="shop-${env_name}-$(date -u -d @1790850600 +%Y%m%d)" # fixed date for the example
echo "release: $release, replicas: $replicas, total pods in 3 regions: $(( replicas * 3 ))"
}
echo "== defaults =="
summary
echo "== set for one command =="
ENVIRONMENT=prod REPLICAS=5 summary
count_args() { echo "$# argument(s)"; }
file="my report.txt"
echo "== unquoted vs quoted =="
count_args $file
count_args "$file"
app=shop
echo "== single vs double quotes =="
echo 'single: $app'
echo "double: $app"
echo "== export =="
color=blue
bash -c 'echo "child sees: ${color:-nothing}"'
export color
bash -c 'echo "child sees: ${color:-nothing}"'
path=/var/log/nginx/access.log
echo "== cutting a path =="
echo "file: ${path##*/}"
echo "folder: ${path%/*}"
echo "gzip: ${path%.log}.gz"RecapThe whole problem in a few lines, for the night before
- Spot it: "default value", "use the output of a command"
- Idea:
${VAR:-default},$(cmd),$(( maths )), and quote every"$var" - Cost: nothing: all done by the shell itself
- Trap: spaces around
=, or an unquoted variable splitting into several words
Interview follow-ups
Stop the script with a clear message when a required variable is missing.
Use the
:?form:: "${DB_URL:?DB_URL must be set, for example postgres://...}". The:command does nothing, but bash still expands its arguments, so a missingDB_URLstops the script right there with your message on stderr and exit code 1. Put these checks at the top of the script, one per required variable, so it fails before doing any work. Withset -u, a missing variable already stops the script, but the message is much less helpful.
Frequently asked questions
Both run a command and use its output: `date` and $(date) do the same thing. $(...) is the modern form and is easier to read, and it nests cleanly: $(dirname "$(readlink -f "$0")"). Nested backticks need backslashes and are easy to get wrong. Use $(...) in all new scripts; you will still see backticks in older ones.
Bash reads name = value as "run the command name with the arguments = and value". Assignments must have no spaces: name=value. If the value has spaces, quote it: msg="hello world". This is one of the most common first mistakes, and shellcheck catches it along with many quoting problems, so it is worth running on every script.
A plain VAR=value exists only in the current shell. Programs you start get a copy of the environment, which holds only exported variables. Use export VAR=value, or set it for one command: VAR=value ./program. The reverse is also true: a child process can never change its parent's variables, which is why cd or export inside a script you run does not change your terminal unless you source the script.