grep: search inside files

grep (from the old editor command g/re/p: globally search for a regular expression and print) reads files or standard input line by line and prints every line that matches a pattern. It is the first tool you reach for in logs and configuration files. The general form is grep [options] 'pattern' file…; without a file it reads standard input, so it also works at the end of a pipe.

The examples on this page search this small application log:

cat <<'EOF' > app.log
2026-10-01 08:00:01 INFO  Server started on port 8080
2026-10-01 08:02:17 WARN  Disk usage at 81%
2026-10-01 08:05:42 ERROR Database connection refused
2026-10-01 08:05:43 INFO  Retrying database connection
2026-10-01 08:05:48 error Database timeout after 5s
2026-10-01 08:09:10 INFO  User ana logged in
2026-10-01 08:12:55 ERROR Payment API returned 503
EOF
grep ERROR app.log
echo "--- -i ignores case:"
grep -i error app.log
echo "--- -c counts matching lines:"
grep -ci error app.log
2026-10-01 08:05:42 ERROR Database connection refused
2026-10-01 08:12:55 ERROR Payment API returned 503
--- -i ignores case:
2026-10-01 08:05:42 ERROR Database connection refused
2026-10-01 08:05:48 error Database timeout after 5s
2026-10-01 08:12:55 ERROR Payment API returned 503
--- -c counts matching lines:
3

grep is case-sensitive by default, which is why the lowercase error line only appears with -i. Short options can be combined: -ci is -c -i.

The options you will actually use

Option Meaning Example
-i Ignore upper/lower case grep -i error app.log
-v Invert: print lines that do not match grep -v '^#' sshd_config
-n Prefix each line with its line number grep -n Port sshd_config
-c Print only the number of matching lines grep -c 'Failed password' auth.log
-r Search all files under a directory, recursively (-R also follows symbolic links) grep -r 'listen' /etc/nginx/
-l Print only the names of files that contain a match grep -rl TODO src/
-w Match whole words only grep -w ana app.log (not "banana")
-x Match whole lines only grep -x 'yes' answers.txt
-o Print only the matching part, one per line grep -oE '[0-9]+%' app.log
-E Extended regular expressions (formerly egrep) grep -E 'ERROR|WARN' app.log
-F Fixed string: no regular expression at all (formerly fgrep) grep -F '3.50' prices.txt
-A n, -B n, -C n Also show n lines after, before, or around each match grep -B2 -A5 panic kern.log
cat <<'EOF' > app.log
2026-10-01 08:00:01 INFO  Server started on port 8080
2026-10-01 08:02:17 WARN  Disk usage at 81%
2026-10-01 08:05:42 ERROR Database connection refused
2026-10-01 08:05:43 INFO  Retrying database connection
2026-10-01 08:05:48 error Database timeout after 5s
2026-10-01 08:09:10 INFO  User ana logged in
2026-10-01 08:12:55 ERROR Payment API returned 503
EOF
echo "--- -v INFO: everything that is not INFO"
grep -v INFO app.log
echo "--- -n: with line numbers"
grep -n WARN app.log
echo "--- -A1: one line of context after"
grep -A1 'connection refused' app.log
echo "--- -o: only the matching part"
grep -oE '[0-9]+%' app.log
--- -v INFO: everything that is not INFO
2026-10-01 08:02:17 WARN  Disk usage at 81%
2026-10-01 08:05:42 ERROR Database connection refused
2026-10-01 08:05:48 error Database timeout after 5s
2026-10-01 08:12:55 ERROR Payment API returned 503
--- -n: with line numbers
2:2026-10-01 08:02:17 WARN  Disk usage at 81%
--- -A1: one line of context after
2026-10-01 08:05:42 ERROR Database connection refused
2026-10-01 08:05:43 INFO  Retrying database connection
--- -o: only the matching part
81%

Context options are how you read an error in a log: the line before often says what the program was doing, the lines after show what it did about it. When several matches have context, grep separates the groups with a -- line.

