Searching with grep, find and Regular Expressions
Finding text inside files with grep, finding files with find, and describing patterns with regular expressions.
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 |