Regular Expressions in grep

grep is the standard Linux tool for searching text by pattern instead of exact string. Paired with regular expressions (regex), it lets you find lines that start with a specific word, contain one of several alternatives, or match a shape like an IP address, without knowing the exact text ahead of time. This lesson covers how grep’s pattern-matching engines work, the syntax for basic and extended regular expressions, and the mistakes that trip up nearly everyone the first time they write a regex.

Overview / How grep and regex work

At its core, grep reads input line by line, from one or more files or from standard input, and tests each line against a pattern. If the pattern matches anywhere in the line, grep prints that line (or, with -v, prints lines that do not match). This line-by-line model is why grep cannot match a pattern that spans two lines without extra tools, and it is also why it is so fast: it never needs to load an entire file into memory.

grep actually supports three different regex flavors, selected by flags:

  • Basic Regular Expressions (BRE) – the default. In BRE, the characters + ? | ( ) { } are treated as literal characters unless you escape them with a backslash, at which point the backslash gives them special meaning. This backwards-feeling rule is a historical quirk inherited from the earliest Unix regex implementations.
  • Extended Regular Expressions (ERE) – enabled with -E (the modern replacement for the old egrep command). In ERE, + ? | ( ) { } are special by default, and a backslash makes them literal instead. Most people find ERE far more readable, which is why almost every real-world example you will see uses grep -E.
  • Perl-Compatible Regular Expressions (PCRE) – enabled with -P on GNU grep builds that include PCRE support. This unlocks shorthand classes like \d (digit), \w (word character), and \s (whitespace), plus non-greedy quantifiers. It is not guaranteed to exist on every minimal system, so portable scripts generally stick to -E.

Internally, grep compiles the pattern once, before it reads any input, into an internal matcher (conceptually a finite state machine). It then reuses that compiled matcher against every single line. This is why grep stays fast even when scanning a multi-gigabyte log file: the expensive step, parsing the pattern, happens exactly once. Matching itself looks for the pattern anywhere in the line by default; a pattern only has to match the whole line if you anchor it with ^ and $.

grep also communicates through its exit status, which matters a lot once you start using it in scripts: 0 means at least one line matched, 1 means no line matched, and 2 means an error occurred (an unreadable file, a broken pattern). This makes constructs like if grep -q "pattern" file; then a natural way to branch on whether something was found, without printing anything.

Syntax

The general form of a grep command looks like this:

grep [OPTIONS] PATTERN [FILE...]

Commonly used options:

Option Meaning
-i Ignore case when matching
-v Invert the match: print lines that do not match
-c Print a count of matching lines instead of the lines themselves
-n Prefix each matching line with its line number
-o Print only the matched portion of each line, not the whole line
-w Match whole words only
-x Match the whole line only
-r / -R Search directories recursively (-R also follows symlinks)
-l Print only the names of files that contain a match
-L Print only the names of files that contain no match
-q Quiet mode: print nothing, only set the exit status
-E Use Extended Regular Expressions
-F Treat the pattern as a fixed literal string, not a regex
-P Use Perl-Compatible Regular Expressions, where supported
--color=auto Highlight the matched text in the output

Regex metacharacters, and how they differ between BRE and ERE:

Metacharacter Meaning In BRE In ERE
^ Start of line Special Special
$ End of line Special Special
. Any single character Special Special
* Zero or more of the preceding element Special Special
[...] Character class: any one listed character Special Special
[^...] Negated character class Special Special
| Alternation (OR) Literal (unless escaped as \|, a GNU extension) Special
+ One or more of the preceding element Literal (unless escaped as \+) Special
? Zero or one of the preceding element Literal (unless escaped as \?) Special
{n,m} Repeat the preceding element n to m times Literal (unless escaped as \{n,m\}) Special
(...) Grouping Literal (unless escaped as \(...\)) Special
\ Escapes the next character Special Special

Examples

The simplest use of a regex is anchoring a search to the start of a line with ^. Suppose /var/log/app.log holds application log lines, and you only want lines that begin with the literal word ERROR, not lines where ERROR merely appears somewhere in the message:

grep '^ERROR' /var/log/app.log

Output:

ERROR 2026-08-03 10:15:02 Database connection timeout
ERROR 2026-08-03 10:15:47 Database connection timeout
ERROR 2026-08-03 11:02:19 Disk write failed on /dev/sda1

The caret anchors the match to the very beginning of each line, so grep skips any line where ERROR shows up later in the text and only returns the lines where it is the first word.

Extended regular expressions make alternation, matching one of several options, straightforward with the pipe character. This searches the same log for either ERROR or WARN lines and shows the line number of each match:

grep -E -n '(ERROR|WARN)' /var/log/app.log

Output:

3:ERROR 2026-08-03 10:15:02 Database connection timeout
5:WARN 2026-08-03 10:16:10 Retrying connection (attempt 2)
9:ERROR 2026-08-03 10:15:47 Database connection timeout
14:WARN 2026-08-03 10:59:03 High memory usage: 87%
21:ERROR 2026-08-03 11:02:19 Disk write failed on /dev/sda1

The -E flag switches grep to Extended Regular Expressions, so the parentheses group the alternatives and the pipe means “or” without needing backslashes. Without -E, this pattern would need to be written as ERROR\|WARN (a GNU extension) or the pipe would just be treated as a literal character.

