Guides

find command in Linux: search by name, size, age and permissions, with -exec and -delete

The find command in Linux by example: -name, -type, -size, -mtime, -perm, -maxdepth, -exec vs -exec +, safe -delete, -print0 with xargs and -prune.

Published ~10 min read
On this page

Short answer: find PATH TESTS ACTION walks every file and directory below PATH and acts on the ones that pass every test. find /var/www -name '*.php' lists every PHP file under /var/www; add tests such as -type f, -size +100M or -mtime +30 to narrow it, and an action such as -exec or -delete to do something with the result. Always quote the pattern, and run a command with the default -print before you let it delete anything.

any Linux
find /var/www -name '*.php'                 # by name
find /var/log -type f -mtime +30            # files not modified in 30 days
find / -xdev -type f -size +100M 2>/dev/null   # files over 100 MiB on the root filesystem

Every output block below comes from GNU findutils 4.9.0 on Debian 12, run against a small test tree in a scratch directory; the rest follows the GNU find man page. BSD and macOS find share the core tests but not every extra (-printf is GNU only, for example).

Cheat sheet: find by task

TaskCommand
By exact name, case-sensitivefind . -name 'config.php'
By pattern, ignoring casefind . -iname '*.jpg'
Only files, or only directoriesfind . -type f / find . -type d
Empty files and directoriesfind . -empty
Bigger than 100 MiBfind / -xdev -type f -size +100M
Modified more than 30 days agofind . -type f -mtime +30
Modified in the last 60 minutesfind . -type f -mmin -60
Owned by a userfind /home -user alice
World-writable filesfind /var/www -type f -perm -o+w
Only this directory, no deeperfind . -maxdepth 1 -type f
Run a command on all resultsfind . -name '*.sh' -exec chmod +x {} +
Delete what matches (test first)find /tmp/app -name '*.tmp' -delete
Skip a directory entirelyfind . -path ./node_modules -prune -o -name '*.js' -print
Safe hand-off to xargsfind . -name '*.log' -print0 | xargs -0 gzip

How find reads a command line

Everything after the starting path is one expression that find checks against each file in turn, left to right. Tests next to each other are joined by an implicit AND, so -type f -name '*.log' means “a regular file and named *.log”. find stops checking a file at the first test that fails. If the expression has no action, find adds -print for you.

The examples below run against this tree:

the test tree
./public/index.php
./public/Config.PHP
./public/app.js
./public/readme.txt                     (mode 666, modified 10 days ago)
./public/uploads/                       (mode 777)
./public/uploads/video.mp4              (3 MiB)
./public/uploads/photo.JPG              (150 KiB)
./public/uploads/my holiday photo.jpg   (20 KiB, note the spaces)
./logs/app.log
./logs/app.log.1                        (modified 45 days ago)
./logs/app.log.2.gz                     (2 MiB, modified 45 days ago)
./cache/session_a                       (empty, modified 20 minutes ago)
./cache/session_b                       (empty, modified 3 hours ago)
./node_modules/pkg/index.js
./.git/HEAD
./empty_dir/

Quote the pattern

An unquoted *.php is expanded by your shell before find ever sees it. When the current directory happens to contain a.php and b.php, find receives -name a.php b.php and stops:

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

With no matching file in the current directory the same command seems to work, and with exactly one it silently searches for that one name instead of the pattern, which is why the bug survives in scripts until the day it does not. Single quotes, -name '*.php', fix it everywhere.

-name and -iname: search by file name

Output
$ find . -name '*.php'
./public/index.php
$ find . -iname '*.php'
./public/index.php
./public/Config.PHP

-name is case-sensitive, -iname is not. Both match the base name only (the part after the last slash) with shell wildcards: *, ? and [abc]. To match against the whole path, such as every PHP file somewhere under an uploads directory, use -path: find . -path '*/uploads/*.php'. That one is a useful check on a hacked website, where a script dropped into an upload folder is a classic sign.

-type: files, directories, links

-type f is a regular file, -type d a directory, -type l a symbolic link. Add -type f to nearly every search for files: without it, -name '*.log' also matches a directory called old.log, and an action like chmod 644 on a directory breaks it. -empty finds empty files and empty directories:

Output
$ find . -type d -empty
./empty_dir

-size: bigger or smaller than

Output
$ find . -type f -size +1M
./public/uploads/video.mp4
./logs/app.log.2.gz

+ means more than, - means less than, no sign means exactly. The units are c (bytes), k (KiB), M (MiB) and G (GiB). With no unit at all the number counts 512-byte blocks, a leftover that surprises everyone once.

Hunting for what fills a disk usually starts with -size; checking disk usage on Linux pairs it with df and du to get there in a few commands.

-mtime and -mmin: by age

