Linux Basic — String Substitution

2 minute read

String Substitution

Bash’s substitution operators ${var/old/new} and ${var//old/new} replace text inside a variable without calling sed. Same speed gain as pattern removal — no fork, instant result.


The Four Forms

Form Replaces
${var/PATTERN/REPL} first match
${var//PATTERN/REPL} all matches
${var/#PATTERN/REPL} match only if at start
${var/%PATTERN/REPL} match only if at end

The pattern is a glob, not a regex. REPL is a literal string.


Single vs Global

s="banana"

echo "${s/a/A}"        # bAnana       (first a only)
echo "${s//a/A}"       # bAnAnA       (every a)

The single-slash form stops at the first match — handy for “fix the first occurrence” semantics. Double-slash is the everyday workhorse.


Anchored at Start or End

s="hello world hello"

echo "${s/#hello/HI}"  # HI world hello       (must be at the start)
echo "${s/%hello/BYE}" # hello world BYE      (must be at the end)

Use # for prefix-only substitution, % for suffix-only. Both are single-shot — they never replace internal matches.


Delete by Leaving REPL Empty

s="abc-123-def"
echo "${s//-}"         # abc123def           (delete every '-')
echo "${s/-}"          # abc123-def          (delete first '-' only)
echo "${s/#abc/}"      # -123-def            (delete prefix)
echo "${s/%def/}"      # abc-123-            (delete suffix)

Three useful idioms:

# Strip whitespace around a value (cheap trim)
s="   hello   "
trimmed=${s#"${s%%[![:space:]]*}"}    # strip leading
trimmed=${trimmed%"${trimmed##*[![:space:]]}"}  # strip trailing
echo "[$trimmed]"
# [hello]

Glob Patterns Work Here Too

s="file_2026-06-17_v1.tar.gz"
echo "${s/_v?/}"       # file_2026-06-17.tar.gz
echo "${s/_[0-9]*_v?/}" # file.tar.gz

With shopt -s extglob:

shopt -s extglob
s="aaa-bbb-ccc"
echo "${s//@(aaa|ccc)/X}"   # X-bbb-X

Substitution in Arrays

Apply to every element with [@]:

files=("a.log" "b.log" "c.txt")
new=( "${files[@]/.log/.gz}" )
printf '%s\n' "${new[@]}"
# a.gz
# b.gz
# c.txt

For all-occurrence within each element:

arr=("a-a" "b-b" "c-c")
res=( "${arr[@]//-/_}" )
printf '%s\n' "${res[@]}"
# a_a
# b_b
# c_c

Substitution + Defaults — Conditional Pipelines

Substitution composes nicely with the default operators:

ext=${file##*.}
ext=${ext,,}                          # to lowercase
mime=${MIMES[$ext]:-application/octet-stream}

When to Use sed Instead

Reach for sed (or awk) when you need:

  • Real regex (capture groups, \d, alternation, lookarounds)
  • Multi-line edits
  • File-streaming (when the data isn’t in a variable)
# Regex with capture groups — sed wins
echo "2026-06-17" | sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})$/\3\/\2\/\1/'
# 17/06/2026

For everything else, native substitution is shorter and faster. sed in scripts is covered in detail in sed & awk in Scripts.


Performance — The Same Story

time (for i in {1..10000}; do echo "hello world" | sed 's/world/Bash/' > /dev/null; done)
# real 0m6.8s

time (for i in {1..10000}; do x="hello world"; x="${x/world/Bash}"; done)
# real 0m0.04s

In a hot loop, native substitution is ~150× faster than spawning sed.


Escaping the Pattern

Patterns are globs, not regex — but you still need to escape the delimiter /:

url="http://example.com/path"
echo "${url//\//_}"        # http:__example.com_path        (use \/)

Inside the pattern, glob metacharacters (*, ?, [) are special. Use [*], [?], [[] to match them literally:

s="a*b?c"
echo "${s//[*]/X}"          # aXb?c
echo "${s//[?]/Y}"          # a*bYc

A Real-World Example — Template Substitution

template="Hello {NAME}, you have {COUNT} new messages."
name="Alice"
count="3"

msg=${template//\{NAME\}/$name}
msg=${msg//\{COUNT\}/$count}
echo "$msg"
# Hello Alice, you have 3 new messages.

Tiny templating engine in three lines. No envsubst, no sed.


Common Mistakes

❌ Wrong ✅ Right Issue
${s/a/A/g} (sed-style flag) ${s//a/A} double // is the “global” form
${s/a/A/i} (case-insensitive) not supported — use ${s,,} first, or sed Bash sub has no i flag
${s//(foo)/bar} regex group use sed -E 's/(foo)/bar/' only globs allowed
${arr/old/new} (no @) ${arr[@]/old/new} without [@], only first element is touched
${url/\//} to drop one slash, but you want all ${url//\//} single-slash form replaces first only

Next → Case Conversion & Splitting

Updated: