A how-to manual: how grep reads, which lines it prints, regular expressions, context, recursive search, binary files, locales, exit status in scripts, ripgrep and git grep, and speed, with every example run for real.
▶Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
grep: prints the lines that match a pattern.
grep ERROR app.log
grep -rn --include='*.py' 'TODO' .
grep -E 'error|warn|fatal' /var/log/syslog
grep is the first tool you reach for when you need to know where something is: which log line mentions the error, which file sets this option, which process is running, whether a setting is present. It is on every Linux system, it works on files and on pipes, and the exit status says whether anything matched, so it is also the way a shell script asks a yes or no question about text.
This guide goes from the one command everyone uses to the parts that cause trouble: a pipe that grep ignores, a recursive search that quietly skips files, a binary file that "matches" and prints nothing, a CRLF file where the end of line does not match, a variable that becomes a pattern, and an exit status that means something different in a script. For editing text use sed (guide here), for columns use awk (guide here), and for JSON and YAML use jq and yq.
Every example below ran for real on Ubuntu 24.04 with GNU grep 3.11 (package grep 3.11-4build1), against the sample files shown in the next section. BusyBox grep 1.36.1, ripgrep 14.1.0 and git grep (git 2.43.0) were run as well where the guide compares them. The grep on macOS and the BSDs is a different program; the points where it differs are marked as not run.
What grep is, which one you have, and the sample data
grep reads its input one line at a time and prints the lines that match a pattern. The name is from the old ed command g/re/p (global, regular expression, print). It never changes a file; it only reads, so it is safe to try.
There are several greps:
GNU grep the grep on Ubuntu and most Linux systems (3.11 here). Basic and extended regexes, Perl regexes with -P, recursive search, context, many options.
BusyBox grep the grep in Alpine and many containers (1.36.1 here). A small subset of the options (see the portability section).
ripgrep (rg) a separate program, 14.1.0 here: recursive by default, skips hidden files, binary files and anything in .gitignore. Install it with: sudo apt install ripgrep
git grep searches the files git tracks, from inside a repository.
BSD grep the grep on macOS and FreeBSD. Not run in the lab; its options differ in places.
$ grep --version | head -1
grep (GNU grep) 3.11
$ dpkg -s grep | grep ^Version
Version: 3.11-4build1
The Ubuntu package also has egrep, fgrep and rgrep. They still run in 3.11 (egrep is grep -E, fgrep is grep -F, rgrep is grep -r), and the guide uses the options.
The examples use these files, which were made in ~/lab. app.log is a small application log (the same one as the sed and awk guides):
$ cat app.log
2026-10-17 02:00:01 INFO api request ok user=ann ip=10.0.1.11
2026-10-17 02:00:02 INFO api request ok user=ed ip=10.0.1.12
2026-10-17 02:00:03 WARN api slow request 910ms user=ann ip=10.0.1.11
2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
2026-10-17 02:00:05 INFO web GET /index.html 200 ip=10.0.3.5
2026-10-17 02:00:06 ERROR pay upstream timeout user=ed ip=10.0.1.12
2026-10-17 02:00:07 INFO web GET /missing 404 ip=10.0.3.6
fruit.txt has seven lines, one with a capital letter and one that contains another word:
$ cat fruit.txt
apple pie
banana split
Cherry tart
apple crumble
date cake
elderberry wine
pineapple
sshd_config is a settings file with comments, a blank line and a trailing comment:
$ cat sshd_config
# sshd settings
Port 22
#PermitRootLogin prohibit-password
PermitRootLogin yes
PasswordAuthentication yes # allow passwords
# indented comment
UsePAM yes
src is a small source tree, next to a node_modules folder, a .git folder, a symbolic link, a binary file and a few small files:
src/app.py import os, import sys, "# TODO: fix paths", def main(), print(os.getcwd()) # TODO later
src/web.js const key = "apiKey123"; and // TODO remove
src/web.min.js var a="apiKey999";
src/lib/util.py import os and TODO = 1
src/.env password=hunter2
node_modules/pkg/index.js module.exports = "TODO"
.git/HEAD ref: TODO
linkdir a symbolic link to src
blob.bin the text "TODO binary" with three control bytes (NUL, 1, 2) in the middle
crlf.txt two lines with Windows (CRLF) line ends
utf8.txt café au lait, naïve, CAFÉ (UTF-8 text)
dots.txt a.b and axb
pats.txt ERROR and WARN, one per line
old.log.gz two lines, compressed
A one-million-line log (63 MB) was made for the speed section.
How grep works: patterns, files and the exit status
grep [options] PATTERN [FILE ...]
grep reads each line of each file (or standard input when there is no file), prints the lines that match the pattern, and sets an exit status:
0 at least one line matched
1 no line matched
2 an error happened (a missing file, a bad pattern, no permission)
$ grep ERROR app.log
2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
2026-10-17 02:00:06 ERROR pay upstream timeout user=ed ip=10.0.1.12
$ grep -c ERROR app.log
2
$ grep -n ERROR app.log
4:2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
6:2026-10-17 02:00:06 ERROR pay upstream timeout user=ed ip=10.0.1.12
$ grep -q ERROR app.log; echo rc=$?
rc=0
$ grep -q NOPE app.log; echo rc=$?
rc=1
$ grep NOPE nofile.log; echo rc=$?
grep: nofile.log: No such file or directory
rc=2
A pattern with no match is not an error: it is exit status 1. That matters in scripts (see the section on scripts).
The pattern is a regular expression, not plain text. A dot matches any character, and a [ starts a set. With one file name, grep prints just the lines; with two or more it puts the file name in front of each line:
$ grep apple fruit.txt sshd_config
fruit.txt:apple pie
fruit.txt:apple crumble
fruit.txt:pineapple
Standard input is read when there is no file name, so grep works on the output of other commands:
$ cat app.log | grep -c WARN
1
$ grep ERROR < app.log | head -1
2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
$ echo 'a b' | grep -H a
(standard input):a b
Quote the pattern with single quotes, so the shell does not expand $, *, ? or [ before grep sees it.
The pattern that is also an option. A pattern that starts with a dash is read as an option. Give the pattern with -e, or put -- before it:
$ printf -- '-v here\n' > dash.txt; grep -e -v dash.txt; grep -- '-v' dash.txt
-v here
-v here
The options you will use
-i, --ignore-case ignore upper and lower case
-v, --invert-match print the lines that do NOT match
-w, --word-regexp match whole words only
-x, --line-regexp match whole lines only
-c, --count print the number of matching lines, not the lines
-l, --files-with-matches print only the names of files that match
-L, --files-without-match print only the names of files that do not match
-m N, --max-count=N stop after N matching lines
-o, --only-matching print only the matching part of each line
-n, --line-number put the line number in front
-b, --byte-offset put the byte offset in front (with -o: of the match)
-H, --with-filename always print the file name -h, --no-filename never print it
-q, --quiet print nothing, exit at the first match (for tests in scripts)
-s, --no-messages hide "No such file" and "Permission denied" messages
-A N, -B N, -C N N lines after, before, or around each match
-r, -R search directories recursively
--include=GLOB --exclude=GLOB --exclude-dir=GLOB which files to read when recursing
-I skip binary files -a treat binary files as text
-e PATTERN a pattern (give -e several times for several patterns)
-f FILE read patterns from a file, one per line
-E -F -G -P extended regex, fixed strings, basic regex (the default), Perl regex
-z, --null-data lines end with a NUL byte, not a newline
-Z, --null print a NUL after a file name, for xargs -0
--color=auto highlight the match (only when the output is a terminal)
--line-buffered flush the output after every line (for pipes)
$ grep -i cherry fruit.txt
Cherry tart
$ grep -v INFO app.log
2026-10-17 02:00:03 WARN api slow request 910ms user=ann ip=10.0.1.11
2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
2026-10-17 02:00:06 ERROR pay upstream timeout user=ed ip=10.0.1.12
$ grep -w apple fruit.txt
apple pie
apple crumble
$ grep apple fruit.txt
apple pie
apple crumble
pineapple
$ grep -x 'pineapple' fruit.txt
pineapple
$ grep -m1 ERROR app.log
2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
Which files match, and which do not:
$ grep -l ERROR app.log fruit.txt sshd_config
app.log
$ grep -L ERROR app.log fruit.txt sshd_config
fruit.txt
sshd_config
$ grep -c apple fruit.txt app.log
fruit.txt:3
app.log:0
-c counts lines, not matches. A line with two matches counts once, and -o prints each match on its own line:
$ printf 'ab ab\nab\n' | grep -c ab
2
$ printf 'ab ab\nab\n' | grep -o ab | wc -l
3
-o prints just the part that matched, which turns grep into an extraction tool:
$ grep -o 'user=[a-z]*' app.log
user=ann
user=ed
user=ann
user=kim
user=ed
$ grep -o 'ip=[0-9.]*' app.log | sort | uniq -c
2 ip=10.0.1.11
2 ip=10.0.1.12
1 ip=10.0.2.31
1 ip=10.0.3.5
1 ip=10.0.3.6
$ grep -ob 'ERROR' app.log
219:ERROR
349:ERROR
$ grep -on ERROR app.log
4:ERROR
6:ERROR
-w means a whole word: the match must have a non-word character (or the line edge) on both sides. It is why apple matches apple pie but not pineapple. A "word" is letters, digits and the underscore:
$ echo 'foo-bar foobar' | grep -ow 'foo'
foo
$ echo 'foo_bar foo' | grep -ow 'foo'
foo
-x requires the whole line to match. A trailing space or a carriage return makes it fail (see the CRLF section).
Two options combined with -l and -c: -l wins, and -c is not shown:
$ grep -lc ERROR app.log fruit.txt
app.log
Standard options can be joined (-rniw) and the ones with a value can follow directly (-A2, -m1). GNU grep also takes options after the file names, and the environment variable GREP_OPTIONS, which older guides use, is ignored in 3.11:
$ GREP_OPTIONS=-i grep CHERRY fruit.txt; echo rc=$?
rc=1
Context lines: -A, -B and -C
A log line is rarely enough. The context options print the lines after (-A), before (-B) or around (-C) each match:
$ grep -A1 ERROR app.log
2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
2026-10-17 02:00:05 INFO web GET /index.html 200 ip=10.0.3.5
2026-10-17 02:00:06 ERROR pay upstream timeout user=ed ip=10.0.1.12
2026-10-17 02:00:07 INFO web GET /missing 404 ip=10.0.3.6
$ grep -B1 'timeout' app.log
2026-10-17 02:00:05 INFO web GET /index.html 200 ip=10.0.3.5
2026-10-17 02:00:06 ERROR pay upstream timeout user=ed ip=10.0.1.12
$ grep -C1 WARN app.log
2026-10-17 02:00:02 INFO api request ok user=ed ip=10.0.1.12
2026-10-17 02:00:03 WARN api slow request 910ms user=ann ip=10.0.1.11
2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
With -n the separator tells a matching line from a context line: a colon for a match, a dash for context:
$ grep -n -C1 'card declined' app.log
3-2026-10-17 02:00:03 WARN api slow request 910ms user=ann ip=10.0.1.11
4:2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
5-2026-10-17 02:00:05 INFO web GET /index.html 200 ip=10.0.3.5
When two matches are close, their context joins into one block. When the blocks are apart, grep prints a line with -- between them. That line is easy to mistake for data in a script; remove it with --no-group-separator, or change it with --group-separator=TEXT:
$ grep -A0 ERROR app.log
2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
--
2026-10-17 02:00:06 ERROR pay upstream timeout user=ed ip=10.0.1.12
$ grep -A1 --no-group-separator ERROR app.log | wc -l
4
With several files, the file name comes before every line, with a colon for a match and a dash for context, so the blocks of different files are told apart:
$ grep -n -C1 TODO src/app.py src/lib/util.py
src/app.py-2-import sys
src/app.py:3:# TODO: fix paths
src/app.py-4-def main():
src/app.py:5: print(os.getcwd()) # TODO later
--
src/lib/util.py-1-import os
src/lib/util.py:2:TODO = 1
Context and -m. -m stops after the Nth match, but grep still prints the context after the last match:
$ grep -m1 -A2 ERROR app.log
2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
2026-10-17 02:00:05 INFO web GET /index.html 200 ip=10.0.3.5
2026-10-17 02:00:06 ERROR pay upstream timeout user=ed ip=10.0.1.12
(the second ERROR line is in the context, so it was printed even though only one match was asked for.) And -c ignores the context options:
$ grep -c -A2 ERROR app.log
2
Context shows lines around a match. To show everything between a start line and an end line, grep is the wrong tool; use sed -n '/start/,/end/p' or awk '/start/,/end/' (see their guides).
Patterns: basic, extended and fixed strings
grep has three pattern languages, chosen by an option:
grep PATTERN basic regular expressions (BRE), the default
grep -E PATTERN extended regular expressions (ERE)
grep -F PATTERN fixed strings: no special characters at all
grep -P PATTERN Perl-compatible regular expressions (next section)
In both BRE and ERE: . matches any character, [abc] is a set, [^abc] is not in the set, ^ and $ are the start and end of the line, and * means zero or more of the previous item. The difference is in ? + | ( ) { }. In ERE they are special. In BRE they are plain characters, and the special form has a backslash (GNU grep supports \? \+ \| \( \) \{ \}):
$ echo 'a+b aab' | grep -o 'a+b'
a+b
$ echo 'a+b aab' | grep -o 'a\+b'
aab
$ echo 'a+b aab' | grep -oE 'a+b'
aab
$ echo 'a+b aab' | grep -oE 'a\+b'
a+b
Alternation. The commonest mistake is writing | in a basic pattern, where it is a plain character and nothing matches:
$ grep 'ERROR|WARN' app.log | wc -l
0
$ grep 'ERROR\|WARN' app.log | wc -l
3
$ grep -E 'ERROR|WARN' app.log | wc -l
3
$ grep -e ERROR -e WARN app.log
2026-10-17 02:00:03 WARN api slow request 910ms user=ann ip=10.0.1.11
2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
2026-10-17 02:00:06 ERROR pay upstream timeout user=ed ip=10.0.1.12
Use -E whenever you need groups, alternatives or counted repeats: it reads like the regexes in other languages.
Groups, repeats and back-references (the same text again). Back-references work in both forms, and they are not in awk's regexes:
$ echo 'ab abab' | grep -oE '(ab)+'
ab
abab
$ echo 'ab abab' | grep -oE '(ab){2}'
abab
$ echo 'ab abab' | grep -o '\(ab\)\{2\}'
abab
$ echo 'abab xyxy abxy' | grep -oE '(..)\1'
abab
xyxy
Anchors and classes:
$ grep -n '^$' sshd_config
3:
$ grep -c '^$' sshd_config
1
$ grep '[[:upper:]]\{4,\}' app.log | head -2
2026-10-17 02:00:01 INFO api request ok user=ann ip=10.0.1.11
2026-10-17 02:00:02 INFO api request ok user=ed ip=10.0.1.12
$ grep -o '[[:digit:]]\+ms' app.log
910ms
$ grep -oE '[0-9]{1,3}(\.[0-9]{1,3}){3}' app.log | sort -u
10.0.1.11
10.0.1.12
10.0.2.31
10.0.3.5
10.0.3.6
The classes are [[:digit:]], [[:alpha:]], [[:alnum:]], [[:upper:]], [[:lower:]], [[:space:]], [[:punct:]] and [[:blank:]] (the double brackets are right: the class is inside a set). GNU grep also understands the shortcuts \w (word character), \W, \s (space), \S, \b (word edge), \< and \> (start and end of a word) in basic and extended patterns:
$ echo 'foo-bar foobar' | grep -o '\bfoo\b'
foo
$ echo 'foo-bar foobar' | grep -o '\<foo'
foo
foo
$ grep -oE '\w+' fruit.txt | sort | uniq -c | sort -rn | head -3
2 apple
1 wine
1 tart
There is no \d in basic or extended patterns. In a basic pattern a backslash before a letter that has no meaning is just the letter, so \d matched the letter d; it quietly matched every line that has a d in it:
$ grep '\d' fruit.txt
date cake
elderberry wine
$ grep -E '\d+ms' app.log | wc -l
0
$ grep -E '[0-9]+ms' app.log | wc -l
1
Use [0-9] or [[:digit:]]. (\d works with -P.)
Special characters. A dot is any character. To look for a literal dot, a star, a bracket or a dollar sign, escape it, put it in brackets ([.]) or use -F:
$ grep 'a.b' dots.txt
a.b
axb
$ grep 'a\.b' dots.txt
a.b
$ grep -F 'a.b' dots.txt
a.b
$ echo 'a*b' | grep -o 'a*b'
b
$ echo 'a*b' | grep -oF 'a*b'
a*b
$ echo 'a*b' | grep -o 'a\*b'
a*b
-F is also faster, and it is the right choice for a user-supplied string, a file name, an IP address or anything with punctuation. With -F, -e and -f give several fixed strings:
$ grep -F -e 'a.b' -e 'x' dots.txt
a.b
axb
Greedy matching. * and + take as much as they can, and ERE and BRE have no lazy form (.*? is just an optional .*). With -o the difference shows. Perl regexes (-P) have the lazy form:
$ echo 'aXbXc' | grep -oE 'a.*X'
aXbX
$ echo 'aXbXc' | grep -oP 'a.*?X'
aX
When two alternatives match at the same place, grep prints the longest (the leftmost-longest rule), so the order of the alternatives does not matter:
$ echo 'aab' | grep -oE 'a|aa|aab'
aab
$ echo 'aab' | grep -oE 'aab|aa|a'
aab
The empty pattern matches every line. It appears when a variable is empty, or when a file for -f has a blank line (see the section on scripts):
$ grep -c '' app.log
7
$ grep -E 'ERROR|' app.log | wc -l
7
$ grep -f pats_blank.txt app.log | wc -l
7
(pats_blank.txt is ERROR, an empty line, WARN. The empty line is a pattern that matches everything, so all seven lines came out.)
A star at the start of a pattern is a plain star in basic patterns, and a warning in extended ones:
$ grep '*ERROR' app.log | wc -l
0
$ grep -E '*ERROR' app.log | wc -l
grep: warning: * at start of expression
2
Perl regular expressions: grep -P
-P turns on Perl-compatible regular expressions (PCRE). It is a GNU grep feature (Ubuntu's grep is built with it) and it adds what basic and extended patterns lack:
\d \D digit, non-digit
\K forget what was matched so far: print only what follows
(?<=x) look-behind: x must come before (?<!x) not before
(?=x) look-ahead: x must come after (?!x) not after
.*? +? lazy repeats
(?i) case-insensitive from here on (?s) the dot matches a newline
$ grep -P '\d{3}ms' app.log | cut -c1-50
2026-10-17 02:00:03 WARN api slow request 910ms
$ echo 'a1b22' | grep -oP '\d+'
1
22
$ grep -oP 'user=\K[a-z]+' app.log
ann
ed
ann
kim
ed
$ grep -oP '(?<=ip=)[0-9.]+' app.log | head -2
10.0.1.11
10.0.1.12
$ grep -P 'ERROR(?!.*timeout)' app.log | cut -c1-60
2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
$ grep -oP '^\S+ \S+' app.log | head -1
2026-10-17 02:00:01
\K is the most useful of these: it prints the value after a label without the label, which with -o replaces most uses of cut. The first example extracted the user names.
"Both of these words on one line, in any order" is a look-ahead pair, which is hard to write any other way (the alternatives are two greps in a pipe, or an alternation of both orders):
$ grep -P '(?=.*ERROR)(?=.*user=ed)' app.log | cut -c1-50
2026-10-17 02:00:06 ERROR pay upstream timeout us
$ grep ERROR app.log | grep user=ed | cut -c1-50
2026-10-17 02:00:06 ERROR pay upstream timeout us
$ grep -E 'user=ed.*ERROR|ERROR.*user=ed' app.log | cut -c1-50
2026-10-17 02:00:06 ERROR pay upstream timeout us
-P is not everywhere. BusyBox grep has no -P (it says "invalid option -- 'P'"), and ripgrep needs --pcre2 for look-around (below). A pattern that is wrong gives a PCRE message:
$ grep -P '(' app.log; echo rc=$?
grep: missing closing parenthesis
rc=2
-P can also fail on a pattern that is correct. A pattern with nested repeats can take exponential time, so PCRE stops at its backtracking limit. grep then prints an error and exits with status 2, which is not "no match". The lab used 25 letters a followed by a b against ^(a+)+$:
$ grep -cP '^(a+)+$' bt.txt; echo rc=$?
grep: bt.txt: exceeded PCRE's backtracking limit
rc=2
$ grep -cE '^(a|aa)+$' bt.txt; echo rc=$?
0
rc=1
The extended pattern gave an answer at once (0 matches), because ERE patterns are matched without backtracking, so they cannot blow up this way. In a script, treat status 2 as an error, not as "not found" (see the scripts section). For input you do not control, use -E or -F instead of -P.
Searching files and directories: -r, include and exclude
grep a directory name and it refuses; add -r (recursive) and it reads every file below:
$ grep TODO src; echo rc=$?
grep: src: Is a directory
rc=2
$ grep -r TODO src
src/lib/util.py:TODO = 1
src/app.py:# TODO: fix paths
src/app.py: print(os.getcwd()) # TODO later
src/web.js:// TODO remove
$ grep -rn TODO src | sort
src/app.py:3:# TODO: fix paths
src/app.py:5: print(os.getcwd()) # TODO later
src/lib/util.py:2:TODO = 1
src/web.js:2:// TODO remove
The files come out in directory order, not sorted, so pipe to sort when the order matters. -r prints the file name even for a single file when the argument is a directory, and not when it is one file:
$ grep -rn TODO src/app.py
3:# TODO: fix paths
5: print(os.getcwd()) # TODO later
grep -r with no file name searches the current directory. This is a trap in a pipe: the standard input is ignored, and the whole directory is searched instead. The lab piped one line into grep -r and got matches from files:
$ printf 'x\n' | grep -r x
app.log:2026-10-17 02:00:05 INFO web GET /index.html 200 ip=10.0.3.5
dots.txt:axb
node_modules/pkg/index.js:module.exports = "TODO"
src/app.py:# TODO: fix paths
crlf.txt:x ERROR y
(and more lines)
$ printf 'x\n' | grep -r x -
x
Give - (a dash) to read standard input when -r is on, or drop the -r.
-r reads everything, including hidden files and folders (.env, .git) and the dependency folders:
$ grep -rl TODO . | sort
./.git/HEAD
./blob.bin
./node_modules/pkg/index.js
./src/app.py
./src/lib/util.py
./src/web.js
$ grep -r password src
src/.env:password=hunter2
The shell glob * does not include hidden files, so grep -r TODO * and grep -r TODO . do not search the same files. With *, the shell hands grep the names of the folders and the link, so the link is followed (it is named on the command line) and .git is never searched:
$ grep -rl TODO * | sort
blob.bin
linkdir/app.py
linkdir/lib/util.py
linkdir/web.js
node_modules/pkg/index.js
src/app.py
src/lib/util.py
src/web.js
Choose files with --include, --exclude and --exclude-dir. Quote the globs, so the shell does not expand them:
$ grep -rl --include='*.py' TODO . | sort
./src/app.py
./src/lib/util.py
$ grep -rl --exclude-dir=.git --exclude-dir=node_modules TODO . | sort
./blob.bin
./src/app.py
./src/lib/util.py
./src/web.js
$ grep -rl --exclude-dir={.git,node_modules} TODO . | sort
./blob.bin
./src/app.py
./src/lib/util.py
./src/web.js
$ grep -rl --include='*.js' --exclude='*.min.js' apiKey . | sort
./src/web.js
The braces in the second form are expanded by the shell into two options. --exclude-dir matches a name (a base name), not a path: lib excluded src/lib, and src/lib did nothing:
$ grep -rl TODO --exclude-dir=lib src | sort
src/app.py
src/web.js
$ grep -rl TODO --exclude-dir=src/lib src | sort
src/app.py
src/lib/util.py
src/web.js
$ grep -rl TODO --exclude-dir='*lib*' src | sort
src/app.py
src/web.js
When --include and --exclude are mixed, the last one that matches a file decides. A file that matches neither is read unless the first such option is an --include:
$ grep -rl --exclude='*.py' --include='*.py' TODO src | sort
src/app.py
src/lib/util.py
src/web.js
$ grep -rl --include='*.py' --exclude='app.py' TODO src | sort
src/lib/util.py
(In the first command web.js matched neither option and the first option was --exclude, so it was read. In the second, app.py matched the last option, the exclusion; web.js matched neither and the first option was --include, so it was not read.)
Counting and listing per file:
$ grep -rc TODO src | sort
src/.env:0
src/app.py:2
src/lib/util.py:1
src/web.js:1
src/web.min.js:0
$ grep -rL TODO src | sort
src/.env
src/web.min.js
$ grep -rh TODO src | sort
print(os.getcwd()) # TODO later
# TODO: fix paths
// TODO remove
TODO = 1
Symbolic links. -r follows a link that is named on the command line, and skips the links it meets while it walks. -R follows them all. In the lab, linkdir was a link to src:
$ grep -rl TODO linkdir
linkdir/lib/util.py
linkdir/app.py
linkdir/web.js
$ grep -rl TODO . | sort | grep -c linkdir
0
$ grep -Rl TODO . | sort
./.git/HEAD
./blob.bin
./linkdir/app.py
./linkdir/lib/util.py
./linkdir/web.js
./node_modules/pkg/index.js
./src/app.py
./src/lib/util.py
./src/web.js
-R is the one that can loop or read the same files twice; use -r unless you want the links followed.
grep never modifies files. But with a redirect, the output file is created before grep runs, and a recursive search would read it. GNU grep notices and stops:
$ grep -r ERROR . > out2.txt; echo rc=$?
grep: ./out2.txt: input file is also the output
rc=2
Send the output to another folder, or to a name that the search skips.
ripgrep, git grep and when to use them
For code, two other tools search a tree without having to type the exclusions each time.
git grep searches the files that git tracks. It follows .gitignore without being told, and it does not read untracked files unless you ask. The lab made a repository with a.txt committed, an ignored ign.log and an untracked c.txt, with the word TODO in each:
$ git grep -n TODO
a.txt:1:TODO a
$ git grep -n --untracked TODO
a.txt:1:TODO a
b.txt:1:TODO b
c.txt:1:TODO untracked
$ git grep -c TODO
a.txt:1
(b.txt existed but was not added, so --untracked found it, and the ignored ign.log was never searched.) Plain grep -r searches every file in the folder, including the ignored ones:
$ grep -rl TODO . --exclude-dir=.git | sort
./a.txt
./b.txt
./c.txt
./ign.log
ripgrep (rg) is recursive by default. It skips hidden files and folders, files that .gitignore names, and binary files, and it uses Rust regexes: Perl-like syntax without look-around or back-references, and fast. The same tree as before:
$ rg -l TODO | sort
node_modules/pkg/index.js
src/app.py
src/lib/util.py
src/web.js
$ rg --hidden -l TODO | sort
.git/HEAD
node_modules/pkg/index.js
src/app.py
src/lib/util.py
src/web.js
$ rg -e 'TODO' --type py -l | sort
src/app.py
src/lib/util.py
rg skipped blob.bin, src/.env (hidden: rg password found only sshd_config until --hidden was added) and linkdir with no options. It searched node_modules because there was no .gitignore naming it. -g '*.js' is its --include:
$ rg -n 'apiKey' -g '*.js'
src/web.js:1:const key = "apiKey123";
src/web.min.js:1:var a="apiKey999";
Its syntax is close to grep -P (\w, \d, lazy repeats), with differences: look-around and back-references need --pcre2, and an error message is long:
$ rg -o 'user=\w+' app.log | head -2
user=ann
user=ed
$ rg --pcre2 -o 'user=\K\w+' app.log | head -2
ann
ed
$ rg 'ERROR(?!.*timeout)' app.log | cut -c1-40
rg: regex parse error:
(?:ERROR(?!.*timeout))
^^^
error: look-around, including look-ahead and look-behind, is not supported
$ rg 'a.b' dots.txt
a.b
axb
$ rg -F 'a.b' dots.txt
a.b
In the lab rg gave the same line counts as grep for single files (-c, -i, -w, -F).
Which to use:
grep one file, a pipe, a log, a system file, or any machine: it is always there, and its exit status is the standard one for scripts
grep -r a directory of system files such as /etc, when you want everything, hidden files included
git grep inside a git repository, tracked files only, fast
rg a big source tree on a development machine, when you want the ignores and the speed
Scripts that must run anywhere use grep. rg is not installed on a default server.
Binary, compressed and odd files
When a file contains bytes that do not look like text (a NUL byte, or invalid text for the locale), GNU grep decides it is binary. If it matches, grep does not print the line; it prints a message, and the exit status is still 0:
$ grep TODO blob.bin; echo rc=$?
grep: blob.bin: binary file matches
rc=0
$ grep -c TODO blob.bin
1
$ grep -l TODO blob.bin
blob.bin
-c and -l work as usual. To see the line, treat the file as text with -a. To skip binary files, use -I:
$ grep -a TODO blob.bin | od -c | head -2
0000000 T O D O b i n a r y \0 001 002
0000020 d a t a \n
$ grep -I TODO blob.bin; echo rc=$?
rc=1
$ grep --binary-files=without-match TODO blob.bin; echo rc=$?
rc=1
-I is the habit for recursive searches in code: grep -rI. The message "binary file matches" also appears when a text file has one stray byte. A log with a NUL in it is binary. So is text that is not valid in the locale: the lab wrote caf followed by the single byte E9 (the Latin-1 é). In a UTF-8 locale that is invalid, so grep called the file binary. In the C locale every byte is valid and the line was printed. -a fixes it in either case (see the section on locales):
$ printf 'caf\xe9\n' | LC_ALL=C.UTF-8 grep caf; echo rc=$?
grep: (standard input): binary file matches
rc=0
$ printf 'caf\xe9\n' | LC_ALL=C grep caf | od -c | head -1
0000000 c a f 351 \n
The same line with -r found the binary file among the text ones:
$ grep -r TODO | sort
grep: blob.bin: binary file matches
.git/HEAD:ref: TODO
node_modules/pkg/index.js:module.exports = "TODO"
src/app.py: print(os.getcwd()) # TODO later
src/app.py:# TODO: fix paths
src/lib/util.py:TODO = 1
src/web.js:// TODO remove
Compressed files. grep does not read inside a .gz file; the lab got no match and status 1. zgrep (and zcat or gzip -dc into grep) does. zgrep takes the same options as grep, but not -r:
$ grep line old.log.gz; echo rc=$?
rc=1
$ zgrep line old.log.gz
line one
line two
$ zgrep -c line old.log.gz
2
$ zcat old.log.gz | grep two
line two
$ zgrep -r line .
/usr/bin/zgrep: -r: option not supported
There are also bzgrep and xzgrep for the other formats (not run in the lab).
Windows line ends (CRLF). The carriage return is part of the line, so a pattern anchored at the end, or -x, fails. The lab file had x ERROR y and ok, each ending in \r\n:
$ grep -c 'ERROR y$' crlf.txt
0
$ grep -c 'y.$' crlf.txt
1
$ grep -c $'y\r$' crlf.txt
1
$ grep -c -P 'y\r$' crlf.txt
1
$ tr -d '\r' < crlf.txt | grep -c 'y$'
1
$ grep -x 'ok' crlf.txt; echo rc=$?
rc=1
Strip the carriage return first with tr -d '\r' (or dos2unix), or write \r in the pattern ($'...' in bash, or -P). The same goes for a trailing space:
$ printf 'apple \napple\n' | grep -x 'apple' | od -c | head -2
0000000 a p p l e \n
0000006
(only the line without the space matched.)
Lines separated by NUL. -z makes grep treat NUL as the line end, so that a whole file with newlines in it is one "line". With -P and (?s) this finds a block that spans lines, and tr turns the NUL back into a newline:
$ cat blk.conf
before
# BEGIN managed
old1
old2
# END managed
after
$ grep -Pzo '(?s)# BEGIN managed.*?# END managed' blk.conf | tr '\0' '\n'
# BEGIN managed
old1
old2
# END managed
The last line of a file that has no trailing newline is still a line:
$ printf 'no newline at end' | grep -c ''
1
Case, Unicode and the locale
What grep treats as a character, a letter or an upper case letter depends on the locale (LANG and LC_ALL). In the lab container no LANG was set, so grep ran in the C locale, which is bytes: every byte is a character and only ASCII has a case. In a UTF-8 locale grep understands multi-byte characters. The lab text had café, naïve and CAFÉ, and each command ran with LC_ALL=C and with LC_ALL=C.UTF-8:
command LC_ALL=C LC_ALL=C.UTF-8
grep -i 'café' utf8.txt café au lait café au lait, CAFÉ
grep -o 'caf.' utf8.txt | od -c c a f 303 (half of the é) c a f 303 251 (the whole é)
grep -ow 'na.ve' utf8.txt (nothing) naïve
grep -c '[[:alpha:]]\{4\}' utf8.txt 1 3
grep -ioP 'caf\X' utf8.txt caf and a broken byte, twice café, CAFÉ
In the C locale the dot matched one byte of the two-byte é and left the other half behind, -i did not match É for é, and the letters with accents were not in [[:alpha:]]. In a UTF-8 locale all of that worked. So for text with accents or other non-ASCII letters, run grep in a UTF-8 locale (LANG=C.UTF-8 or en_US.UTF-8).
The other way round, the C locale is faster for plain ASCII data, which is why scripts on big logs set LC_ALL=C (see the speed section), and it matches the way sort and others work in the same script. If a file is not valid UTF-8, grep in a UTF-8 locale may call it binary and print "binary file matches"; -a or LC_ALL=C reads it as bytes.
-i with the options and the characters you expect. -i makes a pattern match both cases of a letter. It does not change what -o prints, which is the text as it is in the file:
$ grep -oi 'cherry\|APPLE' fruit.txt
apple
Cherry
apple
apple
$ grep -oiw 'APPLE' fruit.txt
apple
apple
Pipelines, buffering and processes
grep is the middle of many pipelines. A few forms are worth knowing.
Count, extract and rank:
$ grep -c ERROR app.log; grep ERROR app.log | wc -l; grep -o ERROR app.log | wc -l
2
2
2
$ grep -oE 'user=[a-z]+' app.log | cut -d= -f2 | sort | uniq -c
2 ann
2 ed
1 kim
Finding a process. ps lists the grep command itself, because its own command line contains the pattern. The lab looked for the sleep process that kept the container running:
$ ps aux | grep sleep | wc -l
2
$ ps aux | grep '[s]leep' | wc -l
1
$ ps aux | grep -c 'sleep infinit[y]'
1
The first count includes the grep. In [s]leep the pattern is a set of one letter: it matches the word sleep, but the grep command line (which contains [s]leep with the brackets) does not match it. pgrep and pidof do the same job without the trick.
Buffering. When grep writes into a pipe instead of a terminal it holds its output in a buffer, so the next program sees nothing until the buffer fills or the input ends. This matters for tail -f log | grep ... | something. The lab sent "a", waited two seconds, sent "b", and printed when each line arrived at the end of the pipe:
pipeline a arrived b arrived
grep '.' +2s +2s (buffered)
grep --line-buffered '.' +0s +2s
busybox grep '.' +2s +2s (buffered)
grep '.' | grep --line-buffered '.' +2s +2s (the first grep held it)
grep --line-buffered '.' | grep '.' +2s +2s (the second one held it)
Every grep that writes to a pipe needs --line-buffered, or the line waits in the first one that does not have it. The last grep in a chain that ends in a terminal prints at once.
Colour. --color=auto colours the match only when the output is a terminal, so a pipe gets plain text. --color=always forces escape codes into the output, which is for less -R and not for scripts:
$ grep --color=auto ERROR app.log | head -1 | cut -c1-40
2026-10-17 02:00:04 ERROR pay card decl
$ grep --color=always ERROR app.log | head -1 | cat -v
2026-10-17 02:00:04 ^[[01;31m^[[KERROR^[[m^[[K pay card declined user=kim ip=10.0.2.31
The colour of the match is set with GREP_COLORS (for example mt=01;34). The older GREP_COLOR variable still works in 3.11 and prints a warning that it is deprecated:
$ GREP_COLORS='mt=01;34' grep --color=always ERROR app.log | head -1 | cat -v | cut -c20-60
^[[01;34m^[[KERROR^[[m^[[K pay card dec
$ GREP_COLOR='1;32' grep --color=always ERROR app.log | head -1
grep: warning: GREP_COLOR='1;32' is deprecated; use GREP_COLORS='mt=1;32'
Many systems alias grep to grep --color=auto in the login shell. The lab container did not (type grep gave /usr/bin/grep), so the examples are plain.
Config files and text recipes
Remove comments and blank lines, to see the settings that are really on. The pattern is a line that is, after optional blanks, empty or a comment. A comment after a setting is not removed, because the line starts with the setting:
$ grep -vE '^\s*(#|$)' sshd_config
Port 22
PermitRootLogin yes
PasswordAuthentication yes # allow passwords
UsePAM yes
$ grep -v '^#' sshd_config
Port 22
PermitRootLogin yes
PasswordAuthentication yes # allow passwords
# indented comment
UsePAM yes
The simple form, grep -v '^#', leaves the blank line and the indented comment.
Is this exact line in the file? -x for the whole line, -F so that the text is not a regex, -q for the exit status. This is the base of a script that adds a line only if it is missing:
$ grep -qxF 'PermitRootLogin yes' sshd_config && echo present
present
$ grep -qxF 'MaxAuthTries 3' sshd_config || echo missing
missing
$ grep -qF 'UsePAM' sshd_config; echo rc=$?
rc=0
The usual form is: grep -qxF 'line' file || echo 'line' >> file. A second run finds the line and adds nothing.
Show the match and its place:
$ grep -n 'managed' blk.conf
2:# BEGIN managed
5:# END managed
Extract a value. -o prints the match, and with -P the \K trick leaves the label out:
$ grep -oP 'user=\K[a-z]+' app.log | sort | uniq -c
2 ann
2 ed
1 kim
$ grep -oE '\w+' fruit.txt | sort | uniq -c | sort -rn | head -3
2 apple
1 wine
1 tart
$ grep -ow '\w*apple\w*' fruit.txt
apple
apple
pineapple
IP addresses. The simple pattern finds anything that looks like four numbers; it does not check the range. The longer one does:
$ echo 'ip 999.999.999.999 and 10.0.0.1' | grep -oE '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b'
999.999.999.999
10.0.0.1
$ echo 'ip 999.999.999.999 and 10.0.0.1' | grep -oE '\b((25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])\b'
10.0.0.1
Both lines on one line, in either order, or not at all:
$ grep ERROR app.log | grep -v timeout | cut -c1-50
2026-10-17 02:00:04 ERROR pay card declined user=
$ grep -v -e INFO -e WARN app.log | cut -c1-50
2026-10-17 02:00:04 ERROR pay card declined user=
2026-10-17 02:00:06 ERROR pay upstream timeout us
A list of things to look for, from a file. -f reads one pattern per line; -F -f makes them plain strings; -w -f whole words; -v -f drops them:
$ grep -f pats.txt app.log
2026-10-17 02:00:03 WARN api slow request 910ms user=ann ip=10.0.1.11
2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
2026-10-17 02:00:06 ERROR pay upstream timeout user=ed ip=10.0.1.12
$ grep -v -f pats.txt app.log | wc -l
4
An empty pattern file matches nothing, so -f /dev/null prints no line (and -v with it prints every line):
$ grep -c -F -f /dev/null app.log; echo rc=$?
rc=1
$ grep -v -F -f /dev/null app.log | wc -l
7
Beware a blank line in the pattern file: it matches every line (shown in the section on patterns). Check the file with grep -c '^$' patterns.txt first, or clean it with grep -v '^$'.
Using grep safely in shell scripts
Most trouble comes from three things: the exit status, the text that becomes the pattern, and the file names. The lab ran each of them.
1. Exit status 1 is not a failure. A script that stops on any non-zero status stops when grep finds nothing. Test with if, or add || true when no match is acceptable:
$ bash -c 'set -e; grep NOPE app.log; echo not-reached'; echo rc=$?
rc=1
$ bash -c 'set -e; grep NOPE app.log || true; echo reached'
reached
$ bash -c 'set -e; if grep -q NOPE app.log; then echo y; else echo no-match-is-fine; fi'
no-match-is-fine
grep -c prints 0 and exits with status 1 when nothing matches, so $(grep -c ...) gives the right number and a wrong status:
$ bash -c 'n=$(grep -c NOPE app.log); echo "count=$n rc=$?"'
count=0 rc=1
2. Status 2 is an error, and it can hide in a test. if grep -q ... treats an error like "no". Test for the cases you care about, for example with the file's existence first, or compare to 1:
grep -q pattern file; rc=$?
[ $rc -eq 0 ] && echo found
[ $rc -eq 1 ] && echo "not found"
[ $rc -gt 1 ] && echo "grep failed"
-q stops at the first match, and a missing file does not change the answer when another file matches (the status is 0, though the message is printed):
$ grep -q ERROR nofile.log app.log; echo rc=$?
grep: nofile.log: No such file or directory
rc=0
$ grep -qs ERROR nofile.log app.log; echo rc=$?
rc=0
-s hides the message but not the status: a missing file alone still gave 2:
$ grep -s x /etc/shadow; echo rc=$?
rc=2
3. grep -q in a pipeline with set -o pipefail. grep -q exits at the first match, and the program that writes into the pipe then gets a broken pipe signal. With pipefail the pipeline's status is that of the failed writer (141), even though the grep found its match:
$ bash -c 'set -o pipefail; yes | grep -q y; echo rc=$?'
rc=141
$ bash -c 'yes | grep -q y; echo rc=${PIPESTATUS[*]}'
rc=141 0
Use grep -c ... > /dev/null (it reads everything, so there is no broken pipe), or put the pipeline in a variable and test the variable, or do not use pipefail around grep -q.
4. A variable becomes a pattern. Everything that applies to a regex applies to the value: a dot matches anything, a * is a repeat, a leading dash is an option:
$ v='a.b'; grep "$v" dots.txt
a.b
axb
$ v='a.b'; grep -F -- "$v" dots.txt
a.b
$ v='.*'; grep -c "$v" app.log
7
$ v='.*'; grep -cF -- "$v" app.log
0
$ v='-v'; printf 'a\n-v\n' | grep "$v"
Usage: grep [OPTION]... PATTERNS [FILE]...
Try 'grep --help' for more information.
$ v='-v'; printf 'a\n-v\n' | grep -e "$v"
-v
$ v='-v'; printf 'a\n-v\n' | grep -F -e "$v"
-v
For a value that is text, use -F, and -e or -- so that a dash at the start is not an option. -F -x -e "$v" for the whole line, -F -w -e "$v" for a word. For a value that must be a pattern, validate it first.
An empty or unset variable is the empty pattern, which matches every line, and a variable with a space, unquoted, becomes two arguments (the second is read as a file):
$ v=''; grep -c "$v" fruit.txt
7
$ unset v; grep -c "$v" fruit.txt
7
$ v='a b'; grep -c $v fruit.txt; echo rc=$?
grep: b: No such file or directory
fruit.txt:6
rc=2
$ v='a b'; grep -c "$v" fruit.txt
0
Quote the variable, and check that it is not empty.
5. File names with spaces. grep -l prints one name per line, and xargs splits on spaces. Use a NUL separator, which a file name cannot contain (-Z and xargs -0), or find with -print0 or -exec:
$ grep -l ERROR *.log | xargs grep -c ERROR; echo rc=$?
app.log:2
grep: my: No such file or directory
grep: file.log: No such file or directory
rc=123
$ grep -lZ ERROR *.log | xargs -0 grep -c ERROR
app.log:2
my file.log:1
$ find . -maxdepth 1 -name '*.log' -exec grep -H ERROR {} +
./app.log:2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
./app.log:2026-10-17 02:00:06 ERROR pay upstream timeout user=ed ip=10.0.1.12
./my file.log:ERROR in space file
(the file in the lab was called my file.log.) A file name that starts with a dash is read as an option: write ./name or put -- before the names.
6. Searching with a redirect in the same folder. grep ERROR *.log > out.txt is safe when out.txt does not end in .log. If the output is one of the files grep reads, GNU grep stops (see the section on recursive search), and the shell has already emptied the file.
7. Patterns that backtrack. -P can fail with status 2 on a harmless-looking pattern (shown in the section on -P). In a script, a status above 1 is an error, never a "no".
8. Permission and device files. Without permission, grep says so and exits with 2 (hide the message with -s):
$ grep x /etc/shadow; echo rc=$?
grep: /etc/shadow: Permission denied
rc=2
A recursive search of /proc, /sys or a device may read forever or return noise. Search a known folder, and use -D skip to skip devices and sockets.
Portability: GNU grep, BusyBox, ripgrep and the BSDs
A grep line from the web may not run on the machine in front of you. The lab ran the same lines on GNU grep 3.11 and on BusyBox grep 1.36.1 (the grep in Alpine and many containers):
feature GNU grep 3.11 BusyBox grep 1.36.1
-E -F -i -v -w -x -c -l -L -o -n yes yes
-m N, -A -B -C, -f, -e, -q -s yes yes
-r, -R yes yes
--include, --exclude-dir yes no ("unrecognized option")
-b (byte offset) yes no
-T (initial tab) yes no
-P (Perl regex) yes no ("invalid option -- 'P'")
-z (NUL lines) yes yes
-I (skip binary files) yes accepted, but binary files were still printed
back-reference \1 yes yes
\d in -E no no
--version yes no ("unrecognized option")
$ busybox grep -E '\d+ms' app.log | wc -l
0
$ busybox grep -E '[0-9]+ms' app.log | wc -l
1
$ busybox grep -r --include='*.py' -l TODO src
grep: unrecognized option '--include=*.py'
$ busybox grep -P x app.log
grep: invalid option -- 'P'
$ busybox grep -I TODO blob.bin; echo rc=$?
TODO binary data
rc=0
$ printf 'aa\n' | busybox grep -E '(a)\1'
aa
$ busybox grep -rl TODO src | sort
src/app.py
src/lib/util.py
src/web.js
A script that has to run on Alpine should stick to the first rows: -E, -F, -i, -v, -w, -x, -c, -l, -o, -n, -q, -s, -r. Without --include, pipe a find into it: find src -name '*.py' -exec grep -l TODO {} +. BusyBox also searched the .git folder with -r because it has no exclusion option. To use a GNU feature on Alpine, install the real grep: apk add grep.
The BSD grep (macOS, FreeBSD) is not run in the lab. Differences that are commonly reported, to check when a script moves there: -P is missing, -r treats symbolic links differently, and \w and \s may be missing in basic patterns. Test on the target, or write the script with the POSIX subset (-E, -F, -i, -v, -c, -l, -q, -n, character classes).
Which grep am I running? grep --version works on GNU grep. BusyBox has no --version: busybox | head -1 prints its version. Many shells alias grep (often to grep --color=auto): type grep shows it, and \grep or command grep skips the alias.
Short, portable pattern rules: use -E for alternatives and counts, -F for plain text, [0-9] instead of \d, [[:space:]] instead of \s, [[:alnum:]_] instead of \w, and quote every pattern with single quotes.
Errors and debugging
grep's messages are short. The ones you will see, with the exit status the lab gave:
$ grep '[' app.log; echo rc=$?
grep: Invalid regular expression
rc=2
$ grep -E '(' app.log; echo rc=$?
grep: Unmatched ( or \(
rc=2
$ grep -E 'a{2,1}' app.log; echo rc=$?
grep: Invalid content of \{\}
rc=2
$ grep -P '(' app.log; echo rc=$?
grep: missing closing parenthesis
rc=2
$ grep NOPE nofile.log; echo rc=$?
grep: nofile.log: No such file or directory
rc=2
$ grep TODO src; echo rc=$?
grep: src: Is a directory
rc=2
$ grep x /etc/shadow; echo rc=$?
grep: /etc/shadow: Permission denied
rc=2
$ grep -r ERROR . > out2.txt; echo rc=$?
grep: ./out2.txt: input file is also the output
rc=2
$ grep -E '*ERROR' app.log | wc -l
grep: warning: * at start of expression
2
$ grep TODO blob.bin; echo rc=$?
grep: blob.bin: binary file matches
rc=0
$ grep -P '^(a+)+$' bt.txt; echo rc=$?
grep: bt.txt: exceeded PCRE's backtracking limit
rc=2
The last line of the list is the one that is not an error: "binary file matches" is exit status 0.
A pattern with no match prints nothing and gives 1. When a search should match and does not, narrow it down:
1. Print the lines that are near: grep -n 'ERR' file, then lengthen the pattern.
2. Check for case: add -i.
3. Check for the invisible: grep -c $'\r' file (CRLF), grep -n ' $' file (trailing space), cat -A file (shows tabs as ^I and line ends as $).
4. Check for the locale: LC_ALL=C grep ... and LC_ALL=C.UTF-8 grep ... on accented text.
5. Check that the pattern reached grep as you wrote it: set -x in a script, or echo the command. A quote that was missing is the commonest cause.
6. Use -F to find out if the text is there at all, ignoring regex rules.
7. Test the regex alone: echo 'sample line' | grep -E 'pattern'.
8. For -r, check what was skipped: it skips nothing, but rg, git grep and --include / --exclude-dir do. List the candidate files first: find . -name '*.py' | head.
$ cat -A crlf.txt
x ERROR y^M$
ok^M$
(the ^M is the carriage return, and $ marks the line end.)
The warning for a pattern that starts with *, and the stray backslash (\d), have no effect on the exit status. In a basic pattern \d is the letter d, and it did not warn in 3.11:
$ grep -c '\d' app.log
4
Speed and big files
grep reads a file at close to the speed of the disk, and its memory use does not depend on the file size. The lab used a log of 1,000,000 lines (63 MB) in the page cache, and timed several jobs. The times are indicative.
job time
grep -c ERROR 0.04 s
grep -c -F ERROR 0.04 s
grep -c -i error 0.06 s
grep -cE 'ERROR|WARN' 0.05 s
grep -c -v INFO 0.06 s
grep -cw ERROR 0.06 s
grep -c 'ip=10\.0\.[0-9]*\.1[0-9]* ' 0.11 s
grep -cP 'ms=\d{3}$' 0.10 s
grep -o 'user=[a-z]*' (prints 1,000,000 matches) 0.44 s
grep -m1 ERROR / grep -q ERROR 0.01 s
grep -c zzzz (no match: the whole file) 0.01 s
For comparison in the same run: rg -c ERROR took 0.04 s, mawk '/ERROR/' 0.06 s, sed -n '/ERROR/p' 0.18 s and BusyBox grep -c ERROR 1.15 s. A plain search on one file is so fast that grep is rarely the slow part of a script. Printing the matches costs more than finding them.
-m1 and -q stop at the first match, so they are the fastest for "is it there" tests on a big file.
A long list of patterns is still fast. The lab used 2,000 patterns of the form user=xxxxx in a file, against the same log:
$ grep -c -F -f pats2000.txt big.log 0.09 s
$ grep -c -f pats2000.txt big.log 0.10 s
$ grep -c -w -F -f pats2000.txt big.log 0.10 s
$ rg -c -F -f pats2000.txt big.log 0.07 s
The locale. For ASCII data LC_ALL=C is faster when the pattern has classes or -i, because the matcher then works on bytes:
command LC_ALL=C LC_ALL=C.UTF-8
grep -c -i error 0.06 s 0.07 s
grep -c '[[:alpha:]]*ERROR' 0.06 s 0.15 s
grep -cE '(ERROR|WARN).*user=(ed|ann)' 0.14 s 0.11 s
A multi-byte locale slowed the class pattern by more than twice and made no difference to the others, so the gain is modest and shows up only on patterns with classes. Do not set LC_ALL=C for text that has accents, where it changes the result (see the section on locales).
Why it is fast: grep compiles the pattern to a state machine, looks for a required fixed string with a fast search first, and uses the buffer directly. Things that slow it down are -P patterns with nested repeats (backtracking), back-references (they cannot use the fast matcher), and printing.
Tips:
1. Use -F when the pattern is plain text, and -m1 or -q when one match is enough.
2. Put the most selective grep first in a pipeline, and let it cut the data before the next stage.
3. Avoid grep ... | grep ... | grep ... when one -E (or -P) can do it, or when an awk program can.
4. For a directory of files, use --include and --exclude-dir, or rg or git grep, so that you do not read what you do not need (node_modules, .git, build output).
5. Do not run grep -r on / or /proc. Search a known folder.
6. For many files at once, find ... -print0 | xargs -0 -P4 grep ... runs four greps in parallel (not run in the lab).
When not to use grep
grep finds lines. It is the wrong tool when you need to understand structure, to change text, or to search for something that spans lines. The lab showed the failure each time:
Changing text. grep never edits. Use sed -i for substitutions, or awk (see their guides).
Columns and totals. grep finds a line; awk reads the fields and adds them up.
Multi-line patterns. grep works on one line. A block between two markers needs -z with -P (shown above), or sed -n '/start/,/end/p', or awk.
JSON and YAML. A line is not a value, and the same text can be a key, a string or a comment. Use jq and yq (guides here).
HTML, XML and CSV with quotes. Use a parser or a CSV tool.
Finding a file by name or by size or date. Use find, locate, or fd. grep reads contents, not names (ls | grep is for a quick look, not a script).
Source code with scoping, such as "every call of this function". grep matches text; use your editor's search, ctags, or a language tool for exact answers.
Validating an IP address, an e-mail or a date. A pattern can reject most bad ones (the 999.999.999.999 example above) but not all, and it gets long. Use a library or the program's own check.
For other jobs grep is shorter than anything else:
want use
lines that contain a word grep word file
count of the lines grep -c word file
the part of a line that matches grep -o
lines that do not contain a word grep -v word file
which files contain a word grep -rl word dir
is a setting in the file grep -q (and test the status)
a window of lines around an error grep -C
processes by name pgrep name (or ps | grep '[n]ame')
The sed, awk and grep guides on this site cover the three: grep to find, sed to change, awk to calculate.
One-liners
All of these ran in the lab on GNU grep 3.11. They use the sample files from the start of the guide.
# Lines that contain a word, with the line number, ignoring case
$ grep -ni 'cherry' fruit.txt
3:Cherry tart
# How many lines, and which lines are not INFO
$ grep -c INFO app.log
4
$ grep -vc INFO app.log
3
# Lines that start with a pattern, and lines that end with one
$ grep '^2026-10-17 02:00:0[1-3]' app.log | wc -l
3
$ grep 'ip=10\.0\.1\.1[12]$' app.log | wc -l
4
# Several patterns, from a file
$ grep -f pats.txt app.log | wc -l
3
# Whole word, whole line
$ grep -w 'api' app.log | wc -l
3
$ grep -x 'pineapple' fruit.txt
pineapple
# Every user in the log, once, with a count
$ grep -oP 'user=\K\w+' app.log | sort | uniq -c | sort -rn
2 ed
2 ann
1 kim
# The IP addresses that appear, once
$ grep -oE '[0-9]{1,3}(\.[0-9]{1,3}){3}' app.log | sort -u
10.0.1.11
10.0.1.12
10.0.2.31
10.0.3.5
10.0.3.6
# The slow request time as a number
$ grep -oP '\d+(?=ms)' app.log
910
# Lines with ERROR and the line before each
$ grep -B1 ERROR app.log | head -4
2026-10-17 02:00:03 WARN api slow request 910ms user=ann ip=10.0.1.11
2026-10-17 02:00:04 ERROR pay card declined user=kim ip=10.0.2.31
--
2026-10-17 02:00:05 INFO web GET /index.html 200 ip=10.0.3.5
# Settings that are on (no comments, no blank lines)
$ grep -vE '^\s*(#|$)' sshd_config
Port 22
PermitRootLogin yes
PasswordAuthentication yes # allow passwords
UsePAM yes
# Is a setting present? Exit status for a script
$ grep -q '^PermitRootLogin yes' sshd_config && echo "root login is on"
root login is on
# Add a line only when it is missing
$ grep -qxF 'MaxAuthTries 3' sshd_config || echo 'MaxAuthTries 3' >> sshd_config
$ tail -1 sshd_config
MaxAuthTries 3
# Find where a name is used in source files
$ grep -rn --include='*.py' 'TODO' src | sort
src/app.py:3:# TODO: fix paths
src/app.py:5: print(os.getcwd()) # TODO later
src/lib/util.py:2:TODO = 1
# Files that mention a word, and files that do not
$ grep -rl 'apiKey' src | sort
src/web.js
src/web.min.js
$ grep -rL 'TODO' src | sort
src/.env
src/web.min.js
# Search the code but skip the folders you do not want
$ grep -rl --exclude-dir={.git,node_modules} TODO . | sort
./blob.bin
./src/app.py
./src/lib/util.py
./src/web.js
# Skip binary files in a recursive search
$ grep -rI TODO . | sort | head -3
./.git/HEAD:ref: TODO
./node_modules/pkg/index.js:module.exports = "TODO"
./src/app.py: print(os.getcwd()) # TODO later
# A process, without the grep itself
$ ps aux | grep '[s]leep' | wc -l
1
# Search compressed logs
$ zgrep -c line old.log.gz
2
# Two words on one line, in any order
$ grep ERROR app.log | grep user=ed | cut -c1-50
2026-10-17 02:00:06 ERROR pay upstream timeout us
# Highlight in a pager (colour kept)
$ grep --color=always ERROR app.log | less -R
# Count the matches in each file, hide the zeros
$ grep -rc TODO src | grep -v ':0$' | sort
src/app.py:2
src/lib/util.py:1
src/web.js:1
# Files that contain a word, safely passed on
$ grep -rlZ TODO src | xargs -0 grep -c TODO | sort
src/app.py:2
src/lib/util.py:1
src/web.js:1
Cheat sheet
Run grep PATTERN file grep -E 'a|b' file cmd | grep PATTERN grep -r PATTERN dir grep -F 'text' file
Show -n line numbers -H -h file names on/off -o only the match -b byte offset -c count -l -L files --color=auto
Select -i ignore case -v invert -w whole word -x whole line -m N stop after N -q quiet (exit status only) -s hide file errors
Context -A N after -B N before -C N around --no-group-separator (with -n: ':' match, '-' context)
Patterns -e P1 -e P2 -f file (blank line = match all) -F fixed -E extended -P Perl -G basic (default)
Basic regex (BRE) . * ^ $ [abc] [^abc] \( \) \{n,m\} \+ \? \| \1 \b \< \> \w \s [[:digit:]] [[:space:]]
Extended (-E) . * ^ $ [abc] ( ) {n,m} + ? | \1 \b \< \> \w \s [[:alpha:]] (a literal + is \+)
Perl (-P) \d \D \w \s \K (?<=x) (?<!x) (?=x) (?!x) .*? (?i) (?s) \X
Recursive -r (links named on the line) -R (all links) --include='*.py' --exclude='*.min.js' --exclude-dir=NAME -I skip binary -a as text
Compressed zgrep PATTERN file.gz zcat file.gz | grep PATTERN (no -r in zgrep)
Binary / lines -a -I -z (NUL separated) -Z (NUL after names, for xargs -0)
Pipes --line-buffered when grep is not the last stage ps | grep '[n]ame' -r with no file ignores stdin: use -
Scripts 0 match 1 no match 2 error -q + || true under set -e pipefail + -q = 141 -F -e "$v" for a variable
Exit status test if grep -q P f; then ... grep -q P f; case $? in 0) ...;; 1) ...;; *) echo error;; esac
Names with spaces grep -lZ P *.log | xargs -0 ... find . -name '*.log' -exec grep -H P {} + ./-name for dashes
Other tools rg (recursive, ignores, fast) git grep (tracked files) pgrep (processes) zgrep sed -n '/a/,/b/p' (ranges) awk (columns)
Common problems
nothing matches with | in the pattern basic patterns treat | as a plain character: use grep -E 'a|b', or 'a\|b'
\d matches the letter d, or nothing there is no \d in basic or extended patterns: use [0-9], or grep -P
a dot or star matches too much they are regex characters: escape them, use [.], or grep -F
grep prints the whole file an empty pattern: an empty variable, or a blank line in the -f file
the variable with a dash is taken as an option grep -e "$v" or grep -F -- "$v"
the variable with a space makes "No such file" quote it: "$v"
grep: binary file matches, and no lines the file has a NUL or bad bytes: use -a to see the lines, -I to skip them
grep -r finds a lot of junk it reads .git, node_modules and build output: use --exclude-dir, rg or git grep
grep -r skips my --include files quote the glob ('*.py'), and remember a file that matches neither option follows the first option
--exclude-dir=src/lib does nothing it matches a name, not a path: --exclude-dir=lib
grep -r ignores the pipe with no file, -r searches the current directory: add - for stdin, or drop -r
grep -r TODO * misses hidden files the shell glob skips them: use . instead of *
a file that is a link is not searched -r skips links met inside a tree: -R follows them, or name the link on the command line
the end of line ($) does not match, or -x fails CRLF line ends or a trailing space: tr -d '\r' first, or use $'\r$'
-i does not match É for é the C locale: set LC_ALL=C.UTF-8 (or LANG) for non-ASCII text
the match is cut inside an accented letter the C locale counts bytes: use a UTF-8 locale
the count is lower than the number of matches -c counts lines: use grep -o ... | wc -l for matches
ps | grep name shows grep itself the grep command line matches: use '[n]ame', or pgrep
the next program in the pipe sees nothing for a while grep buffers into a pipe: --line-buffered on every grep that is not the last
grep: input file is also the output the redirect target is in the search: write the output elsewhere
script stops when grep finds nothing (set -e) status 1 is not an error: use if grep -q ..., or || true
a count prints 0 and the script sees a failure grep -c exits 1 on a count of 0: || true, or ignore the status
the pipeline returns 141 with grep -q broken pipe from the writer under pipefail: do not use -q there, or use -c > /dev/null
grep -P: exceeded PCRE's backtracking limit (status 2) nested repeats on a long line: simplify the pattern, or use -E or -F
the xargs list breaks on names with spaces use grep -lZ with xargs -0, or find -print0 / -exec
works on Ubuntu, fails on Alpine or a Mac BusyBox has no -P, --include, -b, -T; BSD grep differs: check the portability section
GREP_OPTIONS has no effect it is ignored in grep 3.x: use an alias or a wrapper script
zgrep: -r: option not supported zgrep has no recursion: use find ... -exec zgrep, or zcat | grep
color codes in a file or a pipe --color=always was used: use --color=auto, or remove it
Tips
Quote the pattern with single quotes, always. Without quotes the shell may change it before grep runs.
Use -F for plain text (an IP address, a file name, a version number, anything with dots or brackets), and -E when you need alternatives or counts.
Use -w or -x when a short word would match inside longer ones.
Use -o with -P and \K to pull a value out of a line, and sort | uniq -c | sort -rn to count them.
Use -n while you look and drop it when you copy the output into another command.
Use -C 2 or -A 5 for logs: the line before and after an error tells the story.
Use -rI --exclude-dir={.git,node_modules} for code, or rg or git grep, so that you do not read junk.
Use -l when you only want file names, and -Z with xargs -0 when the names may have spaces.
In a script: test with if grep -q, expect status 1 for "not found", treat status 2 as an error, and never feed an unquoted variable to grep. Use -F -e "$v" for text.
Add || true (or use if) when no match is fine under set -e, and keep grep -q out of a pipefail pipeline.
Give grep - as the file when you use -r with a pipe.
Set LANG=C.UTF-8 (or your language) for accented text, and LC_ALL=C when a big ASCII file is slow.
Use [0-9], [[:space:]] and [[:alnum:]_] for portable patterns; \d needs -P and \s \w need GNU grep.
Use ps aux | grep '[n]ame' or pgrep to find a process, and never count the grep line.
Use --line-buffered with tail -f when the next stage must see lines at once.
Check CRLF files with cat -A, and a trailing space the same way, when a match that should work does not.
When the job is changing text, reach for sed; for columns, awk; for JSON and YAML, jq and yq. grep finds, and it is very good at it.