Lesson 4 of 14
Finding Files and Searching Text
This lesson covers the two questions developers ask most often in a terminal: which files match a condition, and which lines inside files contain a pattern. After it, a reader can locate files by name, type, size, age, and depth, run a command on every match, search file contents with grep and rg, and join both steps in one command.
| Command | Purpose |
|---|---|
find |
Walk a directory tree and print the entries that match tests (name, type, size, time, depth) |
find -exec |
Run a command on each matching entry |
find -delete |
Remove each matching entry |
grep |
Print the lines of a file that match a pattern |
rg (ripgrep) |
Recursive line search that skips ignored and hidden files by default |
All output below comes from GNU find 4.9.0, GNU grep 3.11, and ripgrep 14.1.0 on Linux. Notes mark where macOS (BSD) tools differ.
Set up the demo project
Every example in this lesson runs against one small project. The commands create it in a demo directory. The touch -d lines give files fixed modification dates so the age examples print the same result for every reader. The git init line matters only for the ripgrep examples, because ripgrep reads .gitignore inside a Git repository.
mkdir -p demo && cd demo
mkdir -p src/util config logs build docs
cat > src/App.java <<'J'
package demo;
public class App {
// TODO: read the port from config
public static void main(String[] args) {
System.out.println("Starting app");
String env = System.getenv("APP_ENV");
if (env == null) {
System.err.println("ERROR: APP_ENV is not set");
}
}
}
J
cat > src/util/Helper.java <<'J'
package demo.util;
public class Helper {
// FIXME: handle null input
public static String trim(String s) {
return s.trim();
}
// TODO: add padding helper
}
J
cat > src/util/Strings.java <<'J'
package demo.util;
public class Strings {
public static boolean isBlank(String s) {
return s == null || s.isBlank();
}
}
J
printf 'port=8080\nhost=localhost\ndebug=false\nDebug_mode=off\n' > config/app.conf
printf 'db.url=jdbc:postgresql://localhost/demo\ndb.user=demo\ndb.password=secret\n' > config/db.properties
cat > logs/app.log <<'J'
2026-10-03 10:00:01 INFO server started on port 8080
2026-10-03 10:00:05 INFO request GET /users 200
2026-10-03 10:00:07 WARN slow query: 1200 ms
2026-10-03 10:00:09 ERROR connection refused to db
2026-10-03 10:00:10 INFO retrying connection
2026-10-03 10:00:12 INFO request GET /orders 500
2026-10-03 10:00:13 ERROR timeout while reading from cache
2026-10-03 10:00:15 INFO request GET /users 200
J
echo "ERROR: disk almost full" > logs/old.log
head -c 2097152 /dev/zero > logs/big.log
head -c 5000 /dev/zero > logs/medium.log
echo "# Demo project" > README.md
echo "todo: write docs" > notes.txt
echo "TODO: document the API" > docs/api.md
echo "compiled TODO marker" > build/out.class
printf 'build/\n*.log\n' > .gitignore
git init -q .
find . -type f -not -path "./.git/*" -exec touch -d "2026-09-01 12:00" {} +
touch -d "2020-01-01 12:00" logs/old.log notes.txt
touch -d "2026-10-02 12:00" config/db.properties
touch -d "2026-10-03 09:00" src/App.javaThe head -c 2097152 /dev/zero lines create files of exactly 2 MiB and 5,000 bytes filled with zero bytes. The order in which find prints entries depends on the file system, so examples that print several lines pipe the result through sort. The listing is then identical on every machine.
Example 1: find by name and type
The first argument to find is the directory to walk. After it come tests that an entry must pass to be printed.
find . -name "*.java" | sort
find . -iname "readme*"
find . -type d -not -path "./.git*" | sort
find . -type f -name "*.log" | sort./src/App.java
./src/util/Helper.java
./src/util/Strings.java
./README.md
.
./build
./config
./docs
./logs
./src
./src/util
./logs/app.log
./logs/big.log
./logs/medium.log
./logs/old.log-name compares the file name (without the directory part) to a glob pattern. The quotes stop the shell from expanding *.java before find sees it. -iname ignores case, so readme* matches README.md. -type d keeps directories and -type f keeps regular files. Other common types are l for symbolic links.
-not (or !) negates the next test. The -not -path "./.git*" part removes the .git directory and everything under it from the directory listing. -path matches against the whole path, not only the last component, which is why it can exclude a directory. The first line of the third output is . because the starting directory is itself an entry of type directory.
Tests written side by side are joined with an implicit AND. Use -o for OR and parentheses (escaped as \( ... \) in a shell) to group them.
On macOS, find needs a path argument, and the flags above exist with the same meaning according to the BSD man page.
Example 2: find by size
-size takes a number and a unit suffix: c for bytes, k for KiB, M for MiB, G for GiB. A leading + means more than, a leading - means less than, and no sign means exactly.
find logs -type f -size +1M
find logs -type f -size +1k -size -100k
find logs -type f -size -1klogs/big.log
logs/medium.logThe third command printed nothing. find rounds each file size up to the next whole unit before it compares. A 5,000-byte file counts as 5 KiB, so medium.log is more than 1 and less than 100. app.log and old.log are under 1 KiB, so they round up to 1 unit. They are not more than 1 unit, and they are not less than 1 unit either. -size -1k therefore matches only empty files. To select small non-empty files, use bytes: -size -1000c.
Two -size tests in one command require both to pass, which gives a range.
Example 3: find by modification time
-mtime counts in whole 24-hour periods and rounds, which makes it hard to predict at the edges. -newermt compares the modification time to a date string and is exact. It is a GNU feature that BSD find also lists in its man page as part of the -newerXY family.
find . -type f -not -path "./.git/*" -newermt "2026-10-03 00:00"
find . -type f -not -path "./.git/*" -newermt "2026-10-02 00:00" ! -newermt "2026-10-03 00:00"
find . -type f -not -path "./.git/*" ! -newermt "2021-01-01" | sort
find . -type f -not -path "./.git/*" -mtime +365 | sort./src/App.java
./config/db.properties
./logs/old.log
./notes.txt
./logs/old.log
./notes.txtThe first command lists files modified on or after 3 October 2026. The second keeps files newer than 2 October and not newer than 3 October, which is a one-day window. The third lists files not modified since 2021. The fourth does the same with -mtime +365, which means "last modified more than 365 days ago". The result depends on the date of the run; it holds for any day before September 2027 given the dates set in the setup.
| Test | Meaning |
|---|---|
-mtime -7 |
Modified less than 7 days ago |
-mtime +30 |
Modified more than 30 days ago |
-mmin -10 |
Modified less than 10 minutes ago |
-newer file |
Modified more recently than file |
-newermt "date" |
Modified after the given date and time |
-atime and -ctime work the same way for access time and inode change time.
Example 4: limit the depth
find descends into every subdirectory by default. -maxdepth N stops at depth N and -mindepth N skips entries above depth N. The starting directory is depth 0.
find . -maxdepth 1 -not -name ".git" | sort
find src -mindepth 2 -type f
find src -maxdepth 1.
./.gitignore
./README.md
./build
./config
./docs
./logs
./notes.txt
./src
src/util/Helper.java
src/util/Strings.java
src
src/util
src/App.java-maxdepth 1 lists only the direct children of the starting directory, which makes it a recursive-free ls. -mindepth 2 under src skips src itself (depth 0) and its direct children (depth 1), so only files inside src/util remain. GNU find prints a warning when -maxdepth comes after other tests, so write depth options first. BSD find also accepts the options.
Limiting depth also saves time: find does not read directories deeper than the limit.
Example 5: run a command with -exec and delete with -delete
-exec command {} \; runs the command once per match, with {} replaced by the path. -exec command {} + collects many paths into one call, like xargs. -delete removes each match.
find src -name "*.java" -exec wc -l {} \; | sort -k2
find src -name "*.java" -exec wc -l {} +
find logs -name "*.log" -size +1M -print -delete
ls logs12 src/App.java
9 src/util/Helper.java
7 src/util/Strings.java
9 src/util/Helper.java
7 src/util/Strings.java
12 src/App.java
28 total
logs/big.log
app.log
medium.log
old.logThe first command starts wc -l three times, once per file. The second starts it once with three arguments, which is why wc prints a total line. The + form is faster for large trees because it starts fewer processes. The \; form is needed when the command accepts only one path or when each run must be separate.
The semicolon is escaped because the shell would otherwise treat it as the end of the find command. In the + form, {} must come directly before the +.
The third command prints the matching path and then deletes it. -delete has no confirmation and no recycle bin. Run the same command with -print only first to see the list. Test order matters: find . -delete -name "*.tmp" deletes everything, because -delete is evaluated for each entry before the name test. Put -delete last.
For a confirmation prompt per file, -ok command {} \; works like -exec but asks first. BSD find supports -exec, -ok, and -delete as well.
Example 6: search a tree with grep -r, -n, and --include
grep pattern file prints each line that contains the pattern. -r searches every file under a directory. -n prefixes each line with its line number.
grep -rn "TODO" src
grep -n "port" config/app.confsrc/util/Helper.java:8: // TODO: add padding helper
src/App.java:4: // TODO: read the port from config
1:port=8080Each result line has the form path:line-number:text. When grep searches one file, it omits the path, as in the second command. When it searches a directory or several files, it adds the path. Use -H to force the path for a single file and -h to remove it.
The pattern is a basic regular expression. A plain word matches itself. Characters such as ., *, [, and ^ have special meaning, so use -F to search for a fixed string that contains them. Quote the pattern so the shell does not interpret it.
Searching a whole tree reads build output and version-control data as well. --include limits the search to file names that match a glob, and --exclude-dir skips directories.
grep -r "TODO" . --include="*.java" --exclude-dir=.git | sort
grep -rn --exclude-dir={.git,build} "TODO" . | sort./src/App.java: // TODO: read the port from config
./src/util/Helper.java: // TODO: add padding helper
./docs/api.md:1:TODO: document the API
./src/App.java:4: // TODO: read the port from config
./src/util/Helper.java:8: // TODO: add padding helperThe first two lines come from the first command and the last three from the second. The first command prints only Java files. The second searches all file types except .git and build, so it also finds docs/api.md but not build/out.class. The brace expansion {.git,build} is done by the shell (bash and zsh) and becomes two --exclude-dir options.
--exclude=GLOB skips files by name, the counterpart of --include. Without --include, grep -r reads binary files too and prints Binary file ... matches instead of the line.
On macOS, the BSD grep supports -r, --include, and --exclude-dir as well, according to its man page.
Example 7: ignore case, match whole words, invert the match
Three options change what counts as a match.
grep -i "debug" config/app.conf
grep -w "debug" config/app.conf
grep -iw "debug" config/app.conf
grep -v "INFO" logs/app.logdebug=false
Debug_mode=off
debug=false
debug=false
2026-10-03 10:00:07 WARN slow query: 1200 ms
2026-10-03 10:00:09 ERROR connection refused to db
2026-10-03 10:00:13 ERROR timeout while reading from cache-i ignores case, so Debug_mode=off matches. -w requires the match to be a whole word. A word is a run of letters, digits, and underscores. In Debug_mode the text Debug is followed by _, which is a word character, so the line is not a whole-word match. With -iw, the case-insensitive match Debug still fails the word test, and only debug=false remains.
-v inverts the test and prints the lines that do not match. The last command removes every INFO line and leaves the warnings and errors. A common use is to drop comment and blank lines with grep -v -e '^#' -e '^$' file. Several -e options combine patterns with OR.
Example 8: list files and count matches with -l, -L, -c, and -q
When the question is "which files" or "how many", the matching lines are not needed.
grep -rl "TODO" . --exclude-dir=.git | sort
grep -rL "TODO" src
grep -c "ERROR" logs/app.log
grep -rc "TODO" src | sort
grep -q "ERROR" logs/app.log; echo "exit=$?"
grep -q "FATAL" logs/app.log; echo "exit=$?"./build/out.class
./docs/api.md
./src/App.java
./src/util/Helper.java
src/util/Strings.java
2
src/App.java:1
src/util/Helper.java:1
src/util/Strings.java:0
exit=0
exit=1| Option | Output |
|---|---|
-l |
Names of files that contain a match |
-L |
Names of files that contain no match |
-c |
Number of matching lines per file |
-q |
Nothing; only the exit status |
-c counts lines, not occurrences. A line with two matches counts once. Files with no match print 0 in the -rc form. grep exits with 0 when it found a match, 1 when it found none, and 2 on an error such as a missing file. Scripts use -q in conditions: if grep -q "ERROR" app.log; then .... The option also stops reading at the first match, which makes it fast.
Example 9: extended patterns with -E and print only the match with -o
-E switches to extended regular expressions, where |, +, ?, {n}, and ( ) have their special meaning without backslashes. -o prints only the matched text, one match per line.
grep -E "ERROR|WARN" logs/app.log
grep -E "^(port|host)=" config/app.conf
grep -oE "GET /[a-z]+ [0-9]{3}" logs/app.log
grep -oE "ERROR [a-z ]+" logs/app.log | sort | uniq -c
grep -oP "port=\K\d+" config/app.conf2026-10-03 10:00:07 WARN slow query: 1200 ms
2026-10-03 10:00:09 ERROR connection refused to db
2026-10-03 10:00:13 ERROR timeout while reading from cache
port=8080
host=localhost
GET /users 200
GET /orders 500
GET /users 200
1 ERROR connection refused to db
1 ERROR timeout while reading from cache
8080The first command matches lines that contain ERROR or WARN. The second anchors the pattern: ^ is the start of the line, and the group (port|host) matches either word followed by =. The third extracts the method, path, and status from each request line. {3} means exactly three repetitions of [0-9]. The fourth pipes the extracted fragments to sort | uniq -c to count each distinct one.
The last command uses -P, which selects Perl-compatible expressions in GNU grep. \K drops everything matched so far from the output, so only the digits print. The BSD grep on macOS does not list -P in its man page. There, grep -oE "port=[0-9]+" file | cut -d= -f2 gives the same result.
Example 10: show context with -A, -B, and -C
A matching line often means little without its neighbors. -A N prints N lines after each match, -B N prints N lines before, and -C N prints N lines on both sides.
grep -A 1 "ERROR" logs/app.log
grep -B 1 "500" logs/app.log
grep -C 1 "timeout" logs/app.log
grep -n -A 2 "main" src/App.java2026-10-03 10:00:09 ERROR connection refused to db
2026-10-03 10:00:10 INFO retrying connection
--
2026-10-03 10:00:13 ERROR timeout while reading from cache
2026-10-03 10:00:15 INFO request GET /users 200
2026-10-03 10:00:10 INFO retrying connection
2026-10-03 10:00:12 INFO request GET /orders 500
2026-10-03 10:00:12 INFO request GET /orders 500
2026-10-03 10:00:13 ERROR timeout while reading from cache
2026-10-03 10:00:15 INFO request GET /users 200
5: public static void main(String[] args) {
6- System.out.println("Starting app");
7- String env = System.getenv("APP_ENV");When two groups of context are not adjacent, grep prints -- between them, as in the first output. With -n, a matching line uses : after the line number and a context line uses -, so line 5 is the match and lines 6 and 7 are context. Adjacent groups that overlap merge into one block.
Example 11: ripgrep and its defaults
rg searches the current directory recursively without needing -r. Its defaults differ from grep in four ways: it respects .gitignore files, it skips hidden files and directories, it skips binary files, and it runs searches in parallel across files. Its output includes line numbers and file names when printed to a terminal, and it uses colors. The commands below use the plain form that appears when output goes to a pipe.
rg "TODO"
rg --no-ignore "TODO" | sort
rg -n -i "debug" config
rg -t java -l "TODO" | sort
rg --files -g "*.java" | sort
rg -c "String" src | sort
rg -w -o -N "TODO|FIXME" src | sort
rg --hidden --files -g "!.git" . | sort
rg -F "String[] args" -n srcdocs/api.md:TODO: document the API
src/App.java: // TODO: read the port from config
src/util/Helper.java: // TODO: add padding helper
build/out.class:compiled TODO marker
docs/api.md:TODO: document the API
src/App.java: // TODO: read the port from config
src/util/Helper.java: // TODO: add padding helper
config/app.conf:3:debug=false
config/app.conf:4:Debug_mode=off
src/App.java
src/util/Helper.java
src/App.java
src/util/Helper.java
src/util/Strings.java
src/App.java:2
src/util/Helper.java:1
src/util/Strings.java:2
src/App.java:TODO
src/util/Helper.java:FIXME
src/util/Helper.java:TODO
./.gitignore
./README.md
./config/app.conf
./config/db.properties
./docs/api.md
./notes.txt
./src/App.java
./src/util/Helper.java
./src/util/Strings.java
src/App.java:5: public static void main(String[] args) {The first command skipped build/out.class because .gitignore lists build/. The second command adds --no-ignore, and build/out.class appears. Without a path, rg searches the current directory when run in a terminal. In a script whose standard input is a pipe, rg with no path searches that input, so scripts pass the path. When a path is given, rg prints file names with that prefix, as the ./ in the --hidden output shows.
| Option | Meaning | Same idea in grep |
|---|---|---|
-n |
Show line numbers (default on a terminal) | -n |
-i |
Ignore case | -i |
-w |
Whole words | -w |
-l |
File names only | -l |
-c |
Count matching lines per file | -c |
-o |
Print only the matched text | -o |
-F |
Fixed string, not a regular expression | -F |
-e P |
Add a pattern (repeatable) | -e |
-t java |
Search only the file type java |
--include="*.java" |
-g GLOB |
Include files by glob, or exclude with !GLOB |
--include, --exclude |
--files |
List the files rg would search, without searching |
find |
--hidden |
Include hidden files | none |
--no-ignore |
Ignore .gitignore rules |
none |
-N |
Never print line numbers | default |
-t java -l restricts the search to files rg classes as Java. --files -g "*.java" lists matching file names without reading any content. --hidden adds hidden files and directories, and the -g "!.git" glob keeps the .git directory out of that list. ripgrep uses Rust regular expressions by default, which support \d, \w, and non-greedy quantifiers without a flag and do not support look-around or backreferences unless -P is given. The syntax is close to grep -E. rg --version printed ripgrep 14.1.0 here.
Speed is the practical reason to use rg on a large repository. It skips the ignored build output, the dependency directories, and the version-control data, which often make up most of the files on disk. On a repository with large node_modules or target directories, the skipped files often decide the difference in time. rg is not part of the POSIX tool set, so a script that must run on any system uses grep.
Example 12: combine find and grep
find decides which files to read, and grep decides which lines to print. The two tools connect through -exec, or through a pipe to xargs.
find . -type f -name "*.java" -print0 | xargs -0 grep -n "TODO"
find . -name "*.java" -exec grep -H "static" {} + | sort
find . -type f -newermt "2026-10-02 00:00" -not -path "./.git/*" -exec grep -l "TODO" {} +
grep -rn "TODO" src | cut -d: -f1 | sort | uniq -c
find . -type f -not -path "./.git/*" -not -path "./build/*" -exec grep -il "todo" {} + | sort./src/util/Helper.java:8: // TODO: add padding helper
./src/App.java:4: // TODO: read the port from config
./src/App.java: public static void main(String[] args) {
./src/util/Helper.java: public static String trim(String s) {
./src/util/Strings.java: public static boolean isBlank(String s) {
./src/App.java
1 src/App.java
1 src/util/Helper.java
./docs/api.md
./notes.txt
./src/App.java
./src/util/Helper.javaThe first command prints the NUL-separated names from find -print0 to xargs -0, which passes them to grep. NUL separators keep names with spaces or newlines intact. The second uses -exec ... {} + and grep -H, which prints the file name even when a batch holds only one file. The third restricts the search to files changed since 2 October, which suits a question such as "which recently edited files still contain a TODO". The fourth counts matches per file by cutting the path from each grep -rn line. The fifth searches every file except .git and build, and -i lets todo in notes.txt match.
A choice between the forms follows from what is needed:
| Need | Form |
|---|---|
| Filter by name, size, or time, then search | find ... -exec grep ... {} + |
| Search by content only, skip build output | grep -r --exclude-dir=... or rg |
| Handle odd file names in a pipe | find -print0 | xargs -0 grep |
| Fast search in a Git repository | rg |
The next lesson covers xargs in detail: xargs and jq.
Interview points
findwalks a tree and tests each entry;grepreads lines of text.findanswers questions about files,grepabout contents.-namematches the base name only;-pathmatches the whole path. Quote globs so the shell does not expand them.-size +1Mrounds file sizes up to whole units before comparing, so-size -1kmatches only empty files.-exec cmd {} \;runs once per file;-exec cmd {} +batches arguments, likexargs. Prefer+for many files.-deletehas no confirmation and depends on test order. List with-printfirst and place-deletelast.find -print0 | xargs -0is the safe way to pass file names that contain spaces or newlines.grepexit status: 0 for a match, 1 for none, 2 for an error.-qis the form for conditions.-wmatches whole words made of letters, digits, and underscores;-ccounts lines, not matches;-oprints each match on its own line.-Eenables extended regular expressions;-P(GNU only) enables Perl-compatible ones;-Fsearches a literal string.rgignores files listed in.gitignore, hidden files, and binary files by default, and searches in parallel.--no-ignoreand--hiddenchange that.
Summary
find DIR tests actionswalks the tree underDIR; tests joined side by side form an AND, and-oforms an OR.- Name tests are
-name(case-sensitive) and-iname(case-insensitive);-type fand-type dselect files and directories. -sizeacceptsc,k,M,Gwith+for more and-for less, rounded up to whole units.-newermt "date"compares modification times exactly;-mtime Ncounts rounded 24-hour periods.-maxdepthand-mindepthlimit the depth, with the starting directory at depth 0.-exec ... {} \;runs a command per match,-exec ... {} +batches matches,-deleteremoves them.grep -rn pattern dirsearches a tree and printspath:line:text;--includeand--exclude-dirnarrow it.-i,-w, and-vchange case sensitivity, word boundaries, and the sense of the match.-l,-L,-c, and-qreport file names, files without matches, counts, and the exit status.-Euses extended regular expressions and-oprints only the matching text.-A N,-B N, and-C Nprint context lines and separate non-adjacent groups with--.rgsearches recursively by default, honors.gitignore, and skips hidden and binary files.findwith-exec grep, orfind -print0 | xargs -0 grep, joins file selection with content search.
