Linux Basic — find & xargs

5 minute read

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 use stat for formatted output.
  • -name matches basename, not the full path. Use -path for path-glob.
  • -name '*.log' quoted: the shell mustn’t expand the glob — find itself does.
  • -delete implies -depth (bottom-up). Don’t combine with -prune casually.

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

Updated: