Module 2: Working with Files and Text

6. Searching inside files with grep

Description

In the previous lesson you learned to find files by their name, type, size, or date with find. But find only looks at the boxes' labels: it never opens one. This lesson teaches you the other half: opening the content of one file, or thousands at once, and finding the exact line you are looking for, with no editor opened and no file browsed one by one.

This is not an academic exercise. Before renaming a function in a two-hundred-file project you need to know which ones use it. Before making a commit you want to verify you did not leave a password or an API key hand-written in the code. When a server fails at three in the morning, you scan a two-million-line log file for the word ERROR and the exact minute it started. In all three cases, the answer is the same tool: grep.

Connection to the module: find tells you where the files are; grep tells you what is inside them. Together they solve the full problem of getting your bearings in a project you do not know — exactly what you are going to practice in this module's final project.

From locating files to reading their content

Think of find as a library's index: it tells you which shelf holds each book, by its title, size, or the date it arrived, but it never opens it. grep is the librarian who does open every book, reads every line of every page, and tells you exactly which one has the phrase you are looking for. The difference matters because they are different questions: "where is the config.py file?" gets answered by find; "which file and which line has the word PAYMENT_API_KEY?" gets answered by grep.

The name comes from global regular expression print: it scans a text line by line, evaluates each line against a pattern, and prints the ones that match. The simplest form is:

grep pattern file

Worked example

Imagine you have your application's log file, access.log, with this content:

INFO  2026-07-18 09:12:03 User 42 logged in
ERROR 2026-07-18 09:14:51 Payment gateway timeout for order 1188
WARN  2026-07-18 09:15:02 Retry scheduled for order 1188
ERROR 2026-07-18 09:15:10 Payment gateway timeout for order 1188
INFO  2026-07-18 09:20:44 User 17 logged in
ERROR 2026-07-18 09:22:19 Database connection refused

You want to see only the lines where something failed:

grep ERROR access.log

What to expect:

ERROR 2026-07-18 09:14:51 Payment gateway timeout for order 1188
ERROR 2026-07-18 09:15:10 Payment gateway timeout for order 1188
ERROR 2026-07-18 09:22:19 Database connection refused

grep read all six lines, compared each one against the pattern ERROR, and printed only the three that contain it. None of this opened an editor or required you to know where in the file the error was.

Now the real leap: instead of one file, you have an entire project. Imagine this structure, web-store/:

web-store/
├── app.py
├── config.py
├── README.md
├── .git/
│   └── (git's internal metadata, not your code)
├── node_modules/
│   └── (thousands of dependency files)
└── tests/
    └── test_orders.py

Where config.py has:

PAYMENT_API_KEY = "sk_live_FAKEEXAMPLE1234567890"

app.py has:

import requests
from config import PAYMENT_API_KEY

def charge_customer(order_id, amount):
    headers = {"Authorization": PAYMENT_API_KEY}
    return requests.post(
        "https://api.payments.example/charge",
        headers=headers,
        json={"order_id": order_id, "amount": amount},
    )

And tests/test_orders.py has:

# Verify PAYMENT_API_KEY is loaded before running the integration tests
from config import PAYMENT_API_KEY

You want to know which files PAYMENT_API_KEY appears in, without opening each one by hand. With -r (recursive), grep enters every subdirectory for you:

grep -rn PAYMENT_API_KEY web-store/

What to expect:

web-store/app.py:2:from config import PAYMENT_API_KEY
web-store/app.py:5:    headers = {"Authorization": PAYMENT_API_KEY}
web-store/config.py:1:PAYMENT_API_KEY = "sk_live_FAKEEXAMPLE1234567890"
web-store/tests/test_orders.py:1:# Verify PAYMENT_API_KEY is loaded before running the integration tests
web-store/tests/test_orders.py:2:from config import PAYMENT_API_KEY

With a single command you located the five exact lines, in three different files, inside a directory tree you did not even have to browse by hand. That is the leap -r gives you: from "searching one file" to "searching your entire project."

The options you will use every day

These six cover the vast majority of your real-world searches. All of them combine with each other and with -r.

-i — ignore case. By default grep distinguishes uppercase from lowercase. access.log has two lines with the word User (capitalized):

grep user access.log

What to expect: nothing. Zero lines, because user (lowercase) is not equal to User.

grep -i user access.log

What to expect:

INFO  2026-07-18 09:12:03 User 42 logged in
INFO  2026-07-18 09:20:44 User 17 logged in

-n — show the line number. You already used this above (-rn). Without it, grep tells you which line matches but not where it is inside the file; with it, you can jump straight to that line in your editor.

-w — whole word only. By default grep searches for the string in any position, even in the middle of another word. Search for log in access.log:

grep log access.log

What to expect:

INFO  2026-07-18 09:12:03 User 42 logged in
INFO  2026-07-18 09:20:44 User 17 logged in

Neither of those lines has the standalone word "log": what matched was the log fragment inside logged. With -w you require the pattern to be a complete word, delimited by spaces, punctuation, or the edge of the line:

grep -w log access.log

What to expect: nothing. access.log has no line with the exact word "log."

-c — count matches instead of showing them.

grep -c ERROR access.log

What to expect:

3

-l — show only the names of files with matches, not the lines. It is the option you want when the question is "which files does this appear in?" and you do not care about the detail yet:

grep -rl PAYMENT_API_KEY web-store/

What to expect:

web-store/app.py
web-store/config.py
web-store/tests/test_orders.py

-v — invert the search, show the lines that do not match. Useful for cutting noise: you want to see everything that is not routine traffic.

grep -v INFO access.log

What to expect:

ERROR 2026-07-18 09:14:51 Payment gateway timeout for order 1188
WARN  2026-07-18 09:15:02 Retry scheduled for order 1188
ERROR 2026-07-18 09:15:10 Payment gateway timeout for order 1188
ERROR 2026-07-18 09:22:19 Database connection refused

Seeing the context around a match

A single line sometimes tells you nothing; you need to see what happened right before or right after. That is what -A (after, lines after), -B (before, lines before), and -C (context, both sides) are for, all followed by how many lines you want.

What happened right after the warning (WARN)?

grep -A 1 WARN access.log

What to expect:

WARN  2026-07-18 09:15:02 Retry scheduled for order 1188
ERROR 2026-07-18 09:15:10 Payment gateway timeout for order 1188

What happened right before the database refused the connection?

grep -B 1 "Database connection refused" access.log

What to expect:

INFO  2026-07-18 09:20:44 User 17 logged in
ERROR 2026-07-18 09:22:19 Database connection refused

And with -C you get both sides in one step:

grep -C 1 WARN access.log

What to expect:

ERROR 2026-07-18 09:14:51 Payment gateway timeout for order 1188
WARN  2026-07-18 09:15:02 Retry scheduled for order 1188
ERROR 2026-07-18 09:15:10 Payment gateway timeout for order 1188

When there are several separate matches and their context blocks do not touch, grep separates them with a -- line so you know they are not consecutive in the original file.

Excluding noisy directories with --exclude-dir

Back to web-store/. The .git/ directory stores the project's full history in a compressed binary format, and node_modules/ can have tens of thousands of dependency files you did not write. grep -r walks through them anyway, one by one, even though it is never going to find anything useful there: that makes the search noticeably slower and adds noise if a stray match shows up in some dependency's file.

grep -rn --exclude-dir=node_modules --exclude-dir=.git PAYMENT_API_KEY web-store/

What to expect: the same result you got before with -rn — the five lines in app.py, config.py, and test_orders.py — but in a fraction of the time, because grep never enters those two directories. You can repeat --exclude-dir as many times as there are directories you want to exclude.

Basic regular expressions

Up to now you searched for literal text. grep's real power shows up when the pattern describes a shape, not an exact text. These are the symbols you are going to use daily:

SymbolMeaningExampleWhat it finds
.any character (exactly one)order 11.8order 1188 (the . covers the second 8)
*zero or more repetitions of the previous charactercolou*rcolor and colour
+one or more repetitions (needs -E)colou+rcolour, but not color
^start of line^ERRORlines that start with ERROR
$end of linein$lines that end in in
[...]a character class^[EW]lines that start with E or W
|alternation — "this or that" (needs -E)WARN|refusedlines with WARN or with refused

Try the anchors on access.log:

grep '^ERROR' access.log

What to expect: the three ERROR lines (they all start there). And for the other end:

grep 'in$' access.log

What to expect: the two lines that end in "logged in."

Character classes need nothing special:

grep '^[EW]' access.log

What to expect: the ERROR and WARN lines, without the INFO ones.

For + and alternation (|) you need to turn on extended syntax with -E, because by default grep uses basic regular expressions (BRE), where those two symbols do not carry that special meaning:

grep -E 'WARN|refused' access.log

What to expect:

WARN  2026-07-18 09:15:02 Retry scheduled for order 1188
ERROR 2026-07-18 09:22:19 Database connection refused

Watch out for |'s precedence: it separates the entire pattern on each side, not just the following word. ^ERROR|WARN does not mean "the line starts with ERROR or with WARN": it means "the line starts with ERROR, or contains WARN anywhere." If you want to anchor both alternatives, you have to repeat the ^ in each one: ^ERROR|^WARN.

And the classic colou*r / colou+r from the table is not a whim: it is the textbook example for handling spelling variants (color/colour, behavior/behaviour) without writing two searches.

Side note: you are going to see references to egrep and fgrep in old tutorials. They are historical shortcuts equivalent to grep -E and grep -F (literal search); GNU marks them as deprecated and recommends using the explicit flags.

Why the pattern always goes in single quotes

grep's pattern uses a lot of the same characters your shell interprets before running any command: *, $, [, ], ?. If you write the pattern with no quotes, the shell can expand it before grep ever sees it, and grep ends up receiving something different from what you typed.

Real example: you want to search for lines containing a literal asterisk, or you simply type a pattern with * without thinking twice:

grep *.log access.log

If your current directory has any file ending in .log, the shell expands *.log into those file names before grep even starts, and you end up running something like grep access.log access.log — searching one file's content inside another, not what you wanted. If you use zsh (the default shell on macOS) and no file matches the wildcard, the error is even more direct: zsh: no matches found: *.log, and the command does not even get to run.

Single quotes ('pattern') avoid all of this: nothing inside them expands, not variables ($HOME), not wildcards, not commands. It is the safe default choice. Double quotes ("pattern") still expand $variables, so they are only worth it if you actually want a shell variable to make it into the pattern.

Honesty: ripgrep exists and is a better tool

ripgrep (the rg command) is noticeably faster than grep on large projects, respects .gitignore automatically — so you never have to remember to exclude node_modules/ or .git/ — and by default ignores binary files. If you work all day on the same code project, it is worth installing.

But grep is on every Linux machine and on macOS with nothing to install. When you SSH into a server someone else administers, or audit a freshly created container, grep is always there. That is why this guide teaches grep first: it is the tool that never fails, even if it is not the fastest.

Common mistakes

1. Believing grep searches for "words" by default (conceptual error). What happens: you search log expecting to find only the standalone word "log," but grep also hands you lines with "logged," "logging," or "catalog," because by default it searches for the string in any position, whether it is part of another word or not. Why: grep compares substrings, not tokens; it does not know what a "word" is unless you ask it to. How to spot it: your results include lines that clearly are not about what you were searching for. How to fix it: add -w to require a whole-word match.

2. Writing the pattern with no single quotes (conceptual error about expansion order). What happens: you use a pattern with *, $, or [...] with no quotes, and grep behaves unexpectedly: it searches the wrong file, it says "No such file or directory," or in zsh it aborts outright with "no matches found." Why: the shell expands wildcards and variables before passing the argument to grep; your pattern never arrives intact. How to spot it: the behavior changes depending on which files exist in your current directory, something that should not matter to a text search. How to fix it: always wrap the pattern in single quotes.

3. Running grep -r at a repository's root without excluding .git/ or node_modules/. What happens: the search takes much longer than expected, and sometimes grep prints lines like binary file web-store/.git/objects/... matches instead of a readable line of code. Why: .git/ stores compressed objects that grep interprets as binary, and node_modules/ can have tens of thousands of irrelevant files. How to spot it: the search feels slow for a simple pattern, or "binary file" mentions show up in the output. How to fix it: add --exclude-dir=.git --exclude-dir=node_modules, or install ripgrep, which excludes these directories by default.

Exercises

1. Count how many lines of access.log mention ERROR, without listing the lines themselves.

See solution
grep -c ERROR access.log

Result: 3. Why it works: -c tells grep to replace the normal output (the matching lines) with a single number: how many matched.

2. Without changing the file, predict and then verify: what does grep user access.log print? And grep -i user access.log?

See solution

grep user access.log prints nothing, because the file has "User" with an initial capital and grep distinguishes uppercase from lowercase by default. grep -i user access.log prints the two lines with "User." Why it works: -i tells grep to ignore the difference between uppercase and lowercase when comparing each line against the pattern.

3. In web-store/, without entering node_modules/ or .git/, find which files PAYMENT_API_KEY appears in, showing only the file names (not the lines).

See solution
grep -rl --exclude-dir=node_modules --exclude-dir=.git PAYMENT_API_KEY web-store/

Result: web-store/app.py, web-store/config.py, and web-store/tests/test_orders.py. Why it works: -r walks subdirectories, -l trims the output to just the file name instead of the full lines, and each --exclude-dir keeps grep from entering a specific directory.

4. Write a pattern with grep -E that finds the lines in access.log that start with ERROR or start with WARN — not just contain them anywhere.

See solution
grep -E '^ERROR|^WARN' access.log

Why it works: the | operator separates the entire pattern on each side, not just the next word. If you wrote ^ERROR|WARN, the ^ anchor would only apply to the left side, and WARN would be searched for anywhere in the line, not just at the start. Repeating the ^ in each alternative is what guarantees both stay anchored.

Summary and next step

With find and grep together you can now locate any file on your machine and read its content searching for exactly what you need, with no editor opened and no folder browsed one by one. That is exactly what you are going to practice in this module's final project, auditing a project you do not know: find to understand what is there, grep to understand what it says.

Later, in the composition module, you are going to connect grep's output directly to other commands using pipes — but for now focus on mastering it on its own.

Everything you did in this lesson was searching and reading: you never changed a single line of any file. That is exactly the gap the next lesson closes — you are going to be able to edit files directly from the terminal, with nano for everyday use and the bare minimum of vim for the day it opens without you asking for it.

Before moving on you should be able to: search for a text pattern in a file and in an entire project with -r; adjust that search with -i, -n, -w, -c, -l, and -v; see the context around a match with -A, -B, and -C; exclude noisy directories with --exclude-dir; and write a basic pattern with anchors, character classes, and alternation, always inside single quotes.

Resources