Bash

dirname() in Bash: Get the Directory Part of a Path

By Nawawi JamiliPublished Updated 5 min read

dirname is the other half of basename(): given a path, return everything except the last component. Scripts use it to find their own directory, to create the parent of an output file, or to group files by folder. PHP has dirname(), Unix has the dirname command, and Bash can do it with ${path%/*}. But you must deal with the cases where that expansion gives the wrong answer.

Those cases are the main reason to write a function. The bare expansion gets "file with no slash", "/vmlinuz" and trailing slashes wrong, and so did my 2009 version in several places. The rewrite below matches GNU coreutils on every path I tried.

The function

bash
# dirname() -- print PATH with its last component removed
# Usage: dirname path
dirname() {
    [[ ${1-} == -- ]] && shift
    (( $# == 1 )) || return 2
    local dir=$1
    # trailing slashes do not count: "a/b/" is the same as "a/b"
    while [[ $dir == */ ]]; do dir=${dir%/}; done
    if [[ -z $dir ]]; then
        # "" has no directory part; "/", "//", "///" are the root
        if [[ -n $1 ]]; then printf '/\n'; else printf '.\n'; fi
        return 0
    fi
    # no slash left: the file is in the current directory
    if [[ $dir != */* ]]; then
        printf '.\n'
        return 0
    fi
    dir=${dir%/*}
    # "a//b" -> "a", "/usr" -> "/"
    while [[ $dir == */ ]]; do dir=${dir%/}; done
    printf '%s\n' "${dir:-/}"
}

How it works

The steps follow the POSIX description of dirname:

  1. Remove trailing slashes. /usr/bin/ names the directory bin, so its parent is /usr, not /usr/bin.
  2. If nothing is left, the input was either empty (answer .) or made only of slashes (answer /).
  3. If there is no slash left, the path is a bare name in the current directory, so the answer is ..
  4. Otherwise remove the last component with ${dir%/*}, the shortest suffix starting with a slash.
  5. Remove any slashes now at the end, so a//b gives a. If that empties the string, the parent was the root: ${dir:-/} prints /.

The leading -- check matters more here than anywhere else. The common idiom for a script's own directory is $(cd -- "$(dirname -- "$0")" && pwd). Without that line, the function sees two arguments, returns 2 and prints nothing. cd -- "" then succeeds without moving, and the "script directory" silently becomes whatever directory you ran the script from. I tried it with the -- line removed, and got /tmp instead of /usr/share/doc/bash in the example below.

Usage

terminal
$ dirname /usr/bin/sort
/usr/bin
$ dirname /usr/bin/
/usr
$ dirname sort
.
$ dirname /
/
$ dirname //
/
$ dirname "my dir/my file"
my dir
$ dirname; echo $?
2

And the idiom mentioned above, with a fixed path used in place of $0:

terminal
$ script=/usr/share/doc/bash/README
$ cd /tmp
$ dir=$(cd -- "$(dirname -- "$script")" && pwd); echo "$dir"
/usr/share/doc/bash

In a real script use "${BASH_SOURCE[0]}" rather than $0. It still points at the right file when the script is sourced.

Compared with coreutils dirname

Here is the function against /usr/bin/dirname (GNU coreutils 8.30) and against the 2009 version. Brackets show the exact output:

PathFunctioncoreutils2009
/usr/bin/sort[/usr/bin][/usr/bin][/usr/bin]
/usr/bin/[/usr][/usr][/usr/bin]
/usr/bin//[/usr][/usr][/usr/bin/]
/usr[/][/][/]
/[/][/][/]
//[/][/][/]
///[/][/][//]
sort[.][.][.]
'' (empty)[.][.][.]
../sort[..][..][..]
a//b[a][a][a/]
a/b/[a][a][a/b]
/usr//bin[/usr][/usr][/usr/]
'/srv/[ab]'[/srv][/srv][/srv/[ab]
'/srv/a*b/c'[/srv/a*b][/srv/a*b][/srv/a*b]
///usr[/][/][//]

The function and coreutils agree on all 21 paths in my test (the table leaves out ., .., ./sort, //usr and a path with spaces, which also agree). POSIX allows a system to treat a leading // specially and return //. GNU returns /, and so does the function.

What was wrong with the 2009 version

bash
dirname_2009() {
	local dir="${1%${1##*/}}";
	[ "${dir:=./}" != "/" ] && dir="${dir%?}";
	echo "$dir";
}

It removed the last component by deleting ${1##*/} from the end, then cut one more character off for the slash. That breaks in three ways:

  • Trailing slashes. For /usr/bin/ the "last component" is empty, so nothing was removed and the result was /usr/bin, the path itself.
  • Repeated slashes. Only one character was removed, so a//b gave a/ and /// gave //.
  • Unquoted inner pattern. ${1%${1##*/}} uses the file name as a glob. A name like [ab] is a bracket expression that does not match the literal text "[ab]", so nothing was removed. The %? step then cut the last character off the file name: /srv/[ab] became /srv/[ab.

The simple cases (/usr/bin/sort, sort, /) were right, which is why the bugs went unnoticed for so long.

Pitfalls

The function shadows the real command

Functions take priority over commands on PATH, so once this is defined every dirname in the script, including in sourced files, runs the function. GNU dirname accepts several paths at once and a -z option. The function does not:

terminal
$ type -t dirname
function
$ dirname /etc/hosts /var/log/messages; echo $?
2
$ command dirname /etc/hosts /var/log/messages
/etc
/var/log

Use command dirname to run the binary, or give your function a different name if the script sources code you do not control.

The bare expansion

Inside a loop over thousands of files, the cost of the function's subshell adds up. Measured on one machine, 1000 calls took 0.788 s with coreutils, 0.249 s with the function and 0.002 s with ${p%/*}. The expansion is fine when you know the path contains a slash that is not the first character. But know the cases where it fails:

terminal
$ f=/etc/ssh/sshd_config
$ printf '%s\n' "${f%/*}"
/etc/ssh
$ f=sshd_config; printf '[%s]\n' "${f%/*}"
[sshd_config]
$ f=/vmlinuz; printf '[%s]\n' "${f%/*}"
[]

A bare name comes back unchanged instead of ., and a file in the root comes back empty instead of /. Paths from find /some/dir are safe. Paths typed by a user are not.

Other details

dirname works on the string only. It does not resolve .. or symlinks and does not check that anything exists. Use cd ... && pwd or realpath for that. The function runs in Bash 3.2 and is safe under set -u. PHP's version, which also has a levels argument, is documented on the dirname() manual page.

History of this snippet

The April 2009 version removed the file name with an unquoted pattern, removed one character for the slash, and ignored trailing and repeated slashes. The rewrite follows the POSIX steps and was compared against coreutils output for every path above.