VPS Snaps

The find command: a practical guide with examples

find walks a directory tree and prints every path that passes a set of tests: find /var/log -name '*.log' -mtime +7 lists log files last modified more than 7 full days ago. This guide covers the tests you will use most, the rounding traps in size and age, how to act on results safely, and how to delete old backups without accidents.

9 min readUpdated Tested on Ubuntu 24.04 LTS, GNU findutils 4.9.0

How a find command is built

Terminal
find /var/www -type f -name '*.php' -print
  • Starting points (/var/www): one or more directories to search. With none, GNU find searches the current directory.
  • Tests (-type f -name '*.php'): conditions joined by an implied AND and checked left to right.
  • Action (-print): what to do with each match. -print is the default, so you can leave it out.

Always quote patterns. Unquoted, the shell expands *.php against the current directory before find sees it. With two .php files in the current directory, find fails like this:

Output
find: paths must precede expression: `b.php'
find: possible unquoted pattern after predicate `-name'?

With exactly one match it is worse: find quietly searches for that single file name. Searching as a normal user, add 2>/dev/null to hide Permission denied messages, or run find with sudo.

Find by name and type

CommandFinds
find /var/www -name '*.php'Names ending in .php. Case-sensitive; *, ? and [...] work as in the shell.
find /var/www -iname '*.php'The same, ignoring case, so Config.PHP matches too.
find /var/www -type d -name uploadsDirectories named uploads.
find /var/www -path '*/uploads/*.jpg'Matches the pattern against the whole path, not just the name.
find /etc -type lSymbolic links.

-type takes f (regular file), d (directory) and l (symbolic link), plus p, s, b and c for pipes, sockets and devices. Add -type f when you mean files: in our test -name '*.php' alone also matched a symlink named link.php.

Find by size

Terminal
find / -xdev -type f -size +100M -exec ls -lh {} +

This lists files over 100 MiB on the root filesystem. -xdev keeps find on one filesystem, so it does not descend into /proc or a mounted backup disk.

SuffixUnit
cBytes
kKiB (1,024 bytes)
MMiB (1,048,576 bytes)
GGiB
none512-byte blocks

+100M means more than 100 MiB, -100M less, and no sign means exactly 100 units. -empty finds empty files and empty directories.

find rounds sizes up to whole units before comparing. -size -1M therefore matches only empty files: in our test it skipped a 500,000-byte file. For under one megabyte, write -size -1048576c, since bytes are never rounded.

Find by age: -mtime, -mmin and -newer

TestMatches files modified
-mtime -1Less than 24 hours ago
-mtime 7Between 7 and 8 days ago
-mtime +78 or more days ago
-mmin -60In the last 60 minutes
-daystart -mtime 0Since midnight today
-newer /backups/.last-runMore recently than that file
-newermt '2026-10-01 12:00'After that date and time

find counts age in whole 24-hour periods and drops the fraction, which is why +7 means 8 days or more. In our test, a file 7 days and 12 hours old matched -mtime 7 but not -mtime +7. Dates work too; this finds archives modified from 20 to 25 September:

Terminal
find /backups -type f -newermt '2026-09-20' ! -newermt '2026-09-26'

-mtime uses the content modification time. -ctime is the inode change time (permissions, owner, renames), not the creation time, and -atime is the last access, which most Linux systems record only approximately.

To pick up everything changed since the last run of a job, keep a marker file: find /var/www -type f -newer /backups/.last-run lists the changes, then touch /backups/.last-run resets the marker.

Combine tests: AND, OR, NOT and parentheses

Terminal
find /var/www -type f \( -name '*.jpg' -o -name '*.mp4' \)

Tests side by side are joined by AND. -o is OR, ! (or -not) is NOT, and \( \) group tests; the backslashes stop the shell from reading the parentheses. AND binds tighter than OR, so without the parentheses, find /var/www -type f -name '*.jpg' -o -name '*.mp4' -print printed only the .mp4 file in our test: -print belonged to the second half.

-prune skips a directory entirely. This lists .js files outside node_modules and never reads inside them:

Terminal
find /var/www -path '*/node_modules' -prune -o -type f -name '*.js' -print

! -path '*/node_modules/*' gives the same list but still walks every file under node_modules, which is slow on large trees. -maxdepth 1 stops find going below the starting directory, and -mindepth 1 leaves the starting directory itself out; write them straight after the starting point, since they apply to the whole search.

Find by permissions and owner

TestMatches
-perm 644Mode exactly 644.
-perm -o+wWorld-writable. With -, all the listed bits must be set; same as -perm -0002.
-perm -4000Setuid files.
-perm /6000Setuid or setgid. With /, any of the listed bits.
-user www-data, -group www-dataOwned by that user or group.
-nouser, -nogroupOwned by a user or group ID with no name on this system.

World-writable files under a web root are worth checking for, especially after a restore:

Terminal
find /var/www -type f -perm -o+w

-nouser helps after restoring files from another server: anything it lists has an owner ID that does not exist here. To reset a web root to the usual modes, directories 755 and files 644 (this also clears execute bits on any scripts):

