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

bash
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.

bash
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:

text
./app.log
./var/app.log
./logs/error.log

Type filters

FlagType
-type fregular file
-type ddirectory
-type lsymlink
-type pnamed pipe
-type ssocket
-type bblock device
-type ccharacter device
bash
find /tmp -type f               # only files
find . -type d -name "__pycache__"
find /etc -type l               # all symlinks in /etc

Output:

text
./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.

bash
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:

text
./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.

bash
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:

text
./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.

bash
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:

text
./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.

bash
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.

bash
# {} 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.

bash
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.

bash
# 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

OperatorSyntax
AND (default)-a or nothing
OR-o
NOT! or -not
Grouping\( ... \)
bash
find . \( -name "*.jpg" -o -name "*.png" \) -size +1M
find . -not \( -name "*.py" -o -name "*.sh" \)

Output:

text
./assets/hero.jpg
./assets/banner.png
./docs/diagram.png

Practical recipes

bash
# Top 10 largest files
find . -type f -printf "%s\t%p\n" | sort -rn | head -10

Output:

text
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
bash
# 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:

text
./bin/old-tool
./lib/libmissing.so.1
bash
# World-writable files (security audit)
find / -xdev -type f -perm -0002 2>/dev/null

Output:

text
/tmp/shared-upload
/var/spool/public-queue
bash
# SUID/SGID binaries
find / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null

Output:

text
/usr/bin/sudo
/usr/bin/passwd
/usr/bin/su
/usr/sbin/mount.nfs
bash
# 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:

text
    142 py
     89 ts
     47 md
     31 json
     18 yaml
      9 sh
bash
# Find duplicate filenames (different paths)
find . -type f -printf "%f\n" | sort | uniq -d

Output:

text
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.

bash
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 single cmd call, like xargs, and can be 10–100× faster on large result sets.

[!WARN] -delete silently follows -depth order (deepest first). Test your expression with -print before replacing it 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.

bash
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:

text
./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 before find ever 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.

bash
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:

text
./scripts/deploy.sh
./scripts/build.sh
./src/utils/helpers.py
./lib/parser.rb

[!WARN] Because -regex is 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.

TestMeans
-mtimecontent modification time
-atimeaccess time (often disabled with noatime)
-ctimeinode change time — chmod, chown, rename, link count
-Btimebirth 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).

bash
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:

text
./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.

bash
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:

text
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 0c or -empty. The bare -size 0 matches 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.

FormMeaning
-perm MODEmode is exactly MODE
-perm -MODEall listed bits are set (mode AND MODE == MODE)
-perm /MODEany 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.

bash
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:

text
./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).

CodeMeaning
%pfull path
%fbasename
%hdirname (path without basename)
%Ppath with start-dir stripped
%ssize in bytes
%ksize in 1 KiB blocks
%ytype letter (f, d, l, s, p, b, c)
%moctal permissions (e.g. 0644)
%Msymbolic permissions (e.g. -rw-r--r--)
%u / %Uowner name / UID
%g / %Ggroup name / GID
%T@mtime as Unix epoch (with fractional seconds)
%TY-%Tm-%Td %TH:%TMmtime as ISO date
%A@ / %C@atime / ctime as epoch
%iinode number
%nhard link count
%ddepth from start point
%lsymlink target
\0NUL byte (pair with xargs -0)
bash
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:

text
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.

bash
# 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.

bash
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.

bash
# 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:

text
./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.

bash
# 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 -o is the "else." Forgetting -o -print makes 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.

FeaturePOSIXGNU onlyBSD find
-name, -type, -permyesyes
-mtime, -newer, -printyesyes
-exec ... {} +yes (since 2008)yes
-maxdepth / -mindepthnoyesyes
-regex, -iregex, -inamenoyesyes
-printfnoyesno (use -print + awk)
-deletenoyesyes
-execdirnoyesyes
-not (as synonym for !)noyesyes
bash
# 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

Taskfind (POSIX/GNU)fd
Find by namefind . -name "*.py"fd -e py
Type filterfind . -type dfd -t d
Hiddenincludedfd -H
Respects .gitignorenoyes (default)
Default matchbasename globbasename 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
Speedsingle-threadedparallel by default
Availabilityevery Unixinstall 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

bash
find /var/log/myapp -type f -name "*.log" -mtime +30 -print
find /var/log/myapp -type f -name "*.log" -mtime +30 -delete

Output:

text
/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.

bash
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:

text
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.

bash
find . -type f -name "*.conf" -exec grep -l "deprecated_setting" {} +

Output:

text
./config/legacy.conf
./etc/myapp/old.conf
bash
find . -xtype l                          # GNU: symlinks pointing nowhere
find . -type l ! -exec test -e {} \; -print   # POSIX equivalent

Output:

text
./bin/old-tool
./lib/libmissing.so.1

Compress log files older than 7 days, in parallel

bash
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

bash
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

bash
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.

bash
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

  1. Unquoted globsfind . -name *.py lets the shell expand *.py against the current directory first. Always quote: find . -name "*.py".
  2. -size 0 is not "empty" — it means "exactly zero 512-byte blocks." Use -size 0c or -empty.
  3. -mtime 0 is "last 24 h," not "today" — and -mtime +7 is strictly more than 7 days. The day-bucket arithmetic catches everyone once.
  4. Forgetting -o -print with -prunefind . -name node_modules -prune silently matches nothing useful. The full idiom is -prune -o ... -print.
  5. Operator precedence-name A -o -name B -size +1k means "(A) OR (B AND size>1k)." Wrap the OR group in \( ... \) to apply size to both.
  6. -delete order-delete implies -depth, which conflicts with -prune. Don't mix the two in one expression.
  7. macOS missing flags-printf, -regextype, -iname, -not, -execdir are GNU extensions. Install findutils via Homebrew and call gfind, or rewrite using POSIX features.
  8. -name matches basename onlyfind . -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 -print with -delete or -exec. Never find -delete an expression you haven't first -print-ed.