A real-world pair: grep -v removes comment and empty lines from a configuration file so only the active settings remain (illustrative):

$ grep -Ev '^(#|$)' /etc/ssh/sshd_config
Include /etc/ssh/sshd_config.d/*.conf
KbdInteractiveAuthentication no
UsePAM yes
X11Forwarding yes
PrintMotd no
AcceptEnv LANG LC_* COLORTERM NO_COLOR
Subsystem	sftp	/usr/lib/openssh/sftp-server

On RHEL, sshd_config has mode 600 (readable by root only), so there you need sudo grep.

Searching a whole directory

-r searches every file under a directory and prefixes each match with the file name; -l prints only the names. The order in which files are visited depends on the file system, so the examples sort the result. They ran with LC_ALL=C.UTF-8, which sorts in byte order; in English locales sort ignores dots, dashes and case at first, so your order may differ.

mkdir -p project/src project/docs
printf 'print("hi")\n# TODO: add tests\n' > project/src/main.py
printf '# Readme\nTODO: write install notes\n' > project/docs/README.md
printf 'version = 2\n' > project/src/config.toml
grep -rn TODO project | sort
echo "---"
grep -rl TODO project | sort
project/docs/README.md:2:TODO: write install notes
project/src/main.py:2:# TODO: add tests
---
project/docs/README.md
project/src/main.py

grep -r on /etc or /var/log as an ordinary user prints "Permission denied" for files you may not read; add 2>/dev/null or, better, -s (suppress messages about unreadable files) to see only the results.

Fixed strings with -F

grep interprets its pattern as a regular expression, where some characters have a special meaning. A dot matches any character, so searching for a price or an Internet Protocol (IP) address matches more than you meant. -F switches all special meanings off:

printf '%s\n' 'price 3.50' 'version 3x50' > items.txt
echo "--- as a regular expression:"
grep '3.50' items.txt
echo "--- as a fixed string:"
grep -F '3.50' items.txt
--- as a regular expression:
price 3.50
version 3x50
--- as a fixed string:
price 3.50

-F is also faster on huge files, and it is the safe choice when the search text comes from a variable you did not type yourself.

Regular expressions

A regular expression (regex) is a pattern that describes a set of strings, built from ordinary characters that match themselves and metacharacters with a special meaning, such as ., *, ^ and [ ]. grep, sed, awk, find's -regex, text editors and almost every programming language use them. Regular expressions are not the same as the shell's wildcards: in the shell *.log means "any file name ending in .log", in a regex * means "the previous item, repeated".

Items marked ERE in the table work as shown only in extended regular expressions (ERE, grep -E); grep's default, basic regular expressions (BRE), needs a backslash in front of them, as explained below.

Pattern Matches Example
abc The literal text "abc", anywhere in the line error matches "an error occurred"
. Any single character c.t: cat, cut, c9t
* The previous item zero or more times ab*c: ac, abc, abbbc
+ The previous item one or more times (ERE) [0-9]+: 7, 42, 2026
? The previous item zero or one time (ERE) https?: http, https
{n,m} The previous item n to m times (ERE; {n} exactly n) [0-9]{1,3}: 1 to 3 digits
^ / $ Start / end of the line ^#: comment lines
[abc], [0-9] One character from the set or range web[0-9]: web1, web7
[^0-9] One character not in the set id=[^0-9]: id=x
(a|b) Group, and alternation: a or b (ERE) (web|db)01
\. A literal dot (the backslash removes the special meaning) 192\.0\.2\.10

The repetition operators *, +, ? and {n,m} apply to the single item directly before them: one character, one bracket expression or one group in parentheses. .* is therefore "any characters, any number of times", the regex way of saying "anything".

printf '%s\n' cat cart coat ct > words.txt
echo "--- c.t (exactly one character between c and t):"
grep 'c.t' words.txt
echo "--- c.*t (anything between c and t):"
grep 'c.*t' words.txt
echo "--- https? with -o:"
echo "http://example.com and https://example.com" | grep -oE 'https?'
--- c.t (exactly one character between c and t):
cat
--- c.*t (anything between c and t):
cat
cart
coat
ct
--- https? with -o:
http
https

Anchors

An anchor is a regular expression element that matches a position instead of a character: ^ matches the start of a line and $ the end. Without anchors a pattern matches anywhere in the line, which is often more than you want: web also matches "webcam". The examples use this host list:

cat <<'EOF' > hosts.txt
web01 192.0.2.10
web02 192.0.2.11
db01 198.51.100.5
mail 203.0.113.25
# backup01 192.0.2.99
webcam 192.0.2.200
EOF
echo "--- ^web:"
grep '^web' hosts.txt
echo "--- ^web[0-9] (web followed by a digit):"
grep '^web[0-9]' hosts.txt
echo "--- not a comment, and ending in 5:"
grep -v '^#' hosts.txt | grep '5$'
--- ^web:
web01 192.0.2.10
web02 192.0.2.11
webcam 192.0.2.200
--- ^web[0-9] (web followed by a digit):
web01 192.0.2.10
web02 192.0.2.11
--- not a comment, and ending in 5:
db01 198.51.100.5
mail 203.0.113.25

^$ matches an empty line, so grep -v '^$' removes empty lines. -w and -x are shortcuts for anchoring to word and line boundaries.

Character classes

A character class (bracket expression) is a set of characters in square brackets that matches exactly one character from the set, such as [aeiou] or the range [0-9]; a ^ right after the opening bracket negates it. Inside brackets most metacharacters lose their meaning: [.] is a literal dot.

POSIX (Portable Operating System Interface) character classes are named sets that are written inside a bracket expression, hence the double brackets. They also respect the language settings, where a range such as [a-z] can, in some locales, include unexpected letters:

Class Same as (in the C locale) Meaning
[[:digit:]] [0-9] Digits
[[:alpha:]] [A-Za-z] Letters
[[:alnum:]] [A-Za-z0-9] Letters and digits
[[:upper:]] / [[:lower:]] [A-Z] / [a-z] Upper- / lowercase letters
[[:space:]] space, tab, newline and similar Whitespace
[[:punct:]] Punctuation
printf '%s\n' 'id=42' 'id=x7' 'id=' > ids.txt
echo "--- id= followed by a digit:"
grep 'id=[[:digit:]]' ids.txt
echo "--- id= followed by something that is not a digit:"
grep 'id=[^0-9]' ids.txt
--- id= followed by a digit:
id=42
--- id= followed by something that is not a digit:
id=x7

id= on its own matches neither: [^0-9] still needs one character, just not a digit.

Grouping, alternation, and basic versus extended syntax

grep knows two dialects. In basic regular expressions (BRE, the default) the characters + ? { } ( ) | are ordinary characters, and you put a backslash in front to make them special (a GNU extension for \+ \? \|). In extended regular expressions (ERE, grep -E) they are special without a backslash. Same search, both ways:

cat <<'EOF' > hosts.txt
web01 192.0.2.10
web02 192.0.2.11
db01 198.51.100.5
mail 203.0.113.25
webcam 192.0.2.200
EOF
echo "--- ERE:"
grep -E '^(web|db)[0-9]+ ' hosts.txt
echo "--- BRE, same meaning:"
grep '^\(web\|db\)[0-9]\+ ' hosts.txt
echo "--- all IPv4-looking addresses:"
grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' hosts.txt
--- ERE:
web01 192.0.2.10
web02 192.0.2.11
db01 198.51.100.5
--- BRE, same meaning:
web01 192.0.2.10
web02 192.0.2.11
db01 198.51.100.5
--- all IPv4-looking addresses:
192.0.2.10
192.0.2.11
198.51.100.5
203.0.113.25
192.0.2.200

Parentheses group several items so a repetition applies to all of them: ([0-9]{1,3}\.){3} is "one to three digits and a dot, three times". The | chooses between alternatives. The IPv4 (Internet Protocol version 4) address pattern is loose on purpose — it would also accept 999.1.1.1 — which is fine for pulling addresses out of a log, not for validating input. Use grep -E whenever the pattern has groups or alternation; it is far easier to read.

find: search for files

find walks a directory tree and tests every file and directory it meets against the conditions you give, printing (or acting on) the ones that match. It searches the live file system, so it is always up to date, and it can test almost any property: name, type, size, age, owner and permissions. The form is find [where] [tests] [action]; without a test it lists everything, without an action it prints the path.

Test Matches Example
-name 'pattern' File name matching a shell wildcard, case-sensitive -name '*.log'
-iname 'pattern' Same, ignoring case -iname '*.jpg' (also .JPG)
-type f / d / l Regular file / directory / symbolic link -type d
-size +n / -n Larger / smaller than n (units k, M, G; no unit means 512-byte blocks) -size +100M
-mtime +n / -n Modified more / less than n days (24-hour periods) ago -mtime +30
-mmin -n Modified less than n minutes ago -mmin -15
-newer file Modified more recently than file -newer deploy.stamp
-user name, -perm mode Owned by name; with permission bits mode -perm -4000 (setuid files)
-maxdepth n Descend at most n levels (put it first) -maxdepth 1

Tests written one after the other must all be true (an implicit AND); -o gives OR and ! negates, as in find . -type f ! -name '*.gz'. AND binds more tightly than -o, so group OR tests in escaped parentheses: find . \( -name '*.log' -o -name '*.tmp' \) -delete. Without the parentheses, -delete belongs to the second test only and removes just the .tmp files. As with grep, quote the pattern: an unquoted *.log is expanded by the shell if a matching file happens to be in the current directory.

The example builds a small project with three log files. Two get a fixed modification date in the past with touch -d, one is created now, so the age tests give the same answer whenever you run them. find lists entries in the order the file system returns them, so the output is sorted:

mkdir -p project/logs project/src project/docs
touch -d '2025-01-15 10:00' project/logs/app-2025-01.log
touch -d '2025-06-30 10:00' project/logs/app-2025-06.log
touch project/logs/app-current.log
truncate -s 20K project/logs/app-current.log
touch project/src/main.py project/src/Setup.PY project/docs/README.md
echo "--- -name '*.log':"
find project -name '*.log' | sort
echo "--- -iname '*.py':"
find project -iname '*.py' | sort
echo "--- -type d:"
find project -type d | sort
echo "--- -size +10k:"
find project -type f -size +10k
echo "--- -mtime +30 (older than 30 days):"
find project -name '*.log' -mtime +30 | sort
echo "--- -mtime -7 (changed in the last 7 days):"
find project -name '*.log' -mtime -7
--- -name '*.log':
project/logs/app-2025-01.log
project/logs/app-2025-06.log
project/logs/app-current.log
--- -iname '*.py':
project/src/Setup.PY
project/src/main.py
--- -type d:
project
project/docs
project/logs
project/src
--- -size +10k:
project/logs/app-current.log
--- -mtime +30 (older than 30 days):
project/logs/app-2025-01.log
project/logs/app-2025-06.log
--- -mtime -7 (changed in the last 7 days):
project/logs/app-current.log

-mtime counts in whole 24-hour periods and drops the fraction, which makes the boundaries surprising: -mtime +1 means "at least two days old", not "more than 24 hours". For precise limits use -mmin or compare against a reference file with -newer. -size has a similar trap: it rounds each file's size up to whole units, so -size -1M finds only empty files. Use -size -1024k instead. On real servers the classic question is "what is filling the disk?" (illustrative):

$ sudo find /var -xdev -type f -size +500M 2>/dev/null
/var/lib/docker/overlay2/3f9c…/merged/opt/data/export.tar
/var/log/app/debug.log

-xdev keeps find on one file system, so it does not wander into other mounted disks.

Acting on the results: -exec and -delete

-exec command {} \; runs a command once for every match, with {} replaced by the path; the \; (an escaped semicolon, so the shell passes it to find) ends the command. -exec command {} + instead passes as many paths as fit to one run of the command, which is much faster for thousands of files:

mkdir notes
printf 'one\ntwo\n' > notes/a.txt
printf 'three\n' > notes/b.txt
echo "--- once per file (\\;):"
find notes -name '*.txt' -exec wc -l {} \; | sort
echo "--- once for all files (+):"
find notes -name '*.txt' -exec wc -l {} + | sort
--- once per file (\;):
1 notes/b.txt
2 notes/a.txt
--- once for all files (+):
 1 notes/b.txt
 2 notes/a.txt
 3 total

-delete removes each match. It is convenient and unforgiving:

mkdir -p build/cache
touch build/a.tmp build/cache/b.tmp build/keep.txt
echo "--- would be deleted:"
find build -name '*.tmp' | sort
find build -name '*.tmp' -delete
echo "--- left over:"
find build | sort
--- would be deleted:
build/a.tmp
build/cache/b.tmp
--- left over:
build
build/cache
build/keep.txt

xargs: turning a list into arguments

Many commands take file names as arguments, not on standard input. xargs reads items from standard input, separated by whitespace or newlines, and runs a command with those items appended as arguments, as many at a time as fit on one command line. It connects the output of find, grep -l or any list to commands such as wc, rm, chmod or tar:

printf '%s\n' web01 web02 db01 | xargs echo "hosts:"
printf '%s\n' web01 web02 db01 | xargs -n 1 echo "checking"
hosts: web01 web02 db01
checking web01
checking web02
checking db01

-n 1 runs the command once per item. The weak spot is file names with spaces: by default xargs splits on any whitespace, so one file called sales q3.txt becomes two arguments, and it also treats quotes and backslashes in names as its own quoting characters. The fix is to separate names with the null character, which can never appear in a file name: find -print0 writes it, xargs -0 reads it (and sort -z sorts such a list):

mkdir reports
printf 'q3 total 1200\n' > "reports/sales q3.txt"
printf 'q4 total 1500\n' > reports/sales-q4.txt
echo "--- newline-separated (breaks on the space):"
find reports -name '*.txt' | sort | xargs wc -l
echo "--- null-separated:"
find reports -name '*.txt' -print0 | sort -z | xargs -0 wc -l
--- newline-separated (breaks on the space):
wc: reports/sales: No such file or directory
wc: q3.txt: No such file or directory
 1 reports/sales-q4.txt
 1 total
--- null-separated:
 1 reports/sales q3.txt
 1 reports/sales-q4.txt
 2 total

grep -lZ and grep -rlZ produce null-separated file names for the same purpose. For simple cases, find … -exec … {} + does the same job as find … | xargs … without the quoting problem at all.

locate: the fast index

locate does not search the disk. It looks names up in a database that updatedb rebuilds, usually once a day by a timer or cron job, so it answers in milliseconds but does not know about files created since the last update, and it matches anywhere in the full path. On current distributions it is provided by the plocate package (mlocate on RHEL 9), which may need to be installed (illustrative):

$ locate sshd_config
$ sudo updatedb          # refresh the database now
/etc/ssh/sshd_config
/etc/ssh/sshd_config.d
/usr/share/man/man5/sshd_config.5.gz
find locate
Searches The live directory tree A prebuilt database of path names
Speed Slower on large trees Near-instant
New files Always found Only after the next updatedb
Tests Name, type, size, age, owner, permissions Name only (-i ignores case)
Actions -exec, -delete None; pipe to xargs