skip to content

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%

answer

  1. one slash versus two
  2. same idea as a familiar regex flag
  3. the left side is not a regex
  4. two anchoring variants exist
  5. omit the replacement and it deletes

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.

solid answer

~40 s

`${path/foo/bar}` substitutes the **first** occurrence of the pattern, `${path//foo/bar}` substitutes **all** of them — the doubled slash means global, exactly like the `g` flag in other tools. Two anchored variants exist: `${path/#foo/bar}` only matches at the start of the value and `${path/%foo/bar}` only at the end. Omitting the replacement entirely, as in `${path//foo}`, deletes every match. The pattern is bash's glob language — `*`, `?`, `[…]`, plus extended globs when `extglob` is on — so `.` is a literal dot and there is no alternation, backreference or capture group. Like the trimming operators this produces a new value; the variable is untouched unless you assign the result back.

code

bash · 8 lines
bash
branch=feature/api/v2
echo "${branch/\//-}"    # feature-api/v2   first match
echo "${branch//\//-}"   # feature-api-v2   all matches

url=http://example.com/http-cache
echo "${url/#http:/https:}"  # anchored at the start
echo "${url/%cache/store}"   # anchored at the end
echo "${url//-}"             # replacement omitted: delete matches

go deeper

for a junior

Remember that one slash changes the first match and two slashes change all of them, and that you must assign the result back to keep it.

for a middle

Explain that the pattern is a glob rather than a regex, name the anchored ${var/#…} and ${var/%…} forms, and show the delete-by-omitting-the-replacement trick.

for a senior

Demonstrate judgment about quoting a pattern that comes from data, and about when a value edit has grown into text processing that belongs in awk or a real language rather than in an expansion.

for a principal

Own the readability call: a chain of nested substitutions is cheap but unreadable and untestable, and at some point the maintainable answer is to move the transformation out of the shell entirely.

## The two forms ```bash p=a.b.c echo "${p/./-}" # a-b.c first match only echo "${p//./-}" # a-b-c every match ``` One slash after the parameter name means "replace the first match"; two mean "replace them all". If you have used `sed s/x/y/` versus `s/x/y/g`, the doubled slash is the same idea, and it is the single most commonly asked detail of this expansion. ## Anchored and deleting variants Bash adds two anchors and a deletion form: ```bash url=http://example.com/http-cache echo "${url/#http:/https:}" # https://example.com/http-cache — start only echo "${url/%cache/store}" # http://example.com/http-store — end only echo "${url//-}" # http://example.com/httpcache — delete all matches ``` `${var/#pat/rep}` requires the match to begin at the first character; `${var/%pat/rep}` requires it to end at the last. Without an anchor the pattern floats anywhere in the value. `${var/pat}` and `${var//pat}` — replacement omitted — delete rather than replace, which is a tidy way to strip characters. ## The pattern is a glob This is the fact candidates get wrong. The left-hand side uses the same pattern language as `case` and filename matching, not POSIX ERE and not PCRE. Consequences: - `.` is an ordinary character. `${p//./-}` really does mean "every literal dot", which is why the first example works. - `*` matches any run of characters and is greedy, so `${s/a*b/X}` can swallow far more than you intended. - There are no capture groups and no backreferences: you cannot refer to "the part the pattern matched" the way `\1` does in `sed`. If you need that, you need `sed`, `awk` or bash's `=~` operator with `BASH_REMATCH`, which lives with the conditional-expression material. - Extended patterns such as `@(a|b)` are available only when `shopt -s extglob` is enabled. ## Quoting, and where the pattern comes from Because the pattern is a glob, characters arriving from data can behave as metacharacters. Quoting the pattern inside the expansion turns it back into literal text: ```bash needle='a*b' echo "${s//"$needle"/X}" # matches the three literal characters a * b echo "${s//$needle/X}" # a, anything, b — glob semantics ``` Always wrap the whole expansion in double quotes as well, or the result is subject to word splitting and pathname expansion like any other unquoted expansion. One version note on the replacement side: bash 5.2 added the `patsub_replacement` shell option, enabled by default, under which an unquoted `&` in the replacement text stands for the whole matched portion; write `\&` for a literal ampersand. On bash 5.1 and earlier the `&` is always literal. If your script may run on both, quote the replacement or escape the ampersand so it behaves the same either way. ## What it is good for, and when to stop Substitution shines on short, structural edits of a single value where forking is wasteful or awkward: ```bash safe_name=${branch//\//-} # feature/x -> feature-x for a docker tag key=${var_name//-/_} # normalise a config key csv=${list// /,} # space-separated to comma-separated ``` It stops being the right tool the moment you need alternation, capture groups, case-insensitive matching, or multi-line input. Parameter expansion operates on one shell value; it is not a stream editor. Reaching for `${var//.../...}` to "parse" a log line or an HTML fragment is a smell — that work belongs to `awk` or a real language. ## Non-destructive, like the rest `${p//./-}` yields a new string. `p` still contains `a.b.c` afterwards. Assign if you want to keep it: `p=${p//./-}`. And because the whole thing is a single expansion, it composes: `${1//x/y}` works on a positional parameter, and the result can be fed straight into a command argument without an intermediate variable.

  • How do you replace a pattern only when it appears at the start of the value?
    Use the anchored form `${var/#pattern/replacement}`, which matches only if the pattern begins at the first character. `${var/%pattern/replacement}` is the end-anchored counterpart. Without an anchor the pattern can match anywhere in the value, so an unanchored replacement of a prefix like `http:` may also hit an occurrence in the middle.
  • The pattern is a glob. What does that stop you doing, and what would you use instead?
    No alternation without `shopt -s extglob`, no quantifiers beyond `*` and `?`, and crucially no capture groups or backreferences — you cannot reuse the matched text in the replacement. When you need that, use `sed`/`awk`, or bash's `[[ $s =~ re ]]` conditional and read the groups out of `BASH_REMATCH`.
  • Is there any case where the replacement text itself is special?
    Yes, in recent bash. Bash 5.2 enables the `patsub_replacement` option by default, making an unquoted `&` in the replacement expand to the matched text; escape it as `\&` or quote the replacement for a literal ampersand. Bash 5.1 and earlier always treat `&` literally, so quote it for consistent behaviour across versions.

saying these in an interview costs you the question

  • Says the left-hand side is a regular expression
  • Expects backreferences like \1 in the replacement
  • Thinks a single slash replaces every occurrence
  • Believes the expansion mutates the variable
  • Leaves the whole expansion unquoted in a command argument

context