Back to Blog

Zsh Tutorial: find & fd — Search Your Filesystem Fast #21

Sandy LaneSandy Lane

Video: Zsh Tutorial: find & fd — Search Your Filesystem Fast #21 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Zsh Lesson 21: find and fd — Search Your Filesystem Fast

find path -name "*.txt" finds files by name. Filter with -type f/d, -size +1M, -mtime -7. Take action with -exec cmd {} \; or -delete. fd is the modern replacement: fd pattern path — simpler syntax, faster, respects .gitignore.

When you need to find files matching criteria across a directory tree, find is the universal tool. fd is the same idea with better defaults.

find: the basics

find demo_data
# demo_data
# demo_data/file1.txt
# demo_data/file2.txt
# ...

find path lists everything under path recursively. Add filters to narrow.

By name

find demo_data -name "*.txt"
find demo_data -name "report.pdf"

# Case-insensitive
find demo_data -iname "readme*"

-name "pattern" with shell globs. -iname for case-insensitive. Quote the pattern — otherwise the shell expands it before find sees it.

By type

find demo_data -type f          # regular files
find demo_data -type d          # directories
find demo_data -type l          # symlinks
find demo_data -type f -name "*.conf"

Combine -type with other filters.

By size

find . -size +1M                # larger than 1 MB
find . -size -100k              # smaller than 100 KB
find . -size +1G                # larger than 1 GB
find . -size +100M -size -1G    # 100 MB to 1 GB

Suffixes: c (bytes), k (KB), M (MB), G (GB). + greater than, - less than, no prefix exact.

By modification time

find . -mtime -7        # modified in last 7 days
find . -mtime +30       # modified more than 30 days ago
find . -mmin -60        # modified in last 60 minutes
find . -newer reference.txt    # newer than this file

-mtime/-mmin for modification time. -atime/-amin for access. -ctime/-cmin for change (different from modification — covers metadata changes).

Multiple conditions

# AND (default)
find . -type f -name "*.log"

# OR
find . -name "*.log" -o -name "*.txt"

# NOT
find . -type f ! -name "*.tmp"

# Group with parentheses (escape!)
find . \( -name "*.log" -o -name "*.txt" \) -type f

Default operator is AND. -o for OR; ! for NOT. Parentheses must be escaped.

Excluding directories

find . -path "*/node_modules" -prune -o -name "*.js" -print
find . -name "node_modules" -prune -o -type f -name "*.json" -print

-prune stops descending into matched directories. The pattern ... -prune -o ... -print is the standard idiom.

For simpler cases:

find . -path "*/node_modules" -o -path "*/.git" -prune -o -name "*.py" -print

Actions: -exec, -delete

# Execute a command per file
find . -name "*.tmp" -exec rm {} \;

# Faster batch form (one exec, multiple files)
find . -name "*.tmp" -exec rm {} +

{} is the matched filename. \; ends the command for one-at-a-time. + batches (faster — fewer process spawns).

find . -name "*.py" -exec wc -l {} +
# (one wc invocation with all files)

-delete:

find . -name "*.tmp" -delete

Always preview first:

find . -name "*.tmp"        # what would I delete?
find . -name "*.tmp" -delete    # do it

find with xargs

find . -name "*.txt" -print0 | xargs -0 wc -l

xargs builds a command from stdin. -print0 and -0 use null separators — safe for filenames with spaces, newlines, etc. Always pair -print0 with xargs -0 when handling untrusted filenames.

xargs parallelism:

find . -name "*.jpg" -print0 | xargs -0 -P 4 -I {} convert {} -resize 50% {}

-P 4 runs 4 jobs in parallel. -I {} is the placeholder.

Common workflows

# Top 10 largest files
find . -type f -exec du -h {} + | sort -rh | head

# Total size of *.log files
find . -name "*.log" -print0 | xargs -0 du -ch | tail -1

# Find empty directories
find . -type d -empty

# Find broken symlinks
find . -type l ! -exec test -e {} \; -print

# Find recently modified config files
find /etc -name "*.conf" -mtime -7 2>/dev/null

# Old log cleanup (preview first!)
find /var/log -name "*.log" -mtime +30 -size +10M
find /var/log -name "*.log" -mtime +30 -size +10M -delete

fd: the modern replacement

brew install fd
fd txt demo_data
# (everything containing "txt" in its name)

fd -e py demo_data
# (all .py files)

fd -t f conf demo_data
# (files matching "conf")

fd -t d . demo_data
# (all directories)

fd pattern path. Defaults are smarter:

  • Recursive (no special flag).
  • Case-insensitive when pattern is lowercase.
  • Respects .gitignore (use --no-ignore to override).
  • Skips .git and other VCS dirs by default.
  • Parallel + Rust-fast.

fd actions

fd -e py . -x echo "Found: {}"
fd -e py . -X wc -l         # batch: -X like find's -exec ... +

-x cmd {} per file; -X cmd batch. Cleaner than find's \; + syntax.

fd vs find

Feature find fd
Default behavior List everything Smart filter
Speed Slow on large trees ~5-10x faster
Regex syntax POSIX Rust regex (PCRE-like)
Respects .gitignore No Yes (default)
Color output No Yes
Cross-platform consistency Variable Consistent
Parallel With xargs Built-in

For day-to-day use, fd. For shell scripts that should run anywhere, find.

Combining find + grep

A common pattern: find files, search inside them.

# find all .py files containing TODO
find . -name "*.py" -exec grep -l "TODO" {} +

# Or with grep alone
grep -r --include="*.py" -l "TODO" .

# Or with rg (the fastest)
rg -l "TODO" --type py

rg (ripgrep) supersedes both for code search.

locate: faster but indexed

locate "*.zshrc"

locate uses a pre-built index. Very fast but stale. Update with sudo updatedb (Linux) or wait for the daily cron (macOS).

mdfind is the macOS Spotlight-backed alternative:

mdfind -name "report.pdf"
mdfind "kMDItemContentType == 'public.image' && kMDItemFSSize > 1000000"

A real-world cleanup script

#!/usr/bin/env zsh
set -euo pipefail

DAYS=30
LOGS=/var/log

# Preview
echo "Files to delete (older than $DAYS days):"
find "$LOGS" -name "*.log.gz" -mtime +$DAYS

read -p "Continue? (y/n) " ans
[[ "$ans" == "y" ]] || exit 0

# Delete
find "$LOGS" -name "*.log.gz" -mtime +$DAYS -delete

echo "Done."

Always preview destructive operations.

Common stumbles

Unquoted pattern. find . -name *.txt — shell expands *.txt first. Quote: find . -name "*.txt".

-exec with shell features. find . -exec echo "Found: {}" \;{} substitutes literally; shell quoting can be tricky. Wrap in sh -c:

find . -name "*.log" -exec sh -c 'gzip "$1"' _ {} \;

-exec ... \; slow. Forks one process per match. Use ... + to batch.

Permission denied spam. find / shows lots of permission errors. Suppress: find / 2>/dev/null.

-delete action. Read carefully — it's destructive. Always preview with -print first.

-mtime -7 is "less than 7 days." OK. +7 is "more than 7 days." 7 exactly is rarely what you want.

find on Linux vs macOS. find -printf works on Linux/GNU, not macOS/BSD. Use find -exec stat -f '%N %m' {} \; (BSD) or -exec stat --format '%n %y' {} \; (GNU).

fd respects .gitignore. If .gitignore excludes *.log, fd -e log finds nothing. Use --no-ignore to disable.

What's next

Lesson 22: JSON with jq. Parse, filter, transform JSON.

Recap

find path -name "pattern" with -type f/d, -size +/-N, -mtime ±N. Combine with -o (OR), ! (NOT), -prune to skip dirs. Action: -exec cmd {} + (batch) or -delete. Always quote patterns, preview before deleting. fd is the modern alternative — simpler syntax, faster, .gitignore-aware. For code search, rg (ripgrep) trumps both.

Next lesson: jq for JSON.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.