skip to content

Parameter Expansion Operators

Bash's built-in string toolkit: defaults for unset variables, prefix and suffix stripping, substrings, and case conversion, all without spawning sed or basename. Interviewers favor ${var:-x} versus ${var:=x} and ${var#} versus ${var##} because the distinctions are exact and easy to check.

part ofBashoverview, primer and where to startread it →
on this pageshow

questions

5

With f=/var/log/app/archive.tar.gz in a bash script, what does each of ${f##*/}, ${f%/*}, ${f%.*} and ${f%%.*} expand to, and what is the rule behind # versus % and single versus double?

level: juniorimportance: must knowfreq 58%

answer

  1. one symbol eats the front, one the back
  2. keyboard position is the mnemonic
  3. doubling changes how much is eaten
  4. the pattern is a glob, not a regex
  5. the variable itself is untouched

basics

~10 s

They give archive.tar.gz, /var/log/app, /var/log/app/archive.tar and /var/log/app/archive. In bash, # strips a matching prefix and % strips a matching suffix; doubling the character strips the longest match instead of the shortest.

solid answer

~40 s

`#` removes a matching pattern from the **front** of the value and `%` removes one from the **back** — a mnemonic is that `#` sits left of `$` on a keyboard and `%` sits right. Single means the shortest match, doubled means the longest. So `${f##*/}` strips the longest leading run ending in a slash, giving `archive.tar.gz` — the basename. `${f%/*}` strips the shortest trailing `/…`, giving `/var/log/app` — the dirname. `${f%.*}` drops just the last extension: `/var/log/app/archive.tar`. `${f%%.*}` drops everything from the *first* dot onward: `/var/log/app/archive`. The right-hand side is a shell glob pattern, not a regex, and the original variable is never modified — the expansion only produces a new value.

code

bash · 10 lines
bash
f=/var/log/app/archive.tar.gz

echo "${f##*/}"   # archive.tar.gz  (basename)
echo "${f#*/}"    # var/log/app/archive.tar.gz
echo "${f%/*}"    # /var/log/app    (dirname)
echo "${f%.*}"    # /var/log/app/archive.tar
echo "${f%%.*}"   # /var/log/app/archive

base=${f##*/}
echo "${base%%.*}"  # archive  (safe order: directory removed first)

go deeper

for a junior

Memorise the four forms and be able to write the basename and dirname equivalents on the spot: ${f##/} and ${f%/}.

for a middle

Explain greediness precisely — walk through why ${f#/} and ${f##/} give different answers on the same value — and note that the right side is a glob pattern with implicit anchoring.

for a senior

Show the judgment: strip the directory before touching extensions, know where these differ from basename and dirname on edge inputs, and justify replacing per-file forks in a hot loop.

for a principal

Be ready to say when string surgery on paths should stop: once a script is reconstructing structured paths by pattern, the failure modes multiply, and a real path library or a different tool is the safer platform-wide standard.

## The four operators Bash can trim the front or back of a string without calling `basename`, `dirname`, `sed` or `cut`. There are exactly four forms: | Form | Trims from | Match length | |---|---|---| | `${var#pattern}` | the front | shortest | | `${var##pattern}` | the front | longest | | `${var%pattern}` | the back | shortest | | `${var%%pattern}` | the back | longest | A useful mnemonic: on a US keyboard `#` is to the left of `$` and `%` is to the right, matching the end of the string each one eats. Doubling the symbol means "be greedy". ## Walking the example With `f=/var/log/app/archive.tar.gz`: ```bash f=/var/log/app/archive.tar.gz echo "${f##*/}" # archive.tar.gz echo "${f#*/}" # var/log/app/archive.tar.gz echo "${f%/*}" # /var/log/app echo "${f%.*}" # /var/log/app/archive.tar echo "${f%%.*}" # /var/log/app/archive ``` - `${f##*/}` — pattern `*/`, longest match from the front is `/var/log/app/`, so what remains is the filename. This is the classic `basename` replacement. - `${f#*/}` — same pattern, shortest match is just the leading `/`, so you get the path minus its first slash. This is the contrast that shows single versus double clearly. - `${f%/*}` — pattern `/*`, shortest match from the back is `/archive.tar.gz`, leaving the directory. The `dirname` replacement. - `${f%.*}` — removes the shortest trailing `.something`, so only `.gz` goes. - `${f%%.*}` — removes the longest trailing `.something`, which starts at the **first** dot in the whole value. That last one carries the trap. `%%.*` is often described as "strip all extensions", but the pattern is applied to the entire value, path included. If any directory name contains a dot — `/opt/app-1.2/data.tar.gz` — then `${f%%.*}` returns `/opt/app-1` and you have silently destroyed the path. Strip the directory first (`base=${f##*/}`) and only then work on extensions. ## Patterns, not regular expressions The right-hand side is the same pattern language bash uses for `case` and for filename matching: `*` matches any string, `?` any single character, `[abc]` a character set. It is **not** a regex, so `.` is a literal dot and there is no `+`, no alternation without extended globs, and no capture groups. Anchoring is implicit: `#` forms are anchored to the start, `%` forms to the end. The pattern is itself subject to expansion, so a variable can supply it — and that is exactly why you quote carefully: ```bash ext=.gz echo "${f%"$ext"}" # quoted: treats the value as literal text ``` Quoting the pattern inside the expansion makes its characters literal rather than glob metacharacters, which matters when the pattern comes from data. ## Non-destructive by design None of these operators modify the variable. `${f%.gz}` produces a new string; `f` still holds the original. To keep the result you assign it: `trimmed=${f%.gz}`. Candidates who expect in-place mutation write loops that never terminate. ## Why prefer this over external tools Every `basename` or `sed` call in a loop is a fork and an exec. In a loop over ten thousand files that is measurable, and it also drags in portability questions about which `sed` you have. Parameter expansion is pure bash, has no argument-length or whitespace hazards, and works on values that are not filenames at all — trimming a `refs/heads/` prefix off a git ref, taking the host out of `host:port`, dropping a trailing slash with `${dir%/}`. The one behavioural difference worth knowing is edge cases: `basename /` prints `/`, while `${f##*/}` on `/` yields the empty string; and `dirname foo` prints `.`, while `${f%/*}` on a bare `foo` (no slash at all) leaves `foo` unchanged, because a pattern that does not match removes nothing. Guard for those when the input can be a bare name or a root path. ## Common uses at a glance ```bash base=${f##*/} # archive.tar.gz stem=${base%%.*} # archive (safe: directory already removed) ext=${base##*.} # gz noslash=${dir%/} # drop one trailing slash if present branch=${ref#refs/heads/} ```

  • Why can ${f%%.*} be a dangerous way to strip all extensions from a path?
    Because the pattern applies to the whole value, not just the filename. If a directory name contains a dot — `/opt/app-1.2/data.tar.gz` — the longest trailing `.*` match starts inside the directory, and you get `/opt/app-1`. Strip the directory first with `base=${f##*/}`, then remove extensions from `base`.
  • How do these differ from calling basename and dirname?
    They are builtin, so no fork per call, and they work on any string rather than only paths. Edge cases differ: `${f##*/}` on `/` gives an empty string where `basename` prints `/`, and `${f%/*}` on a name with no slash returns the name unchanged where `dirname` prints `.`. Guard those inputs explicitly.
  • Does ${f%.gz} change f?
    No. Every parameter expansion produces a value; none of the trimming operators assign back to the variable. If you want to keep the trimmed result you assign it, as in `f=${f%.gz}`. The only default-value operator that writes back is `${var:=word}`.

saying these in an interview costs you the question

  • Thinks # and % modify the variable in place
  • Treats the right-hand side as a regular expression
  • Cannot say which of # and % trims the front
  • Assumes ## and %% differ in direction rather than greediness
  • Uses ${f%%.*} on a full path with dotted directory names

context

open as a page

In bash, what do the parameter expansions ${VAR:-default}, ${VAR-default}, ${VAR:=default} and ${VAR:?message} each do, and what difference does the colon make?

level: middleimportance: must knowfreq 75%

basics

~20 s

${VAR:-default} substitutes default when VAR is unset or empty; ${VAR-default} substitutes only when VAR is unset. ${VAR:=default} substitutes and also assigns it back to VAR. ${VAR:?message} prints message to stderr and aborts the script instead of substituting.

open as a page

In bash, how do ${path/foo/bar} and ${path//foo/bar} differ, and what kind of pattern is allowed on the left-hand side?

level: middleimportance: should knowfreq 45%

basics

~20 s

A single slash replaces only the first match; a double slash replaces every match. The left-hand side is a shell glob pattern, not a regular expression, so metacharacters like * and ? apply but dots are literal and there are no capture groups.

open as a page

In a bash script with s=deployment, what do ${s:4:3}, ${s:-3} and ${s: -3} each produce, and why are the last two different?

level: middleimportance: should knowfreq 32%

basics

~20 s

They produce oym, deployment and ent. ${s:4:3} takes three characters from zero-based offset 4. ${s:-3} is not a substring at all — it is the default-value operator, which returns s because s is set. Only ${s: -3}, with a space, means the last three characters.

open as a page

A bash deploy script holds an environment name in $ENV (PROD or STAGING) and reads configuration from variables named PROD_HOST and STAGING_HOST. How does bash's indirect expansion ${!name} let you read the right one, and how does it compare with using eval?

level: seniorimportance: nice to knowfreq 28%

basics

~20 s

Build the variable name in a string, then expand it indirectly: key="${ENV}_HOST"; host="${!key}". Bash reads the variable whose name is the value of key. Unlike eval, it substitutes only a value and never executes the name as code.

open as a page