Bash & Shell Scripting for Engineers

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.

KEY CONCEPT

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.

PRO TIP

: "${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.

KEY CONCEPT

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:

value of vardefault if unset or emptyassign default if unset or empty, then usedie with msg if unset or emptyalt only if var IS setstrip shortest prefixstrip longest prefixstrip shortest suffixstrip longest suffixreplace first 'a' with 'b'replace all 'a' with 'b'uppercase alllowercase all

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.

WARNING

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

KNOWLEDGE CHECK

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 (/, //) replaces sed '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/tr for 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.