backend / linux bash / 07_find_and_xargs.md

find and xargs

6 interview angles 7 min read source

find and xargs

find walks file trees and matches by name, type, mtime, size, permission. xargs turns stdin into command-line arguments. Together they’re the standard “do something with every file matching X” pattern.

find — basic syntax

find <path> [conditions] [actions]

If no path: defaults to . in some implementations, errors in others. Always pass an explicit path.

find . -name "*.py"                  # all .py files under .
find /var/log -type f -name "*.log"  # files (not dirs) named *.log
find . -mtime -7                     # modified in last 7 days
find . -size +100M                   # larger than 100 MB
find . -empty                        # empty files or dirs

Common conditions

Condition Matches
-name "pattern" name (glob, NOT regex) — quote to prevent shell expansion
-iname "pattern" case-insensitive name
-path "*/test/*" path pattern (matches the whole path)
-type f regular file
-type d directory
-type l symbolic link
-mtime -N / +N / N modified less than / more than / exactly N days ago
-mmin -N / +N modified less than / more than N minutes ago
-newer file newer than the mtime of file
-size +100M / -100k / 100c size: M=megabytes, k=kilobytes, c=bytes
-empty empty file or directory
-perm -u=x has user-execute bit set
-user alice owned by user alice
-group www-data owned by group www-data

-mtime units are confusing:

Means
-mtime 0 modified today (within the last 24 hours)
-mtime 1 modified between 24–48 hours ago
-mtime -7 modified in the last 7 days
-mtime +30 modified more than 30 days ago

For “modified in the last hour”: -mmin -60.

Combining conditions

find . -name "*.py" -type f -mtime -1                  # implicit AND
find . \( -name "*.py" -o -name "*.js" \)              # OR (escape parens)
find . -name "*.py" ! -path "./.venv/*"                # NOT
find . -type f -name "*.tmp" -size +10M                 # AND chained

Important: -o has lower precedence than implicit AND. Without parens:

find . -name "*.py" -o -name "*.js" -mtime -1
# means: (-name *.py) OR (-name *.js AND -mtime -1)
# probably NOT what you wanted

find . \( -name "*.py" -o -name "*.js" \) -mtime -1
# means: (*.py OR *.js) AND mtime -1

Actions

Action What
-print print the path (default if no action)
-print0 print path followed by NUL — safe for piping to xargs
-delete delete the matched file (or empty dir)
-exec cmd {} \; run cmd for each match, one at a time
-exec cmd {} + run cmd with as many matches as possible per invocation
-ls print details (like ls -l)

-exec patterns

find . -name "*.tmp" -exec rm {} \;            # one rm per file (slow)
find . -name "*.tmp" -exec rm {} +             # rm with batched args (fast)
find . -name "*.txt" -exec grep -l "TODO" {} +
find . -type f -exec chmod 644 {} +

{} is the filename. The terminator must be \; (single file per call) or + (batched). Always prefer + for speed.

xargs — turn stdin into args

echo "1 2 3" | xargs echo                  # echo 1 2 3 (one call with 3 args)
seq 5 | xargs -n 1 echo                    # echo 1; echo 2; ... (one arg per call)
seq 100 | xargs -n 10 echo                 # 10 args per call
seq 100 | xargs -P 4 -n 1 ./worker.sh      # 4 parallel workers
Flag Effect
-n N max N args per command invocation
-P N up to N parallel processes
-I {} replace {} with each input (one per call)
-0 NUL-separated input (paired with find -print0)
-r don’t run if no input (GNU extension; useful with -0)
find . -name "*.log" | xargs grep -l "error"          # grep for "error"; broken on space-in-name
find . -name "*.log" -print0 | xargs -0 grep -l "error"  # safe

-I {} replaces the placeholder per call:

ls *.txt | xargs -I {} cp {} {}.bak       # cp foo.txt foo.txt.bak (one at a time)

-I implies -n 1 — one arg per call, slower than batched. Use only when you need the placeholder.

find vs xargs

