Finding Files with find

find is the standard Linux tool for locating files and directories anywhere on the filesystem, based on criteria you specify: name, type, size, modification time, permissions, and more. Unlike shell globbing (*.log), which only looks in the current directory, find walks an entire directory tree and can act directly on what it discovers — printing paths, running commands, or deleting matches. It is one of the most powerful tools in the Linux command line, and also one of the most dangerous when combined carelessly with deletion.

Overview: How find Works

find takes a starting path (or several) and recursively walks the directory tree beneath it, the same way a program would using the readdir() and stat() system calls: for every directory it opens, it lists each entry, calls stat() to get that entry’s type, size, timestamps, permissions, and owner, and then checks that information against the tests you gave it. If an entry matches all your tests, find performs the requested action — by default, simply printing the path.

Conceptually, a find command is a pipeline of tests connected by implicit logic. find /var/log -name "*.log" -type f reads as: for every entry under /var/log, test whether the name matches *.log and whether it is a regular file. Tests are evaluated left to right and short-circuit like && in a shell expression — if -name fails, find never bothers calling stat() to check -type for that entry. This matters for both performance and for understanding why reordering tests can change how fast a search runs on very large trees.

Traversal is depth-first by default: find descends into a directory before moving to the next sibling, so output is not alphabetically sorted unless you pipe it through sort. You can bound how deep it goes with -maxdepth, or skip a directory entirely with -prune (useful for excluding node_modules or .git from a search).

Because find can both locate and act on files in one command, it is commonly used for cleanup scripts (deleting old logs), auditing (finding world-writable files), and batch operations (recompressing every .csv under a directory). That power is exactly why it deserves care — a mistyped test with -delete or -exec rm can silently remove far more than intended.

Syntax

find "<starting-path>" "<options>" "<tests>" "<actions>"

This is the general shape, not a real command — a real invocation picks a starting path, one or more tests, and optionally an action. If you omit an action, find assumes -print.

Test / Option Meaning
-name "pattern" Match filename against a case-sensitive glob pattern
-iname "pattern" Same as -name, but case-insensitive
-type f|d|l Match regular files (f), directories (d), or symlinks (l)
-size +N / -N / N Size greater than, less than, or exactly N (suffix c=bytes, k=KB, M=MB, G=GB)
-mtime +N / -N / N Modified more than, less than, or exactly N days ago
-newer file Modified more recently than the given reference file
-perm mode Match an exact permission mode, e.g. -perm 644
-user name Match files owned by the given user
-empty Match empty files or empty directories
-maxdepth N / -mindepth N Limit how many directory levels deep find descends
-not / !, -a, -o Logical NOT, AND (default between tests), and OR
-print0 Print matches separated by NUL bytes instead of newlines (safe for filenames with spaces)
-delete Delete each matched file or empty directory
-exec cmd {} \; Run cmd once per match, substituting {} with the path
-exec cmd {} + Run cmd once total, batching as many matches as possible onto the command line

Examples

Example 1: Find log files by name

find /var/log -name "*.log"

Output:

/var/log/syslog
/var/log/auth.log
/var/log/apt/history.log

This walks the entire /var/log tree and prints the full path of every entry whose name matches the glob pattern *.log. The pattern is quoted so the shell passes the literal string *.log to find, which does its own matching against each filename — if it were unquoted, the shell would try to expand it in the current directory first, which is almost never what you want here (see Common Mistakes).

Example 2: Find large files by type and size

find /home/user/projects -type f -size +10M

Output:

/home/user/projects/dataset/raw_data.csv
/home/user/projects/build/app.bin

Here two tests are combined with an implicit AND: the entry must be a regular file (-type f, which excludes directories and symlinks) and must be larger than 10 megabytes (-size +10M). This is a common way to hunt down what is eating disk space inside a project directory.

Example 3: Clean up old temporary files

find /tmp -type f -name "*.tmp" -mtime +7 -delete

This finds every regular file under /tmp named *.tmp that was last modified more than 7 days ago, and deletes each one directly — find prints nothing on success, since -delete is an action, not -print. Note that -type f is essential here: without it, a directory that happens to match *.tmp could also be targeted, and -delete only removes it if it is empty (it will not recursively delete a non-empty directory, but it is still safer to restrict the test to files explicitly).

Example 4: Run a command on every match with -exec

find /home/user/projects -type f -name "*.sh" -exec chmod +x {} \;

This finds every shell script under /home/user/projects and makes each one executable. The {} is replaced with the matched path, and the trailing \; (an escaped semicolon, so the shell passes a literal ; to find instead of ending the command) tells find where the -exec command ends. With \;, find forks and execs chmod once per matched file; for large result sets this is slow. The alternative terminator + batches as many matches as will fit on one command line into a single invocation:

find /home/user/projects -type f -name "*.sh" -exec chmod +x {} +

