Linux Basic — Pattern Removal

3 minute read

Pattern Removal

Four tiny operators — #, ##, %, %% — let you trim the front or the back of a string using glob patterns. This is how you implement basename, dirname, and “strip the extension” without ever spawning another process.


The Four Operators

Form Trim from Match
${var#pattern} beginning shortest match
${var##pattern} beginning longest match
${var%pattern} end shortest match
${var%%pattern} end longest match

Mnemonic:

  • # is on the left of $ on a US keyboard → trims the front
  • % is on the right → trims the end
  • Single = shortest, double = longest

The patterns are glob patterns, not regex: *, ?, [...], [!...].


Examples That Show the Difference

path="/var/log/nginx/access.log"

echo "${path#*/}"        # var/log/nginx/access.log     ← shortest: removed "/"
echo "${path##*/}"       # access.log                    ← longest: removed everything up to last /
echo "${path%/*}"        # /var/log/nginx                ← shortest from end: removed "/access.log"
echo "${path%%/*}"       #                                ← longest from end: removed "/var/log/nginx/access.log" — nothing left

That last line returns empty because the pattern /* from the longest end-side match consumes everything starting at the first /.


Recipe 1 — basename Without Forking

path="/var/log/nginx/access.log"
echo "${path##*/}"
# access.log

*/ matches “anything then /”. The longest match from the front leaves whatever follows the last /.

For the basename of a directory (no trailing-slash issues):

path="/var/log/nginx/"
# Strip any trailing /
path="${path%/}"
echo "${path##*/}"
# nginx

Recipe 2 — dirname Without Forking

path="/var/log/nginx/access.log"
echo "${path%/*}"
# /var/log/nginx

/* matches “/ then anything”. Shortest match from the end strips just the final component.

Edge case — no / in input:

path="hello.txt"
echo "${path%/*}"
# hello.txt        ← unchanged because there's no / to match

dirname(1) would return . here. If you need that:

dir=${path%/*}
[[ "$dir" == "$path" ]] && dir=.

Recipe 3 — Strip File Extension

file="archive.tar.gz"
echo "${file%.*}"          # archive.tar     ← shortest: drop last extension only
echo "${file%%.*}"         # archive          ← longest: drop ALL extensions

To keep only the extension:

echo "${file##*.}"         # gz

To change an extension:

out="${file%.gz}.bak"
echo "$out"
# archive.tar.bak

Recipe 4 — Strip a Known Prefix or Suffix

s="Bearer eyJhbGc..."
token="${s#Bearer }"        # strip the prefix
echo "$token"
# eyJhbGc...

env="staging-eu-west-1"
region="${env#staging-}"    # strip env prefix
echo "$region"
# eu-west-1

url="https://example.com/api/"
echo "${url%/}"             # strip trailing slash
# https://example.com/api

If the pattern isn’t present, the string is returned unchanged — no error. That makes it safe in pipelines.


Glob Patterns Inside # ## % %%

file="2026-06-17.log"

# Pattern matches a date-shaped prefix
echo "${file#????-??-??.}"     # log

# Bracket classes
echo "${file#[0-9][0-9][0-9][0-9]}"   # -06-17.log

With shopt -s extglob you also get:

Pattern Means
?(p) zero or one of p
*(p) zero or more of p
+(p) one or more of p
@(p\|q) exactly one of p or q
!(p) anything except p
shopt -s extglob
s="aaa-bbb-ccc"
echo "${s%%@(-bbb|-ccc)-ccc}"  # aaa

These are most useful in Glob Patterns (Chapter 12).


Pattern Removal vs sed

# ❌ Slower — one fork per call
basename=$(echo "$path" | sed 's|.*/||')

# ✅ Fork-free, same result
basename="${path##*/}"

A simple time shows the gap:

time (for i in {1..10000}; do x=$(echo /a/b/c | sed 's|.*/||'); done)
# real 0m6.412s

time (for i in {1..10000}; do x="${var##*/}"; done)
# real 0m0.034s

~200× faster for the same answer. In hot loops, always prefer parameter expansion.


Combining Multiple Steps

You can’t chain expansions directly (${${var%/*}#*/} doesn’t parse). Use a temp:

path="/var/log/nginx/access.log"
parent="${path%/*}"          # /var/log/nginx
grandparent="${parent%/*}"   # /var/log

Or use the longest-from-end + glob:

echo "${path%/*/*/*}"        # /var      (drop the last three components)

Common Mistakes

❌ Wrong ✅ Right Issue
Treating patterns as regex (\d, +) use globs (*, ?, […]) #/% use glob syntax, not regex
${var#.*} to drop ext (regex thinking) ${var%.*} .* is the suffix, % strips from end
${path##*/} on a dir with trailing / strip / first: ${path%/} then ${…##*/} trailing slash leaves “basename” empty
Chaining ${${var…}…} use a temp variable nested expansions aren’t supported
basename in a loop ${var##*/} shave seconds off scripts

Next → String Substitution

Updated: