Create a copy-on-write Aurora clone in minutes, prove it is isolated from the source, and learn when …
Filename Extraction: basename to a Production File Pipeline Filename Extraction: basename to a Production File Pipeline

Summary
Log processing is where filename handling quietly breaks. Picture services all writing logs to /var/log/<service>/<timestamp>-<hostname>.log, and a job that has to extract just the service name from each path.
This kind of exercise teaches filename extraction through edge cases: filenames with spaces, symlinks, process-spawning overhead, multiple extensions. This article walks through that progression: start simple, hit the bugs, fix them, and end with a benchmark script you can run to see which approach performs best.

Step 1: The Simple Approach (basename)
The requirement: given /var/log/auth-api/2024-03-15-prod01.log, extract auth-api.
The obvious approach is basename:
path="/var/log/auth-api/2024-03-15-prod01.log"
service=$(basename $(dirname "$path"))
echo "$service"
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
auth-api
This works. dirname gives /var/log/auth-api, then basename extracts auth-api. Simple, readable, done.
But with a dozen services writing logs, you need it to work for all of them. Test across the set:
for path in /var/log/*/2024-03-15-*.log; do
service=$(basename $(dirname "$path"))
echo "$service"
done
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
auth-api
payment-svc
user-svc
notification-svc
This works in dev. It works in staging. It can still break in production.
Expand your knowledge with Go + DynamoDB: Build a Simple CRUD App
Step 2: The Symlink Deploy Bug
A common failure report looks like this: “Service name extraction broken for auth-api on prod03.”
Check the log paths on the affected host:
ls -la /var/log/ | grep auth
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
lrwxrwxrwx 1 root root 28 Mar 15 10:30 auth-api -> /mnt/shared-logs/auth-api
Symlink. The log directory is a symlink to shared NFS storage. On most servers, /var/log/auth-api is a real directory. On some it is a symlink — and that is enough to break extraction inconsistently across a fleet.
Try the extraction on a symlink:
path="/var/log/auth-api/2024-03-15-prod03.log"
service=$(basename $(dirname "$path"))
echo "$service"
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
auth-api
That worked. Try a more realistic test:
# Create a symlink test
mkdir -p /tmp/real-logs/service-a
ln -s /tmp/real-logs/service-a /tmp/logs-link
path="/tmp/logs-link/2024-03-15.log"
service=$(basename $(dirname "$path"))
echo "$service"
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
logs-link
There’s the bug. basename gives the symlink name, not the target. In production, some paths look like:
/var/log/auth-api -> /mnt/shared/logs/auth-api
The dirname gave /var/log/auth-api, basename gave auth-api. But for resolved paths:
/mnt/shared/logs/auth-api/2024-03-15.log
The dirname gave /mnt/shared/logs/auth-api, basename gave auth-api. Same result.
But the actual problem is more subtle. Suppose the log path on the affected host isn’t /var/log/auth-api/file.log but the resolved real path: /mnt/shared/logs/auth-api/file.log. That happens when something normalizes paths with readlink -f before processing:
path=$(readlink -f "/var/log/auth-api/2024-03-15.log")
# path is now /mnt/shared/logs/auth-api/2024-03-15.log
service=$(basename $(dirname "$path"))
# service is "auth-api" — correct!
That case still resolves correctly. So trace what actually breaks. Consider a monitoring script doing:
for log_file in /var/log/*/2024-03-15-*.log; do
# log_file here is the symlink path: /var/log/auth-api/2024-03-15-prod03.log
service=$(basename $(dirname "$log_file"))
echo "$service"
done
That should still work because log_file contains the literal path. The breakage shows up in a different shape of the monitoring code:
find /var/log -name "*.log" | while read -r path; do
service=$(basename $(dirname "$path"))
echo "$service"
done
And here’s the problem. On an affected host:
find /var/log -name "*.log" -print
Output (truncated):
/var/log/syslog
/var/log/auth-api/2024-03-15-prod03.log
But with -L (follow symlinks), which can be set globally via alias find='find -L' on the affected servers:
find -L /var/log -name "*.log" -print
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
/mnt/shared/logs/auth-api/2024-03-15-prod03.log
The symlink got resolved by find -L, the path became /mnt/shared/logs/..., and the extraction logic expected /var/log/....
Deepen your understanding in Deploy Jenkins on Amazon EKS: A Practical Tutorial
Step 3: The Fix — Use Basename on the Parent Directory Name
The real issue: you need the directory name directly under /var/log, not the resolved path. The fix:
path="/mnt/shared/logs/auth-api/2024-03-15.log"
# Extract the service name from the original symlink location
# by not using readlink and not using find -L
service=$(basename $(dirname "$path"))
But if the paths are already resolved because of a global alias, that isn’t enough. One option is to remove alias find='find -L' from the server configs — but that risks breaking other scripts. A safer option is to change the extraction logic to look for the pattern directly:
# Match /var/log/<service>/ or /mnt/shared/logs/<service>/
service=$(echo "$path" | sed 's|.*/log[s]*/\([^/]*\)/.*|\1|')
echo "$service"
Test:
echo "/var/log/auth-api/2024-03-15.log" | sed 's|.*/log[s]*/\([^/]*\)/.*|\1|'
echo "/mnt/shared/logs/auth-api/2024-03-15.log" | sed 's|.*/log[s]*/\([^/]*\)/.*|\1|'
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
auth-api
auth-api
Fixed. Both paths now extract correctly.
Explore this further in Containers From Scratch in Go
Step 4: Handling Files with Spaces
This sed fix works — until a service appears with a space in the directory name, e.g. reporting service.
Its logs go to /var/log/reporting service/2024-04-01.log.
The extraction breaks:
path="/var/log/reporting service/2024-04-01.log"
service=$(echo "$path" | sed 's|.*/log[s]*/\([^/]*\)/.*|\1|')
echo "$service"
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
reporting service
That part works. But a downstream script that consumes the value can still break:
service="reporting service"
mkdir "/backup/$service"
Error:
mkdir: cannot create directory '/backup/reporting': No such file or directory
mkdir: cannot create directory 'service': No such file or directory
No quotes around $service would split the space into two arguments — a classic bash mistake. But here the quotes are already present, so the real problem is elsewhere. The extraction is piped into xargs:
echo "/var/log/reporting service/2024-04-01.log" | \
sed 's|.*/log[s]*/\([^/]*\)/.*|\1|' | \
xargs -I {} mkdir "/backup/{}"
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
mkdir: cannot create directory '/backup/reporting': No such file or directory
xargs split on whitespace. The {} got replaced with reporting only. The solution:
echo "/var/log/reporting service/2024-04-01.log" | \
sed 's|.*/log[s]*/\([^/]*\)/.*|\1|' | \
xargs -d '\n' -I {} mkdir "/backup/{}"
-d '\n' tells xargs to split on newlines only, not whitespace. Now:
ls /backup/
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
reporting service/
Fixed. But this exposed another question: is there a faster way than spawning sed for every path?
Discover related concepts in How to Replace Text in Multiple Files with Sed
Step 5: Performance — basename vs Parameter Expansion vs awk
When a script processes a lot of files, the per-file cost adds up. Not huge, but enough that spawning a process for each filename matters.
Four approaches:
- basename (spawn process)
- Parameter expansion (pure bash)
- awk (spawn process)
- sed (already tested)
Benchmark them yourself with the script below. I won’t quote exact timings — they vary too much by machine to be meaningful. What is consistent is the relative ordering: pure-bash parameter expansion avoids the per-call process spawn, so it comes out far ahead of the tools that fork a process per path.
Test Setup
#!/bin/bash
# Generate 10,000 sample paths
paths=()
for i in $(seq 1 10000); do
paths+=("/var/log/service-$((i % 50))/2024-03-15-host$i.log")
done
echo "Testing 10,000 paths..."
Approach 1: basename + dirname
start=$(date +%s%N)
for path in "${paths[@]}"; do
service=$(basename $(dirname "$path"))
done
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 ))
echo "basename: ${elapsed}ms"
This forks a subshell and two processes (dirname, basename) per path, so expect it to take seconds for 10,000 paths.
Approach 2: Parameter Expansion
start=$(date +%s%N)
for path in "${paths[@]}"; do
service="${path%/*}" # Remove everything after last /
service="${service##*/}" # Remove everything before last /
done
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 ))
echo "parameter expansion: ${elapsed}ms"
This one runs entirely inside the shell — no process spawning at all. On any machine it finishes in a small fraction of the time the other approaches take.
Approach 3: awk
start=$(date +%s%N)
for path in "${paths[@]}"; do
service=$(echo "$path" | awk -F'/' '{print $(NF-1)}')
done
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 ))
echo "awk: ${elapsed}ms"
A subshell plus an awk process per path — in the same slow ballpark as basename, often a bit slower.
Approach 4: sed
start=$(date +%s%N)
for path in "${paths[@]}"; do
service=$(echo "$path" | sed 's|.*/\([^/]*\)/[^/]*$|\1|')
done
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 ))
echo "sed: ${elapsed}ms"
Same story as awk: a process per path.
Results Summary
Run the four blocks yourself — the exact milliseconds depend on your machine, but the ordering is consistent every time:
| Method | Process spawns per path | Relative Speed |
|---|---|---|
| Parameter expansion | 0 | fastest by a wide margin |
| basename | subshell + dirname + basename | slow |
| sed | subshell + sed | slow |
| awk | subshell + awk | slow |
Winner: parameter expansion. Pure bash, no process spawning, handles spaces correctly when quoted.
The final version of the script:
for log_file in /var/log/*/202*.log; do
dir="${log_file%/*}" # /var/log/auth-api/2024-03-15.log -> /var/log/auth-api
service="${dir##*/}" # /var/log/auth-api -> auth-api
# Process with proper quoting
mkdir -p "/backup/$service"
cp "$log_file" "/backup/$service/"
done
Parameter expansion deals with spaces, symlinks (as long as you don’t resolve them), and avoids a process spawn per file — the more files you process, the more that matters.
Uncover more details in Bash String Functions: Trimming, Case, and Reversal
Step 6: Removing Extensions (The Next Requirement)
Once filename extraction is stable, the next requirement usually follows: strip file extensions.
Input: 2024-03-15-prod01.log
Output: 2024-03-15-prod01
Approach 1: basename with Suffix
filename="2024-03-15-prod01.log"
base=$(basename "$filename" .log)
echo "$base"
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
2024-03-15-prod01
Works. But what if the extension varies? Some files were .log, others .log.gz.
basename "2024-03-15-prod01.log.gz" .log.gz # Works
basename "2024-03-15-prod01.log.gz" .gz # Gives "2024-03-15-prod01.log"
You need to know the exact extension.
Approach 2: Parameter Expansion
filename="2024-03-15-prod01.log"
base="${filename%.*}"
echo "$base"
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
2024-03-15-prod01
Removes everything after the last .. Handles any extension:
filename="2024-03-15-prod01.log.gz"
base="${filename%.*}"
echo "$base"
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
2024-03-15-prod01.log
Removes only .gz. To remove all extensions:
filename="2024-03-15-prod01.log.gz"
base="${filename%%.*}"
echo "$base"
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
2024-03-15-prod01
%% removes the longest match, so it strips everything after the first .. But this breaks filenames with dots:
filename="service.v2.log"
base="${filename%%.*}"
echo "$base"
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
service
Lost v2. The correct pattern depends on your naming convention.
For logs that always end with .log or .log.gz, the correct pattern:
filename="${filename%.log.gz}"
filename="${filename%.log}"
echo "$filename"
Apply both removals in sequence. If .log.gz exists, remove it. Otherwise remove .log.
The Bug: Order Matters
Try this:
filename="2024-03-15.log"
filename="${filename%.log}"
filename="${filename%.log.gz}"
echo "$filename"
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
2024-03-15
Works. Now reverse the order:
filename="2024-03-15.log.gz"
filename="${filename%.log.gz}"
filename="${filename%.log}"
echo "$filename"
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
2024-03-15
Still works. Both orders work because:
.log.gzfile: first pattern removes.log.gz, second pattern finds no.log, does nothing.logfile: first pattern finds no.log.gz, does nothing, second pattern removes.log
Good. Final version:
# Extract service name and remove extensions
path="/var/log/auth-api/2024-03-15-prod01.log.gz"
dir="${path%/*}"
service="${dir##*/}"
filename="${path##*/}"
filename="${filename%.log.gz}"
filename="${filename%.log}"
echo "Service: $service"
echo "Base filename: $filename"
Output:
Journey deeper into this topic with Linux Access Control: From sudo to a Go Security Scanner
Service: auth-api
Base filename: 2024-03-15-prod01
What We Built: A Robust Log-Processing Script
Starting from a simple requirement (extract service names from log paths), we hit the common real-world edge cases:
- Simple basename — works until symlinks show up
- Symlink bug —
find -Lresolved paths, breaking extraction assumptions - Sed fix — pattern matching worked regardless of symlink resolution
- Spaces in filenames — broke xargs, fixed with
-d '\n' - Performance — parameter expansion beats every process-spawning approach by a wide margin
- Extension removal — multiple approaches, parameter expansion won again
The final script:
#!/bin/bash
# Process all logs, extract service name, archive by service
for log_file in /var/log/*/202*.log*; do
[ -f "$log_file" ] || continue
# Extract service name (pure bash, fast)
dir="${log_file%/*}"
service="${dir##*/}"
# Extract and clean filename
filename="${log_file##*/}"
filename="${filename%.log.gz}"
filename="${filename%.log}"
# Archive (with proper quoting for spaces)
mkdir -p "/backup/$service"
cp "$log_file" "/backup/$service/"
done
With the fixes in place, this handles spaces, symlinks, and mixed extensions without surprises.
Enrich your learning with Advanced Bash Scripting for Automation
Cheat Sheet
Extract directory from path:
dir="${path%/*}" # /var/log/auth-api/file.log → /var/log/auth-api
Extract filename from path:
filename="${path##*/}" # /var/log/auth-api/file.log → file.log
Extract parent directory name:
dir="${path%/*}" # Get directory
service="${dir##*/}" # Get last component
# /var/log/auth-api/file.log → auth-api
Remove file extension:
base="${filename%.*}" # file.log → file (last extension)
base="${filename%%.*}" # file.tar.gz → file (all extensions)
Remove specific extension:
filename="${filename%.log.gz}" # Try .log.gz first
filename="${filename%.log}" # Then try .log
Process spawning comparison (per path processed):
Gain comprehensive insights from kubectl Cheat Sheet: 30+ Essential Commands
- Parameter expansion: no processes — fastest by far
- basename: subshell + dirname + basename
- sed: subshell + sed
- awk: subshell + awk
Key Rules
- Use parameter expansion for performance — no process spawn per file
- Quote everything —
"$variable"prevents word splitting on spaces - Use
xargs -d '\n'when piping filenames with spaces - Don’t use
find -Lunless you actually need symlink resolution %removes from end,#removes from beginning —%%and##are longest match- Test with edge cases — spaces, symlinks, dots in names, multiple extensions
FAQ
Q: When should I use basename vs parameter expansion?
A: Use parameter expansion (${var##*/}) for scripts that process many files — it avoids a process spawn per file. Use basename for interactive one-liners where readability matters more than performance.
Q: How do I handle filenames with spaces?
A: Always quote variables: "$filename". When using xargs, add -d '\n' to split on newlines instead of whitespace.
Q: What’s the difference between % and %%?
A: Both strip from the end, but they differ by how much of a glob pattern they match: % removes the shortest trailing match, %% removes the longest. For a literal suffix like .log (no wildcards) there’s nothing to match short vs long, so ${var%.log} and ${var%%.log} behave identically. The difference only shows up with a pattern: on file.tar.gz, ${var%.*} removes the shortest match (.gz, giving file.tar), while ${var%%.*} removes the longest (.tar.gz, giving file). For trimming a single extension, you usually want %.* (shortest).
Q: Why did symlinks break my script?
A: find -L and readlink -f resolve symlinks to real paths. If your extraction logic expects the symlink path, disable symlink resolution or adjust the pattern to work with both.
Q: Which is faster: awk or sed? A: For filename extraction, they’re roughly the same speed (both slow compared to parameter expansion). Both spawn a process per path, and that spawn dominates the runtime. Run the benchmark script above if you want numbers for your own machine.
Delve into specifics at Upgrading Jenkins: Migration Strategies for Major Version Jumps
Keep Reading
- Mastering Bash: The Ultimate Guide to Command Line Productivity — more bash patterns and productivity techniques
- Linux Automation: From Cron to a Go Task Runner — apply these filename patterns in automation pipelines
- Sed Cheat Sheet: 30 Essential One-Liners — when you need more power than parameter expansion
Similar Articles
Related Content
More from devops
Enable the RDS Data API on Aurora PostgreSQL and run SQL over HTTPS with no persistent connection, …
Let a read replica accept writes with Aurora write forwarding. Hands-on local write forwarding in …
You Might Also Like
Practical sed patterns for log analysis: extract errors, filter time ranges, anonymize PII, parse …
The sed gotchas that bite in production: GNU vs BSD differences, in-place editing safety, escape …
Use sed safely in CI/CD pipelines: idempotent edits, exit-code checks, dry-run patterns, and the …
Contents
- Step 1: The Simple Approach (basename)
- Step 2: The Symlink Deploy Bug
- Step 3: The Fix — Use Basename on the Parent Directory Name
- Step 4: Handling Files with Spaces
- Step 5: Performance — basename vs Parameter Expansion vs awk
- Step 6: Removing Extensions (The Next Requirement)
- What We Built: A Robust Log-Processing Script
- Cheat Sheet
- Key Rules
- FAQ
- Keep Reading

