cheat sheet
find
POSIX file finder with capable expression-based filters for name, type, size, time, permissions, and ownership. Covers exec actions, pruning, and real-world recipes.
find — File Search
What it is
find is a POSIX-standard command-line utility included in every Unix and Linux system for recursively searching directory trees using expression-based filters. It can match on filename, type, size, modification time, permissions, owner, and more, and can execute arbitrary commands on each result with -exec. Reach for find when you need maximum portability or complex multi-criteria expressions; for day-to-day use in development trees, fd is faster and simpler.
Syntax
find [PATH...] [EXPRESSION]
Output: (none — exits 0 on success)
Name and path filters
Match files by their name or full path using shell globs. -name is case-sensitive; -iname ignores case; -path matches against the entire path including directory components.
find . -name "*.log" # glob match on filename
find . -iname "readme*" # case-insensitive
find . -path "*/src/*.ts" # match full path
find . -not -name "*.min.js" # exclude pattern
find . -name "*.py" -o -name "*.rb" # OR: .py or .rb
Output:
./app.log
./var/app.log
./logs/error.log
Type filters
| Flag | Type |
|---|---|
-type f | regular file |
-type d | directory |
-type l | symlink |
-type p | named pipe |
-type s | socket |
-type b | block device |
-type c | character device |
find /tmp -type f # only files
find . -type d -name "__pycache__"
find /etc -type l # all symlinks in /etc
Output:
./src/__pycache__
./tests/__pycache__
./lib/__pycache__
Size filters
Filter by file size using + (greater than), - (less than), or exact values. Units are c (bytes), k (kilobytes), M (megabytes), and G (gigabytes); sizes are rounded up to the next unit boundary.
find . -size +10M # larger than 10 MB
find . -size -100k # smaller than 100 KB
find . -size +1G -type f # files over 1 GB
find . -empty # zero-byte files or empty dirs
# Units: c (bytes) k (KB) M (MB) G (GB)
Output:
./backups/archive-2025.tar.gz
./data/dump.sql.gz
Time filters
Filter by when a file was last modified (-mtime), accessed (-atime), or had its inode changed (-ctime). Values are in days; use -mmin/-amin/-cmin for minutes. -newer FILE matches anything modified after the reference file.
find . -mtime -1 # modified within last 24 h
find . -mtime +30 # modified more than 30 days ago
find . -mtime 0 # modified today (0–24 h ago)
find . -newer /tmp/stamp # modified after the stamp file
find . -atime -7 # accessed within 7 days
find . -ctime -1 # inode changed within 24 h
# -mmin / -amin / -cmin for minutes instead of days
find . -mmin -60 # modified within last hour
Output:
./src/main.py
./config/settings.yaml
./README.md
Permission and ownership
Filter by permission bits or file owner. -perm MODE matches exactly; -perm -MODE means all listed bits are set; -perm /MODE means any listed bit is set. -user and -group accept names or numeric IDs.
find . -perm 0644 # exactly 0644
find . -perm -0644 # at least these bits set
find . -perm /0111 # any execute bit set
find . -user alice # owned by alice
find . -group wheel # owned by wheel group
find . -not -user root # not owned by root
find /tmp -perm -1777 -type d # sticky-bit dirs (shared tmp)
Output:
./scripts/deploy.sh
./scripts/build.sh
./bin/run
Depth control
Limit how deep find descends into the directory tree. -maxdepth 1 restricts results to immediate children; -mindepth skips the top levels. Place these before other filters for efficiency.
find . -maxdepth 1 # only immediate children
find . -mindepth 2 -maxdepth 4 # depth 2–4 only
find . -maxdepth 0 # only the start path itself
Output: (none — exits 0 on success)
Execute actions
Run a command on each matched file with -exec. Use \; to invoke the command once per file, or + to batch many files into a single invocation (faster, like xargs). -execdir runs the command from the file's own directory instead of the search root.
# {} is replaced by the current file path
find . -name "*.tmp" -exec rm {} \; # delete one at a time
find . -name "*.sh" -exec chmod +x {} \; # make executable
find . -type f -exec wc -l {} + # batch with + (faster)
# execdir — run command in the file's directory
find . -name "*.py" -execdir python -m py_compile {} \;
# Print with format
find . -name "*.log" -printf "%p\t%s\n" # path + size (bytes)
find . -type f -printf "%T@ %p\n" | sort -n # sort by mtime
Output: (none — exits 0 on success)
Delete
Remove matched files or empty directories in one step, without piping to rm. -delete implicitly enables -depth (deepest nodes first), which is required to delete directories before their parents — always test with -print first.
find . -name "*.pyc" -delete # delete matching files
find . -type d -name __pycache__ -delete # delete matching dirs
# -delete implies -depth; use with care
Output: (none — exits 0 on success)
Prune (skip directories)
Tell find to skip a directory entirely without descending into it. The idiom is -path PATTERN -prune -o EXPRESSION -print; without -o -print, pruned paths would still be printed as matches.
# Skip .git and node_modules
find . -name ".git" -prune -o -name "node_modules" -prune -o \
-name "*.js" -print
# General pattern: -path SKIP -prune -o EXPR -print
find . \( -path "./.git" -o -path "./dist" \) -prune \
-o -type f -name "*.md" -print
Output: (none — exits 0 on success)
Logical operators
| Operator | Syntax |
|---|---|
| AND (default) | -a or nothing |
| OR | -o |
| NOT | ! or -not |
| Grouping | \( ... \) |
find . \( -name "*.jpg" -o -name "*.png" \) -size +1M
find . -not \( -name "*.py" -o -name "*.sh" \)
Output:
./assets/hero.jpg
./assets/banner.png
./docs/diagram.png
Practical recipes
# Top 10 largest files
find . -type f -printf "%s\t%p\n" | sort -rn | head -10
Output:
524288000 ./backups/full-2025-03.tar.gz
104857600 ./data/events.parquet
52428800 ./logs/access.log.2025-03
31457280 ./dist/bundle.js
20971520 ./data/users.csv
# Find files modified in the last 5 minutes (useful post-install)
find / -type f -newer /tmp/before -mmin -5 2>/dev/null
# Broken symlinks
find . -type l ! -exec test -e {} \; -print
Output:
./bin/old-tool
./lib/libmissing.so.1
# World-writable files (security audit)
find / -xdev -type f -perm -0002 2>/dev/null
Output:
/tmp/shared-upload
/var/spool/public-queue
# SUID/SGID binaries
find / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null
Output:
/usr/bin/sudo
/usr/bin/passwd
/usr/bin/su
/usr/sbin/mount.nfs
# Find and compress logs older than 7 days
find /var/log -name "*.log" -mtime +7 -exec gzip {} \;
# Delete empty directories
find . -type d -empty -delete
# Replace string in all matched files (with sed)
find . -name "*.conf" -exec sed -i 's/oldhost/newhost/g' {} +
# Count files per extension
find . -type f | sed 's/.*\.//' | sort | uniq -c | sort -rn
Output:
142 py
89 ts
47 md
31 json
18 yaml
9 sh
# Find duplicate filenames (different paths)
find . -type f -printf "%f\n" | sort | uniq -d
Output:
config.yaml
index.js
utils.py
Cross-filesystem
Prevent find from crossing mount boundaries with -xdev (or its synonym -mount). Essential when searching from / to avoid descending into network shares, bind mounts, or /proc.
find / -xdev -name "*.core" # don't cross mount boundaries
find / -mount -name "lost+found"
Output: (none — exits 0 on success)
Use
find ... -exec cmd {} +(plus sign) instead of\;(semicolon) when possible. The+variant batches many filenames into a singlecmdcall, likexargs, and can be 10–100× faster on large result sets.
[!WARN]
-deletesilently follows-depthorder (deepest first). Test your expression with-delete.
Name vs. path matching
-name matches only the basename — the last component of the path — and uses shell-style globs (*, ?, [abc]). -path matches the entire path string including all directory components, which is the only way to filter by where a file lives rather than only what it is called. The case-insensitive variants -iname and -ipath are essential on filesystems that mix .JPG and .jpg or when you don't trust your input.
find . -name "main.py" # any file named exactly main.py
find . -name "*.py" -not -name "_*" # .py files not starting with underscore
find . -iname "*.JPG" # matches .jpg, .JPG, .Jpg, .jPg, ...
find . -path "*/tests/*.py" # only .py files under any tests/ dir
find . -ipath "*/Docs/*.md" # case-insensitive path match
find . -path "*/node_modules" -prune -o -name "*.js" -print
Output:
./tests/test_models.py
./tests/auth/test_login.py
./src/tests/test_helpers.py
Always quote the pattern (
"*.log", not*.log). Without quotes the shell expands the glob against the current directory beforefindever sees it, producing surprising results in directories that contain a matching file.
Regex matching
-regex matches the regular expression against the entire path — anchored from the start to the end — using the emacs regex flavor by default. Switch dialects with -regextype (posix-basic, posix-extended, posix-egrep, posix-awk). -iregex is the case-insensitive form.
find . -regextype posix-extended -regex '.*\.(py|rb|sh)$'
find . -regextype posix-extended -regex './src/[a-z]+/[A-Z][a-zA-Z]+\.tsx?'
find . -iregex '.*/readme\.(md|rst|txt)'
Output:
./scripts/deploy.sh
./scripts/build.sh
./src/utils/helpers.py
./lib/parser.rb
[!WARN] Because
-regexis anchored, you almost always need a leading.*and a trailing$.find . -regex 'foo\.py'will not match./foo.py— write'.*foo\.py$'instead.
Time filters in depth
find exposes three timestamps and two unit families. The day variants (-mtime, -atime, -ctime) round down to a 24-hour bucket; the minute variants (-mmin, -amin, -cmin) are exact. The -newerXY family compares two timestamps from two files (or a literal time) and is far more precise.
| Test | Means |
|---|---|
-mtime | content modification time |
-atime | access time (often disabled with noatime) |
-ctime | inode change time — chmod, chown, rename, link count |
-Btime | birth time (creation), where supported |
The off-by-one trap: -mtime N means N full 24-hour periods ago. So -mtime 0 matches files modified in the last 24 hours; -mtime 1 matches files modified between 24 and 48 hours ago; -mtime +7 is strictly more than 7 days (not 7 or more).
find . -mtime 0 # modified in the last 24 h
find . -mtime -7 # modified in the last week
find . -mtime +30 -mtime -60 # between 30 and 60 days ago
find . -mmin -15 # modified within last 15 minutes
# Compare against another file's timestamp
touch -d "2026-01-01" /tmp/jan1
find . -newer /tmp/jan1 # changed after Jan 1
find . -newerXY ref # X,Y in {a,B,c,m} — e.g. -newermt
find . -newermt "2026-04-01" # mtime newer than April 1
find . -newermt "2026-04-01" ! -newermt "2026-04-15" # range
Output:
./src/main.py
./README.md
./config/settings.yaml
Size filters in depth
-size N is interpreted by units, defaulting to 512-byte blocks if you omit the suffix — a common foot-gun. The supported units are c (bytes), w (2-byte words), b (512-byte blocks, the default), k (KiB), M (MiB), G (GiB). The value is rounded up to the next whole unit, so -size 1k matches anything 1–1024 bytes.
find . -size 0 # exactly 0 blocks (NOT zero-byte files!)
find . -size 0c # exactly 0 bytes — use this
find . -size +1M -size -10M # between 1 MiB and 10 MiB
find . -type f -size +100M -printf "%s %p\n" | sort -rn
find . -type f -size +1G # files over 1 GiB
Output:
1572864000 ./backups/2026-04.dump
524288000 ./data/events.parquet
209715200 ./logs/access.log
104857600 ./video/intro.mp4
For "files of exactly zero bytes" use
-size 0cor-empty. The bare-size 0matches files that round down to zero 512-byte blocks, which includes anything under 512 bytes.
Permission tests
-perm accepts three prefix styles that mean very different things. Get them right and a single expression replaces a chmod audit. See permissions for the underlying bit semantics.
| Form | Meaning |
|---|---|
-perm MODE | mode is exactly MODE |
-perm -MODE | all listed bits are set (mode AND MODE == MODE) |
-perm /MODE | any listed bit is set (mode AND MODE != 0) |
Symbolic forms (u=r, g+w, o-x) work in all three positions and are more readable for ad-hoc audits.
find . -perm 644 # exactly rw-r--r--
find . -perm -u+w # owner has write (other bits don't matter)
find . -perm /u+x,g+x,o+x # any execute bit set
find . -perm -0750 # at least owner rwx + group rx
find . -perm /4000 # any setuid bit
find . -type f -perm -0002 # world-writable files (security audit)
find / -xdev -type f \( -perm -4000 -o -perm -2000 \) -ls 2>/dev/null
Output:
./scripts/deploy.sh
./scripts/build.sh
./bin/run
printf format codes
-printf is a GNU extension that replaces -print with a custom format string — far more flexible than piping -ls into awk. It writes the formatted text directly with no trailing newline (you supply \n).
| Code | Meaning |
|---|---|
%p | full path |
%f | basename |
%h | dirname (path without basename) |
%P | path with start-dir stripped |
%s | size in bytes |
%k | size in 1 KiB blocks |
%y | type letter (f, d, l, s, p, b, c) |
%m | octal permissions (e.g. 0644) |
%M | symbolic permissions (e.g. -rw-r--r--) |
%u / %U | owner name / UID |
%g / %G | group name / GID |
%T@ | mtime as Unix epoch (with fractional seconds) |
%TY-%Tm-%Td %TH:%TM | mtime as ISO date |
%A@ / %C@ | atime / ctime as epoch |
%i | inode number |
%n | hard link count |
%d | depth from start point |
%l | symlink target |
\0 | NUL byte (pair with xargs -0) |
find . -type f -printf "%s\t%p\n" | sort -rn | head -5
find . -type f -printf "%m %u:%g %p\n" | head
find /var/log -type f -printf "%T@ %TY-%Tm-%Td %p\n" | sort -n | tail -5
Output:
524288000 ./backups/full-2026-03.tar.gz
209715200 ./logs/access.log
104857600 ./data/events.parquet
31457280 ./dist/bundle.js
20971520 ./data/users.csv
-exec semicolon vs. plus
-exec CMD {} \; invokes CMD once per matched file, which is correct but slow on large result sets — one fork+exec per match. -exec CMD {} + collects matches into batches and runs CMD once per batch (subject to ARG_MAX), like xargs. Use + whenever the command can take multiple arguments; reserve \; for commands that only accept one argument or whose behavior changes with more.
# One process per file — slow on large trees
find . -name "*.tmp" -exec rm {} \;
# One process per batch — typically 10-100x faster
find . -name "*.tmp" -exec rm {} +
# When the command MUST see one file at a time (uses {} multiple times,
# or has output that depends on per-file invocation):
find . -name "*.bak" -exec sh -c 'mv "$1" "${1%.bak}"' _ {} \;
Output: (none — exits 0 on success)
-execdir is the safer cousin of -exec: it changes to the matched file's directory before running the command, so relative paths in the command resolve as expected, and a hostile filename like -rf / can't escape because the {} expands to ./filename. Prefer -execdir whenever the action is destructive.
find . -name "*.py" -execdir python -m py_compile {} \;
find . -name "*.bak" -execdir rm {} +
Output: (none — exits 0 on success)
Operators and precedence
find builds a boolean expression tree. Tests like -name and -type evaluate to true or false; operators combine them. Default operator is AND (-a), so -name "*.py" -type f means both must match. Use -o for OR and ! or -not for negation. Parentheses must be quoted or escaped to protect them from the shell.
# AND (implicit)
find . -type f -name "*.py"
# OR — note that without parens, -o binds looser than implicit AND
find . -type f \( -name "*.py" -o -name "*.rb" \) -size +1k
# NOT
find . -type f ! -name "*.min.*"
find . -type d -not -path "*/node_modules/*"
# De Morgan: NOT (A OR B) == NOT A AND NOT B
find . ! \( -name "*.tmp" -o -name "*.bak" \)
Output:
./src/main.py
./src/api/routes.py
./lib/parser.rb
Whitespace separates tokens.
-name"*.py"is an error;-name "*.py"is fine. Parens must be their own tokens too:\( -name "*.py" \), never\(-name "*.py"\).
Pruning recipes
-prune tells find not to descend into a directory once it has matched. Combined with -o (OR), this becomes the standard idiom for excluding entire subtrees from a search. Without -o, pruned directories would still be printed as matches.
# Skip a single directory, print everything else
find . -path "./node_modules" -prune -o -type f -print
# Skip multiple, find files of interest
find . \( -path "./.git" -o -path "./node_modules" -o -path "./dist" \) -prune \
-o -type f -name "*.ts" -print
# Skip every .git anywhere (use -name not -path)
find . -name ".git" -prune -o -type f -print
# Skip hidden directories at any depth
find . -type d -name ".*" -prune -o -type f -print
Output: (none — exits 0 on success)
Read the prune idiom as "if path is X, prune; else, print." The
-ois the "else." Forgetting-o -printmakes the entire find silent — the pruned branch matched but had no action attached.
POSIX find vs. GNU extensions
A surprising number of common flags are GNU extensions absent from BSD/macOS find. Scripts that need to run on both should either stick to POSIX or detect and switch.
| Feature | POSIX | GNU only | BSD find |
|---|---|---|---|
-name, -type, -perm | yes | — | yes |
-mtime, -newer, -print | yes | — | yes |
-exec ... {} + | yes (since 2008) | — | yes |
-maxdepth / -mindepth | no | yes | yes |
-regex, -iregex, -iname | no | yes | yes |
-printf | no | yes | no (use -print + awk) |
-delete | no | yes | yes |
-execdir | no | yes | yes |
-not (as synonym for !) | no | yes | yes |
# Detect GNU find
if find --version 2>/dev/null | grep -q GNU; then
FIND_PRINTF="-printf '%s %p\n'"
else
FIND_PRINTF="-exec stat -f '%z %N' {} +"
fi
Output: (none — exits 0 on success)
find vs. fd
| Task | find (POSIX/GNU) | fd |
|---|---|---|
| Find by name | find . -name "*.py" | fd -e py |
| Type filter | find . -type d | fd -t d |
| Hidden | included | fd -H |
Respects .gitignore | no | yes (default) |
| Default match | basename glob | basename regex |
| Time filter | -mtime -1 | --changed-within 1d |
| Exec per file | -exec cmd {} \; | -x cmd {} |
| Exec batched | -exec cmd {} + | -X cmd |
| Skip dirs | -prune idiom | -E pattern |
| Speed | single-threaded | parallel by default |
| Availability | every Unix | install separately |
Reach for fd (sections/linux/fd-find) inside a dev tree; reach for find on production servers, in shell-portable scripts, and when only POSIX features are guaranteed.
More real-world recipes
Delete files older than 30 days, dry-run first
find /var/log/myapp -type f -name "*.log" -mtime +30 -print
find /var/log/myapp -type f -name "*.log" -mtime +30 -delete
Output:
/var/log/myapp/2026-02-15.log
/var/log/myapp/2026-02-16.log
/var/log/myapp/2026-02-17.log
Dedupe files by size, then by hash
A common audit: find files that share both size and content. Comparing by size first cuts the hash work to near zero on a typical tree.
find . -type f -printf "%s %p\n" | sort -n |
awk 'NR==FNR{c[$1]++;next} c[$1]>1' /dev/stdin - |
cut -d' ' -f2- |
xargs -d'\n' -I{} sha256sum "{}" |
sort | awk '{print $1}' | uniq -d
Output:
3a7bd3e2360a3d63d2a9c0c2c4...
9f1c5d5a8e6a4b6e8a5e8d9c7...
Find every file containing a string, fast
find ... -exec grep is the portable way to combine path filtering and content matching. Use + so grep sees many files at once.
find . -type f -name "*.conf" -exec grep -l "deprecated_setting" {} +
Output:
./config/legacy.conf
./etc/myapp/old.conf
Find broken symlinks across a tree
find . -xtype l # GNU: symlinks pointing nowhere
find . -type l ! -exec test -e {} \; -print # POSIX equivalent
Output:
./bin/old-tool
./lib/libmissing.so.1
Compress log files older than 7 days, in parallel
find /var/log -type f -name "*.log" -mtime +7 -print0 |
xargs -0 -n1 -P4 gzip
Output: (none — exits 0 on success)
Reset file/dir permissions on a web root
find /var/www/site -type f -exec chmod 644 {} +
find /var/www/site -type d -exec chmod 755 {} +
Output: (none — exits 0 on success)
Find files modified in a date range and tar them
find . -type f \
-newermt "2026-04-01" ! -newermt "2026-05-01" -print0 |
tar --null -czf april.tar.gz --files-from -
Output: (none — exits 0 on success)
NUL-safe pipeline for hostile filenames
Whenever a filename might contain spaces, newlines, or shell metacharacters, use -print0 and consume with xargs -0 (or tar --null, sort -z, etc.). It is the only general-purpose safe pattern.
find . -name "*.tmp" -print0 | xargs -0 rm --
find . -type f -print0 | xargs -0 stat -c '%s %n' | sort -rn | head
Output: (none — exits 0 on success)
Common pitfalls
- Unquoted globs —
find . -name *.pylets the shell expand*.pyagainst the current directory first. Always quote:find . -name "*.py". -size 0is not "empty" — it means "exactly zero 512-byte blocks." Use-size 0cor-empty.-mtime 0is "last 24 h," not "today" — and-mtime +7is strictly more than 7 days. The day-bucket arithmetic catches everyone once.- Forgetting
-o -printwith-prune—find . -name node_modules -prunesilently matches nothing useful. The full idiom is-prune -o ... -print. - Operator precedence —
-name A -o -name B -size +1kmeans "(A) OR (B AND size>1k)." Wrap the OR group in\( ... \)to apply size to both. -deleteorder —-deleteimplies-depth, which conflicts with-prune. Don't mix the two in one expression.- macOS missing flags —
-printf,-regextype,-iname,-not,-execdirare GNU extensions. Installfindutilsvia Homebrew and callgfind, or rewrite using POSIX features. -namematches basename only —find . -name "src/*.ts"always fails. Use-path "*/src/*.ts".
When in doubt, build the expression incrementally: start with
find . -name pattern -print, verify the matches, then replace-deleteor-exec. Neverfind -deletean expression you haven't first