Functionally equivalent here, but far fewer processes are spawned — prefer + when the command supports multiple file arguments (like chmod, chown, or grep).

How It Works Step by Step

Take find /etc -maxdepth 1 -type f -not -name "*.conf":

find /etc -maxdepth 1 -type f -not -name "*.conf"

Output:

/etc/hostname
/etc/hosts
/etc/fstab
  1. find opens /etc and reads its directory entries one at a time via readdir().
  2. Because -maxdepth 1 is set, it will list entries directly inside /etc but not descend into subdirectories like /etc/apt.
  3. For each entry, it calls stat() to learn the file type, checking it against -type f first (cheapest test, evaluated left to right).
  4. If the entry is a regular file, it then evaluates -not -name "*.conf", which inverts the result of the name match — the entry passes only if its name does not end in .conf.
  5. Any entry that passes every test falls through to the default action, -print, which writes its path to standard output followed by a newline.

This left-to-right, short-circuit evaluation is why put your cheapest or most selective test first when searching enormous trees — it can noticeably cut how many stat() calls find has to make.

Common Mistakes

Mistake 1: Leaving the pattern unquoted

find . -name *.txt

Without quotes, the shell expands *.txt against files in the current directory before find ever runs. If exactly one .txt file exists there, find receives that single filename as the pattern instead of a wildcard, silently searching for the wrong thing. If no .txt files exist there, bash leaves the literal asterisk, which usually still “works” by accident — but if two or more match, find errors out with a “paths must precede expression” message. Always quote the pattern so find does the matching itself:

find . -name "*.txt"

Mistake 2: Looping over unquoted command substitution

for file in $(find . -name "*.log"); do
  rm "$file"
done

Even though $file is quoted inside the loop, the damage happens earlier: the unquoted $(find ...) is word-split on whitespace before the loop even starts. A file named error report.log becomes two separate loop items, error and report.log, neither of which exists. The fix is to avoid feeding filenames through word-splitting entirely, using NUL-separated output and a read loop:

find . -name "*.log" -print0 | while IFS= read -r -d '' file; do
  rm "$file"
done

-print0 separates results with a NUL byte (which cannot appear in a filename), and read -d '' reads up to each NUL instead of each newline, so filenames containing spaces, tabs, or even newlines survive intact.

Mistake 3: A loose -exec rm -rf that matches more than files

find /tmp -name "*.old" -exec rm -rf {} \;

This has no -type f test, so it matches directories too — a directory named cache.old gets wiped out entirely, recursively, with no confirmation, because rm -rf doesn’t care what it’s given. Combined with a broad or mistyped starting path, this pattern is how people accidentally delete far more than they meant to. Restrict the test to regular files, and drop the unnecessary -r/-f flags so rm fails loudly instead of silently succeeding on the wrong target:

find /tmp -type f -name "*.old" -exec rm {} \;

When in doubt, run the exact same find command with -print (or no action at all) first, review the list of matches, and only add -delete or -exec rm once you are confident it’s correct.

Best Practices

  • Always quote glob patterns passed to -name/-iname so find does the matching, not the shell.
  • Test a destructive find command with -print (or no action) before switching to -delete or -exec rm.
  • Restrict deletions and destructive -exec calls with -type f (or -type d) so you never accidentally match the wrong kind of entry.
  • Prefer -exec cmd {} + over -exec cmd {} \; when the command accepts multiple arguments — it spawns far fewer processes.
  • Use -print0 piped into read -r -d '' (or xargs -0) instead of looping over unquoted $(find ...) output, to handle filenames with spaces or special characters safely.
  • Use -maxdepth to bound a search and -prune to skip directories like .git or node_modules when searching source trees.
  • Put cheaper or more selective tests first in a long find expression, since evaluation short-circuits left to right.

Practice Exercises

  1. In your home directory, write a single find command that lists every regular file larger than 5 megabytes, sorted by nothing in particular is fine — just get the matches right using -type and -size.
  2. Write a find command that searches /var/log for files modified in the last 24 hours (hint: look at -mtime with a negative value) and prints only their paths.
  3. Write a safe cleanup command for a directory of your choice that deletes files ending in .bak that are more than 30 days old — first run it with -print to review matches, then re-run with -delete once you’ve confirmed the list is correct.

Summary

  • find recursively walks a directory tree, testing each entry’s name, type, size, timestamps, and permissions against the criteria you give it.
  • Multiple tests are combined with an implicit AND and evaluated left to right; use -o for OR and -not/! for negation.
  • The default action is -print; -delete and -exec let you act directly on matches, with -exec cmd {} + batching for efficiency over -exec cmd {} \;.
  • Always quote patterns passed to -name, and use -print0/NUL-delimited reads instead of unquoted command substitution when handling filenames.
  • Test destructive commands with -print before adding -delete or -exec rm, and scope them with -type f to avoid matching directories by accident.