TechEarl

How to Find (and Delete) Empty Directories and Files

find . -type d -empty lists every empty directory; find . -type f -empty lists every empty file. The catch is what 'empty' means (a hidden file makes a directory not empty) and the -depth trap that lets find -delete collapse whole nested empty trees in one pass. The flag reference, the safe two-pass cleanup, the BSD vs GNU notes, and the mistakes that bite.

Ishan KarunaratneIshan Karunaratne⏱️ 13 min readUpdated
Use find -type d -empty to list empty directories and find -type f -empty for empty files. The -depth trap for deleting nested empty trees, the hidden-file gotcha, the safe two-pass cleanup, and BSD vs GNU find notes.

find . -type d -empty lists every empty directory under the current path. Swap -type d for -type f and you get every empty file instead. Both rely on the -empty test, which matches anything with nothing in it: zero entries for a directory, zero bytes for a file.

The subtlety that catches people is what "empty" means, and the order find walks the tree when you add -delete. An empty directory stops being empty the moment it holds a single hidden file or one subdirectory, even an empty one. And find -delete collapses whole nested empty trees in a single pass only because of a flag most people forget. This page is the reference I keep open when I write disk-cleanup and post-build scripts.

Set your values

Try it with your own values

Set your OS and search path. Every find example below updates with your values.

The one-liner

Empty directories:

bash· Linux (GNU)
find :search_path -type d -empty

Empty files:

bash· Linux (GNU)
find :search_path -type f -empty

-type d restricts the match to directories, -type f to regular files, and -empty does the actual emptiness test. Drop the -type filter and find :search_path -empty returns both at once, which is occasionally what you want for a raw inventory but rarely what you want before a delete.

What "empty" actually means

For a file, empty is simple: zero bytes. A file with a single newline or a single space is not empty; it has content.

For a directory, empty means zero entries. This is where the surprises live:

  • A directory that contains one subdirectory is not empty, even if that subdirectory is itself empty. The subdirectory counts as an entry.
  • A directory that contains one hidden file (.gitkeep, .DS_Store, .env) is not empty. -empty does not skip dotfiles; a hidden entry is still an entry.
  • A directory that contains a broken symlink is not empty. The link is an entry regardless of whether its target exists.

That last point about hidden files is the single most common reason find -type d -empty returns fewer directories than someone expects. A folder that looks empty in Finder or in a plain ls can be holding a .DS_Store that macOS dropped there, and find correctly reports it as non-empty. Run ls -la (note the -a) on a directory before you assume find is wrong.

Delete empty directories

The cleanup form adds -delete, and it needs one more flag to behave:

bash· Linux (GNU)
find :search_path -depth -type d -empty -delete

The -depth flag is doing real work here, and leaving it off is the most common mistake in this whole topic.

By default find processes a directory before its contents (pre-order traversal). With -depth, it processes the contents first and the directory itself last (post-order, depth-first). That ordering matters for -delete because of a chain reaction.

Picture a/b/c where c is empty, b contains only c, and a contains only b. Without -depth, find visits a first; at that moment a is not empty (it holds b), so a is skipped. It then visits b (not empty, holds c) and skips it, then visits c, finds it empty, and deletes it. Result: only c is gone, a and b survive even though the entire tree was effectively empty.

With -depth, find visits c first and deletes it. Now b is empty, so when find reaches b (after its children), b is deleted too. Then a becomes empty and gets deleted. The whole nested tree collapses in one pass.

find -delete implies -depth automatically in modern GNU and BSD find, so the bare find . -type d -empty -delete often works. But the -empty test is evaluated as find walks the tree, and whether it sees the post-deletion state depends on traversal order being applied before the test runs. Writing -depth explicitly, before the other tests, removes all doubt and makes the intent obvious to whoever reads the script next. I always write it.

As with any destructive find, dry-run first. Swap -delete for -print (or -print plus -depth to see the exact order), eyeball the list, then put -delete back. There is no confirmation and no undo. The find and delete files article covers the safe-delete pattern in more depth.

The two-pass cleanup

A directory full of empty files is not itself empty, so a single -type d -empty -delete pass will not touch it. The standard fix is two passes: delete the empty files first, then delete the directories that the first pass just emptied out.

bash· Linux (GNU)
find :search_path -type f -empty -delete
find :search_path -depth -type d -empty -delete

Pass one removes every zero-byte file. Pass two then sees directories that held only those files as genuinely empty and removes them, with -depth collapsing any nested chains. The order is not optional: run the directory pass first and it skips folders that the file pass would have emptied, so you would have to run it again.

This is the pattern I reach for after a failed export, a half-finished rsync, or a build that scattered placeholder files. One run, two lines, and the stale skeleton is gone.

Find empty directories, excluding some paths

When the search path contains a subtree you never want to touch (node_modules, .git, a mounted backup), filter it out with -not -path:

bash· Linux (GNU)
find :search_path -type d -empty -not -path '*/node_modules/*' -not -path '*/.git/*'

-not -path PATTERN (you can also write it ! -path PATTERN) drops any path matching the glob. The */ prefix and /* suffix make it match the directory at any depth. Chain as many -not -path clauses as you need.

For large trees there is a faster idiom: -prune stops find from descending into a subtree at all, rather than visiting every entry and discarding it. find :search_path -path '*/node_modules' -prune -o -type d -empty -print skips node_modules wholesale. -prune is the better choice when the excluded subtree is huge; -not -path is simpler when it is small. The find command cheat sheet has the full -prune pattern.

