Parameter expansion
Defaults, trimming, replacing and slicing, without a fork
${var} can carry an operator that edits the value on its way out. A script that calls
basename, sed or cut to pull a filename apart is often asking for something the shell will
do without starting a process:
path=/var/log/nginx/access.log
echo "${path##*/}"
echo "${path%/*}"
access.log
/var/log/nginx
All of it happens inside the braces, and none of it tolerates a space: ${path ## */} is a
bad substitution rather than a tidier way of writing the same thing.
Defaults, and what the colon changes
${var:-default} expands to default when var is unset or empty, without assigning anything.
${var:=default} assigns as well. Dropping the colon from either narrows the test to unset
alone, which is the distinction between a variable nobody has set and one deliberately set to
nothing:
unset name
empty=""
echo "unset: [${name:-deb1}]"
echo "empty: [${empty:-deb1}]"
echo "unset, no colon: [${name-deb1}]"
echo "empty, no colon: [${empty-deb1}]"
unset: [deb1]
empty: [deb1]
unset, no colon: [deb1]
empty, no colon: []
Most scripts want the colon. An empty $EDITOR is no more usable than a missing one, and the
distinction is worth drawing only where the empty string is a legitimate setting.
${var:+alternative} is the mirror image: it expands to alternative when var does have a
value, and to nothing when it does not. That is how an optional flag reaches a command line
without an if:
verbose=""
echo "run 1: grep [${verbose:+--line-number}] pattern file"
verbose=yes
echo "run 2: grep [${verbose:+--line-number}] pattern file"
run 1: grep [] pattern file
run 2: grep [--line-number] pattern file
${var:?message} refuses to carry on. The shell prints the message to stderr and a
non-interactive shell exits on the spot:
unset TARGET
bash -c 'echo "starting"; echo "deploying to ${TARGET:?no TARGET set}"; echo "unreachable"'
echo "status: $?"
starting
bash: line 1: TARGET: no TARGET set
status: 127
The third echo never ran. This is worth using at the top of any script that reads configuration
from the environment, where the alternative is a cp into / or an rm -rf under an empty
prefix. set -u makes every unset variable fatal, but :? is the one that can say which variable
and why.
Trimming from either end
# trims a pattern from the front and % trims from the back. Doubling the character makes the
match greedy:
file=access.log.1
echo "shortest from the end: ${file%.*}"
echo "longest from the end: ${file%%.*}"
echo "shortest from the front: ${file#*.}"
echo "longest from the front: ${file##*.}"
shortest from the end: access.log
longest from the end: access
shortest from the front: log.1
longest from the front: 1
The pattern is a glob, the same vocabulary as a filename match or a case label: *, ? and
[...] work, and a regular expression does not. ${file%.log} with no wildcard at all is the
common case, stripping a known suffix.
Applied to an array, the trim runs on every element:
paths=(/srv/www/index.html "/srv/www/style sheet.css" /srv/www/logo.svg)
printf "[%s]\n" "${paths[@]##*/}"
[index.html]
[style sheet.css]
[logo.svg]
A batch rename becomes a loop with nothing in it but mv:
for f in *.log; do
mv -- "$f" "${f%.log}.log.old"
done
ls
access.log.old
error.log.old
report.txt
Renaming files in bulk goes further into that, including the cases where the loop is the wrong tool.
Replacing text inside the value
${var/from/to} replaces the first match and ${var//from/to} replaces every one. Anchor the
pattern to the front with /# or to the back with /%:
line=deb1,deb2,deb3
echo "${line/,/ }"
echo "${line//,/ }"
echo "${line/#deb/host}"
echo "${line/%3/X}"
echo "${line//deb/}"
deb1 deb2,deb3
deb1 deb2 deb3
host1,deb2,deb3
deb1,deb2,debX
1,2,3
An empty replacement deletes. from is a glob here too, so ${line//[0-9]/} strips the digits
and ${line//./} strips literal dots rather than everything.
Length, slices and case
${#var} is the length in characters. ${var:offset} and ${var:offset:length} take a
substring, counting from zero:
line=deb1,deb2,deb3
echo "length: ${#line}"
echo "from 5: ${line:5}"
echo "4 at 5: ${line:5:4}"
echo "last 4: ${line: -4}"
length: 14
from 5: deb2,deb3
4 at 5: deb2
last 4: deb3
The space in ${line: -4} is required. Without it the shell reads :- and gives you the default
operator from the first section instead, silently and with a plausible-looking result.
^^ upper-cases the whole value, ,, lower-cases it, and a single character does the first
letter alone:
name=deb1
echo "${name^^}"
echo "${name^}"
shouty=DEB1
echo "${shouty,,}"
DEB1
Deb1
deb1
Which of these your shell has
${var:-default}, ${var:=default}, ${var:?message}, ${var:+alt}, ${#var} and all four
trims are POSIX, so they work in dash, which is what /bin/sh is on Debian. Replacement,
slicing, case conversion and the array forms are bash extensions: a script using them needs
#!/usr/bin/env bash on the first line rather than #!/bin/sh.
sh vs bash vs dash has the rest of that boundary.
Under sh there is no graceful degradation:
dash -c 'line=deb1,deb2; echo ${line//,/ }'
echo "status: $?"
dash: 1: Bad substitution
status: 2
A script that runs under bash on your machine and sh on a server fails there and nowhere else.
Exercises
-
log=/var/log/app/2026-08-29.log. Produce2026-08-29using one expansion.Answer
name=${log##*/} # 2026-08-29.log echo "${name%.log}" # 2026-08-29Two, in fact. These forms do not nest:
${${log##*/}%.log}gets youbad substitution, and the answer is an intermediate variable rather than a cleverer expansion. -
A script takes an optional output directory. Give it a default of
/tmpand make it fail loudly if the input file is missing from the environment.Answer
outdir=${OUTDIR:-/tmp} infile=${INFILE:?set INFILE to the file to process}:-for the one with a sensible fallback,:?for the one where guessing would be worse than stopping. -
Why does
${path:-2}not give you the last two characters of$path?Answer
Because it is the default-value operator: it expands to the string
2whenpathis unset or empty, and to the whole path otherwise. The substring form needs a space,${path: -2}, or parentheses,${path:$((-2))}.
What's next
Between these forms and Arrays, most of the string handling a shell script
needs no longer requires sed, cut or a subshell to do it.
Where a script lives is next, and it uses two of the trims for
their commonest job: taking a path apart.