Back to Blog

Zsh Shell Tutorial #17: Master grep for Text Searching

Sandy LaneSandy Lane

Video: Zsh Shell Tutorial #17: Master grep for Text Searching by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Zsh Lesson 17: Master grep — Search Files for Patterns

grep pattern file. -i case-insensitive, -n line numbers, -c count, -v invert, -r recursive. -E extended regex. ^ start of line, $ end. -A 2 -B 2 for context. The single most useful Unix command for finding things.

grep searches text for patterns. You'll use it dozens of times a day.

Basic search

cat > colors.txt <<EOF
Red Apple
green grape
Blue Berry
red cherry
ORANGE peel
blueberry jam
EOF

grep "red" colors.txt
# red cherry

grep pattern file prints lines containing pattern. Default is case-sensitive.

Case-insensitive: -i

grep -i "red" colors.txt
# Red Apple
# red cherry

-i ignores case for both pattern and text.

Line numbers: -n

grep -n "blue" colors.txt
# 6:blueberry jam

-n prefixes each match with its line number.

Count: -c

grep -ic "blue" colors.txt
# 2

-c prints just the count of matching lines (not the matches themselves).

Invert: -v

grep -vi "red" colors.txt
# green grape
# Blue Berry
# ORANGE peel
# blueberry jam

-v prints lines that DON'T match. Useful for filtering out noise:

ps aux | grep -v grep         # find processes, exclude the grep itself

Multiple files

grep "TODO" *.py
# main.py:    # TODO: refactor
# utils.py:   # TODO: add tests

Match multiple files. Output prefixes each line with the filename (when more than one file).

Recursive: -r (or -R)

grep -r "TODO" project/
# project/src/app.py:# TODO fix bug
# project/src/main.js:# TODO add tests
# project/docs/readme.md:TODO update docs

-r walks directories. Use it for "where in this project is X?"

To restrict to specific file types:

grep -r --include="*.py" "TODO" project/
grep -r --include="*.{js,ts}" "import" src/

# Or exclude:
grep -r --exclude-dir=node_modules "TODO" .
grep -r --exclude="*.min.js" "console.log" .

Show context: -A, -B, -C

grep -B 1 -A 2 "ERROR" log.txt    # 1 before, 2 after
grep -C 3 "ERROR" log.txt          # 3 lines on each side

Context flags help when matches are part of a larger block.

Multiple patterns

grep -E "(error|warning|fail)" log.txt
grep -e "error" -e "warning" log.txt

-E enables extended regex (ERE) — alternation |, grouping (), quantifiers +, ?. Without -E, those need backslash escape.

-e pattern lets you specify multiple patterns separately.

For literal strings (no regex), use -F:

grep -F "192.168.1.1" log.txt    # treat dots as literal

-F is faster too — no regex engine.

From a list of patterns

grep -f patterns.txt logfile.txt

-f file reads patterns from file, one per line. Useful for matching against many keywords.

Just the matching part: -o

echo "Email: alice@example.com" | grep -oE "[a-z]+@[a-z]+\.[a-z]+"
# alice@example.com

-o prints only the matched portion of each line, not the whole line. Combine with regex to extract.

Regex basics

^pattern        # start of line
pattern$        # end of line
.               # any single character
[abc]           # one of a, b, c
[^abc]          # NOT one of a, b, c
[a-z]           # range
*               # 0 or more of previous
+               # 1 or more (ERE)
?               # 0 or 1 (ERE)
{n,m}           # n to m repetitions (ERE)
grep "^Error" log.txt          # lines starting with "Error"
grep "ERROR$" log.txt          # lines ending with "ERROR"
grep "^[A-Z]+:" data.txt       # lines starting with uppercase letters then colon (ERE)
grep "[0-9]\{3\}-[0-9]\{4\}" file    # phone-like (BRE)
grep -E "[0-9]{3}-[0-9]{4}" file     # same with ERE

