Linux Basic — Pattern Removal
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