Zsh Tutorial: find & fd — Search Your Filesystem Fast #21
Video: Zsh Tutorial: find & fd — Search Your Filesystem Fast #21 by Taught by Celeste AI - AI Coding Coach
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-ignoreto override). - Skips
.gitand 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.