-exec ... + and xargs do similar things. Why both?

find -exec ... + find -print0 | xargs -0
Easier syntax yes requires -print0/-0
Parallelism no yes via -P
Batching yes (one per arg list) yes
Built-in requires find uses two tools

For simple “run this on every match”: find -exec. For parallelism or fancier batching: xargs.

The “spaces in filenames” problem

find . -name "*.txt" | xargs cat            # breaks on "file with spaces.txt"
find . -name "*.txt" -print0 | xargs -0 cat  #
find . -name "*.txt" -exec cat {} +          #

-print0 outputs NUL-separated names; -0 reads them. NUL can’t appear in filenames, so this is bulletproof.

For while read loops:

find . -name "*.txt" -print0 | while IFS= read -r -d '' f; do
    echo "got: $f"
done

Common one-liners

# Total size of matched files
find . -name "*.log" -exec du -ch {} + | tail -1

# Find and delete files older than 30 days
find /var/log -name "*.log" -mtime +30 -delete

# Find files containing a string
find . -type f -exec grep -l "TODO" {} +
# faster:
grep -rl "TODO" .                          # ripgrep / grep -r is usually better than find -exec

# Find broken symlinks
find . -xtype l

# Find duplicate files by size (initial filter)
find . -type f -size +1M -printf "%s %p\n" | sort -n

# Recursive chmod on directories only
find . -type d -exec chmod 755 {} +
find . -type f -exec chmod 644 {} +

# Find files modified by a specific user in the last hour
find . -user alice -mmin -60

# Cleanup empty dirs (run multiple times for nested)
find . -type d -empty -delete

When to skip find for ripgrep / fd

Modern alternatives are faster and friendlier:

# fd (https://github.com/sharkdp/fd) — friendlier find
fd ".log$" /var/log
fd -t f -e log /var/log              # type=file, extension=log

# ripgrep — when you're using find to grep
rg "TODO" --type py

For interactive use, fd and rg are usually better. For scripts that must work without external dependencies, find is universal.

Common pitfalls

  • Forgetting to quote -name "*.py" — without quotes, the shell expands *.py first. If there are no .py files in the cwd, the glob stays literal and works. If there are, find sees -name foo.py bar.py and errors.
  • -mtime confusion-mtime 1 is “between 24 and 48 hours ago”, not “1 day or less”. Use -mtime -1 for “less than 1 day.”
  • Forgetting \( \) to group-o binds tighter than expected without parens.
  • Piping to xargs without -print0/-0 — breaks on spaces, newlines, quotes in filenames.
  • -exec ... \; when + would work — N forks instead of 1, drastically slower.

Common interview confusions

  • find -name accepts regex.” — it accepts globs (*, ?, [abc]). For regex use -regex (and remember it matches the whole path, not just the basename).
  • xargs always runs the command once per input.” — by default it batches as many args as fit in ARG_MAX. Use -n 1 for one-per-call.
  • -exec and xargs are interchangeable.” — close, but xargs -P N adds parallelism that -exec lacks; -exec ... + is simpler when you don’t need it.

Interview angle

  • “How do you find all .py files modified in the last day?”find . -name "*.py" -type f -mtime -1.
  • “How do you delete files older than 30 days under /var/log?”find /var/log -type f -mtime +30 -delete. Or -exec rm {} + if -delete isn’t supported.
  • -exec ... \; vs -exec ... + — what’s the difference?”\; runs once per match (one fork each); + batches as many matches as fit (one fork for many). Always prefer + unless you specifically need per-file invocation.
  • “How do you handle filenames with spaces when piping find to xargs?”find ... -print0 | xargs -0 cmd. NUL bytes can’t appear in filenames, so this is safe.
  • “How would you run something in parallel for every match?”find ... -print0 | xargs -0 -P 4 -n 1 ./worker.sh runs up to 4 workers, one file each.
  • -mtime 1 matches files modified when?” — between 24 and 48 hours ago. -mtime -1 is “in the last 24 hours”; -mtime +30 is “more than 30 days ago”.