Shell Scripting: Variable Substitution (Parameter Expansion)

The fastest way I know to make a shell script feel “professional” isn’t adding more flags or clever pipelines—it’s making it resilient when the environment isn’t what you expected. Your script runs fine on your laptop, then fails in CI because an environment variable is missing. Or it silently writes logs to / because a path variable was empty. Or a teammate sets REGION="" thinking it “turns off” a feature, and your script interprets that as “present and valid”.

Variable substitution (more precisely, parameter expansion) is the shell’s built-in tool for handling those realities. It lets you express defaults, required values, and conditional behavior without spawning external processes. Done right, it turns brittle scripts into predictable, self-checking tools.

I’m going to show you the core forms you’ll see in POSIX sh and modern shells (Bash, dash, BusyBox ash, ksh, zsh), how “unset vs empty” changes behavior, and patterns I use in 2026 for containerized apps, CI runners, and developer tooling. You’ll get runnable examples, common mistakes I see in code reviews, and a few rules that keep substitution safe and readable.

Substitution vs escape sequences: what the shell replaces for you

Before variable substitution, it helps to separate two ideas that often get mixed together:

  • Escape sequences are about how you represent special characters (newlines, tabs, backslashes) inside strings.
  • Parameter expansion is about how the shell replaces $NAME (or ${NAME...}) with a value.

A small but important 2026 habit: I prefer printf over echo for anything involving escapes. echo -e behaves differently across shells, and even plain echo can interpret -n or backslashes depending on the implementation.

Here’s a portable example you can run with /bin/sh:

#!/bin/sh

printf ‘Line one\n‘

printf ‘\tIndented with a tab\n‘

printf ‘A backslash looks like this: \\ \n‘

The shell does not “magically” interpret \n everywhere; printf does because you asked it to. That distinction matters later when you combine parameter expansion with printf format strings.

The mental model: unset, empty, and why the colon matters

In substitution forms like ${NAME:-default}, the shell is not checking “truthiness”. It’s checking state:

  • Unset: the variable was never assigned.
  • Set but empty: the variable exists, but its value is "".
  • Set and non-empty: the variable has characters.

In POSIX shells, parameter expansion has two families:

  • Without : (e.g., ${NAME-default}): treats only unset as “missing”.
  • With : (e.g., ${NAME:-default}): treats unset or empty as “missing”.

This one character is the source of a lot of production weirdness.

Runnable demo:

#!/bin/sh

show() {

label="$1"

value="$2"

printf ‘%s: %s\n‘ "$label" "$value"

}

unset SERVICE_PORT

show ‘1) unset + -‘ "${SERVICE_PORT-8080}"

show ‘2) unset + :-‘ "${SERVICE_PORT:-8080}"

SERVICE_PORT=""

show ‘3) empty + -‘ "${SERVICE_PORT-8080}"

show ‘4) empty + :-‘ "${SERVICE_PORT:-8080}"

SERVICE_PORT="9090"

show ‘5) value + -‘ "${SERVICE_PORT-8080}"

show ‘6) value + :-‘ "${SERVICE_PORT:-8080}"

What you should take away:

  • If an empty string is a meaningful configuration (rare, but possible), avoid the colon forms.
  • If empty should behave like “not configured” (common), use the colon forms.

Plain substitution and the “always brace” rule

You can reference variables as $NAME or ${NAME}. I strongly prefer braces in anything beyond the simplest one-liner because it prevents ambiguity and makes refactors safer:

  • ${LOG_DIR}/app.log is unambiguous.
  • $LOGDIR/app.log is fine, but ${LOGDIR}suffix vs $LOG_DIRsuffix is a classic foot-gun.

Example:

#!/bin/sh

APP_NAME="payments"

LOGDIR="/var/log/${APPNAME}"

printf ‘Log file: %s\n‘ "${LOG_DIR}/worker.log"

Braces are also required for the substitution operators (:-, :=, :+, :?), so it’s consistent to default to them.

Defaulting values with :- (and when not to)

${NAME:-value} substitutes value when NAME is unset or empty; otherwise it substitutes the current value. It does not change the variable.

This is my everyday tool for “reasonable defaults”:

#!/bin/sh

Use an environment override if present; otherwise default.

APIBASEURL="${APIBASEURL:-https://api.internal.example}"