Output
$ find . -type f -mtime +30
./logs/app.log.1
./logs/app.log.2.gz
$ find . -type f -mmin -60 -name 'session*'
./cache/session_a
$ find . -type f -mmin +60 -name 'session*'
./cache/session_b
  • -mtime N counts in whole 24-hour periods and throws away the fraction. So -mtime +30 matches files at least 31 full days old, and -mtime 0 means “modified in the last 24 hours”.
  • -mmin N is the same in minutes. -mmin -60 is “changed in the last hour”.
  • -newer FILE matches anything modified more recently than FILE, handy after a deploy: touch a marker file first, then list everything changed since.
  • -ctime is not creation time. It is the last change to the file’s metadata (permissions, owner, rename). -atime (last read) is unreliable on most servers, because filesystems are mounted with relatime and update it only now and then.

-user and -perm: by owner and permissions

Output
$ find . -type f -perm -o+w
./public/readme.txt
$ find . -perm 777
./public/uploads

-perm has three forms, and only one of them is usually what you want:

FormMatchesExample
-perm 644Exactly this mode, nothing more or lessFiles that are exactly rw-r--r--
-perm -o+wAll of these bits are set (others may be too)Anything world-writable
-perm /u+x,g+xAny of these bits is setExecutable by owner or group
permission audits
find /var/www -type f -perm -o+w          # world-writable files in a web root
find / -xdev -type f -perm -4000          # SUID programs, worth knowing by name
find /var/www ! -user www-data            # anything the web server user does not own
find / -xdev -nouser                      # files whose owner account no longer exists

The old -perm +mode form no longer works in GNU find; use -perm /mode. What the bits mean, and why 777 is the wrong fix for almost every permission error, is in the chmod guide.

-maxdepth and -mindepth: how deep to go

Output
$ find . -maxdepth 1 -type d
.
./empty_dir
./.git
./node_modules
./cache
./public
./logs

-maxdepth 1 stays in the starting directory, -maxdepth 2 goes one level down, and -mindepth 1 leaves out the starting point itself (the . above). Both are global options: they apply to the whole search wherever you write them, so put them first. Written after a test, GNU find warns you when you run it from a terminal:

Output
find: warning: you have specified the global option -maxdepth after the argument -type,
but global options are not positional, i.e., -maxdepth affects tests specified before it
as well as those specified after it.  Please specify global options before other arguments.

Note the output order: find lists entries in the order the filesystem returns them, not alphabetically. Pipe into sort when order matters.

-exec {} \; vs -exec {} +

-exec runs a command on what find found, with {} standing for the file name. The ending decides how often the command runs:

Output
$ find . -type f -name '*.log*' -exec echo {} \;
./logs/app.log.1
./logs/app.log.2.gz
./logs/app.log
$ find . -type f -name '*.log*' -exec echo {} +
./logs/app.log.1 ./logs/app.log.2.gz ./logs/app.log

With \; the command runs once per file. With +, find packs as many names as fit onto one command line, like xargs does. On 5,000 empty files in the same scratch directory, -exec true {} \; took about 1.8 seconds and -exec true {} + about 3 milliseconds, because starting a process is the expensive part. Use + whenever the command accepts several files, which chmod, chown, rm, ls, grep and gzip all do.

any Linux
find /var/www/site -type d -exec chmod 755 {} +      # directories
find /var/www/site -type f -exec chmod 644 {} +      # files
find . -type f -name '*.php' -exec grep -l 'eval(' {} +   # which files contain a string
find . -name '*.log' -exec cp -t /root/log-backup/ {} +  # target directory first, with -t