Terminal
find /var/www/html -type d -exec chmod 755 {} +
Terminal
find /var/www/html -type f -exec chmod 644 {} +

Run commands on the results: -exec, -exec + and xargs

-exec runs a command for matches, with {} replaced by the path. Ending it with \; runs the command once per file; ending it with + passes as many paths as fit on one command line, which is much faster for many files. Inside /var/www/html/uploads:

Terminal
find . -name '*.jpg' -exec echo gzip {} \;
Output
gzip ./my photo.jpg
gzip ./medium.jpg
Terminal
find . -name '*.jpg' -exec echo gzip {} +
Output
gzip ./my photo.jpg ./medium.jpg

Both are safe with spaces in names, because find hands each path over as a separate argument. A plain pipe to xargs is not:

Terminal
find . -name '*.jpg' | xargs ls -l
Output
ls: cannot access './my': No such file or directory
ls: cannot access 'photo.jpg': No such file or directory
-rw-r--r-- 1 root root 500000 Oct  3 16:26 ./medium.jpg
Terminal
find . -name '*.jpg' -print0 | xargs -0 -r ls -l
  • -print0 ends each path with a null byte, which cannot appear in a file name, and xargs -0 splits on it.
  • -r stops xargs running the command at all when there is no input. Without it, xargs ls -ld with no input listed the current directory in our test; with a destructive command that default is a trap.
  • xargs -P 4 runs up to four commands in parallel, which -exec cannot do.

Use -exec ... {} + by default, and -print0 | xargs -0 -r when you need parallel runs or a filter in between.

Delete old backups safely

Run the command with -print first and read the list:

Terminal
find /backups -maxdepth 1 -type f -name 'www-*.tar.gz' -mtime +14 -print

When the list is right, replace -print with -delete:

Terminal
find /backups -maxdepth 1 -type f -name 'www-*.tar.gz' -mtime +14 -delete

-delete is an action, and find evaluates left to right. Written first, as in find /backups -delete -name '*.tar.gz', it deletes everything under /backups before the name test is ever applied; in our test it removed the starting directory as well. Keep -delete last, with -type f and a specific -name before it.

On directories, -delete works bottom-up, so a directory that becomes empty once its empty subdirectories are gone is removed too. In our test, -type d -empty -print listed one directory and -delete removed two.

Age-based pruning has one dangerous failure mode. If backups stop running, another good archive passes 14 days every day, until -mtime +14 has deleted the last one. Only prune when a fresh backup exists:

/usr/local/bin/prune-backups.sh
#!/bin/sh
if [ -n "$(find /backups -maxdepth 1 -name 'www-*.tar.gz' -mtime -2 -print -quit)" ]; then
  find /backups -maxdepth 1 -type f -name 'www-*.tar.gz' -mtime +14 -delete
else
  echo "no backup in the last 2 days, not pruning" >&2
  exit 1
fi

-print -quit stops at the first match, so the check ends early. Alternatively, keep a fixed number of archives rather than a fixed age. This keeps the newest 7, whatever their dates:

Terminal
find /backups -maxdepth 1 -type f -name 'www-*.tar.gz' -printf '%T@ %p\n' | sort -rn | tail -n +8 | cut -d' ' -f2- | xargs -r -d '\n' rm --

%T@ prints the modification time in seconds, sort -rn puts the newest first, and tail -n +8 passes on everything from the 8th line down. Run it without the final | xargs -r -d '\n' rm -- to see what would go. It assumes names without newlines, which holds for archive names you generate yourself.

-printf formats each match. The 10 largest files under /var, with sizes in bytes:

Terminal
find /var -xdev -type f -printf '%s\t%p\n' | sort -rn | head -10
DirectivePrints
%pThe path
%fThe file name only
%sSize in bytes
%TY-%Tm-%TdModification date, such as 2026-10-03
%T@Modification time in seconds since 1970
%u, %gOwner and group names
%mPermissions in octal, such as 644

-ls prints an ls -dils style line for each match without starting ls.

Deleting old archives with find is a retention policy in one line. To choose the numbers, see how long to keep backups.

Frequently asked questions

How do I find files larger than 1 GB?
find / -xdev -type f -size +1G. Add -exec ls -lh {} + to see their sizes.
How do I find files modified in the last 24 hours?
find /path -type f -mtime -1, or -mmin -1440. For files changed since midnight, use -daystart -mtime 0.
What is the difference between -exec {} \; and -exec {} +?
\; runs the command once per file. + passes many files to each run, like xargs, which is much faster on large result sets.
How do I exclude a directory from find?
Prune it: find /var/www -path '*/node_modules' -prune -o -type f -print. The -prune branch skips the directory, and the explicit -print applies only to the other branch.
Why does find say "paths must precede expression"?
A pattern was not quoted, so the shell expanded it into several file names. Quote it: -name '*.php'.

How this was checked

The commands were run on Ubuntu 24.04 LTS, GNU findutils 4.9.0 on October 3, 2026. Any that need something this test server does not have, such as a second server, a cloud account or another database engine, were checked against the official pages below instead.

Sources, on October 3, 2026: