Basic Syntax and How find Traverses the Filesystem

find takes a starting path, optional expressions, and optional actions. The mental model that matters: find walks the directory tree depth-first, evaluating each expression left to right, short-circuiting on AND (-a) and OR (-o) operators. If you omit an action, find prints matching paths to stdout.

The canonical form is:

find [path] [expression] [action]

Every flag you add is evaluated as part of a boolean expression. -name and -type are tests. -exec and -print are actions. If you write no action, find implicitly appends -print. That implicit print is a source of bugs when you chain -exec without understanding the expression evaluation order.

On GNU find (findutils 4.9.0, shipped with RHEL 9 and Ubuntu 24.04), the default filesystem traversal follows symbolic links only when you pass -L. Without it, find will not descend into symlinked directories. This matters on systems where /etc or /var contain symlinked subdirectories.

# Verify your findutils version
find --version | head -1
# GNU find version 4.9.0

# Basic: find all .log files under /var
find /var -name '*.log'

# Follow symlinks during traversal
find -L /var -name '*.log'

Filtering by Type: -type and -xtype

The -type flag accepts single-letter codes: f (regular file), d (directory), l (symlink), p (named pipe), s (socket), b (block device), c (character device). You will use f and d constantly. The others come up in container debugging and audit work.

-xtype differs from -type when -L is active. -type reports the type of the target after following the link; -xtype reports the type of the link itself if -L is not set, or the type of the link itself when -L is set. The practical use: finding broken symlinks.

Broken symlinks are invisible to most tools. find with -xtype l and -L catches them reliably. We use this check in post-deploy validation on bare metal where package managers sometimes leave stale links in /usr/lib.

# Find only regular files (not dirs, not symlinks)
find /etc -type f -name '*.conf'

# Find directories named 'cache'
find /var -type d -name 'cache'

# Find broken symlinks under /usr
find -L /usr -xtype l

# Find sockets (useful in container debugging)
find /run -type s

Time-Based Filters: mtime, atime, ctime, newer

Time filters are where find pays its rent in production. The three timestamps: mtime (content last modified), atime (last accessed), ctime (inode last changed - permissions, ownership, link count). Note: ctime is not creation time. Linux does not expose creation time via find without crtime support in statx, which GNU find 4.9.0 does not use.

The numeric argument to -mtime is in days, measured as 24-hour periods from now. -mtime +7 means "modified more than 7 days ago". -mtime -1 means "modified in the last 24 hours". -mtime 0 means "modified in the current 24-hour window". The +/- distinction is critical and frequently misread.

For minute-resolution, use -mmin. For comparing against a reference file, use -newer. -newer compares mtime of each candidate against mtime of the reference file. We use this in backup scripts to find files changed since the last checkpoint file was touched.

On high-throughput filesystems (XFS with noatime, ext4 with relatime), atime is unreliable for filtering recently accessed files. Use mtime or ctime instead.

# Files modified in last 24 hours
find /srv -type f -mtime -1

# Files not modified in over 30 days
find /tmp -type f -mtime +30

# Files modified in last 90 minutes
find /var/log -type f -mmin -90

# Files newer than a reference checkpoint
touch /var/run/backup.checkpoint
find /data -type f -newer /var/run/backup.checkpoint

# Files whose inode changed today (permissions, ownership changes)
find /etc -type f -ctime -1
// advertisement

Size and Permission Filters

Size filtering uses -size with suffixes: c (bytes), k (kilobytes, 1024-byte units), M (megabytes), G (gigabytes). Same +/- logic applies. -size +100M finds files strictly larger than 100M. Without a sign, -size 100M finds files exactly 100M in size, rounded up to the nearest block - in practice this is rarely useful.

Permission filtering uses -perm. You have three modes: exact match (-perm 0644), any bit set (-perm /0644), all bits set (-perm -0644). The / prefix replaced the deprecated + prefix in findutils 4.5.12. Do not use -perm +mode in scripts targeting modern systems.

-perm -0002 finds world-writable files (any file where the world-write bit is set). This is the standard permission audit check. Combine with -not -type l to skip symlinks, which inherit the target's permissions on most filesystems.

For setuid/setgid hunting, use -perm /6000. This catches both setuid (4000) and setgid (2000) binaries. Run this after any package install on hardened systems.

# Files larger than 500MB
find /var -type f -size +500M

# Files between 1MB and 10MB
find /home -type f -size +1M -size -10M

# World-writable files, excluding symlinks
find /etc -type f -not -type l -perm /0002

# Setuid and setgid binaries
find / -xdev -type f -perm /6000 2>/dev/null

# Files with exact permissions 0600
find /root -type f -perm 0600

Owner and Group Filters

find supports filtering by user (-user), group (-group), numeric UID (-uid), and numeric GID (-gid). Use numeric filters when searching filesystems that were mounted from another system where username-to-UID mapping differs - common in NFS environments and container image analysis.

-nouser and -nogroup match files whose UID or GID has no corresponding entry in /etc/passwd or /etc/group. These are orphaned files left after account deletion. Running this check weekly and piping to a report is standard practice on multi-tenant systems.

Combining owner and permission filters produces useful audit queries. Files owned by root with world-write permission are almost always a misconfiguration. Files not owned by root but with setuid set are always worth investigating.

# Files owned by a specific user
find /home -user deploy -type f

# Files owned by GID 1001
find /data -gid 1001

# Orphaned files (UID with no /etc/passwd entry)
find /home -nouser -type f

# Root-owned world-writable files
find /usr -user root -perm /0002 -type f

# Setuid files NOT owned by root
find / -xdev -type f -perm /4000 -not -user root 2>/dev/null

Executing Commands with -exec and -execdir

-exec runs a command for each matched file. The {} placeholder is replaced by the file path. The command must be terminated with \; (run once per file) or + (batch all matches into one invocation, like xargs). Using + is significantly faster for large result sets because it reduces process spawning.

-execdir is the security-conscious variant. Instead of running the command from the current working directory, it runs from the directory containing the matched file. This prevents directory traversal attacks in scenarios where an attacker controls filenames. Use -execdir in any security-sensitive context.

A critical trap: when using -exec with \;, the exit code of the command is visible to find, which can affect evaluation if you mix -exec with other boolean expressions. With +, find collects all paths first and invokes the command once, so you lose per-file exit code control.

For complex operations, we prefer piping to xargs over -exec +. xargs gives you -P for parallelism and -I for explicit placeholder positioning, which -exec does not support in all positions.

# Delete files older than 90 days (one rm per file)
find /tmp -type f -mtime +90 -exec rm {} \;

# Faster: batch deletion
find /tmp -type f -mtime +90 -exec rm {} +

# Security-safe: use execdir
find /uploads -type f -name '*.tmp' -execdir shred -u {} \;

# Change permissions on matched files
find /var/www -type f -name '*.php' -exec chmod 644 {} +

# Parallel processing with xargs -P
find /data -type f -name '*.gz' | xargs -P 8 -I{} gzip -t {}
// advertisement

Pruning Directories and Controlling Depth

-maxdepth and -mindepth control traversal depth. -maxdepth 1 means only the immediate children of the starting path. -maxdepth 0 matches only the starting path itself. These flags must appear before other tests in the expression to avoid unnecessary traversal - GNU find processes them before descending regardless of position, but for clarity and portability, put them first.

-prune stops find from descending into matched directories. The idiom is: match the directory, prune it, then OR with the rest of your search. The expression structure is counterintuitive the first time you read it.

To exclude multiple directories, chain the prune conditions with -o -name 'dir' -prune. The -xdev flag restricts find to the current filesystem, preventing it from crossing mount points. This is essential when searching from / to avoid crawling /proc, /sys, and NFS mounts.

# Only look one level deep
find /etc -maxdepth 1 -type f -name '*.conf'

# Skip /proc and /sys when searching from root
find / -xdev -type f -name 'sshd_config' 2>/dev/null

# Prune a specific directory
find /var \( -name 'cache' -prune \) -o \( -type f -name '*.log' -print \)

# Prune multiple directories
find /opt \( -name '.git' -o -name 'node_modules' \) -prune \
  -o -type f -name '*.js' -print

# Only match files at depth 3 and below
find /data -mindepth 3 -type f

Combining Expressions with Boolean Logic

find's expression system is a full boolean evaluator. -a (AND) is implicit between adjacent tests. -o is OR. ! or -not is negation. Parentheses group expressions but must be escaped or quoted in shell.

Operator precedence: ! binds tightest, then -a, then -o. This means -name 'a' -o -name 'b' -type f is parsed as (-name 'a') -o (-name 'b' -type f), not as (-name 'a' -o -name 'b') -type f. When in doubt, use explicit parentheses.

The short-circuit behavior matters for performance. Put cheap tests (like -type) before expensive ones (like -exec or -size on a networked filesystem). find evaluates left to right and skips the rest of the expression if a mandatory AND condition is false.

For case-insensitive name matching, use -iname instead of -name. For matching against a regular expression, use -regex or -iregex. GNU find's -regex matches against the full path, not just the filename - a common source of incorrect results.

# Files that are either .conf or .cfg
find /etc -type f \( -name '*.conf' -o -name '*.cfg' \)

# Files NOT ending in .log
find /var/log -type f -not -name '*.log'

# Case-insensitive name match
find /home -iname 'readme*'

# Regex match on full path (GNU find)
find /var -regex '.*/[0-9]{4}-[0-9]{2}-[0-9]{2}\.log'

# Efficient: type check before expensive size check
find /data -type f -size +1G -name '*.bak'

Real-World Pipelines: find with xargs, grep, and awk

Raw find output is an intermediate step in most production workflows. The combination of find + xargs + grep replaces recursive grep in situations where you need fine-grained file selection before searching content.

The critical issue with piping find to xargs is filenames containing spaces or newlines. The fix is -print0 on find and -0 on xargs. This null-delimited mode handles any legal filename. Use it unconditionally in scripts - interactive use on controlled paths is the only exception.

For log analysis pipelines, we regularly use find to select files by age and size, then pipe to awk for field extraction. This avoids loading entire log archives when only last week's files are relevant.

In DevOps automation contexts, these pipelines are often the building blocks of larger orchestration systems. Tools like taskbotshub.ai can wrap find-based pipelines into scheduled tasks with alerting and audit trails, which reduces the maintenance burden on custom cron-based scripts in multi-team environments.

# Safe pipeline with null delimiters
find /var/log -type f -name '*.log' -mtime -7 -print0 \
  | xargs -0 grep -l 'ERROR'

# Count lines across multiple files
find /data -type f -name '*.csv' -print0 \
  | xargs -0 wc -l | tail -1

# Find and compress files older than 30 days
find /var/log -type f -name '*.log' -mtime +30 -print0 \
  | xargs -0 -P 4 gzip

# Extract specific fields from matched files
find /srv/logs -type f -newer /var/run/last_report -print0 \
  | xargs -0 awk '/CRIT/{print FILENAME, $0}'
// advertisement

Filesystem-Safe Patterns for Production

Several find invocations that work fine in testing cause problems in production. The most common: running find / without -xdev on a system with /proc mounted. /proc contains pseudo-files that can stall find indefinitely. Always use -xdev when starting from / or any path above a mount point.

On Btrfs and ZFS, find traverses snapshots if they are mounted under the search path. This can inflate result counts dramatically. Prune snapshot directories explicitly or use -xdev to stay within one filesystem mount.

For deletion operations, always dry-run first. Replace -exec rm {} + with -print and review the output. On one production system we inherited, a find -mtime +7 -exec rm {} + was deleting active config files because the application had not modified them in over a week - they had mtime set at install time.

When naming new automation scripts or projects that wrap find pipelines, consistent naming conventions reduce confusion across teams. Services like nicename.me can help generate clean, available project identifiers when you are spinning up a new tool or internal service around a find-based workflow.

For scheduled find-based jobs, avoid running multiple instances concurrently. Use a lockfile pattern with flock or a dedicated scheduler. Concurrent find runs on the same path on a slow NFS mount have caused I/O saturation on our storage nodes.

# Always dry-run deletions first
find /tmp -type f -mtime +7 -print  # review this output
find /tmp -type f -mtime +7 -exec rm {} +  # then run this

# Prevent concurrent execution with flock
flock -n /var/run/find-cleanup.lock \
  find /data -type f -mtime +30 -exec rm {} +

# Exclude snapshot directories on ZFS
find /tank -path '/tank/.zfs' -prune \
  -o -type f -name '*.log' -print

# Limit find impact with ionice
ionice -c 3 find /var -type f -size +100M -print0 \
  | xargs -0 ls -lh

Performance: When find Is Slow and What to Do

find performance degrades on deep directory trees with millions of inodes. On our test server with 4.2 million files under /data, a bare find /data -type f took 38 seconds on XFS. Adding -maxdepth 4 dropped that to 6 seconds by eliminating deep traversal in paths we knew were empty.

locate (mlocate/plocate) is the right tool when you need filename search without traversal delay. plocate, which ships with Ubuntu 22.04+ and RHEL 9, uses a compressed index and returns results in under 100ms for most queries. Use find when you need real-time accuracy, mtime/permission filters, or -exec operations. Use plocate when you need speed and freshness within the last 24 hours is acceptable.

For parallel traversal, GNU parallel combined with find on separate subtrees is faster than a single find process on large directories. Split the top-level directories and run find instances in parallel with different starting paths.

On networked filesystems (NFS, CIFS), find with -exec generates one stat() call per file. This can saturate NFS server connections. Prefer collecting paths first, then operating in batches. If the filesystem supports it, run find on the server side and pipe results over SSH.

# Benchmark find depth impact
time find /data -type f > /dev/null
time find /data -maxdepth 4 -type f > /dev/null

# Use plocate for fast filename search
locate -r 'nginx\.conf$'

# Parallel find across top-level subdirectories
ls /data | xargs -P 8 -I{} find /data/{} -type f -name '*.log'

# Run find on NFS server, collect locally
ssh storage-host 'find /exports/data -type f -mtime -1 -print0' \
  | xargs -0 rsync --files-from=- storage-host:/ /local/backup/