Why this matters
Globs select files in one directory (or, with **, by name only). Real questions are richer: which log files under /var are bigger than 100 MB? which files did someone change in the last day? which directories are empty? delete every .pyc file in this tree, but not in the virtualenv. find answers all of these. It walks a directory tree and evaluates an expression for every entry, and that expression can print, delete or run commands. It is one of the most useful and most misused Unix tools; the misuse almost always comes from not knowing how the expression is evaluated, which is the heart of this lesson.
The shape of a find command
find [starting points...] [expression]
project src/ -name '*.c' -size +10k -print
find visits each starting point and everything below it, depth first, and for each entry evaluates the expression from left to right. The expression is made of:
- tests, which are true or false for the current entry:
-name,-type,-size,-mtime; - actions, which do something and are also true or false:
-print,-exec,-delete; - operators combining them:
-a(and, implied between two items),-o(or),!(not),\( ... \)(grouping); - options affecting the whole walk:
-maxdepth,-mindepth,-xdev.
With no action at all, find adds -print, so find project -name '*.c' prints matching paths. With no expression, it prints everything:
$ find project
project
project/src
project/src/main.c
project/src/util.h
...
Paths start with the starting point as you wrote it: find . prints ./src/main.c, find project prints project/src/main.c, find /work/project prints absolute paths.
Evaluation: short-circuit, left to right
For each entry, find evaluates test1 test2 test3 as test1 -a test2 -a test3, stopping at the first false. This is the same short-circuit logic as && and || in the shell (module 1):
find project -type f -name '*.conf' -size +1k -print
─────── ────────────── ───────── ──────
for project/config/app.conf: true → true → true → print (true)
for project/src: false → (stop; nothing else is evaluated)
for project/src/main.c: true → false → (stop)
Two consequences:
- Order affects speed (cheap tests like
-namebefore expensive ones like-exec), and order affects behavior when actions are involved:-printbefore a test prints entries whether or not they pass the test. -abinds tighter than-o.A -o B -exec ...meansA -o (B -a -exec ...), as the first quiz shows. Group with escaped parentheses:
find . \( -name '*.txt' -o -name '*.md' \) -print
The parentheses must be quoted (\( or '(') because they are special to bash, and they must be separate words, with spaces around them.
The common tests
| Test | True when |
|---|---|
-name 'pat' | the base name matches the glob (use -iname for case-insensitive) |
-path 'pat' | the whole path matches (* matches / here) |
-type f / d / l | regular file / directory / symbolic link |
-size +10k | larger than 10 KiB (- smaller, no sign: exactly; units c bytes, k, M, G) |
-empty | empty file or empty directory |
-mtime -7 | modified less than 7 days ago (+7: more than 7; units of 24 hours) |
-mmin -30 | modified less than 30 minutes ago |
-newer ref | modified more recently than the file ref |
-perm -u+x | has (at least) user-execute permission |
-user name, -group name | owned by |
-maxdepth N | (an option) do not descend more than N levels; put it first |
Always quote the pattern in -name. Unquoted, the shell expands it first:
$ find . -name *.c # in a directory containing a.c and b.c
find: paths must precede expression: `b.c'
bash turned it into find . -name a.c b.c. Worse, if exactly one .c file is in the current directory, it silently searches for that one name only.
Sizes and times are rounded
-size +1k compares the size rounded up to whole KiB units, so a 10-byte file counts as 1k and is not +1k; use c (bytes) for precision: -size +1024c. -mtime +1 means "more than one whole 24-hour period ago", that is, at least 48 hours. These rounding rules surprise everyone once; when precision matters, use bytes, minutes, or -newer with a reference file made by touch -d '2026-10-01' /tmp/ref.
Actions
| Action | Effect |
|---|---|
-print | print the path and a newline (the default) |
-print0 | print the path and a NUL byte (lesson 3) |
-printf 'fmt' | formatted output: %p path, %P path without the starting point, %f base name, %s size, %TY-%Tm-%Td date, \n newline |
-ls | an ls -l style line |
-delete | delete the entry (implies -depth) |
-exec cmd {} \; | run cmd once per entry, {} replaced by the path |
-exec cmd {} + | run cmd with as many paths as fit, like xargs |
-execdir cmd {} \; | like -exec, but run from the entry's directory |
-prune | do not descend into this directory |
-quit | stop immediately |
$ find project/config -type f -printf '%s\t%P\n'
2000 app.conf
8 small.conf
5000 sub/big.conf
-exec in detail
find . -name '*.log' -exec gzip {} \; # gzip a.log; gzip b.log; ... one process each
find . -name '*.log' -exec gzip {} + # gzip a.log b.log c.log ... few processes
The ; ends the command and must be escaped (\; or ';') because it is special to bash. {} is replaced by the path by find, without any shell involved, so names with spaces arrive as single arguments; no quoting needed or possible. That is exactly why find's -exec is safe and for f in $(find ...) is not.
With +, find collects paths and runs the command as few times as possible, which is much faster for thousands of files. The {} must then be the last thing before +. If your command needs the path in the middle, or needs shell features, run a small shell:
find . -name '*.jpg' -exec sh -c 'for f; do mv -- "$f" "${f%.jpg}.jpeg"; done' sh {} +
The paths become the inner shell's positional parameters (for f loops over "$@"); the lone sh fills $0.
-exec is also a test: it is true when the command exits 0. find . -type f -exec grep -q TODO {} \; -print prints only files containing TODO.
Pruning
To skip a whole directory, match it and -prune it, then -o the real work:
find . -path ./node_modules -prune -o -name '*.js' -print
Read it as: "if this is ./node_modules, prune it (and the result is true, so the -o side is skipped); otherwise, if the name matches, print it". The explicit -print matters: without it the default -print would apply to the whole expression and print ./node_modules itself.
-delete implies -depth (children are processed before their directory, so directories can be deleted after emptying them), and with -depth -prune has no effect. GNU find warns about the combination. With pruning, delete through -exec rm {} +, or exclude with ! -path './keep/*'.
Safety habits
- Run with
-printfirst, read the list, then replace it with-deleteor-exec rm. - Put
-deletelast.find . -delete -name '*.tmp'deletes everything:-deleteruns before the name test for every entry. - Add
-type fwhen you mean files, so a directory namedx.bakis not touched. - Restrict depth with
-maxdepthwhen you only mean the top level.
Permission errors
Searching places you cannot read produces lines like find: '/root': Permission denied on stderr. As the sandbox user you will see them if you search from /. Silence them with 2>/dev/null only after you know that is all they are.
Common mistakes
- Unquoted
-name *.txt:paths must precede expression, or a silently wrong search. -owithout parentheses next to an action.-deleteearly in the expression or combined with-prune.\;forgotten:find: missing argument to '-exec'.for f in $(find ...): breaks on spaces; use-exec, or-print0with xargs or a read loop (lesson 3).- Trusting
-sizeand-mtimerounding for exact comparisons.
Exercises
Open the shell for this lesson: /work/project is a small tree with source files, configs, backups and a keep directory. Two tasks use it (the second deletes things, so do the first one first), then a quiz on precedence and a script using -printf.
Recap
find START... EXPRESSIONwalks the tree and evaluates the expression for each entry, left to right, short-circuiting; implicit-abinds tighter than-o; group with\( \).- Tests:
-name,-path,-type,-size,-mtime/-mmin,-newer,-perm,-empty. - Actions:
-print,-printf,-delete(last!),-exec ... {} \;or{} +,-prune. - Quote patterns, parentheses and
;. find passes names to-execwithout any shell splitting. - Print first, then act.
Going further: find --help lists every test and action; the -printf directives are in man find once installed. Try find / -xdev -type f -size +10M 2>/dev/null to find the large files in the sandbox's root filesystem.