With +, {} must come last, right before the +. Writing -exec cp {} /backup/ + fails with find: missing argument to `-exec'; GNU cp -t DIR and mv -t DIR take the target first so the file names can go last. For anything more complex than one command, hand the batch to a small shell loop:

any Linux
find . -type f -iname '*.jpg' -exec sh -c 'for f; do echo "would convert: $f"; done' sh {} +

-execdir works like -exec but runs the command from the directory that contains the file, and -ok asks for confirmation before each run. For searching inside files rather than by name, the grep guide covers grep -r, which is often all you need.

-delete without losing data

-delete removes every file that reaches it in the expression, with no confirmation and no undo. Because find evaluates left to right, its position is everything: find . -delete -name '*.tmp' deletes everything under ., then checks the name. The man page warns about exactly this, and in the scratch directory used for this guide it did exactly that and emptied the whole test tree. A safe routine:

  1. 1Run it with -print first
    any Linux
    find /var/log/myapp -type f -name '*.gz' -mtime +30 -print
  2. 2Count and skim the result
    any Linux
    find /var/log/myapp -type f -name '*.gz' -mtime +30 | wc -l
  3. 3Replace -print with -delete, at the very end
    any Linux
    find /var/log/myapp -type f -name '*.gz' -mtime +30 -delete

Two more limits from the man page: -delete cannot remove a directory that is not empty, and it switches on -depth, which makes -prune stop working, so do not combine the two. To remove whole directories, use -exec rm -rf {} +, and run the -print version first there too.

Names with spaces: -print0 and xargs -0

By default find prints one name per line and xargs splits its input on any whitespace, so a file called my holiday photo.jpg arrives as three arguments:

Output
$ find . -name '*.jpg' | xargs ls -l
ls: cannot access './public/uploads/my': No such file or directory
ls: cannot access 'holiday': No such file or directory
ls: cannot access 'photo.jpg': No such file or directory
$ find . -name '*.jpg' -print0 | xargs -0 ls -l
-rw-r--r-- 1 root root 20480 Oct 21 12:19 ./public/uploads/my holiday photo.jpg

-print0 ends each name with a null byte, the one character a Linux path cannot contain, and xargs -0 splits on exactly that. Use the pair every time find feeds xargs, or skip xargs and use -exec ... {} +, which never splits names in the first place. xargs earns its place when you need parallel runs or a fixed batch size.

Excluding directories: -prune

any Linux
# skip node_modules
find . -path ./node_modules -prune -o -name '*.js' -print

# skip several directories by name, wherever they are
find . \( -name node_modules -o -name .git \) -prune -o -type f -print

Read it as: “if this is ./node_modules, do not descend into it; otherwise (-o), if the name matches, print it.” The explicit -print at the end matters. Leave it out and find adds its default print around the whole expression, so the pruned directory itself shows up in the results:

Output
$ find . -path ./node_modules -prune -o -name '*.js'
./node_modules
./public/app.js
$ find . -path ./node_modules -prune -o -name '*.js' -print
./public/app.js

-not -path './node_modules/*' gives the same list and is easier to read, but find still walks every file inside the excluded directory and throws the results away. On a big node_modules or a mail spool, -prune is much faster.

OR, NOT and parentheses

-o is OR, ! (or -not) is NOT, and AND binds tighter than OR. That last rule bites as soon as an action follows an OR:

Output
$ find . -name '*.php' -o -name '*.txt' -exec echo found {} \;
found ./public/readme.txt
$ find . \( -name '*.php' -o -name '*.txt' \) -exec echo found {} \;
found ./public/readme.txt
found ./public/index.php

Without parentheses the -exec is ANDed to the second name test only, so the PHP file was matched and silently ignored. Group OR conditions in \( and \) (escaped, with spaces around them) whenever an action follows.

Common find errors and fixes

  • find: ‘/proc/1234/task’: No such file or directory or Permission denied by the hundred. Searching / as a normal user, or through /proc. Add -xdev to stay on one filesystem, use sudo if you need everything, or hide the noise with 2>/dev/null.
  • paths must precede expression: an unquoted wildcard, see above.
  • missing argument to `-exec': the \; or + is missing, or {} is not the last thing before +.
  • Nothing found that should be. Check the size rounding, the case of -name, and whether a pruned path or a -maxdepth keeps find out of the directory.

find on a server: four jobs it does every week

any Linux
# 1. what filled the disk: the 20 largest files on /
sudo find / -xdev -type f -printf '%s\t%p\n' 2>/dev/null | sort -nr | head -20

# 2. what changed in the web root in the last day (after a deploy, or a break-in)
sudo find /var/www -type f -mtime 0 -printf '%TY-%Tm-%Td %TH:%TM  %p\n' | sort

# 3. old backups to clear, checked first, then deleted
sudo find /srv/backups -name '*.tar.gz' -mtime +14 -print

# 4. a big scan that should not slow the site down
sudo nice -n 19 find / -xdev -type f -name '*.sql' 2>/dev/null

-printf is GNU only and prints whatever fields you ask for: %s the size in bytes, %p the path, %T plus a letter the modification time. A recursive search over millions of small files is real disk work; on a busy VPS, start it with nice and limit it with -xdev and a narrow starting path rather than /. When the job is “the disk is full right now”, No space left on device has the full checklist.

Frequently asked

How do I find a file by name in Linux?

Run find with a starting directory and -name: find / -name 'nginx.conf' 2>/dev/null searches the whole system and hides permission errors. Use -iname to ignore case, quote any pattern that contains * or ?, and add -type f so directories with a matching name are left out.

What is the difference between find -exec {} \; and -exec {} +?

With \; the command runs once for every file found. With + find passes as many file names as fit to a single run, the way xargs does, which is far faster on many files. Use + whenever the command accepts several files and {} can go last.

How do I exclude a directory from find?

Use -prune: find . -path ./node_modules -prune -o -name '*.js' -print skips node_modules entirely. Keep the explicit -print at the end, or the pruned directory itself appears in the output. -not -path './node_modules/*' gives the same list but still walks through the directory, which is slower.

How do I find files larger than 100 MB?

find / -xdev -type f -size +100M 2>/dev/null lists files over 100 MiB on the root filesystem; -xdev keeps find on that one filesystem. Sizes are rounded up to whole units before comparing, so for an upper limit such as under 1 MiB write -size -1024k, not -size -1M, which matches only empty files.

Related articles

All guides