Regex quantifiers let you match a shape rather than exact text. This pulls every IPv4 address out of an nginx access log, printing only the matched addresses (not the full lines) with -o:

grep -oE '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' /var/log/nginx/access.log

Output:

203.0.113.42
198.51.100.7
203.0.113.42
192.0.2.15

The interval {1,3} means “one to three of the preceding element”, so each [0-9]{1,3} matches one to three digits, and the escaped dots between them match literal periods. Because -o was used, grep printed just the matched address on each line instead of the surrounding log text.

How it works step by step

Walking through grep -E '(ERROR|WARN)' /var/log/app.log:

  1. The shell parses the command line. Because the pattern is single-quoted, the shell passes (ERROR|WARN) to grep completely unchanged, without expanding the parentheses or pipe itself.
  2. grep parses its own arguments, recognizes -E, and compiles the pattern into an internal matcher once, before reading any input.
  3. grep opens /var/log/app.log and reads it line by line, stripping the trailing newline from each line before testing it.
  4. For each line, the compiled matcher checks whether ERROR or WARN occurs anywhere in the line; no anchors were used, so the match can start at any position.
  5. Every line where the matcher succeeds is written to standard output, in the order it appeared in the file.
  6. Once the file has been fully read, grep sets its exit status: 0 if at least one line matched, 1 if none did.

Common Mistakes

Forgetting -E for alternation

In basic regular expressions, the default, the pipe character has no special meaning; it is just a literal pipe. Searching for ERROR or WARN without -E silently looks for the literal text “ERROR|WARN”, which almost never appears in a real log:

grep 'ERROR|WARN' /var/log/app.log

Fix it by switching to extended regular expressions with -E, which makes the pipe mean “or”:

grep -E 'ERROR|WARN' /var/log/app.log

Leaving a pattern variable unquoted

Storing a pattern in a variable and then expanding it without quotes lets the shell word-split it into multiple arguments before grep ever sees it. If the pattern contains a space, grep treats everything after the first word as a filename instead of part of the pattern:

pattern="ERROR 500"
grep $pattern /var/log/app.log

This runs as though you had typed grep ERROR 500 /var/log/app.log: grep searches for the literal pattern ERROR and treats 500 and /var/log/app.log as two files to search, failing with “500: No such file or directory”. Quoting the expansion keeps it a single argument:

pattern="ERROR 500"
grep "$pattern" /var/log/app.log

Treating . as a literal period

Inside a regex, a bare dot matches any single character, not just a literal period. Searching for an IP address like this looks correct but is looser than intended:

grep '192.168.1.1' /var/log/nginx/access.log

This also matches strings like “192a168b1c1”, because each unescaped dot matches any character at all. Escape the dots to match them literally:

grep '192\.168\.1\.1' /var/log/nginx/access.log

Best Practices

  • Always wrap regex patterns in single quotes so the shell passes them to grep unchanged, letting grep, not the shell, interpret metacharacters like *, $, and [ ].
  • Prefer grep -E over plain grep for anything beyond a trivial literal search, so parentheses, +, ?, and | behave the way most people expect without backslash escaping.
  • Anchor patterns with ^ and $ whenever you mean “the whole line” or “the start/end of the line”, not just “somewhere in the line”; an unanchored pattern is a much looser match than most people intend.
  • Use -F instead of a regex when searching for a fixed string containing characters like ., *, or $ that have no special meaning in your search; it is faster and removes any chance of an accidental metacharacter match.
  • Use -q in scripts when you only care whether a pattern matched, not what it matched, as in if grep -q pattern file; then.
  • Use -w to match whole words (so a search for “log” does not also match “logger” or “catalog”) and -x to match a whole line exactly.
  • Test unfamiliar patterns against a small sample file first, and use --color=auto to see exactly which part of a line matched.
  • Remember that grep exits with status 1 when nothing matches; under set -e in a script, guard a grep call whose zero-match result is an expected outcome (not an error) with || true.

Practice Exercises

  • /var/log/auth.log on a Debian server logs every SSH login attempt. Write a grep command using extended regex that finds every line containing “Failed password” and prints only the count of matching lines.
  • You have a file named domains.txt with one hostname per line. Write a regex-based grep command that prints only the lines that look like a real hostname, starting with a letter or digit and containing at least one dot, while skipping blank lines and lines that start with #.
  • Extract every IPv4 address that appears in /var/log/nginx/access.log into a new file named ips.txt, using -o so only the matched addresses, not the whole line, are saved. Hint: reuse the interval quantifier {1,3} from the Examples section.

Summary

  • grep searches text using patterns; regular expressions extend those patterns beyond exact strings to shapes, alternatives, and positions within a line.
  • grep’s default pattern language is POSIX Basic Regular Expressions (BRE); -E switches to Extended Regular Expressions (ERE), where + ? | ( ) {} are special without backslashes; -P enables Perl-Compatible Regular Expressions where supported.
  • grep compiles a pattern once and reuses it for every line, which is why it scans large files quickly; a match only needs to occur somewhere in the line unless anchored with ^ and $.
  • grep’s exit status (0 = matched, 1 = no match, 2 = error) makes it a natural building block for conditionals in Bash scripts.
  • Always quote patterns and variable expansions, prefer -E for readable alternation and grouping, and remember that . matches any character, not just a literal period.