Parameter Expansion Deep Dive
Parameter expansion is where Bash surprises people — pleasantly, for once. Most of what engineers reach for sed, awk, cut, and tr to do can be done inline with a parameter expansion. No subprocess, no temp variable, no format-string juggling.
This lesson is a tour of the forms every production Bash script uses: defaults, conditional alternates, prefix/suffix stripping, substring extraction, substitution, and case conversion. Memorizing these is the single biggest productivity upgrade once you are past beginner Bash.
Parameter expansion replaces most external text-processing tools for single-variable operations. A sed invocation to strip a prefix is a process fork. ${var#prefix} is pure Bash. On hot paths this matters; everywhere else it's just cleaner.
The default forms — unset vs empty
Four closely related forms for handling unset or empty values:
${var:-default} # if var is unset or empty, use 'default' (does NOT set var)
${var:=default} # if unset or empty, assign 'default' to var and use it
${var:?error} # if unset or empty, print 'error' to stderr and exit
${var:+alt} # if var IS set and non-empty, use 'alt'; else use empty
Without the colon, the test is "unset" only. With the colon, it's "unset OR empty."
var=""
echo ${var:-fallback} # prints "fallback" — var is empty
echo ${var-fallback} # prints "" — var IS set, just empty
unset var
echo ${var:-fallback} # prints "fallback"
echo ${var-fallback} # prints "fallback"
In practice, you almost always want the colon form (treat empty the same as unset).
:- — the default value idiom
# Read LOG_LEVEL env var, default to "info"
LOG_LEVEL="${LOG_LEVEL:-info}"
# Read a positional arg, default to current dir
DIR="${1:-.}"
# Use the user-supplied SSH key or fall back
KEY="${SSH_KEY:-$HOME/.ssh/id_rsa}"
This is the idiom for "config from env with a default." You will write it dozens of times in any real script.
:= — assign and use
# Same as :- but also stores the default back into the variable
LOG_LEVEL="${LOG_LEVEL:=info}"
echo "$LOG_LEVEL" # definitely "info" now, for the rest of the script
Less commonly useful than :- because most scripts just use :- and then work with the result.
:? — required value, or die
# Fail fast if a critical env var is missing
: "${DATABASE_URL:?must be set}"
# If DATABASE_URL is unset, the script exits here with:
# ./script.sh: line 4: DATABASE_URL: must be set
The : at the start is a no-op command — it exists just to force the expansion to evaluate. Using : lets you do the check without producing output or assigning anywhere.
: "${REQUIRED_VAR:?}" at the top of your script is the one-line fix for scripts that fail halfway through because an env var was missing. Put one per required variable right after set -euo pipefail.
:+ — "if set, use this instead"
Useful for conditional flags:
debug_flag=""
[[ "$DEBUG" = true ]] && debug_flag="--debug"
command ${debug_flag} # passes --debug only if set
# Equivalent with :+
command ${DEBUG:+--debug}
# If DEBUG is set and non-empty, expands to "--debug". Else expands to nothing.
The second form is shorter and eliminates the intermediate variable.
Prefix and suffix stripping — the #, ##, %, %% forms
For manipulating paths, filenames, and URLs without calling sed or basename:
path="/home/alice/documents/report.final.pdf"
# Strip shortest prefix matching pattern
echo "${path#*/}" # home/alice/documents/report.final.pdf
# Strip longest prefix matching pattern
echo "${path##*/}" # report.final.pdf (equivalent of basename)
# Strip shortest suffix matching pattern
echo "${path%.*}" # /home/alice/documents/report.final
# Strip longest suffix matching pattern
echo "${path%%.*}" # /home/alice/documents/report
# Strip shortest prefix up to and including the last '/'
echo "${path%/*}" # /home/alice/documents (equivalent of dirname)
Memorize the shapes: # eats from the front, % eats from the back. Double (##, %%) is greedy, single is lazy.
Common real-world uses
# Get the file extension (last .ext)
filename="report.final.pdf"
ext="${filename##*.}" # pdf
# Get the filename without extension
base="${filename%.*}" # report.final
# Strip a URL scheme
url="https://example.com/path"
host_and_path="${url#*://}" # example.com/path
# Get just the hostname
host="${host_and_path%%/*}" # example.com
# Strip a known prefix
branch="refs/heads/main"
short="${branch#refs/heads/}" # main
These four operators together replace basename, dirname, and most sed 's/prefix//' invocations for simple patterns.
Prefix/suffix operators use glob patterns, not regex. * matches any string, ? matches any single char, [abc] matches one of a/b/c. No .*, no \d, no anchors.
Substring extraction
var="Hello, World!"
echo "${var:0:5}" # Hello (start=0, length=5)
echo "${var:7}" # World! (from position 7 to end)
echo "${var:7:5}" # World (from position 7, 5 chars)
echo "${var: -6}" # World! (negative start — from end)
# NOTE: space before the minus is required
echo "${var: -6:5}" # World
Handy for fixed-width data, chopping timestamps, and the like:
datetime="20260421T143000Z"
year="${datetime:0:4}" # 2026
month="${datetime:4:2}" # 04
day="${datetime:6:2}" # 21
time="${datetime:9:6}" # 143000
Substitution — replace substrings
var="the quick brown fox"
echo "${var/quick/slow}" # the slow brown fox (first match only)
echo "${var//o/0}" # the quick br0wn f0x (ALL matches — note the //)
echo "${var/#the/THE}" # THE quick brown fox (# = anchor at start)
echo "${var/%fox/dog}" # the quick brown dog (% = anchor at end)
echo "${var/quick/}" # the brown fox (delete — empty replacement)
Common uses
# Replace spaces with underscores (valid filename)
name="my important file"
safe="${name// /_}" # my_important_file
# Strip all whitespace (well, spaces specifically)
value=" 42 "
trimmed="${value// /}" # 42
# Sanitize path: replace / with _
key="/user/42/orders"
flat="${key//\//_}" # _user_42_orders (backslash escapes the /)
The substitution pattern is also a glob, so you can match classes:
s="a1b2c3"
echo "${s//[0-9]/-}" # a-b-c-
Case conversion
var="Hello World"
echo "${var^}" # "Hello World" — uppercase first character
echo "${var^^}" # "HELLO WORLD" — uppercase all
echo "${var,}" # "hello World" — lowercase first character
echo "${var,,}" # "hello world" — lowercase all
Classic uses:
# Normalize environment comparison
if [[ "${ENV^^}" == "PROD" ]]; then ...
# Normalize user input
read -r yn
if [[ "${yn,,}" == "y" ]]; then ...
Bash 4+ required; on macOS the default /bin/bash is 3.x, so scripts relying on ^^ won't work there unless you install a newer bash and use #!/usr/bin/env bash.
Length
var="Hello"
echo "${#var}" # 5 — length in characters
arr=(a b c d e)
echo "${#arr[@]}" # 5 — number of elements in array
echo "${#arr[0]}" # 1 — length of first element
Use it for validation:
if (( ${#password} < 8 )); then
echo "password too short" >&2
exit 1
fi
Indirection — ${!var}
Variables-of-variables:
name="HOME"
echo "${!name}" # prints the value of $HOME, not the literal "HOME"
Useful for config-driven scripts:
config_key="DATABASE_URL"
value="${!config_key}"
echo "$value"
Indirection has a more powerful cousin for iterating over variables with a prefix:
PREFIX_foo=1
PREFIX_bar=2
PREFIX_baz=3
for var in "${!PREFIX_@}"; do
echo "$var = ${!var}"
done
# PREFIX_foo = 1
# PREFIX_bar = 2
# PREFIX_baz = 3
This is how you iterate "every environment variable starting with APP_" without parsing env output.
Patterns for $@ and $*
Special rules for the positional parameter set:
echo "$#" # number of positional params
echo "$@" # all params as separate words (unquoted)
echo "$*" # all params joined by $IFS
echo "${@:2}" # from param 2 onwards
echo "${@:2:3}" # 3 params starting at param 2
Almost always what you want: "$@" — quoted, expands each param as its own word with quoting preserved.
my_wrapper() {
pre-processing
real_command "$@" # pass all args to the real command, preserving spaces
post-processing
}
Passing $* or $@ without quotes is almost always wrong.
The full expansion reference
A summary table of the forms:
When parameter expansion beats the alternatives
# BAD — slow (forks sed)
ext=$(echo "$file" | sed 's/.*\.//')
# GOOD — pure bash
ext="${file##*.}"
# BAD — slow (basename is a fork + exec)
name=$(basename "$path")
# GOOD
name="${path##*/}"
# BAD — slow and unnecessary
lower=$(echo "$str" | tr '[:upper:]' '[:lower:]')
# GOOD
lower="${str,,}"
On a hot path — say, iterating over thousands of files — this matters a lot. A sed call forks an entire process; parameter expansion doesn't. A tight loop that processed 10,000 filenames in 30 seconds using sed can drop to a fraction of a second using parameter expansion.
Do not reach for sed/awk/cut/tr reflexively. For single-variable transformations, Bash parameter expansion is almost always the better choice.
Putting it together — a realistic example
A function that reads a URL and extracts its parts:
parse_url() {
local url="$1"
# Strip scheme (http:// or https://)
local rest="${url#*://}"
# Extract host (everything before the first /)
local host="${rest%%/*}"
# Extract path (everything from / onward, default /)
local path="/${rest#*/}"
[[ "$rest" == "$host" ]] && path="/"
# Extract port if present (after :)
local port=""
if [[ "$host" == *:* ]]; then
port="${host##*:}"
host="${host%:*}"
fi
echo "host=$host port=$port path=$path"
}
parse_url "https://example.com:8080/path/to/resource"
# host=example.com port=8080 path=/path/to/resource
No sed, no awk, no regex library — just parameter expansion. Fast, portable, and self-contained.
Quiz
You have a filename stored in file equal to server-dated.log.gz (three dots total: in the base, before log, before gz). Which parameter expansion gets you just the base portion (everything before .log.gz)?
What to take away
- Default forms (
:-,:=,:?,:+) cover config-with-defaults, require-or-die, and conditional flags. - Strip forms (
#,##,%,%%) replace basename/dirname and most simple sed usage. - Substitution (
/,//) replacessed 's/a/b/'for simple cases. - Substring (
:start:len) for fixed-position extraction. - Case conversion (
^^,,,) for normalizing input. - Indirection (
${!var}) for var-of-var;${!PREFIX_@}to list variables by prefix. - Prefer parameter expansion over forking to
sed/awk/cut/trfor single-value operations. Faster, cleaner, and one fewer dependency.
Next lesson: arrays and associative arrays — the safe way to build command-argument lists and the "$@" vs "$*" distinction.