BRE (basic regex, default) requires backslashes for +, ?, {}, (). ERE (-E) doesn't. Always use -E unless you have a specific reason — cleaner syntax.

Word boundaries: -w

grep -w "cat" file.txt
# matches "cat" but not "category" or "scattered"

-w treats the pattern as a whole word — boundaries on both sides.

Quiet check: -q

if grep -q "ERROR" log.txt; then
  echo "errors found"
fi

-q suppresses output; just sets the exit code (0 if found, 1 if not). Use for boolean checks.

Files with matches: -l (and -L)

grep -l "TODO" *.py        # files containing TODO
grep -L "TODO" *.py        # files NOT containing TODO

Just file names, no content. Useful for "find me files that mention X."

# Edit every file containing TODO
$EDITOR $(grep -l "TODO" *.py)

In pipes

ps aux | grep "node" | grep -v grep
ls -la | grep "^d"           # only directories

grep in a pipeline is the most common usage. The grep -v grep idiom is to exclude the grep process itself when searching ps.

A cleaner alternative for ps:

pgrep node                    # PIDs of node
pgrep -af node                # full command lines

Working with logs

# Last 100 errors
grep "ERROR" /var/log/system.log | tail -100

# Errors in the last hour (assuming RFC3339-ish dates)
grep "$(date -v-1H +%Y-%m-%dT%H)" /var/log/system.log | grep ERROR

# Unique error messages
grep "ERROR" log.txt | sort -u

# Most common errors (top 10)
grep "ERROR" log.txt | sort | uniq -c | sort -rn | head

Standard log analysis pipelines.

Color: --color

grep --color=auto "pattern" file.txt

Highlights matches in color. Add to your .zshrc:

alias grep='grep --color=auto'

auto means "color when stdout is a terminal, plain text when piped." Always-on (always) breaks pipelines.

Performance: ripgrep

brew install ripgrep
rg "pattern" .

rg (ripgrep) is a Rust-based grep replacement — way faster, smarter defaults (respects .gitignore), unicode-aware, simpler regex syntax.

For modern dev work, rg is the default. grep remains for portability and tiny scripts.

A code-search workflow

# Find all TODOs
rg TODO

# Find function definitions in Python
rg "^def " --type py

# Files that import a specific module
rg "from sqlalchemy" -l

# Replace across files (rg --files-with-matches + sed)
rg -l "old_function" | xargs sed -i '' 's/old_function/new_function/g'

For Python, JS, Go, anything — rg finds things fast.

Common stumbles

Forgetting -E. grep "(a|b)" matches the literal string (a|b). Use -E for regex.

Special chars in pattern. grep "$VAR" works for safe inputs, but if $VAR contains regex metacharacters (., *, [, \), they're interpreted. Use grep -F "$VAR" for literal.

Quote your pattern. grep $foo file — if $foo has spaces, splits into multiple args. Always grep "$foo" file.

Match a literal -. grep "-help" file is interpreted as a flag. Use grep -- "-help" file (-- ends flags) or grep -e "-help" file.

Recursive search excludes hidden by default? No. grep -r includes them. Use --exclude=".*" to skip.

grep in ps aux matches itself. Famous gotcha. Use pgrep or ps aux | grep '[n]ode' (the brackets prevent self-match).

Case-insensitive -i with -w. Both flags work together.

Newlines in patterns. grep is line-by-line; can't easily match across lines. Use pcre2grep (with -M) or scripted approaches.

What's next

Lesson 18: sed. Stream editor — replace, delete, insert, by line or pattern.

Recap

grep pattern file — find lines matching. -i case-insensitive, -n line numbers, -c count, -v invert, -r recursive, -w word match, -q quiet (exit code only), -l filenames only, -A/-B/-C context. -E for extended regex. -F for literal. --include/--exclude-dir to filter. For modern dev, ripgrep (rg) is the faster, smarter default.

Next lesson: sed.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.