Count empty directories

Pipe the listing into wc -l to get a number instead of a list:

bash· Linux (GNU)
find :search_path -type d -empty | wc -l

wc -l counts newlines, so each matched path adds one to the total. The caveat that applies to every find | wc -l: a directory name containing a literal newline would be counted twice. That is rare enough to ignore for a quick disk audit, but if you are counting paths you do not control, the safe form is find :search_path -type d -empty -printf '.' | wc -c on GNU (BSD find has no -printf).

macOS BSD vs GNU find

The -empty test is one of the well-behaved corners of find. Both implementations support the flags this article uses, with the same meaning:

BehaviorGNU findBSD find (macOS default)
-empty testSupportedSupported
-depth (post-order traversal)SupportedSupported
-delete actionSupported (implies -depth)Supported (implies -depth)
-not / ! operatorSupportedSupported
-path testSupportedSupported
-prune actionSupportedSupported
-printf (for safe counting)SupportedNOT supported; use -exec

So the empty-directory and empty-file workflow is portable: a script written with -empty, -depth, -delete, -not -path, and -prune runs unchanged on both Linux and macOS. The only gap is -printf, which you only need for the newline-safe count. If a cleanup script has to run on both platforms, stick to that intersection and you will not get bitten.

Common mistakes

1. Forgetting -depth when deleting directories. Without it, find evaluates a parent directory before deleting its children, sees the parent as non-empty, and skips it. Nested empty trees survive except for the innermost leaf. find -delete implies -depth, but write -depth explicitly so the intent is unambiguous and the command is correct even if you later swap -delete for -exec rmdir.

2. The hidden-file gotcha. A directory holding a single .gitkeep, .DS_Store, or .env is not empty, and find -type d -empty correctly skips it. People see a "blank" folder in a file browser, run the command, get nothing, and assume find is broken. It is not. Run ls -la on the directory to see the dotfile.

3. Treating a directory of empty files as empty. A folder containing zero-byte files is itself non-empty; it has entries. A single -type d -empty pass will not remove it. Use the two-pass cleanup: empty files first, then empty directories.

4. Running it against /. find / -type d -empty -delete is a foot-cannon. It will walk the entire filesystem, including /proc, /sys, and every mounted volume, and happily delete empty directories that the OS or other software depends on. Always anchor the search at a real project or scratch directory, and add -xdev if you want to stay on one filesystem.

5. Skipping the dry run. find -delete has no confirmation prompt and no undo. Always run the command with -print first, read the list, then swap in -delete. This costs two seconds and has saved me from deleting directories I forgot were load-bearing.

6. Putting -depth after the tests. -depth is a global option; placing it after -type d is technically legal but reads as if it were a test. Convention, and clarity, put it right after the search path: find :search_path -depth -type d -empty -delete.

When NOT to use this

find -empty is the right tool for cleaning up structural cruft, but reach for something else when:

  • You want to free disk space. Empty directories take essentially no space. If the goal is reclaiming gigabytes, you want du -sh */ | sort -h or find files larger than a size, not an empty-directory sweep.
  • The "empty" directories hold intentional placeholders. Many repos use .gitkeep to keep an otherwise-empty directory tracked in Git. Those are not empty (the dotfile is an entry), so find -type d -empty skips them correctly. But if you have a habit of using truly empty directories as markers, a blanket delete will wipe your markers. Know your tree.
  • You need the directories gone in a specific order with logging. find -delete is silent and order is fixed by -depth. If you need to log each removal, handle errors per directory, or delete conditionally, loop with -exec rmdir {} \; or a bash while loop over -print0 output instead.
  • Another process is writing into the tree. find walking a directory while a build or an upload is still creating files in it will produce a stale snapshot, and a delete pass can race the writer. Run cleanup only when nothing else is touching the path.

See also

FAQ

TagsfindCLILinuxmacOSBSDCleanup ScriptsDevOps
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years across software, Linux systems, DevOps, and infrastructure — and a more recent focus on AI. Currently Chief Technology Officer at a tech startup in the healthcare space.

Keep reading

Related posts

find -delete removes every matched file with no confirmation. The safe -print-first dry-run pattern, depth-first directory deletion, when to use -exec rm vs xargs rm -f, and the BSD vs GNU differences.

How to Find and Delete Files Safely with find -delete

find -delete removes every matched file with no confirmation and no undo. The safe pattern is to write the command with -print first, eyeball the list, then swap -print for -delete. Plus the directory-depth-first trap, when to use -exec rm instead, and the find -delete vs xargs rm -f tradeoff.

Skip node_modules, .git, and build output from grep with --exclude-dir, exclude filename globs with --exclude, and search only matching files with --include. The one-liner, brace expansion, --exclude-from, and the BSD grep fallback.

How to Exclude Files and Directories from grep

grep does not read .gitignore, so skipping node_modules, .git, and build output is on you. The flags that do it: --exclude for filename globs, --exclude-dir for whole directories, --include for the inverse, --exclude-from to read the list from a file, plus the find -prune fallback for older macOS grep.