Bash

emptydir() in Bash: Check Whether a Directory Is Empty

By Nawawi JamiliPublished Updated 6 min read

Before removing a directory, rotating into it or deciding whether a job produced anything, a script often needs to know one thing: is this directory empty? PHP users would use scandir() and count the entries. Bash has no single test for it. But a glob with two shell options set gives a reliable answer without starting a process, if you remember the hidden files.

The function

bash
# emptydir() -- test whether a directory is empty (hidden files count)
# Usage: emptydir directory
# Returns 0 if empty, 1 if not empty, 2 if not a readable directory
emptydir() {
    if [ $# -ne 1 ]; then
        printf 'Usage: emptydir directory\n' >&2
        return 2
    fi
    local dir="$1" ng=0 dg=0
    local -a entries
    [ -d "$dir" ] && [ -r "$dir" ] && [ -x "$dir" ] || return 2
    shopt -q nullglob && ng=1
    shopt -q dotglob && dg=1
    shopt -s nullglob dotglob
    entries=("$dir"/*)
    [ "$ng" -eq 1 ] || shopt -u nullglob
    [ "$dg" -eq 1 ] || shopt -u dotglob
    [ ${#entries[@]} -eq 0 ]
}

How it works

The main part is entries=("$dir"/*): expand a glob into an array and count the elements. By default that has two problems, and the two shopt options fix one each.

nullglob. Normally a glob that matches nothing is left as it is, so in an empty directory "$dir"/* becomes the literal string empty/* and the array has one element. With nullglob a non-matching glob expands to nothing, and the array is really empty.

dotglob. Normally * skips names that start with a dot, so a directory holding only .env or .git would look empty. With dotglob those names are included. Bash never matches . and .. with *, even with dotglob on, so they are not counted.

Shell options are global, so the function records whether each one was already on (shopt -q returns 0 if set) and switches back off only what it switched on. Your script's own globbing behaves the same after the call as before. I first wrote this with saved=$(shopt -p nullglob dotglob) and eval "$saved". That is shorter, but the command substitution forks a subshell on every call. It made the function about 17 times slower in the timing below.

The quotes around "$dir" matter. Only the * is left unquoted, so a directory called my dir is one word, and any glob characters in the directory name itself are taken literally.

The final [ ... -eq 0 ] is the last command, so its status is the function's return value. The return codes follow the 2009 original: 0 for empty, 1 for not empty, 2 when the argument is not a directory you can read.

Usage

I set up a few test directories. One is empty, one holds only a dotfile, and one has normal files. One has a space in its name and contains only .keep. One I cannot read (mode 000). One contains a file literally named *, and one contains ..foo. There is also a regular file and a name that does not exist.

bash
for d in empty hidden full "my dir" noread stars dotdot file missing; do
    emptydir "$d"; printf '%-8s %s\n' "$d" "$?"
done
emptydir; echo "no args: $?"
output
empty    0
hidden   1
full     1
my dir   1
noread   2
stars    1
dotdot   1
file     2
missing  2
Usage: emptydir directory
no args: 2

In a script it reads naturally with if:

bash
if emptydir /var/spool/myjob/out; then
    echo "nothing to upload"
else
    upload_results
fi

Because "not a directory" returns 2 rather than 1, if ! emptydir "$d" is true both for a full directory and for a missing one. When the difference matters, capture the status with emptydir "$d"; rc=$? and test for 1 explicitly.

Pitfalls

Unreadable directories are not empty

If you cannot read a directory, a glob inside it simply matches nothing. Without the -r and -x checks, the function would report a mode-000 directory as empty. A cleanup script might then rmdir it or, worse, decide nothing needs backing up. The 2009 version had exactly that bug. Running as root hides it, because root can read everything.

Large directories

The glob reads and sorts every name in the directory just to answer yes or no. For a few hundred entries that is nothing. For a mail spool or a cache directory with hundreds of thousands of files it becomes noticeable, and there find is faster (measured below).

-d follows symlinks, so emptydir link-to-dir checks the target. A broken symlink inside the directory still counts as an entry, which is correct: rmdir would refuse to remove the directory too.

Bash versions

nullglob, dotglob, arrays and local -a all work in Bash 3.2, so the function runs on the default macOS /bin/bash. It is not POSIX sh, though. For that use the find version.

Alternative: find with -quit

find can stop as soon as it sees the first entry:

terminal
$ find hidden -mindepth 1 -maxdepth 1 -print -quit
hidden/.env
$ find empty -mindepth 1 -maxdepth 1 -print -quit

The second command prints nothing. -mindepth 1 excludes the directory itself, -maxdepth 1 stops it going into subdirectories, and -quit exits after the first match. Empty output means an empty directory. Wrapped in a function with the same return codes:

bash
# emptydir_find() -- same return codes, but stops at the first entry
emptydir_find() {
    [ -d "$1" ] && [ -r "$1" ] && [ -x "$1" ] || return 2
    [ -z "$(find "$1" -mindepth 1 -maxdepth 1 -print -quit)" ]
}

GNU find also has find dir -maxdepth 0 -empty, which prints the directory name if it is empty. Both -quit and -empty are extensions rather than POSIX, but GNU find and the BSD find on macOS support them.

Which is faster depends on the directory. I timed both on one machine (GNU find 4.6.0, Bash 4.4), with a directory of 200,000 empty files and with 1,000 calls on a two-file directory:

Testemptydir (glob)emptydir_find
One call, 200,000 entries0.317 s0.026 s
1,000 calls, 2 entries0.048 s1.238 s

The glob version avoids starting a process, so it is much faster when called often on ordinary directories. find needs a fork and exec every time but never reads more than one entry, so it is faster on huge directories. In practice I use the glob version and switch to find for spools and caches.

A third option you will see is [ -z "$(ls -A "$dir")" ]. It works, but it lists and sorts every name like the glob does and also starts a process, so it combines the costs of both.

History of this snippet

The 2009 version looped over $_dir/* $_dir/.*, skipped . and .. by name and returned 1 at the first entry that existed. The idea was good, but $_dir was unquoted, so a directory with a space in its name was reported as empty even when it was not. Unreadable directories were also reported as empty. And the usage example in its header did not even parse, because it used do where if needs then:

terminal
$ bash -n example_orig.sh
example_orig.sh: line 1: syntax error near unexpected token `do'
example_orig.sh: line 1: `if ! emptydir /tmp; do'

The .* method is also out of date now: Bash 5.2 added the globskipdots option, on by default, so .* no longer matches . and .. at all. dotglob avoids the whole problem. See the shopt section of the Bash manual for the full list of glob options.