Bash

basename() in Bash: Strip the Directory From a Path

By Nawawi JamiliPublished Updated 5 min read

Almost every script needs to get the file name out of a path. It may log which file it is working on, build an output name from an input name, or strip .tar.gz off an archive. PHP has basename(), and every Unix system has the basename command. In Bash you can get the same result with parameter expansion and no extra process, if you handle the same special cases that the command handles.

My 2009 version missed most of those special cases. This rewrite matches GNU coreutils basename on every input I tested, and I show the comparison below.

The function

bash
# basename() -- print the last component of PATH, minus SUFFIX
# Usage: basename path [suffix]
basename() {
    [[ ${1-} == -- ]] && shift
    (( $# == 1 || $# == 2 )) || return 2
    local name=$1
    # drop trailing slashes: "dir/" is the same as "dir"
    while [[ $name == */ ]]; do name=${name%/}; done
    # a path made only of slashes is the root directory
    if [[ -z $name && -n $1 ]]; then
        printf '/\n'
        return 0
    fi
    name=${name##*/}
    # like coreutils: never strip a suffix that is the whole name
    if [[ -n ${2-} && $name != "$2" ]]; then
        name=${name%"$2"}
    fi
    printf '%s\n' "$name"
}

How it works

The main part is ${name##*/}: delete the longest prefix that ends in a slash, which leaves everything after the last /. On its own, that gives the wrong answer for /var/log/. It returns an empty string, because the last slash is at the end. So the loop first removes trailing slashes one at a time with ${name%/}.

If removing trailing slashes leaves nothing, the path was /, // or similar, and the answer is /, which is what coreutils prints. The -n $1 check keeps an empty argument separate: basename "" prints an empty line, again matching coreutils.

The suffix is removed with ${name%"$2"}. The quotes make it a literal match, so a suffix like .* is not treated as a wildcard. One rule copied from coreutils: if the suffix is the entire name, nothing is removed, so basename .bashrc .bashrc stays .bashrc rather than becoming empty.

The first line accepts an optional --, because basename -- "$path" is a common idiom for paths that might start with a dash. Without it, a script that already uses -- would break as soon as this function is defined.

Usage

terminal
$ basename /usr/bin/sort
sort
$ basename /var/log/nginx/access.log .log
access
$ basename /var/log/
log
$ basename /
/
$ basename "my file.txt" .txt
my file
$ basename .bashrc .bashrc
.bashrc
$ basename; echo $?
2

Compared with coreutils basename

I ran the function, /usr/bin/basename from GNU coreutils 8.30, and the 2009 version against the same arguments. Brackets show the exact output, so an empty result is visible as []:

ArgumentsFunctioncoreutils2009
/usr/bin/sort[sort][sort][sort]
/usr/bin/sort.exe .exe[sort][sort][sort]
/usr/bin/sort.exe exe[sort.][sort.][sort]
/backup/notexe.exe exe[notexe.][notexe.][not]
/var/log/[log][log][]
/var/log//[log][log][]
/[/][/][]
//[/][/][]
sort[sort][sort][sort]
'' (empty)[][][]
..[..][..][..]
.bashrc .bashrc[.bashrc][.bashrc][]
archive.tar.gz .gz[archive.tar][archive.tar][archive.tar]
archive.tar.gz '.*'[archive.tar.gz][archive.tar.gz][archive.tar]
'/srv/a*b/c?d'[c?d][c?d][c?d]
dir/.hidden[.hidden][.hidden][.hidden]

The function and coreutils agree on all of them (the full test also covered ///, ., /usr/ and a name with spaces). The 2009 version disagrees in three distinct ways, all explained below.

What was wrong with the 2009 version

bash
basename_2009() {
	local name="${1##*/}";
	local opt="$2";
	[ -n "$opt" ] && name="${name%.$opt}";
	echo "${name%$2}";
}

The suffix was stripped twice. It first removed .$opt (a dot plus the suffix), then removed $2 again. With a suffix of exe, notexe.exe lost ".exe" in the first step and "exe" in the second, leaving "not". Even sort.exe exe came out different from coreutils, which strips only the literal suffix you give it and leaves "sort.".

Trailing slashes and the root. ${1##*/} on /var/log/ or / is empty, and the old code had no special case for either.

Unquoted suffix. ${name%$2} treated the suffix as a glob, so .* stripped any extension. That may be useful, but it is not what basename means, and surprising when a suffix contains [ or ?. It also did not handle a suffix equal to the whole name.

Pitfalls

The function shadows the real command

Bash looks up functions before commands on PATH. Once this function is defined, every basename in the script, including in code you sourced from elsewhere, runs the function instead of /usr/bin/basename. The function does not support the coreutils options -a, -s or -z:

terminal
$ type -t basename
function
$ basename -s .txt notes.txt; echo $?
2
$ command basename -s .txt notes.txt
notes
$ type -P basename
/usr/bin/basename

command basename skips the function and runs the real binary. If you share a library of helpers with other people, consider giving the function a different name, such as path_basename. Or only define it in scripts that you fully control.

Speed, and when a function is pointless

Called as x=$(basename "$p"), the function still needs a subshell, but it does not start a new program. I measured 1000 calls in a loop on one machine: 0.953 s for the coreutils binary, 0.302 s for the function, and 0.003 s for the bare expansion x=${p##*/}. In a loop that runs many times, use the expansion directly:

terminal
$ f=/var/log/nginx/access.log
$ name=${f##*/}; printf '%s\n' "$name"
access.log
$ printf '%s\n' "${name%.log}"
access
$ printf '%s\n' "${name%.*}" "${name##*.}"
access
log

${name%.*} and ${name##*.} split off the last extension, which is often what you actually wanted from basename file .ext.

Other details

Command substitution strips trailing newlines, so a file name ending in a newline loses it with either the function or the binary. The function works in Bash 3.2 on macOS and is safe under set -u, since ${1-} and ${2-} supply defaults. For PHP's behaviour, which differs in places, see the basename() manual page.

History of this snippet

The April 2009 version used ${1##*/} with no handling of trailing slashes or the root. It stripped the suffix twice and with an extra dot, and it left the suffix unquoted. The rewrite was checked line by line against coreutils output.