DEPLOYREGION="${DEPLOYREGION:-us-east-1}"

printf ‘API base: %s\n‘ "${APIBASEURL}"

printf ‘Region: %s\n‘ "${DEPLOY_REGION}"

Two guidelines I follow:

1) Defaulting is for optional config. If a value is actually required, defaulting can hide real problems.

2) Keep defaults close to where they matter. Don’t scatter them across unrelated files.

A subtle but useful variant is the non-colon form ${NAME-default} if you want empty to be allowed:

# Empty string is intentionally meaningful here.

BANNERTEXT="${BANNERTEXT-default banner}"

That reads as: “only use the default if it was never set at all.”

Assigning defaults with := (and why it’s more than a shortcut)

${NAME:=value} does two things when NAME is unset or empty:

  • It substitutes value.
  • It assigns value to NAME.

This is useful when multiple parts of the script need the same computed default and you don’t want to repeat it.

Runnable example:

#!/bin/sh

Compute a default log directory once, and persist it.

: "${APP_NAME:=payments}"

: "${LOGDIR:=/var/log/${APPNAME}}"

printf ‘APPNAME=%s\n‘ "${APPNAME}"

printf ‘LOGDIR=%s\n‘ "${LOGDIR}"

Two things to notice:

  • I’m using the : builtin (a no-op command) to make the assignment stand alone. It reads cleanly and works in POSIX sh.
  • The expansion happens before the command runs, so the assignment is applied by the shell itself.

Caution I mention in code reviews: := can surprise you if you treat empty as “present but disabled”. If someone exports LOGDIR="" to disable logging, := will override that. In that case, use the non-colon form ${LOGDIR=/var/log/...} or make the disable behavior explicit.

Conditional substitution with :+: feature toggles without branching

${NAME:+value} substitutes value only if NAME is set (or with :+, set and non-empty). Otherwise it substitutes an empty string.

This is handy for composing optional flags safely.

Example: add --verbose only when requested.

#!/bin/sh

VERBOSE="${VERBOSE:-}"

cmd="deploy-tool"

cmd="${cmd}${VERBOSE:+ --verbose}"

printf ‘Running: %s\n‘ "${cmd}"

shellcheck disable=SC2086

$cmd

A few notes:

  • For real scripts, I usually prefer building arguments via set -- to avoid word-splitting surprises (I’ll show that pattern shortly).
  • :+ reads well for “presence means enable”, especially when the variable is an env var used as a toggle.

Guardrails with :?: fail fast with a message you’ll thank yourself for

${NAME:?message} is my favorite form for required configuration. If NAME is unset (or with :?, unset or empty), the shell prints the message to standard error and (in most shells) aborts the script with a non-zero status.

This is the antidote to the “mysterious blank value” bug.

#!/bin/sh

set -eu

Require a token and a target environment.

: "${DEPLOYENV:?Set DEPLOYENV to staging or production}"

: "${APITOKEN:?Export APITOKEN with deploy credentials}"

printf ‘Deploying to %s...\n‘ "${DEPLOY_ENV}"

Why I like the : pattern here:

  • It keeps the guard in one place.
  • It doesn’t do extra work.
  • It pairs naturally with set -u (treat unset variables as errors).

One caution: when you use set -e, errors abort quickly, which is often what you want. If you need more controlled error handling (for example, validating multiple required variables and printing all missing ones), you can check them explicitly instead of relying on :?.

A practical pattern I use: layered config without external tools

Shell scripts often need to assemble configuration from:

  • environment variables (CI, containers)
  • optional .env files (local dev)
  • CLI flags
  • safe defaults

You can do a lot of this with parameter expansion, without reaching for sed, awk, or envsubst.

Here’s a runnable POSIX sh pattern for argument parsing + substitution, using set -- to avoid quoting pitfalls:

#!/bin/sh

set -eu

DEPLOYENV="${DEPLOYENV:-}"

DEPLOYREGION="${DEPLOYREGION:-us-east-1}"

VERBOSE="${VERBOSE:-}"

while [ "$#" -gt 0 ]; do

case "$1" in

--env)

DEPLOY_ENV="$2"

shift 2

;;

--region)

DEPLOY_REGION="$2"

shift 2

;;

--verbose)

VERBOSE=1

