What a pattern is
A regular expression, regex for short, is a search spec applied one line at a time: grep reads a line, asks whether the pattern matches somewhere in it, and prints the line if it does. The text processing chapter put grep and sed to work with literal strings. This guide is the grammar underneath, so that matches happen on purpose instead of by luck:
$ printf '%s\n' cat cut c-t coat ct category 'a cat category' > words.txt
$ grep 'cat' words.txt
cat
category
a cat category
The pattern is the three letters c-a-t, and every line holding them came
back. Each line is tested in turn and either qualifies or does not. That
first printf '%s\n' writes each argument as its own line, the fixture
idiom for the whole guide.
One warning first. Readers arrive trained on filename globs, which the
shell essentials chapter
covers, and the two languages share characters but not meanings. In a
glob, * is any string and ? is exactly one arbitrary character. In a
regex, * is zero or more of the item before it, and ? will mean that
item is optional. Everything below was checked on Ubuntu 26.04, Fedora
44, and Arch, with byte-for-byte identical output.
Literals and metacharacters
Letters and digits are literal, and grep hunts them as plain text. A
few punctuation marks have side jobs instead: the dot ., brackets
[ ], caret ^, dollar $, star *, and the backslash \, with
+ ? ( ) | { } joining once we switch dialects. The dot matches any
single character:
$ printf '%s\n' a.b axb 'a b' a-b > litdot.txt
$ grep 'a.b' litdot.txt
a.b
axb
a b
a-b
The dot stood in for an x, a space, a dash, and finally for itself. That last twist is the habit this guide is really about: before trusting a pattern with punctuation in it, ask character by character whether each one is meant literally. Treat punctuation as guilty until proven literal. Every pattern below wears quotes for reasons the quoting section demonstrates, and the dialect section has a flag that switches all metacharacters off.
Anchors
^ pins a pattern to the start of the line, $ to the end, and the two
together describe an empty line:
$ printf '%s\n' '2026-09-21 ERROR: disk full' 'WARNING: low memory' '2026-09-20 INFO: backup done' 'ERROR: timeout' '2026-09-19 INFO: cleanup ok' > date.log
$ grep '^ERROR' date.log
ERROR: timeout
$ printf '%s\n' box fox oxen boxy > endx.txt
$ grep 'x$' endx.txt
box
fox
$ printf '%s\n' 'line one' '' 'line two' '' '' 'line three' > blanks.txt
$ grep -c '^$' blanks.txt
3
One other log line contains ERROR, but not at the front, and oxen and
boxy carry their x somewhere other than the end. ^$ counted to 3
because -c swaps the matching lines for a count of them.
Character classes
Brackets match one character but let you vote on which one: [abc] is
a, b, or c, a single character drawn from the list:
$ printf '%s\n' abc abx xyz > negset.txt
$ LC_ALL=C grep '[^abc]' negset.txt
abx
xyz
[^abc] flips the vote to any character except a, b, or c, so the pure
abc line is all excluded characters and drops out, while abx
survives on the strength of its x. A dash inside the brackets makes a
range: [0-9] is any digit, [a-z] any lowercase letter, and both do
real work later in this guide. Range behavior depends on the locale, so
the example above runs LC_ALL=C grep ... to pin the plain POSIX
default. The common sets also have named classes with double brackets,
the outer brackets part of the spelling: [[:digit:]] catches digits,
[[:upper:]] uppercase, [[:space:]] whitespace, and the named forms
sidestep the locale questions ranges can raise. Hand [[:upper:]] a
mixed-case file and it keeps only the line with a capital in it:
$ printf '%s\n' ABC abc > classes.txt
$ grep '[[:upper:]]' classes.txt
ABC
Quantifiers, part one: the star
* attaches to the item before it and repeats it zero or more times.
Zero is the part that surprises people, and stacking the dot and the
star as .* gives any run of anything:
$ printf '%s\n' ac abc abbc 'ab+c' xyz > quant.txt
$ grep 'ab*c' quant.txt
ac
abc
abbc
$ grep 'a.*c' quant.txt
ac
abc
abbc
ab+c
$ printf '<a><b>\n' | grep -o '<.*>'
<a><b>
ab*c reads: a, then any number of b including none, then c, which is
why ac matches without a single b. The star binds to the single item
before it, so it is the b that repeats, never the whole ab. The ab+c
line makes the cut under a.*c too: it starts with a real a, ends with
a real c, and the .* happily spans the + in between. That same
appetite is greed. The last command used -o, which prints each match
on its own line instead of the whole line, and the match started at the
first < and ran to the last >, taking in both tags. <a> alone
would have satisfied the pattern, but a greedy star reaches for the
longest run available and hands characters back only when the rest of
the pattern forces it to.
BRE and ERE, the two dialects
Now the trap every regex learner hits. Say you want one or more b and write it the obvious way, and grep answers with the one line that contains those four characters as literal text:
$ grep 'ab+c' quant.txt
ab+c
$ grep -E 'ab+c' quant.txt
abc
abbc
$ printf '%s\n' color colour colouur > colr.txt
$ grep -E 'colou?r' colr.txt
color
colour
$ printf '%s\n' o oo ooo book root > oos.txt
$ grep -oE 'o{2,3}' oos.txt
oo
ooo
oo
oo
$ grep 'o{2,3}' oos.txt
grep did not fail on the first command. In its default dialect, BRE for
basic regular expressions, the plus sign is an ordinary character, and
so is the question mark. The extended dialect, ERE, is where +, ?,
braces, parentheses, and the pipe become active, and -E selects it,
which is why the second command found one or more b. The optional u
makes both familiar colour spellings match while colouur matches
neither. Intervals count repeats: {n} is exactly n, {n,} is
n or more, {n,m} is a range, and o{2,3} is two to three o’s, so the
lone o drops out and -o shows the rest: the full ooo, then one
oo each from book and root.
The bare-brace command right after is the trap. In BRE the braces need
backslashes, spelled \{2,3\}, and the bare form is not a tolerated
spelling: on current GNU grep, 3.12 on all three test systems, bare
o{2,3} is ordinary characters through and through. It silently
searched for that literal text, found it on no line, printed nothing,
and exited 1. When a pattern with braces finds nothing, check the
dialect before blaming the data. Give the braces their backslashes and
the same search works in plain BRE, printing whole lines this time
because there is no -o:
$ grep 'o\{2,3\}' oos.txt
oo
ooo
book
root
And when the goal is a pattern with no magic at all, -F treats the
whole thing as literal text:
$ grep -F 'a.b' litdot.txt
a.b
The dot mystery from earlier ends there. Between -E and -F, plain
BRE is what remains: literals, classes, anchors, and the star, plus the
backslashed spellings, the \{2,3\} interval form shown above and the
groups and backreferences coming in the next section.
Groups and alternation
The pipe means or, and parentheses group, which keeps anchors and alternatives in agreement:
$ printf '%s\n' cat dog catdog hotdog fish > pets.txt
$ grep -E 'cat|dog' pets.txt
cat
dog
catdog
hotdog
$ grep -E '^(cat|dog)$' pets.txt
cat
dog
Four lines carry a cat or a dog somewhere. fish is the exception.
With the group, the whole line has to be one animal or the other, and
the two hybrid names stay out.
One portability note: older BRE-style material writes groups and
alternation with backslashes, as in \(cat\|dog\). The backslashed
groups \( \) and the \1 through \9 backreferences are the
standard BRE spelling. The backslashed pipe \| is the odd one out: a
GNU extension, because POSIX BRE has no alternation operator at all.
The portable habit is grep -E.
Precision flags
grep has three ways to tighten what counts as a match. -w demands a
whole word, -x the whole line, and there is also a pattern-level way
to mark word boundaries, \b:
$ grep -w 'cat' words.txt
cat
a cat category
$ grep -x 'cat' words.txt
cat
$ printf '%s\n' 'a cat category' category > wb.txt
$ grep '\bcat\b' wb.txt
a cat category
$ grep -o '\bcat\b' wb.txt
cat
category contains the letters c-a-t but they are glued to other
letters, so the whole-word test rejects it, -x pushes out even the
sentence, and \b fails it the same way. The caveat matters: \b is
GNU grep behavior, and the manual notes it is unspecified outside GNU,
so portable scripts avoid it. grep -w covers the same ground on GNU
grep, but POSIX does not specify it, and the same is true of -o from
the star section, which still combines with any pattern when you want
to see only what matched.
Patterns in sed
sed takes patterns in two places. The first is the address, the part
before a command that selects which lines it runs on: /^ERROR/ selects
lines matching the pattern and p prints them, with -n quieting sed’s
habit of echoing every line. The second is substitution,
s/pattern/replacement/flags, where capture groups earn their keep by
reordering: wrap pieces of the pattern in parentheses, then refer to
them in the replacement as \1, \2, and so on:
$ printf '%s\n' 'ERROR: disk full' 'WARNING: low memory' 'ERROR: timeout' 'info: ok' > log.txt
$ sed -n '/^ERROR/p' log.txt
ERROR: disk full
ERROR: timeout
$ sed -E 's/o+/0/g' oos.txt
0
0
0
b0k
r0t
$ echo 'item-42 and cog-7' | sed -E 's/([a-z]+)-([0-9]+)/\2-\1/'
42-item and cog-7
The address is the same ^ERROR as the grep example. In the
substitution, every run of o’s became a single 0, with -E meaning what
it means for grep. -r is an older spelling of the same flag, and the
manual recommends -E for portability. The g flag extends the swap
to every occurrence in the line: leave g off and only the first
occurrence per line is replaced. The swap caught letters and digits
around a dash, and
\2-\1 traded their places. Look at the second field: cog-7 came
through untouched, the
no-g rule in action. Anything the pattern did not match passes through
unchanged.
Quoting: the shell gets your pattern first
A regex reaches grep only after bash has taken its pass, and bash
rewrites several regex characters for its own purposes. To watch it
happen, make a small log and one saboteur, an empty file named cat:
$ printf '%s\n' 'cut here' 'c-t here' 'Error: one' 'plain cat word' > f.log
$ touch cat
$ grep 'c[au]t' f.log
cut here
plain cat word
$ grep c[au]t f.log
plain cat word
$ grep Error: one f.log
grep: one: No such file or directory
f.log:Error: one
$ grep (cat|word) f.log
bash: -c: line 1: syntax error near unexpected token 'cat'
bash: -c: line 1: 'grep (cat|word) f.log'
$ grep -E '(cat|word)' f.log
plain cat word
The quoted version is the class doing its job: cut matches, c-t does
not, and the plain cat matches too. The unquoted version came back with
one line instead of two.
bash saw c[au]t, recognized a glob, found the file named cat in this
directory, and replaced the whole word with that filename, so grep
searched for the string cat. The cut here line vanished, the command
exited 0, and nothing hints that the pattern you typed is not the
pattern grep ran. grep never saw your brackets. bash had already eaten
them and handed over a filename.
The space failure at least confesses: the line split into the pattern
Error: plus two filenames, and grep went looking in one, which does
not exist. The parentheses are shell syntax, so bash rejected that line
before any searching started, while the quoted -E version worked.
To be fair to bash, ^ is not special to it and a lone $ before a
space stays literal, which is why grep '^ERROR' date.log happens to
work without quotes. But brackets, spaces, and parentheses all get
mangled, and there is no prize for remembering which are safe.
Single-quote the pattern every time and the shell passes it through
untouched.
Capstone — mining a mini log
The date log from the anchors section plus two small files is enough to run the toolkit in order. First, pull the lines that carry a date: four digits, dash, two digits, dash, two digits, then a space. Then count the damage, flatten case, and reshape a field:
$ grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2} ' date.log
2026-09-21 ERROR: disk full
2026-09-20 INFO: backup done
2026-09-19 INFO: cleanup ok
$ grep -c 'ERROR' log.txt
2
$ printf '%s\n' 'ERROR: disk full' 'error: minor' 'Error: mid' > case.txt
$ grep -i 'error' case.txt
ERROR: disk full
error: minor
Error: mid
$ echo 'item-42' | sed -E 's/([a-z]+)-([0-9]+)/\2-\1/'
42-item
Read the date pattern one ingredient at a time: ^ anchors to the line
start, [0-9] is the digit class, {4} and the two {2} are ERE
intervals demanding exact counts, and the trailing space keeps the
pattern honest about what follows the date. The two undated lines fail
at the anchor. -c counted matching lines, and -i ignored case so the
three spellings of the same level collapse into one list.
Where you are now
A pattern is text describing text, but the parts add up quickly:
literals, the dot, anchors, classes, the star, then the ERE set of +,
?, intervals, groups, and alternation, with sed putting the same
grammar to work on addresses and substitution. You can read a pattern
like ^[0-9]{4}-[0-9]{2}-[0-9]{2} piece by piece, quote it
so bash cannot rewrite it, and say which dialect a command is speaking.
From here, the text processing chapter remains the reference for the tools themselves, and its recipes read differently now that the pattern half is no longer opaque. For the full formal story, three references are worth keeping open:
- GNU grep manual — pattern syntax and every flag used here
- GNU sed manual — addresses, the s command, and the rest
- regex(7) man page — the POSIX grammar behind both dialects