opscape

Bash scripting guide · part 7 of 32

Parameter expansion

The ${...} operators for defaults, lengths, substrings, trimming prefixes and suffixes, search and replace, and case conversion, without calling sed or cut.

${name} is the plain form of $name. Inside the braces, bash accepts a set of operators that transform the value as it is expanded: supply a default, cut off a prefix, replace text, change case. They run inside the shell, with no extra process, so they are much faster than piping a variable through sed, cut or basename, and they handle any characters correctly. The parameter expansion cheat sheet lists every form on one page.

Defaults for unset or empty variables

Four operators deal with variables that may be missing. With the colon they treat an empty value like an unset one; without it, only truly unset counts.

Form Result when var is unset or empty
${var:-word} expands to word; var unchanged
${var:=word} assigns word to var, then expands to it
${var:?msg} prints msg and fails (a script exits)
${var:+word} the opposite: word only when var is set and non-empty

Default values for optional settings

port=""
echo "port: ${port:-8080}"
echo "port without colon: [${port-8080}]"
echo "log level: ${LOG_LEVEL:=info}, and now LOG_LEVEL=$LOG_LEVEL"
verbose=1
echo "flags: ${verbose:+--verbose}"
output
port: 8080
port without colon: []
log level: info, and now LOG_LEVEL=info
flags: --verbose

port is set but empty, so :- used 8080, while - without the colon kept the empty value. := assigned info to LOG_LEVEL as well. :+ is handy for optional flags: it produces --verbose only when verbose has a value.

${1:-default} is the standard way to give a script argument a default, as the fixture's scripts/greet.sh does. := cannot assign to positional parameters, only to named variables; the usual pattern is : "${OUTPUT_DIR:=./out}", where : is the do-nothing command that just causes the expansion.

Fail early when a required value is missing

deploy.sh
#!/usr/bin/env bash
target=${1:?usage: deploy.sh TARGET}
echo "deploying to $target"
bash deploy.sh staging
bash deploy.sh
outputexit 1
deploying to staging
deploy.sh: line 2: 1: usage: deploy.sh TARGET

With no argument, :? printed the message, prefixed with the script name, line and parameter, and the script exited with status 1 before doing anything.

Length and substrings

${#var} is the length in characters (not bytes, in a UTF-8 locale). ${var:offset:length} extracts a substring, counting from 0. A negative offset counts from the end, but it needs a space or parentheses before the minus, because ${var:-...} already means "default".

Length, substrings and negative offsets

ts="2026-03-14 08:00:01"
echo "length: ${#ts}"
echo "date: ${ts:0:10}  time: ${ts:11}"
echo "last two: ${ts: -2}"
echo "wrong:    ${ts:-2}"
word="café"; echo "${#word} characters"
output
length: 19
date: 2026-03-14  time: 08:00:01
last two: 01
wrong:    2026-03-14 08:00:01
4 characters

${ts:11} runs to the end. ${ts: -2} takes the last two characters, but without the space ${ts:-2} is the default operator and returns the whole (non-empty) value. café counts as 4 characters because the locale is UTF-8.

Removing a prefix or suffix

These four take a glob pattern (Globbing), not a regular expression:

  • ${var#pattern} removes the shortest match from the start, ## the longest.
  • ${var%pattern} removes the shortest match from the end, %% the longest.

A way to remember: on a US keyboard # is left of $ and % is right of it. They are the shell's built-in basename, dirname and extension handling.

Strip directories and extensions

path=/var/log/nginx/access.log.2.gz
echo "file:      ${path##*/}"
echo "dir:       ${path%/*}"
echo "no ext:    ${path%.*}"
echo "base name: ${path%%.*}"
echo "first ext: ${path#*.}"
output
file:      access.log.2.gz
dir:       /var/log/nginx
no ext:    /var/log/nginx/access.log.2
base name: /var/log/nginx/access
first ext: log.2.gz

##*/ removed everything up to the last slash. %.* removed the shortest .something from the end (.gz), while %%.* removed from the first dot onward. #*. removed up to and including the first dot.

Change file extensions in a loop

for f in photos/*.png; do
  echo mv "$f" "${f%.png}.jpg"
done
output
mv photos/IMG_2041.png photos/IMG_2041.jpg
mv photos/beach.png photos/beach.jpg
mv photos/forest.png photos/forest.jpg

${f%.png} drops the extension, then .jpg is appended. The echo in front makes this a dry run; remove it to actually rename. The same pattern converts .txt to .md, .tar.gz to .tgz (with %.tar.gz), and so on.

Search and replace

${var/pattern/replacement} replaces the first match, ${var//pattern/replacement} every match. Anchor a pattern with /# (must match at the start) or /% (at the end). An empty replacement deletes. Patterns are globs again, so * is greedy and ? matches one character.

Replace text inside a variable

line="fix login bug, fix logout bug"
echo "${line/fix/FIXED}"
echo "${line//fix/FIXED}"
echo "${line//bug/}"
f="report.txt.txt"; echo "${f/%.txt/}"
csv="a b  c"; echo "${csv// /,}"
output
FIXED login bug, fix logout bug
FIXED login bug, FIXED logout bug
fix login , fix logout 
report.txt
a,b,,c

One / changed only the first fix; // changed both. An empty replacement removed every bug. /% anchored the match at the end, so only the last .txt went. Turning spaces into commas keeps the two consecutive spaces as two commas.

In bash 5.2, & in the replacement stands for the matched text when the patsub_replacement option is on, which it is by default. Quote it (\& or inside quotes) if you mean a literal ampersand.

Case conversion

${var^^} uppercases the whole value, ${var,,} lowercases it, and the single forms ^ and , change only the first character. These need bash 4 or later; macOS's /bin/bash 3.2 does not have them.

Uppercase, lowercase and capitalize

env_name=Production
echo "${env_name^^} ${env_name,,}"
name=alice; echo "${name^}"
read -r answer <<< "YES"
[[ ${answer,,} == y* ]] && echo "accepted: $answer"
output
PRODUCTION production
Alice
accepted: YES

Lowercasing before a comparison is the easy way to accept yes, Yes and YES alike.

Indirection and transformations

${!name} expands the variable whose name is stored in name. ${!prefix@} lists all variable names starting with prefix. Bash 4.4 added ${var@op} transformations; the most useful are @Q (quote the value for reuse as shell input) and @U/@L (case, bash 5.1).

Look up a variable by name

db_host=db01; db_port=5432; db_name=demo
for key in host port name; do
  var="db_$key"
  echo "$var = ${!var}"
done
echo "all: ${!db_@}"
msg="it's 5 * 2"; echo "${msg@Q}"
output
db_host = db01
db_port = 5432
db_name = demo
all: db_host db_name db_port
'it'\''s 5 * 2'

${!var} read db_host, db_port and db_name through a computed name. ${!db_@} listed the matching variable names. @Q printed the value quoted so it can be pasted back into a shell safely. For real lookups by key, an associative array (Associative arrays) is clearer than indirection.

Operators on arrays and positional parameters

Every operator above also works on "${array[@]}" and "$@", applied to each element: "${files[@]%.txt}" strips the extension from every element. ${#array[@]} is the number of elements, and ${array[@]:1:2} slices elements rather than characters.

Apply an operator to every element

files=(notes.txt todo.txt "file with spaces.txt")
printf '[%s]\n' "${files[@]%.txt}"
echo "count: ${#files[@]}, slice: ${files[@]:1:2}"
set -- alpha beta; echo "${@^}"
output
[notes]
[todo]
[file with spaces]
count: 3, slice: todo.txt file with spaces.txt
Alpha Beta

%.txt was applied to each of the three elements, and quoting kept the one with spaces whole. The slice took two elements starting at index 1. ${@^} capitalized every positional parameter.

Related