Finding Files with find and xargs
Reviewed & published by Brayan K
By the end of this lesson you'll be able to hunt down files by name, type, size, time, and permissions with find, run a command on every match with -exec, and pipe results into xargs for fast, safe bulk operations — including the all-important -print0 / -0 pairing that survives filenames with spaces.
Part of the free Command Line course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Search by -name / -iname, -type f and -type d
- Filter by -size, -mtime, and -perm
- Run commands on matches with -exec {} \; vs {} +
- Pipe results into xargs for fast bulk work
- Handle spaces safely with -print0 and xargs -0
- Place args with -I {} and parallelize with -P N
0. Setup: a predictable directory tree
Run this once. It creates a tiny project/ folder with source files, logs, and text files — plus one file deliberately named with a space (my report.txt) so we can later prove why -print0 matters. Every block after this assumes this exact layout.
# Build a small, predictable tree so every example below is reproducible.
# (Run this first; the later blocks assume this layout exists.)
mkdir -p project/src project/logs project/docs
# Some source and text files...
touch project/src/main.c project/src/util.c
touch project/docs/readme.txt project/docs/notes.TXT
echo "ready" > project/logs/app.log
echo "boot" > project/logs/old.log
# A file whose NAME contains a space — this is what motivates -print0 later.
touch "project/docs/my report.txt"
# Make old.log look 40 days old so -mtime examples have something to find.
touch -d "40 days ago" project/logs/old.log
# Show what we built (sorted for a stable view):
find project -print | sort1. Searching by Name and Type
find takes a starting path and walks every directory beneath it, keeping entries that pass your tests. -name "*.txt" matches by filename (case-sensitive); -iname is the case-insensitive version. -type f limits results to regular files and -type d to directories. Tests combine — listing them together means "all of these must be true".
# find STARTS at a path and walks every subdirectory under it.
# Tests select what to keep. With no test it prints everything.
echo "--- all .txt files (case-sensitive: misses notes.TXT) ---"
find project -name "*.txt"
echo "--- -iname is case-INsensitive: catches .txt AND .TXT ---"
find project -iname "*.txt"
echo "--- -type f = regular files only, -type d = directories only ---"
find project -type d
echo "--- combine tests: regular files ending in .log ---"
find project -type f -name "*.log"2. Filtering by Size, Time, and Permissions
Three more tests do most of the heavy lifting. -size takes a number with a suffix (c bytes, k KB, M MB) — +1M means larger than 1 MB, -100k smaller than 100 KB. -mtime counts in days: -7 is "within the last 7 days" and +30 is "older than 30 days". -perm filters by permission bits — -u+x matches anything the owner can execute.
# Filter by SIZE with -size. Suffixes: c=bytes, k=kilobytes, M=megabytes.
# -size +1M bigger than 1 MB -size -100k smaller than 100 KB
# a leading + means "more than", - means "less than", none means "exactly".
echo "--- files smaller than 100k (our tiny files all qualify) ---"
find project -type f -size -100k | sort
# Filter by MODIFICATION TIME with -mtime, measured in 24-hour days:
# -mtime -7 modified within the last 7 days
# -mtime +30 modified MORE than 30 days ago
echo "--- modified within the last 7 days (the fresh files) ---"
find project -type f -mtime -7 | sort
echo "--- older than 30 days (old.log, which we aged to 40 days) ---"
find project -type f -mtime +30
# Filter by PERMISSIONS with -perm. -perm -u+x means 'has the user-execute
# bit set' (the - prefix means "at least these bits"). None of ours are
# executable yet, so this prints nothing:
echo "--- files the owner can execute ---"
find project -type f -perm -u+x3. Acting on Matches with -exec
Finding files is half the job — usually you want to do something with them. -exec runs a command on each match, with {} standing in for the filename. The action must be terminated, and how you terminate it matters: \; runs the command once per file, while + packs many filenames into fewer invocations (just like xargs), which is dramatically faster for large result sets.
# -exec runs a command on each match. {} is replaced by the filename.
# The action MUST be terminated: \; for one-per-file, + for batching.
echo "--- -exec ... {} \; runs the command ONCE PER FILE ---"
# Here 'echo Processing' fires separately for every .c file.
find project -name "*.c" -exec echo "Processing {}" \;
echo "--- -exec ... {} + BATCHES many files into one invocation ---"
# wc -l receives BOTH .c files in a single call, so it can print a total.
find project -name "*.c" -exec wc -l {} +
echo "--- a real bulk action: grep INSIDE the found files ---"
# find selects the files; grep searches their contents. -l prints only
# the names of files that contain the word 'boot'.
find project -name "*.log" -exec grep -l "boot" {} +4. Piping to xargs (and Why -print0 Matters)
xargs reads items from standard input and appends them as arguments to a command — the classic way to feed find's output into another tool in a single fast call. But plain find | xargs splits on whitespace, so a filename containing a space becomes two broken arguments. The fix is the safe pairing: find … -print0 | xargs -0, which separates names with a NUL byte that can never appear inside a filename.
5. Placeholders and Parallelism: -I and -P
By default xargs appends arguments at the end of the command. When you need the filename elsewhere — say to build cp file file.bak — use -I {} to define a placeholder that gets substituted in place (this also makes xargs run one item per invocation). And when the work is slow, -P N runs up to N jobs in parallel, putting your CPU cores to use.
# By default xargs puts the arguments at the END of the command. When you
# need them somewhere ELSE (or one-at-a-time), use -I {} as a placeholder.
echo "--- bulk RENAME: give every .log a .log.bak copy ---"
# {} stands in for each filename; with -I, xargs runs the command per item.
find project -name "*.log" -print0 | xargs -0 -I {} cp {} {}.bak
find project -name "*.bak" | sort
echo "--- run jobs in PARALLEL with -P (here, up to 4 at once) ---"
# -P N starts N invocations concurrently — great for slow, CPU-bound work.
# Output order may interleave, but every item still gets processed.
find project -name "*.c" -print0 | xargs -0 -P 4 -I {} echo "built {}" | sort🎯 Your Turn: find by name and type
Fill in the one blank marked ___ using the # 👉 hint so the search catches every capitalization of .txt, then run it and check the Output panel matches.
# 🎯 YOUR TURN — list every regular .txt file under project,
# matching ANY capitalization (.txt, .TXT, .Txt).
# Fill the blank with the case-insensitive name test.
find project -type f ___ "*.txt" | sort # 👉 replace ___ with -iname
# ✅ Expected output:
# project/docs/my report.txt
# project/docs/notes.TXT
# project/docs/readme.txt🎯 Your Turn: count lines safely with xargs
Wire up the safe NUL-separated pipeline. Fill in both blanks so a filename with a space could never break the command, then run it.
# 🎯 YOUR TURN — count the lines of every .log file SAFELY,
# so a filename with a space could never break the command.
# 1) Tell find to NUL-terminate each name.
find project -name "*.log" ___ | \ # 👉 replace ___ with -print0
# 2) Tell xargs to split on NUL.
xargs ___ wc -l # 👉 replace ___ with -0
# ✅ Expected output:
# 0 project/logs/app.log
# 0 project/logs/old.log
# 0 totalPro Tips
- 💡 Quote your patterns. Write find . -name "*.log", not find . -name *.log — without quotes the shell expands *.log first and find never sees the pattern.
- 💡 Make -print0 | xargs -0 a reflex. It's the only pairing that survives spaces, tabs, and newlines in filenames. The plain pipe is a foot-gun on real data.
- 💡 Prefer {} + (or xargs) over {} \; when the command takes many files. Batching can turn ten thousand process launches into one.
- 💡 find . -name "*.tmp" -delete is the safe, built-in way to remove matches — no pipe to rm needed, and it handles odd filenames for you.
- 💡 Speed up CPU-bound bulk work with xargs -P. For example image conversion: find . -name "*.png" -print0 | xargs -0 -P 4 -I {} convert {} {}.webp.
Common Errors (and the fix)
- "find: paths must precede expression" — your -name pattern wasn't quoted, so the shell expanded it into multiple filenames. Wrap it in quotes: find . -name "*.txt".
- -exec says "missing argument to `-exec'" — you forgot the terminator. End it with \; (one per file) or + (batched).
- xargs deletes or mangles the wrong files — a filename had a space and the plain pipe split it. Always use find … -print0 | xargs -0 ….
- -mtime +7 didn't match a file you edited yesterday — +7 means older than 7 days. You wanted -7 ("within the last 7 days").
- You meant to search file contents but used find — find matches by attributes, not text. To search inside files, use grep -r, or combine: find . -name "*.py" -exec grep -l TODO {} +.
📋 Quick Reference
| Task | Command | Notes |
|---|---|---|
| Match by name | find . -name "*.txt" | Quote the pattern |
| Case-insensitive name | find . -iname "*.txt" | .txt and .TXT |
| Files / directories | -type f / -type d | Regular file vs dir |
| By size | -size +1M / -size -100k | + more, - less |
| By modified time | -mtime -7 / -mtime +30 | Days; - within, + older |
| Run once per file | -exec cmd {} \; | One invocation each |
| Batch into fewer calls | -exec cmd {} + | Like xargs, faster |
| Safe pipe to xargs | -print0 | xargs -0 | Handles spaces |
| Placeholder for arg | xargs -I {} cmd {} | Put arg anywhere |
| Run in parallel | xargs -P 4 | Up to 4 jobs at once |
| Delete matches | find . -name "*.tmp" -delete | Safe built-in |
Mini-Challenge: tidy up the project
No blanks this time — just a brief and an outline. Combine name/type tests, -mtime, the safe -print0 | xargs -0 pairing, and -exec … + into a few commands over the project/ tree, and check your results against the hints.
# 🎯 MINI-CHALLENGE: a tiny "tidy up the project" script
# Using the project/ tree from the setup block, write commands that:
#
# 1. COUNT how many regular files live under project/
# (hint: find ... -type f | wc -l).
# 2. Find every .log OLDER than 30 days and print it
# (hint: -mtime +30).
# 3. SAFELY make a .bak copy of every .txt file, even 'my report.txt'
# (hint: -print0 | xargs -0 -I {} cp {} {}.bak).
# 4. Find which .log files CONTAIN the word 'boot'
# (hint: -exec grep -l "boot" {} +).
#
# Bonus: speed step 3 up with xargs -P 4.
# your commands here🎉 Lesson Complete!
- ✅ find walks a tree and selects entries by -name/-iname, -type, -size, -mtime, and -perm
- ✅ -exec cmd {} \; runs once per file; -exec cmd {} + batches many files into fewer, faster invocations
- ✅ xargs turns find's output into arguments; find … -print0 | xargs -0 is the safe pairing for names with spaces
- ✅ xargs -I {} places the argument anywhere, and xargs -P N runs N jobs in parallel
- ✅ Bulk operations: count with wc -l, search contents with grep, copy/rename with cp, and remove with -delete
Practice quiz
What is the core job of the find command?
- It searches a directory tree for files and directories matching tests like name, type, size, or time
- It searches the contents of files for text
- It downloads files from the web
- It renames every file in a folder
Answer: It searches a directory tree for files and directories matching tests like name, type, size, or time. find walks a directory tree and selects entries by tests (-name, -type, -size, -mtime, ...). grep is the tool that searches text INSIDE files.
Which test matches only regular files (not directories or symlinks)?
- -file
- -type d
- -type f
- -type l
Answer: -type f. -type f selects regular files; -type d selects directories; -type l selects symbolic links.
How do you match files named with any capitalization of .TXT, like a.txt, B.Txt, and c.TXT?
- find . -type txt
- find . -iname "*.txt"
- find . -name "*.txt"
- find . -match "*.txt"
Answer: find . -iname "*.txt". -iname is the case-insensitive version of -name, so it matches *.txt regardless of letter case. -name is case-sensitive.
What does find . -mtime -7 select?
- The 7 most recently changed files
- Files modified more than 7 days ago
- Files exactly 7 bytes in size
- Files modified within the last 7 days
Answer: Files modified within the last 7 days. -mtime -7 means modified less than 7 days ago (within the last week). A plain 7 means exactly 7 days, and +7 means older than 7 days.
What is the difference between -exec cmd {} (semicolon) and -exec cmd {} + in find?
- The semicolon form runs cmd once per file, while the plus form passes many files to fewer cmd invocations
- The plus form runs once per file, while the semicolon form batches
- The plus form deletes files and the semicolon form copies them
- They are identical
Answer: The semicolon form runs cmd once per file, while the plus form passes many files to fewer cmd invocations. -exec cmd {} (semicolon) runs the command once for each matched file. -exec cmd {} + appends as many filenames as possible per invocation, like xargs, which is far faster for many files.
Why is find . -print0 | xargs -0 safer than find . | xargs?
- It sorts the filenames first
- It separates filenames with a NUL character so names containing spaces or newlines are handled correctly
- It runs commands in parallel
- It compresses the output
Answer: It separates filenames with a NUL character so names containing spaces or newlines are handled correctly. -print0 ends each name with a NUL byte and xargs -0 splits on NUL, so spaces, tabs, and newlines inside filenames no longer break the command.
What does the -I {} option do in xargs?
- It runs jobs in parallel
- It ignores blank input lines
- It reads input separated by NUL
- It defines a placeholder so each input argument is substituted at that spot, not just appended at the end
Answer: It defines a placeholder so each input argument is substituted at that spot, not just appended at the end. xargs -I {} sets {} as a replacement string, letting you place the argument anywhere in the command (for example, in the middle) instead of only at the end.
What does xargs -P 4 do?
- It limits output to 4 lines
- It passes at most 4 arguments per command
- It runs up to 4 command invocations in parallel
- It prints the command before running it
Answer: It runs up to 4 command invocations in parallel. xargs -P 4 runs as many as 4 jobs at the same time, which can dramatically speed up CPU-bound bulk work on multi-core machines.
Which command counts how many .log files exist under the current directory?
- find . -name "*.log" -size
- find . -type f -name "*.log" | wc -l
- grep -c log .
- ls *.log
Answer: find . -type f -name "*.log" | wc -l. find emits one path per line for each match, so piping the .log matches to wc -l counts them. ls *.log would miss files in subdirectories.
You want to delete every .tmp file safely, even ones with spaces in their names. Which is best?
- find . -name "*.tmp" -print0 | xargs -0 rm
- rm *.tmp
- find . -name "*.tmp" | xargs rm
- find . -name "*.tmp" -delete -now
Answer: find . -name "*.tmp" -print0 | xargs -0 rm. Pairing -print0 with xargs -0 NUL-separates the names so spaces and newlines are safe. find . -name "*.tmp" -delete also works, but the plain pipe to xargs breaks on spaces.
Continue this course
- Previous: Redirection and File Descriptors
- Next: HTTP from the Terminal: curl and wget — Make requests, send data and headers, follow redirects and download files
- Quick reference: Command Line cheat sheet › Navigation
Frequently asked questions
What is the difference between find and grep?
They answer different questions. find searches a directory tree for files and folders by their attributes -- name, type, size, modification time, permissions -- and prints the matching paths. grep searches INSIDE files for lines of text that match a pattern. A very common combo is to let find pick the files and hand them to grep, for example find . -name "*.py" -exec grep -l TODO {} + which lists Python files that contain the word TODO.
When should I use -exec ... {} ; versus -exec ... {} + ?
Both run a command on the files find matched, and {} is replaced by the filename. The semicolon form (written as backslash semicolon) runs the command once per file, which is necessary when the command can only take one argument or when you need it to run separately each time. The plus form appends as many filenames as fit onto a single command line, so it runs far fewer times -- much faster for thousands of files. Use plus when the command accepts many file arguments (like wc, grep, or rm), and the semicolon form otherwise.
Why do people always pair find -print0 with xargs -0?
By default find prints one filename per line and xargs splits its input on whitespace, so any filename containing a space, tab, or newline gets split into multiple bogus arguments -- which can delete or clobber the wrong thing. -print0 ends each name with a NUL byte (a character that cannot appear in a filename), and xargs -0 splits only on NUL. Together they handle every legal filename safely. It is the recommended default whenever you pipe find into xargs.
What does xargs -I {} actually change?
Normally xargs tacks the incoming arguments onto the END of the command. -I {} defines {} as a replacement string, so each input item is substituted wherever you wrote {} -- letting you put the argument in the middle of a command, use it twice, or build things like cp {} {}.bak. A side effect is that -I makes xargs process one item per command invocation, so it is handy when you need exactly one argument at a time.
How do I make bulk operations faster?
Two big levers. First, prefer -exec ... {} + or piping to xargs over -exec ... once-per-file (the backslash-semicolon form), because batching turns thousands of process launches into a handful. Second, add xargs -P N to run N jobs in parallel across your CPU cores, for example find . -name "*.png" -print0 | xargs -0 -P 4 -I {} convert {} {}.webp. For deletes specifically, find . -name "*.tmp" -delete is both safe and fast.