Skip to main content
Settings
Color Mode
Theme Skin
Background

Appearance preferences are saved in this browser only.

Environment
Current Environment Production

Built with JEKYLL_ENV=production. Changes require deployment.

Quick Links
Theme & Build
Jekyll v3.10.0
Last Build Jul 22, 20:33
Page Location
Page Info
Layout article
Collection posts
Path _posts/tools/2026-07-17-grep-honest-review.md
URL /tools/grep-honest-review/
Date 2026-07-17

grep: the honest review

Verdict: you already have it, you already use it, and you should keep it — but learn the -E flag before you trust it, because its default regex dialect makes half the patterns you paste silently match nothing. grep is for everyone with a terminal: it’s the floor the fancier tools are built to beat. It’s the tool you reach for in a pipe without thinking, the one baked into every CI image and every locked-down server. It is not the tool you want for combing a huge repo interactively — ripgrep will lap it — and it’s not the tool that will guess what regex dialect you meant. More on both below.

grep is free and open source (GNU grep is GPL-3.0; the BSD one on your Mac is BSD-licensed). We have no relationship with it, no affiliate link, and — refreshingly — nothing to satirize, because grep is the one tool on this whole site with no landing page, no funnel, no “Pro” tier, and no account. It’s a fifty-year-old program that prints lines matching a pattern. That’s the entire pitch, and it has never once tried to upsell you.

Install

You don’t. That’s the feature.

Every Unix-like machine already has it. The only install-time question worth asking is which grep you have, because it changes what works later:

grep --version
# grep (GNU grep) 3.11   <- Linux, most CI images
# grep (BSD grep) ...    <- macOS ships this one

GNU grep (Linux) and BSD grep (macOS) are close cousins, not twins. Keep that in your back pocket for the portability section.

The three-and-a-half flags that matter

Everything below ran against a throwaway tree we built for the occasion — a src/ dir, a node_modules/, a .git/, and a couple of files with TODO in them. Search every file under here for TODO, with line numbers, recursively:

$ grep -rn TODO .
./src/pool.rs:8:// TODO: add retry budget
./node_modules/huge.js:1:TODO vendored junk

-r recurses, -n prints line numbers. Note the second hit: grep happily grepped node_modules/, because grep has never heard of your .gitignore and never will. That’s sometimes a bug and sometimes exactly what you want (more on that later). To skip the junk, name it:

$ grep -rn --exclude-dir={.git,node_modules} TODO .
./src/pool.rs:8:// TODO: add retry budget

Case-insensitive, with a line of context on either side:

$ grep -rin -C1 "connection refused" src
src/pool.rs-3-        .map_err(|e| {
src/pool.rs:4:            warn!("connection refused: {e}");
src/pool.rs-5-            Backoff::reset()

The flags worth burning into muscle memory: -r recursive, -n line numbers, -i case-insensitive, -C N context, -l print only matching filenames (great for piping), -v invert (lines that don’t match), -o print only the matched text, --color=auto to highlight. And the half-flag that saves you the most grief, -E, which we’re about to spend a whole section on.

The one default that will confuse you

By default, grep speaks Basic Regular Expressions (BRE), a dialect from before ?, +, |, (, and { were special. In BRE, those are literal characters. So the perfectly reasonable regex you learned everywhere else quietly matches nothing:

$ printf 'color\ncolour\n' | grep "colou?r"
$            # <- no output. grep read "?" as a literal question mark.

No error. No warning. Just an empty result and a slow erosion of your faith in regular expressions. The pattern isn’t wrong; grep is reading it in a dialect where ? means “a literal ?”, and none of your lines contain one.

The fix is one flag — -E, for Extended Regular Expressions, where ?/+/|/() do what you expect:

$ printf 'color\ncolour\n' | grep -E "colou?r"
color
colour

If you can’t add the flag (someone else’s script, a pipe you’re editing in place), the BRE escape hatch is a backslash — \?, \+, \|, \(, \{:

$ printf 'color\ncolour\n' | grep "colou\?r"
color
colour

Yes, that’s backwards from every other regex engine you use, where you escape a special character to make it literal. In BRE you escape it to make it special. Learn -E, reach for it reflexively, and this stops being a papercut. (egrep is the old alias for grep -E; it still works but prints a deprecation nag on modern GNU grep, so just type grep -E.)

The portability trap, in reverse

Our ripgrep review warns you that rg-only flags die on machines that don’t have ripgrep. grep has the same trap one layer down: GNU-only flags die on a Mac.

The big one is -P, Perl-Compatible Regular Expressions — lookahead, \d, non-greedy, the works:

$ grep -oP 'foo(?=bar)' <<< 'foobar foobaz'
foo           # matched the "foo" that "bar" follows; the one in "foobaz" is skipped

-P is a GNU extension. BSD grep on macOS doesn’t have it. A script that leans on grep -P (or \d, or GNU’s --exclude-dir spelling, or GNU-flavored \| alternation in BRE) will work beautifully on your Linux CI and then explode the first time a colleague runs it on their laptop. If a shell script has to run on machines you don’t control, stay inside POSIX: basic patterns, -E for extended, and no -P. Test it on both if you can.

Two gotchas that have burned everyone

grep -c counts lines, not matches. This surprises people who expected a match count:

$ printf 'a a a\nb\n' | grep -c a
1                                   # one *line* contains "a"
$ printf 'a a a\nb\n' | grep -o a | wc -l
3                                   # ...but three actual matches

If you want to count occurrences, not lines, it’s grep -o pattern | wc -l.

grep greps itself. The single most-run grep pipe in history, ps aux | grep something, always matches its own process, because by the time ps snapshots the process table, the grep something command is in it:

$ ps aux | grep 'sleep 300'
root  8399  ... sleep 300          # the process you wanted
root  8404  ... grep sleep 300     # grep matching its own command line

The fix is a ten-cent trick: wrap the first character in a character class. [s]leep matches the string sleep, but the text of the grep process is now literally [s]leep, which doesn’t contain the substring sleep, so grep no longer finds itself:

$ ps aux | grep '[s]leep 300'
root  8399  ... sleep 300          # just the one you wanted

When the shiny tool wins — and when grep is still the right call

We are not telling you to alias grep=rg. Both are true at once:

Reach for a modern tool when you’re searching a codebase interactively. On a big repo, ripgrep is faster, skips .gitignored junk for free, and colorizes and groups by default. Pair it with fd for filenames. For that job, grep -rn is the slow option, and you’ll feel it.

Reach for plain grep when:

  • It has to already be there. CI base images, minimal containers, a stranger’s server, a recovery shell. grep is POSIX and universal; rg is a thing you have to install first.
  • You’re writing a script other people run. Portability is the whole game. rg flags aren’t POSIX; neither is grep -P. Stick to grep / grep -E.
  • It’s a tiny one-off pipe. ps aux | grep, history | grep, ... | grep -v DEBUG. No repo to recurse, no reason to reach for anything fancier.
  • You actually want to search ignored files. grep’s .gitignore-blindness is a feature the moment you’re hunting a leaked key inside a build artifact or a vendored blob — the exact files ripgrep hides from you by default.

What made us close the tab

Nothing, because there’s no tab. No telemetry, no account, no cloud sync waiting to lapse, no pricing page that changes the week after you commit. The honest “cost” of grep is paid in surprises, not dollars: the BRE default that makes your first regex lie, the -c that counts the wrong thing, and its cheerful willingness to grind through node_modules/ because nobody told it not to. Every one of those has a one-flag fix, and once your fingers know them, grep gets out of the way and stays there.

When it goes wrong: your search comes back empty and you’re certain the text is there. Nine times out of ten you’re in BRE mode — your ?, +, or ( is being read literally. Re-run with grep -E. If instead your recursive search is crawling and full of minified vendor noise, grep isn’t gitignore-aware; add --exclude-dir, or switch to rg. And if a script that sings on Linux dies on a Mac, you reached for a GNU-only flag — most likely -P.

You will never install grep, and you will never delete it. That’s the whole review.