shift

;;

*)

printf ‘Unknown argument: %s\n‘ "$1" >&2

exit 2

;;

esac

done

: "${DEPLOYENV:?Pass --env or export DEPLOYENV}"

set -- deploy-tool --env "${DEPLOYENV}" --region "${DEPLOYREGION}"

if [ -n "${VERBOSE}" ]; then

set -- "$@" --verbose

fi

printf ‘Running:‘

for arg in "$@"; do printf ‘ %s‘ "$arg"; done

printf ‘\n‘

"$@"

Where substitution shows up:

  • "${DEPLOY_REGION:-us-east-1}" gives you a default.
  • "${DEPLOY_ENV:?...}" enforces a requirement.
  • "${VERBOSE:-}" plus -n drives a toggle.

This style scales because it keeps quoting correct and avoids building a command as a single string.

Common mistakes (and the exact fixes I recommend)

These are the substitution-related issues I keep seeing in real repos.

1) Unquoted expansions

If you write:

  • rm -rf $TARGET_DIR

and TARGET_DIR is empty, you just ran rm -rf on the current directory (or worse, the expansion splits into multiple paths). Always quote unless you have a specific, reviewed reason not to.

Fix:

  • rm -rf "${TARGETDIR:?Set TARGETDIR}"

2) Confusing empty with unset

Teams often treat FEATUREFLAG="" as “off”. If your script uses ${FEATUREFLAG:-0}, that empty value becomes 0 and you might think it’s safe—but later you might use :+ and get a different behavior.

Fix:

  • Decide policy: “unset means off” or “empty means off”.
  • Encode it with colon vs non-colon expansions consistently.

3) Relying on echo -e for escapes

As mentioned earlier, this varies across shells.

Fix:

  • Use printf and put escape handling where it belongs.

4) Using := where you only wanted a temporary default

${NAME:=value} changes state. That can break later checks like “was this user-provided?”

Fix:

  • Use ${NAME:-value} when you don’t want assignment.
  • Or store computed defaults in a separate variable like EFFECTIVE_NAME.

5) Combining substitution with eval

The moment you write eval, your risk profile changes. Parameter expansion is safe; eval re-parses text as code.

Fix:

  • Prefer set -- for argument lists.
  • If you truly need templating, consider a dedicated tool and treat input as untrusted.

A quick reference table (the forms I actually use)

When I’m scanning a script in a code review, these are the operators I expect to see, and I expect them to be used consistently.

Form

When it substitutes the word

Empty counts as “missing”?

Side effect

—:

${VAR-word}

VAR is unset

No

None

${VAR:-word}

VAR is unset or empty

Yes

None

${VAR=word}

VAR is unset

No

Assigns VAR

${VAR:=word}

VAR is unset or empty

Yes

Assigns VAR

${VAR+word}

VAR is set

No

None

${VAR:+word}

VAR is set and non-empty

Yes

None

${VAR?msg}

VAR is unset

No

Error/exit (typical)

${VAR:?msg}

VAR is unset or empty

Yes

Error/exit (typical)Two small but important details that don’t fit in a table:

  • The “word” part is expanded too. That means ${VAR:=$(date)} runs date in shells that support $(...) in that context (most do). I try not to do that in parameter expansion; I prefer computing values explicitly so side effects are obvious.
  • The colon versions (:-, :=, :+, :?) treat empty as missing. If your team uses empty string as a meaningful value, pick the non-colon versions and stick to them.

set -u and substitutions that won’t explode

A lot of people turn on set -u (treat unset variables as errors) and then immediately hit a wall: the script starts crashing on perfectly normal optional variables.

The trick is that some expansions are “safe” under set -u because they provide an explicit fallback. For optional vars, I typically initialize them early:

#!/bin/sh

set -eu

Safe under set -u: the - form does not require VAR to be set.

VERBOSE="${VERBOSE-}"

CACHEDIR="${CACHEDIR-}"

if [ -n "${VERBOSE}" ]; then

printf ‘Verbose logging enabled\n‘

fi

What I avoid under set -u:

  • Writing if [ -n "$VERBOSE" ]; then ... when VERBOSE might be unset.
  • Using ${VAR} bare in lots of places before you’ve decided whether it’s required or optional.

A pattern I like in larger scripts is to define “effective” variables once, right after parsing inputs:

