find, in depth
The Navigating the Filesystem
chapter introduced find in a table: a pattern here, an age there, one
-exec line at the bottom. That is enough to copy a working command. This
guide is the step past copying, because find is one of the few tools where
the order of the words changes what the command does, in one case enough
to empty a directory by accident.
Everything here was checked on Ubuntu 26.04 and Fedora 44 (findutils 4.10.0) and Arch (4.11.0), with the same behavior on all three. The command ships with the base system on every one of those distro families, so there is nothing to install before the first example.
One filter chain, left to right
find takes a starting directory and, optionally, a chain of tests and
actions. With neither, it lists the starting point and everything under
it, recursively. Build this small sandbox to search:
mkdir -p ~/logs/cache ~/logs/old ~/logs/sizes
cd ~/logs
touch app.log cache.log debug.log junk1.tmp junk2.tmp
touch "space test.log"
touch cache/session.log
echo "rotate failed on Tuesday" > notes.md
printf 'x' > sizes/one.txt # exactly one byte
touch sizes/empty.txt # zero bytes
head -c 1572864 /dev/zero > sizes/big.bin # 1.5 MiB of zeros
touch -d "1 day ago" mark.txt # -d sets the file's timestamp
touch -d "9 days ago" old/keep.txt # aged like the logs, but not a .log
ln -s app.log latest.log # a symlink (see chapter 3)
Every example below runs against this directory, and every word after
the starting path is a test or an action. Tests chain with an implicit
AND: find ~/logs -name "*.tmp" -type f requires both the name and the
type to match.
Order matters more than it looks, because find evaluates the chain left to right. Watch what a test placed after an action fails to do:
$ find ~/logs -name "*.tmp" -print -name junk1.tmp
/home/user/logs/junk1.tmp
/home/user/logs/junk2.tmp
$ find ~/logs -name junk1.tmp -print
/home/user/logs/junk1.tmp
The second -name sits after -print, so the printing has already
happened by the time find reaches it. A test written after an action
never affects that action. That sounds like trivia until you meet
-delete, which is an action too, and then it is the most important
sentence in this guide. One comfort on the way: -print is the default
action, so leaving it out changes nothing.
By name and type
-name matches case-sensitively and -iname ignores case:
find ~/logs -iname "*.MD" finds notes.md, where -name "*.MD" comes
back empty.
Now the single most common find mistake, staged somewhere the glob
actually matches. In a directory containing several .txt files, such
as your Documents folder, try the pattern without quotes:
$ find . -name *.txt
find: paths must precede expression: `aa.txt'
find: possible unquoted pattern after predicate `-name'?
Exit status 1, no results. The shell expanded the pattern into the
matching filenames before find ever started (the
shell essentials chapter
covers globbing), handing it extra arguments it never asked for. The
annoying part: in a directory with no .txt
files at all, the same command works, because an unmatched glob reaches
find untouched as a literal pattern. Whether it breaks depends on what
happens to be lying around. Quote the pattern,
find . -name "*.txt", and the problem disappears.
-type filters by what a thing is: -type f for files, -type d for
directories, -type l for symlinks. The sandbox has one symlink, and it
shows how strictly find draws these lines by default:
$ find ~/logs -name latest.log -type f
$ find ~/logs -name latest.log -type l
/home/user/logs/latest.log
$ find -L ~/logs -name latest.log -type f
/home/user/logs/latest.log
A symlink to a file is not a file as far as find is concerned, so
-type f skips it and -type l catches the link itself. (ln -s, which
built it, lives in the
files and directories chapter.)
-L placed before the starting path (it is an option, not a test) makes
find follow links, after which the target matches -type f. After the
path, it is an unknown-predicate error instead.
By age and size
Make four of the logs look stale with
touch -d "9 days ago" cache.log debug.log old/app.log old/error.log,
then search:
$ find ~/logs -name "*.log" -type f -mtime +7
/home/user/logs/cache.log
/home/user/logs/debug.log
/home/user/logs/old/app.log
/home/user/logs/old/error.log
-mtime measures age in whole days and rounds up. -mtime 0 catches
the last 24 hours, -mtime 7 catches day seven (a file 7 to 8 days
old), and -mtime +7 catches anything older than 7 full days, which is
what just matched. The same +/- pattern works in minutes too:
-mmin -30 matches files modified less than 30 minutes ago,
-mmin +30 files older than that.
-newer FILE finds things modified more recently than FILE. The sandbox
has mark.txt from yesterday, so the logs younger than yesterday are:
$ find ~/logs -name "*.log" -newer ~/logs/mark.txt -type f
/home/user/logs/app.log
/home/user/logs/cache/session.log
/home/user/logs/space test.log
Pair it with -type f more often than not: the starting directory
carries an mtime too, and when files are created inside it, the
directory can appear in -newer results alongside the files. The
-type f test keeps the list to files.
Size takes fixed units, and all of them round up:
| You write | find reads it as |
|---|---|
n (bare number) |
512-byte blocks |
c |
bytes |
k |
kibibytes (1024 bytes) |
M |
mebibytes (1024 KiB) |
G |
gibibytes (1024 MiB) |
The rounding produces results that look wrong until you see the rule:
$ find ~/logs/sizes -type f -size 1M
/home/user/logs/sizes/one.txt
$ find ~/logs/sizes -type f -size +1M
/home/user/logs/sizes/big.bin
$ find ~/logs/sizes -type f -size 2M
/home/user/logs/sizes/big.bin
one.txt is one byte long, and find rounds it up to a full mebibyte,
which is generous of it. Anything from 1 byte through exactly 1 MiB
matches -size 1M, while +1M means strictly larger. big.bin at
1.5 MiB rounds up again, which is why it answers to -size 2M. The
zero-byte empty.txt matches neither, since zero rounds to zero. For
empty things specifically there is -empty, which matches empty files
and, just as usefully, empty directories.
By owner and permission
-user NAME and -group NAME filter by ownership. On a single-user
desktop they mostly confirm what you expect, but on a machine with
several accounts they partition results cleanly: find /home -user emil
hands you Emil’s files and nobody else’s.
Permissions have three spellings, and the difference between them is the
whole game. -perm 644 matches only files that are exactly rw-r--r--.
-perm -644 matches files with all of those bits set, so a 755 file
qualifies too. -perm /700 matches anything where the owner holds at
least one of the three bits.
The classic use is auditing for world-writable files with -0002: all
bits in 0002 must be set, and 0002 is the write bit for everyone
else. Plant one and see:
$ chmod 666 notes.md
$ find ~/logs -type f -perm -0002
/home/user/logs/notes.md
$ chmod 644 notes.md
Anything this search finds is writable by every account on the machine,
which is rarely the intent. If you ever run the audit without
-type f, add ! -type l to keep symlinks out of the results: their
permissions are always wide open, so each link would match the test.
One warning while we are here. Older scripts sometimes carry
-perm +644. That form was removed from GNU findutils years ago and now
stops find with an invalid-mode error instead of doing anything useful.
Steering the search
Three options control where find looks rather than what it matches.
-maxdepth N stops the descent after N levels, and the pairing
-mindepth 1 -maxdepth 1 lists a directory’s own entries, without
descending and without listing the directory itself:
$ find ~/logs -mindepth 1 -maxdepth 1 -type d
/home/user/logs/cache
/home/user/logs/old
/home/user/logs/sizes
-prune is stronger: it tells find not to walk into a matched directory
at all. It almost always appears in this pairing, where the left side
matches what to skip and the right side prints the rest:
$ mkdir -p /tmp/prunedemo/node_modules
$ touch /tmp/prunedemo/node_modules/dep.js
$ find /tmp/prunedemo
/tmp/prunedemo
/tmp/prunedemo/node_modules
/tmp/prunedemo/node_modules/dep.js
$ find /tmp/prunedemo -name node_modules -prune -o -print
/tmp/prunedemo
node_modules and everything under it is gone, and the skipped
directory itself is not printed either. Anyone who has waited for a
backup to crawl through node_modules will recognize the use case.
One combination to refuse outright: prune and -delete do not mix. Ask
find for both and it refuses the job before it starts, prints a warning
on stderr, exits with status 1, and deletes nothing, not even files a
preview had listed:
$ find /tmp/prunedemo -name node_modules -prune -o -type f -mtime +7 -delete
find: The -delete action automatically turns on -depth, but -prune does nothing when -depth is in effect. If you want to carry on anyway, just explicitly use the -depth option.
The trap is the warning’s own advice. Add an explicit -depth and the
refusal vanishes: prune turns into a no-op, find silently deletes every
matching file, including the ones inside node_modules, and exits 0
without a complaint. (Fresh files and non-empty directories survive.)
One more reason the preview habit matters: the printed list is what
tells you a delete is safe.
-xdev tells find not to descend into directories that sit on another
filesystem. The starting point and its own filesystem are always
searched. That matters for wide searches, because /proc and friends
sit on filesystems of their own (the
filesystem layout guide
tours them). A whole-tree search makes the difference visible. The hit
outside /proc is wherever your distro puts it, and the command also
prints Permission denied lines on stderr, more on those in a moment:
$ find / -name uptime
/proc/uptime
/usr/bin/uptime
$ find / -xdev -name uptime
/usr/bin/uptime
With -xdev, the search never leaves the root filesystem, so nothing
under /proc appears. For wide searches that is exactly what you want,
and the
disks and storage chapter
has more on what counts as one filesystem.
Wide searches also cross directories you cannot read, and find reports
each one on stderr. Stage one with mode 000
(mkdir ~/logs/locked; chmod 000 ~/logs/locked):
$ find ~/logs -name "*.tmp" -type f
find: ‘/home/user/logs/locked’: Permission denied
/home/user/logs/junk1.tmp
/home/user/logs/junk2.tmp
$ find ~/logs -name "*.tmp" -type f 2>/dev/null
/home/user/logs/junk1.tmp
/home/user/logs/junk2.tmp
The wording is identical on Ubuntu, Fedora, and Arch, and 2>/dev/null
silences the line. Silencing is not fixing: the directory is still
unreadable and find still exits with status 1. Remove the roadblock
afterward with rmdir ~/logs/locked. The
troubleshooting guide
covers Permission denied as a symptom in its own right.
Doing things with results
Printing is the default, but find can hand results to other forms. -ls
gives an ls -dils-style listing, one line per match:
$ find ~/logs/sizes -type f -ls
1185073 4 -rw-r--r-- 1 user user 1 Sep 7 10:31 /home/user/logs/sizes/one.txt
1185074 0 -rw-r--r-- 1 user user 0 Sep 7 10:31 /home/user/logs/sizes/empty.txt
1185075 1536 -rw-r--r-- 1 user user 1572864 Sep 7 10:31 /home/user/logs/sizes/big.bin
-exec CMD {} \; runs CMD once per match, with {} replaced by the
path. The two trailing forms behave very differently:
$ find ~/logs/sizes -type f -exec echo {} \;
/home/user/logs/sizes/one.txt
/home/user/logs/sizes/empty.txt
/home/user/logs/sizes/big.bin
$ find ~/logs/sizes -type f -exec echo {} +
/home/user/logs/sizes/one.txt /home/user/logs/sizes/empty.txt /home/user/logs/sizes/big.bin
With \; find ran echo three times, once per file. With + it batched
all three paths into a single invocation, which pays off on large result
sets. A close relative, -execdir, runs the command inside each file’s
own directory with {} as ./basename, while plain -exec always
works in the starting directory: run
find ~/logs/old -name "*.log" -exec pwd \; and pwd answers with the
starting directory twice, once per match.
When the command is destructive and you want a say in each case, -ok
works like -exec \; but asks first:
$ find ~/logs -name "*.tmp" -ok rm {} \;
< rm ... /home/user/logs/junk1.tmp > ? y
< rm ... /home/user/logs/junk2.tmp > ? n
The ... in the prompt is find’s own abbreviation for the command, not
a typo. Answering y removed junk1.tmp, and answering n left
junk2.tmp untouched, where it still sits.
Deleting without regrets
-delete removes matched files. The safe way to use it is a ritual:
run the command with -print first, read the list, then swap -print
for -delete. One subtlety: the tests alone are not what you need to
preview. The whole command, in its exact order, is what runs.
Build a throwaway directory in /tmp and copy it, so both orderings
get tried:
$ mkdir -p /tmp/trap-demo/sub
$ cd /tmp/trap-demo
$ touch a.txt b.txt data.csv keep.md t1.tmp t2.tmp sub/nested.txt sub/photo.jpg
$ cp -r /tmp/trap-demo /tmp/trap-demo2
$ find .
./a.txt
./b.txt
./data.csv
./keep.md
./sub
./sub/nested.txt
./sub/photo.jpg
./t1.tmp
./t2.tmp
Nine files and directories. Now delete “the txt files”, written the way people naturally write it, action first:
$ find . -delete -name "*.txt"
$ find .
.
Everything is gone: the csv, the markdown file, the nested text file,
and the photo inside sub/, the two tmp files, and the sub directory
itself, leaving only the starting point. The command ran flawlessly.
That is the problem. -delete is an action, and this chain reads left
to right, so the deleting happened before the -name test was ever
reached and every path matched. The directory vanished too because
-delete implies -depth: find processes contents before the directory
itself, emptying sub/ and then removing it.
The copy survived for contrast:
$ cd /tmp/trap-demo2
$ find . -name "*.txt" -delete
$ find .
.
./data.csv
./keep.md
./sub
./sub/photo.jpg
./t1.tmp
./t2.tmp
Same files, test first, and only the three .txt files are missing.
Tests before actions, always. When you preview, preview the entire
command and change only the last word.
Pairing find with other commands
Filenames with spaces are where the obvious plumbing breaks, so stage one outside the sandbox:
$ mkdir /tmp/pipe-demo
$ touch "/tmp/pipe-demo/space test.log" /tmp/pipe-demo/plain.log
$ find /tmp/pipe-demo -name "*.log" -print0 | xargs -0 rm
$ find /tmp/pipe-demo -name "*.log"
A plain pipe would have split space test.log into two arguments, rm
would have failed on both halves with exit status 123, and the spaced
file itself would have survived untouched. -print0 ends each result
with a null byte instead of a newline, and xargs -0 reads them back
the same way. The pair is needed when filenames contain spaces or
newlines, and harmless otherwise.
For counting rather than acting, pipe to wc -l:
find ~/logs -name "*.log" -type f | wc -l answers 7 right now. And
when what you are looking for lives inside files rather than in their
names, that is grep territory: grep -rl "rotate" ~/logs lists files
whose contents mention “rotate”, while find only ever examines names
and metadata. The
text processing chapter
covers grep and the rest of that toolbox.
Capstone — clean out an old log directory
The sandbox now looks like a real log directory: nine-day-old logs at
the top level, an old/ folder nobody prunes, a cache/ that belongs
to some program, and a scattering of personal files. The goal: remove
the stale logs, touch nothing else, and be able to prove it.
First an inventory, leaving cache/ out of the count, since whatever
is in there is the program’s own business:
$ find ~/logs -name cache -prune -o -type f -print | wc -l
13
Thirteen files outside the cache. Now the candidates: logs older than a week, files only, printed for inspection:
$ find ~/logs -name "*.log" -type f -mtime +7 -print
/home/user/logs/cache.log
/home/user/logs/debug.log
/home/user/logs/old/app.log
/home/user/logs/old/error.log
Four files, none of them under cache/. The fresh session.log in
there is far too young to match, and old/keep.txt fails the name
test, so neither appears. This printed list is the complete deletion
set, because the preview and the delete differ by one word. Read it
once, then swap:
$ find ~/logs -name "*.log" -type f -mtime +7 -delete
$ find ~/logs -name "*.log" -type f
/home/user/logs/app.log
/home/user/logs/cache/session.log
/home/user/logs/space test.log
The four stale logs are gone. Everything else is still standing: the
fresh logs, keep.txt, the notes file, the cache, the tmp file that
-ok spared earlier. Run the inventory count again and it reports 9.
The same ritual scales to system log directories when you have the
rights for them, and the
filesystem layout guide
explains what lives where.
Where you are now
You can read any find command as a left-to-right chain of tests and
actions, filter by name, type, age, size, owner, and permission, steer
the search with -maxdepth, -prune, and -xdev, and act on results
with -ls, -exec, and -ok. The habit that ties it together is the
preview ritual, and the memory that saves a directory one day is that
-delete is an action, subject to the same left-to-right order as
everything else.
For the full option surface, the official references are the ones to keep open:
- GNU Findutils manual — the complete official documentation
- find(1) man page — every test and action in reference form
- xargs(1) man page — the receiving end of
-print0