Guide contents

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.

Terminal
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.java

The 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.

Terminal
find . -name "*.java" | sort
find . -iname "readme*"
find . -type d -not -path "./.git*" | sort
find . -type f -name "*.log" | sort
Output
./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.

Terminal
find logs -type f -size +1M
find logs -type f -size +1k -size -100k
find logs -type f -size -1k
Output
logs/big.log
logs/medium.log

The 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.

Terminal
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
Output
./src/App.java
./config/db.properties
./logs/old.log
./notes.txt
./logs/old.log
./notes.txt

The 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.

Terminal
find . -maxdepth 1 -not -name ".git" | sort
find src -mindepth 2 -type f
find src -maxdepth 1
Output
.
./.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.

Terminal
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 logs
Output
12 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.log

The 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.

Terminal
grep -rn "TODO" src
grep -n "port" config/app.conf
Output
src/util/Helper.java:8:    // TODO: add padding helper
src/App.java:4:    // TODO: read the port from config
1:port=8080

Each 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.

Terminal
grep -r "TODO" . --include="*.java" --exclude-dir=.git | sort
grep -rn --exclude-dir={.git,build} "TODO" . | sort
Output
./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 helper

The 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.

Terminal
grep -i "debug" config/app.conf
grep -w "debug" config/app.conf
grep -iw "debug" config/app.conf
grep -v "INFO" logs/app.log
Output
debug=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.

Terminal
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=$?"
Output
./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.

Terminal
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.conf
Output
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
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
8080

The 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.

Terminal
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.java
Output
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: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.

Terminal
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 src
Output
docs/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.

Terminal
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
Output
./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.java

The 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

  • find walks a tree and tests each entry; grep reads lines of text. find answers questions about files, grep about contents.
  • -name matches the base name only; -path matches the whole path. Quote globs so the shell does not expand them.
  • -size +1M rounds file sizes up to whole units before comparing, so -size -1k matches only empty files.
  • -exec cmd {} \; runs once per file; -exec cmd {} + batches arguments, like xargs. Prefer + for many files.
  • -delete has no confirmation and depends on test order. List with -print first and place -delete last.
  • find -print0 | xargs -0 is the safe way to pass file names that contain spaces or newlines.
  • grep exit status: 0 for a match, 1 for none, 2 for an error. -q is the form for conditions.
  • -w matches whole words made of letters, digits, and underscores; -c counts lines, not matches; -o prints each match on its own line.
  • -E enables extended regular expressions; -P (GNU only) enables Perl-compatible ones; -F searches a literal string.
  • rg ignores files listed in .gitignore, hidden files, and binary files by default, and searches in parallel. --no-ignore and --hidden change that.

Summary

  • find DIR tests actions walks the tree under DIR; tests joined side by side form an AND, and -o forms an OR.
  • Name tests are -name (case-sensitive) and -iname (case-insensitive); -type f and -type d select files and directories.
  • -size accepts c, k, M, G with + for more and - for less, rounded up to whole units.
  • -newermt "date" compares modification times exactly; -mtime N counts rounded 24-hour periods.
  • -maxdepth and -mindepth limit the depth, with the starting directory at depth 0.
  • -exec ... {} \; runs a command per match, -exec ... {} + batches matches, -delete removes them.
  • grep -rn pattern dir searches a tree and prints path:line:text; --include and --exclude-dir narrow it.
  • -i, -w, and -v change case sensitivity, word boundaries, and the sense of the match.
  • -l, -L, -c, and -q report file names, files without matches, counts, and the exit status.
  • -E uses extended regular expressions and -o prints only the matching text.
  • -A N, -B N, and -C N print context lines and separate non-adjacent groups with --.
  • rg searches recursively by default, honors .gitignore, and skips hidden and binary files.
  • find with -exec grep, or find -print0 | xargs -0 grep, joins file selection with content search.