EFFECTIVEREGION="${DEPLOYREGION:-us-east-1}"

EFFECTIVELOGDIR="${LOG_DIR:-/var/log/app}"

Then later code uses only EFFECTIVE_* values. It keeps the rest of the script boring (which is a compliment).

Strings are data: trimming and rewriting with # and %

So far I’ve focused on defaults and guardrails. The other half of parameter expansion is string manipulation, and it’s surprisingly useful for cleaning up environment-provided values.

These forms are POSIX and extremely common:

  • ${VAR#pattern}: remove the shortest matching pattern from the start
  • ${VAR##pattern}: remove the longest matching pattern from the start
  • ${VAR%pattern}: remove the shortest matching pattern from the end
  • ${VAR%%pattern}: remove the longest matching pattern from the end

The pattern is a shell pattern (globbing), not a regex.

Normalizing paths (no trailing slash, no double slashes)

When scripts compose paths, trailing slashes are a recurring annoyance. You can normalize a directory path so it never ends with /:

#!/bin/sh

LOGDIR="${LOGDIR:-/var/log/myapp}"

LOGDIR="${LOGDIR%/}"

printf ‘Log dir: %s\n‘ "${LOG_DIR}"

printf ‘Log file: %s\n‘ "${LOG_DIR}/app.log"

If LOGDIR was /var/log/myapp/, ${LOGDIR%/} yields /var/log/myapp. If it was already without a trailing slash, it stays unchanged.

If you need to remove all trailing slashes (e.g., ////), you can use the greedy form:

LOGDIR="${LOGDIR%%/}"

Extracting pieces (protocol, host, and tag-like strings)

I don’t recommend writing a full URL parser in shell, but simple, controlled strings are fair game.

Example: strip a leading https:// or http:// if present:

#!/bin/sh

BASEURL="${BASEURL:-https://example.internal}"

BASEURL="${BASEURL#http://}"

BASEURL="${BASEURL#https://}"

printf ‘Host-ish: %s\n‘ "${BASE_URL}"

Because # only removes if the prefix matches, doing it twice is safe.

Example: split name:tag where the tag is optional:

#!/bin/sh

IMAGEREF="${IMAGEREF:-service:latest}"

NAME="${IMAGE_REF%%:*}"

TAG="${IMAGE_REF#*:}"

If there was no colon, TAG becomes the whole string, so guard it.

if [ "${NAME}" = "${IMAGE_REF}" ]; then

TAG="latest"

fi

printf ‘NAME=%s TAG=%s\n‘ "${NAME}" "${TAG}"

This isn’t a generic parser, but for a script where you control input format it’s a pragmatic, dependency-free solution.

Practical warning: patterns are globs

Because the pattern is a glob, characters like * and ? are special. If your variables can contain those characters literally (rare for paths, more common for user-provided strings), treat string manipulation with extra care and consider a different approach.

Safer paths: making destructive commands boring

If you do one “production hardening” upgrade to a script, make it this: treat paths as hazardous until proven otherwise.

Here’s the minimum safe posture I aim for when deleting or rewriting anything:

1) Require the variable (:?).

2) Normalize it (trim trailing /).

3) Refuse dangerous values (empty, /, ., or a known parent directory).

4) Quote it.

A POSIX sh helper I reuse:

#!/bin/sh

requiresafedir() {

dir="$1"

# Normalize.

dir="${dir%/}"

case "$dir" in

‘‘/.)

printf ‘Refusing dangerous dir: %s\n‘ "$dir" >&2

return 1

;;

esac

# Optional: refuse deleting home or repo root (customize as needed).

case "$dir" in

"$HOME"|"$HOME"/*)

printf ‘Refusing HOME dir: %s\n‘ "$dir" >&2

return 1

;;

esac

printf ‘%s\n‘ "$dir"

}

TARGETDIR="${TARGETDIR:?Set TARGET_DIR to a directory to delete}"

TARGETDIR="$(requiresafedir "$TARGETDIR")" || exit 1

rm -rf -- "$TARGET_DIR"

I’m intentionally mixing substitution with a tiny amount of explicit validation. Parameter expansion is great, but it’s not a full validation system. The combination is what makes scripts safe.

Validating variable shape: enums, numbers, and “non-empty is not enough”

:? tells you the value exists, not that it’s valid. In production scripts, a “present but wrong” value is often more dangerous than a missing one.

Enum validation with case

When I have a variable that should only take one of a few values, case is the cleanest POSIX tool:

#!/bin/sh

set -eu

DEPLOYENV="${DEPLOYENV:?Set DEPLOY_ENV}"

case "$DEPLOY_ENV" in

devstagingproduction)

:

;;

*)

printf ‘Invalid DEPLOYENV: %s (expected devstagingproduction)\n‘ "$DEPLOYENV" >&2

exit 2

;;

esac

I like doing this right after reading configuration. It localizes failures and prevents long scripts from doing partial work with invalid state.

Numeric validation without external tools

If the value should be a positive integer (ports, retries, timeouts), you can validate with pattern matching:

#!/bin/sh

RETRIES="${RETRIES:-3}"

case "$RETRIES" in

‘‘|[!0-9])

printf ‘Invalid RETRIES: %s (expected digits)\n‘ "$RETRIES" >&2

exit 2

;;

esac

You can also forbid 0 if that matters.

if [ "$RETRIES" -eq 0 ]; then

printf ‘Invalid RETRIES: 0 (must be >= 1)\n‘ >&2

exit 2

fi

This avoids spawning grep or awk, and it stays portable.

Positional parameters: defaults and required args

Parameter expansion works on positional parameters too, which is great for small scripts that want “arg or env or default” behavior.

Required argument with a good error

If you want the first argument to be required:

#!/bin/sh

set -eu

INPUT_PATH="${1:?Usage: $0 }"

printf ‘Reading %s\n‘ "$INPUT_PATH"

That error message is short, but it does its job. If you want richer help, you can still use a usage() function, but this is a nice baseline.

Arg-or-env fallback without branching

If you want --env style input but also allow DEPLOY_ENV:

#!/bin/sh

set -eu

DEPLOYENV="${1-${DEPLOYENV-}}"

DEPLOYENV="${DEPLOYENV:?Usage: $0 (or export DEPLOY_ENV)}"

printf ‘Deploy env: %s\n‘ "$DEPLOY_ENV"

Notice the non-colon - form here: I’m explicitly saying “if the positional parameter is unset, fall back to the env var if it’s set; then require it.”

Here-docs and templating: when variable substitution is the templating engine

I see a lot of scripts reach for envsubst or sed templating immediately. Sometimes that’s the right call (especially for complex templates), but for simple config files, parameter expansion plus a here-doc is often enough.

A simple template with explicit defaults

#!/bin/sh

set -eu

APPPORT="${APPPORT:-8080}"

LOGLEVEL="${LOGLEVEL:-info}"

cat > app.conf <<EOF

port=${APP_PORT}

loglevel=${LOGLEVEL}

EOF

This is intentionally boring: defaults are declared, then the file is written.

Preventing substitution when you want a literal $

When you want a here-doc to contain literal $HOME or ${VAR} text, quote the delimiter:

cat > literal.txt <<'EOF'

This is not expanded: $HOME

Neither is this: ${VAR:-default}

EOF

Knowing both forms (expand vs literal) lets you keep templating explicit and reduces surprise.

Alternative approaches (and when I pick them instead)

Parameter expansion is powerful, but it’s not always the best tool.

  • For complex configuration (nested structures, lists, JSON), I usually stop fighting shell and use a language with a parser (Python, Node, Go) or a purpose-built tool like jq.
  • For user input from outside the repo (CI variables from forks, runtime user-provided values), I assume it’s untrusted. I still use :? and :-, but I pair them with strict validation and avoid dynamic evaluation.
  • For large templates that need repeated substitutions across many files, a dedicated templater can be more maintainable. The main risk to manage is input sanitization and quoting (especially if templates generate shell).

What I do not do is treat parameter expansion as “string interpolation” like in other languages. In shell, interpolation interacts with word splitting and globbing, so I bias toward explicit quoting and argument-list building.

Portability and performance: what matters in 2026 shells

Parameter expansion is one of the rare tools that is both fast and portable.

  • Fast: it runs inside the shell process. In CI containers, avoiding external processes typically saves noticeable time in loops (think “a few milliseconds per spawn” in the best cases, and often much worse on cold or busy runners).
  • Portable: :-, :=, :+, :?, plus #/% trimming, are POSIX. That matters if your script runs under dash (common on Debian/Ubuntu), BusyBox ash (common in minimal containers), or sh in CI runners.

A few portability notes I keep in mind:

  • ${var:offset} substring and case conversion like ${var,,} are not POSIX (Bash/Zsh/Ksh features). Use them only when you control the shell (#!/usr/bin/env bash) and enforce it.
  • set -o pipefail is not POSIX; it’s a Bash/Ksh/Zsh feature. If you need it, be explicit about your shell.

A quick “builtin vs external” comparison you can feel

Here’s the kind of micro-pattern that adds up in real scripts. Suppose you want to remove a trailing slash:

  • External approach: LOGDIR=$(printf ‘%s‘ "$LOGDIR" | sed ‘s:/*$::‘)
  • Builtin approach: LOGDIR="${LOGDIR%/}"

Both can work, but the builtin approach avoids a pipeline, avoids subshell overhead, and avoids depending on sed behavior in minimal environments. In a tight loop, that difference becomes visible.

Performance note with a reality check

I don’t obsess over micro-optimizations in scripts that run once. I do care when:

  • the script runs in CI on every commit
  • the script runs as part of a build step for many services
  • the script runs on constrained systems (tiny containers, embedded busybox environments)

In those cases, parameter expansion is an easy win: it improves both speed and reliability by reducing moving parts.

Modern workflow note: I almost always run ShellCheck and a formatter (shfmt) in pre-commit or CI. AI assistants can draft shell snippets quickly, but ShellCheck is still the fastest way to catch subtle expansion and quoting mistakes before they ship.

A few extra pitfalls I watch for in code reviews

Beyond the “common mistakes” list, these are the ones that bite experienced developers too.

1) Using ${VAR:-...} inside arithmetic contexts

Arithmetic expansion ($(( ... ))) has its own rules, and mixing string defaults into it can produce confusing errors.

If you’re going to do arithmetic, validate first and then use arithmetic:

TIMEOUT="${TIMEOUT:-30}"

case "$TIMEOUT" in ‘‘|[!0-9]) printf ‘Bad TIMEOUT\n‘ >&2; exit 2;; esac

TIMEOUT_SECS=$((TIMEOUT + 0))

2) Assuming “set” implies “exported”

Parameter expansion reads shell variables and environment variables the same way, but child processes only see exported values.

If you compute a default with := and then call another tool that needs it, export explicitly:

: "${APIBASEURL:=https://api.internal.example}"

export APIBASEURL

3) Forgetting that defaults are evaluated every time

${VAR:-word} does not assign, so if word is expensive (a command substitution, a long string building expression), it will be evaluated each time you expand it.

If the default is computed, I prefer computing once:

if [ -z "${CACHE_DIR-}" ]; then

CACHE_DIR="/tmp/myapp-cache"

fi

Or if you truly want to stick to parameter expansion, use := and be explicit about the side effect.

4) Confusing ${VAR+word} with ${VAR:+word}

This is one of those tiny syntactic differences that causes big behavior differences.

  • ${VAR+word} triggers if VAR is set (even if empty).
  • ${VAR:+word} triggers only if VAR is set and non-empty.

If your toggle variable is allowed to be empty, ${VAR+word} may be the correct choice—but it’s rare, so I comment or name it clearly when I use it.

Key takeaways I want you to keep

  • Treat variable substitution as your script’s contract with its environment: defaults for optional values, guards for required ones.
  • Be explicit about unset vs empty. The colon in :-, :=, :+, :? encodes that decision.
  • Prefer printf for escapes and always quote expansions unless you have a strong reason not to.
  • Use string-trimming expansions (# and %) to normalize data at the boundaries (paths, refs, simple prefixes).
  • Pair substitution with validation when values have a required shape (enums, numbers, safe directories).

If you want a practical next step, pick one script in your repo that reads env vars and make it stricter: add set -eu, replace silent defaults on required values with : "${NAME:?message}", and convert any string-built commands into set -- argument lists. Then run ShellCheck and fix only what it flags around expansions and quoting first—you’ll usually remove the most failure-prone behavior in under an hour, and your future self (and CI) will be noticeably calmer.

Scroll to Top