← back to latest

Pull the filename, directory, or extension out of a path without basename

p=/var/log/nginx/access.log.2.gz; echo "${p##*/} ${p%/*} ${p%.*} ${p##*.}"

anatomy

p=/var/log/nginx/access.log.2.gz
An ordinary variable holding a path. Parameter expansion only works on variables, not on literal strings, so anything you want to trim has to land in a variable first, including the loop variable in a `for f in *` loop.
${p##*/}
Remove the longest match of `*/` from the front. The longest match runs up to the last slash, so what survives is the filename: the same answer as `basename`, without starting a second process to get it.
${p%/*}
Remove the shortest match of `/*` from the end. The shortest match starts at the last slash, so what survives is the directory: the same answer as `dirname` for an ordinary path, with the edge cases covered in the gotchas.
${p%.*}
Remove the shortest match of `.*` from the end, which strips exactly one extension. On `access.log.2.gz` that is the `.gz`, leaving the rotated log name. Double the operator to `%%` and it removes the longest match instead, which cuts everything after the first dot.
${p##*.}
Remove the longest match of `*.` from the front, which leaves whatever follows the last dot. That is the extension when there is one, and the whole string when there is not.
"..."
The double quotes stop the results from being word split and glob expanded after the trim. A filename with a space in it survives the expansion intact only if the expansion is quoted.

Sample output

$ p=/var/log/nginx/access.log.2.gz; echo "${p##*/} ${p%/*} ${p%.*} ${p##*.}"
access.log.2.gz /var/log/nginx /var/log/nginx/access.log.2 gz

Four answers from one string, and no second process involved. The pattern after each operator is a glob, not a regex, and the operator decides which end it is matched against and whether it is greedy: # for the front, % for the end, doubled for the longest match.

When you would reach for it

Inside a loop over files, where $(basename "$f") forks a process for every iteration and a thousand files means a thousand forks. It is also the tool for building one filename out of another: the .bak next to the original, the .txt that replaces the .md, the output directory that mirrors the input. Anything you would reach for sed to do to a single variable, one of these four operators probably does without leaving the shell.

Gotchas

  • These are not exact copies of basename and dirname. With a trailing slash, p=/tmp/dir/ gives an empty ${p##*/} where basename prints dir. With no slash at all, p=notes.txt gives notes.txt back from ${p%/*} where dirname prints .. If paths arrive from users or from other tools, strip the trailing slash first with ${p%/}, and check whether the directory result still contains a slash before trusting it.
  • A missing extension does not produce an empty string. p=Makefile gives Makefile from ${p##*.} because the pattern never matched, so nothing was removed. Test for it with [ "${p##*.}" = "$p" ], or check that the name contains a dot before you ask for the extension.
  • Dotfiles break the extension logic. p=.bashrc gives an empty string from ${p%.*}, since the whole name counts as an extension, and running the counting variant below over a tree with .hidden-config in it reports hidden-config as a file type. Filter dotfiles out first if the answer matters.
  • A pattern stored in a variable is still a pattern. With ext='*.gz', ${p%$ext} treats the asterisk as a wildcard, and ${p%"$ext"} quotes it into a literal. The quotes go inside the braces, around the pattern alone.

Variants

$ for f in docs/*.md; do echo mv -- "$f" "${f%.md}.txt"; done

A dry run for a bulk rename, printing each mv rather than running it. Read the list, then remove the echo to do it for real, and check before you run it: mv overwrites an existing .txt without asking

$ find . -type f | while IFS= read -r f; do echo "${f##*.}"; done | sort | uniq -c | sort -rn

Count the file types in a tree by extension. Files with no dot show up as their full path, which is a quick way to spot them

$ f="Q3 sales report (final).csv"; echo "${f// /_}"

Replace every space with an underscore. The ${var//old/new} substitution form is bash, ksh, and zsh only, and dash rejects it with “Bad substitution”, so keep it out of #!/bin/sh scripts

lineage

The Bourne shell of 1979 could test whether a variable was set and substitute a default, but it could not change a single character of one. Scripts that needed a filename out of a path started a separate program for it, which is why `basename`, `dirname`, and `expr` became fixtures of early shell scripts, each one a fork and an exec to answer a question about a string already sitting in memory. David Korn's shell at Bell Labs added the four trimming operators in ksh88, and POSIX.2 wrote them into the standard shell in 1992, which is why they work in dash, in BusyBox ash, and in the bash 3.2 that macOS still ships. The operators come with a mnemonic that is widely repeated and happens to be true on a US keyboard: `#` sits to the left of `$` and `%` sits to the right, so `#` trims from the left of the value and `%` trims from the right.