Linux Basic — find & xargs
find & xargs
For recursive file traversal in scripts, find is unbeatable. It can filter by name, size, type, time, permissions — and feed results to other commands via -exec or by piping NUL-delimited names to xargs. This page covers the patterns you actually need.
Basic find Anatomy
find <starting-dirs> <expressions> <actions>
find . -type f # all regular files under cwd
find /var/log -name '*.log' # by name
find . -type d -name 'node_modules' # all node_modules directories
find is recursive by default. Use -maxdepth N to limit depth, -mindepth N to skip near-root.
The Most Useful Predicates
| Predicate | Meaning |
|---|---|
-type f / -type d / -type l |
file / dir / symlink |
-name 'pat' |
basename matches glob pat |
-iname 'pat' |
case-insensitive -name |
-path '*/x/*' |
full path matches glob |
-size +10M |
larger than 10 MB |
-size -1k |
smaller than 1 KB |
-mtime -1 |
modified less than 1 day ago |
-mtime +30 |
modified more than 30 days ago |
-mmin -60 |
modified less than 60 minutes ago |
-newer file |
newer than file |
-empty |
zero-size files or empty dirs |
-user alice / -group dev |
owned by user/group |
-perm -u+x |
has user-executable bit set |
Combine with -a (AND, implicit), -o (OR), ! (NOT), and parentheses:
find /var/log \( -name '*.log' -o -name '*.gz' \) -mtime +7
Quote the parens so the shell doesn’t try to interpret them.
Pruning — Skip Directories
# Don't descend into node_modules or .git
find . -type d \( -name node_modules -o -name .git \) -prune -o -type f -print
-prune says “stop descending here.” The structure is:
( <dirs-to-skip> ) -prune -o <real-filter> -print
Without explicit -print at the end, you’ll get pruned-dir names in the output.
Actions — -print, -print0, -delete, -exec
| Action | Meaning |
|---|---|
-print |
print full path + newline (default if no action given) |
-print0 |
print full path + NUL — the safe form |
-delete |
delete the match (use with care) |
-exec cmd {} \; |
run cmd PATH once per match |
-exec cmd {} + |
run cmd PATH1 PATH2 … batched |
# Delete .pyc files anywhere
find . -name '*.pyc' -delete
# Batch chmod
find . -type f -name '*.sh' -exec chmod +x {} +
# Per-file (slower — one fork per file)
find . -type f -name '*.sh' -exec chmod +x {} \;
The + form is much faster than \; — it batches arguments like xargs. Use + whenever the command accepts multiple file args.
Pairing find With xargs
For commands that don’t accept multiple args, or when you need extra flags, pipe to xargs:
find . -type f -name '*.log' -print0 | xargs -0 grep -l ERROR
Always pair -print0 with xargs -0 — they use NUL as the separator, which is the only byte forbidden in filenames. This is the only safe way to handle arbitrary filenames in a pipeline.
xargs -0 flags worth knowing:
| Flag | Meaning |
|---|---|
-0 |
input is NUL-delimited |
-n N |
pass at most N items per invocation |
-I {} |
use {} as placeholder (treats each item separately) |
-P N |
run N invocations in parallel |
-r |
don’t run if input is empty (GNU; BSD: default on) |
Example — process in parallel with up to 4 workers:
find . -name '*.jpg' -print0 | xargs -0 -n1 -P4 mogrify -resize 800x
-n1 = one file per process; -P4 = up to 4 parallel processes.
Iterating find Output in Bash
Safest form — mapfile -d '':
mapfile -d '' -t files < <(find . -type f -name '*.log' -print0)
for f in "${files[@]}"; do
echo "[$f]"
done
Or the streaming form:
while IFS= read -r -d '' f; do
echo "[$f]"
done < <(find . -type f -print0)
The -d '' to read means “delimiter is NUL.” Combined with find -print0, this round-trip handles every legal filename.
Time-Based Filters
# Logs modified in the last 7 days
find /var/log -name '*.log' -mtime -7
# Files NOT touched in the last year
find /home -type f -mtime +365
# Files modified in the last hour
find /tmp -mmin -60
-mtime N is in days: +N means strictly more than, -N strictly less than, N exactly N (modulo 24h rounding).
For “newer than this file”:
find . -type f -newer reference.txt
Common Patterns
Find + count
find . -type f -name '*.py' | wc -l
For zero-fork count of just one directory:
files=( *.py )
echo "${#files[@]}"
Find + size
find . -type f -name '*.log' -size +100M
Find + grep (faster: grep -rl)
# Files containing ERROR
grep -rlE 'ERROR' /var/log/myapp 2>/dev/null
grep -rl is purpose-built and usually faster than find … | xargs grep.
Find + remove empty dirs
find . -type d -empty -delete
The -delete action removes successfully — directories are deleted bottom-up.
Find + tar
find . -name '*.log' -print0 | tar --null --files-from=- -czf logs.tar.gz
--null makes tar accept NUL-delimited names.
find Quirks
- macOS BSD find doesn’t support
-printf(GNU-only). Stick to portable predicates and usestatfor formatted output. -namematches basename, not the full path. Use-pathfor path-glob.-name '*.log'quoted: the shell mustn’t expand the glob —finditself does.-deleteimplies-depth(bottom-up). Don’t combine with-prunecasually.
A Real Example — Clean Old Logs With Backup
#!/usr/bin/env bash
set -euo pipefail
LOG_DIR=/var/log/myapp
BACKUP_DIR=/backup/logs
AGE_DAYS=14
# Backup, then delete, files older than $AGE_DAYS
find "$LOG_DIR" -type f -name '*.log' -mtime "+$AGE_DAYS" -print0 \
| while IFS= read -r -d '' f; do
rel=${f#$LOG_DIR/}
dest="$BACKUP_DIR/$rel"
mkdir -p "$(dirname "$dest")"
gzip -c "$f" > "$dest.gz" && rm "$f"
echo "Archived: $f → $dest.gz"
done
-print0 + read -d '' makes this safe for any filename.
Common Mistakes
| ❌ Wrong | ✅ Right | Issue |
|---|---|---|
find . -name *.log (unquoted) |
find . -name '*.log' |
shell expands the glob first |
for f in $(find …) |
find -print0 \| mapfile -d '' or while read -d '' |
output is whitespace-split |
-exec rm {} \; for many files |
-exec rm {} + |
one fork per file is slow |
find ... \| xargs cmd (no -print0/-0) |
use both -print0 and xargs -0 |
breaks on spaces/newlines |
-mtime 7 expecting “older than 7 days” |
-mtime +7 |
7 means “exactly day 7” |
Next → Practice Exercises