On this page

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: