Anchors and Word Boundaries
Matching positions instead of characters: ^ and $, the start and end of the whole text, multiline mode, word boundaries, and why anchors turn a search into a validation.
Positions, not characters
Most parts of a
regular expression
consume text: a matches one character,
[0-9]+ matches one or more. An
anchor is
different. It checks a position: the start of the text, the
end of a line, the spot between a letter and a space. If the check
succeeds, the engine stays where it is and continues with the rest
of the pattern.
That makes anchors a kind of
zero-width assertion: a
test that either succeeds or fails at the current position but
consumes no characters, so it adds nothing to the matched text.
^, $, \A, \z,
\b and the lookarounds on
Lookahead and Lookbehind
all work this way.
It helps to picture a string of n characters as having n + 1 positions: one before the first character, one between every pair, and one after the last. The diagram shows the positions in a short ticket note where each anchor can succeed.
^ and
\A succeed only at position 0, $ and
\z only at the end; \b succeeds wherever
a word character meets a non-word character or the edge of the
text.
^ and $: start and end
The two anchors everyone meets first are ^ (caret),
which succeeds at the start of the text, and
$ (dollar), which succeeds at the end. They already
appear in the
grep introduction; this page adds what happens in programming languages and in the
corners.
Search versus validation
By default a regex engine searches: it tries the pattern at position 0, then 1, then 2, and reports the first place where it fits. That is what you want in a log file, and exactly what you do not want when you check a form field. A Dutch postal code is four digits (the first not zero), an optional space and two capital letters. Without anchors, the pattern happily finds a postal code inside longer text:
| Pattern | Matches (somewhere in) | Does not match |
|---|---|---|
[1-9][0-9]{3} ?[A-Z]{2} |
2511 CV, Postcode: 2511 CV,
2511 CVX
|
2511 cv, 0511 CV |
^[1-9][0-9]{3} ?[A-Z]{2}$ |
2511 CV, 2511CV |
Postcode: 2511 CV, 2511 CVX |
import re
loose = r"[1-9][0-9]{3} ?[A-Z]{2}"
anchored = r"^[1-9][0-9]{3} ?[A-Z]{2}$"
for value in ["2511 CV", "2511CV", "Postcode: 2511 CV", "2511 CVX", "0511 CV"]:
found = re.search(loose, value) is not None
valid = re.search(anchored, value) is not None
print(f"{value:18} loose={found!s:5} anchored={valid}")
2511 CV loose=True anchored=True 2511CV loose=True anchored=True Postcode: 2511 CV loose=True anchored=False 2511 CVX loose=True anchored=False 0511 CV loose=False anchored=False
The examples use [0-9] rather than \d on
purpose: in Python, \d also accepts digits from other
scripts, such as Arabic-Indic digits (see
Character Classes and Shorthands). For validation, say exactly which characters you accept.
The trailing-newline trap
Is $ really "the end of the text"? Not quite, and the
flavors disagree. For historical reasons (text read line by line
keeps its \n), Perl, PCRE and Python let
$ also succeed
just before a newline that ends the text. JavaScript does
not. The same value, "2511 CV\n", gives different
answers:
import re
value = "2511 CV\n"
print(re.search(r"^[1-9][0-9]{3} [A-Z]{2}$", value) is not None)
print(re.search(r"^[1-9][0-9]{3} [A-Z]{2}\Z", value) is not None)
print(re.fullmatch(r"[1-9][0-9]{3} [A-Z]{2}", value) is not None)
True False False
const value = "2511 CV\n";
console.log(/^[1-9][0-9]{3} [A-Z]{2}$/.test(value));
console.log(/^[1-9][0-9]{3} [A-Z]{2}$/m.test(value)); // multiline mode
false true
<?php
$value = "2511 CV\n";
var_dump(preg_match('/^[1-9][0-9]{3} [A-Z]{2}$/', $value));
var_dump(preg_match('/^[1-9][0-9]{3} [A-Z]{2}$/D', $value)); // D: $ only at the very end
var_dump(preg_match('/\A[1-9][0-9]{3} [A-Z]{2}\z/', $value));
var_dump(preg_match('/\A[1-9][0-9]{3} [A-Z]{2}\Z/', $value));
int(1) int(0) int(0) int(1)
Python and PHP accept the value with a plain $;
JavaScript rejects it unless multiline mode is on. Why should you
care? A value that ends in a newline can slip through validation and
then break the next step: a log line splits in two, a header or a
CSV record gets an extra line. Strip input deliberately
(value.strip(), trim()) if a trailing
newline is harmless, and use an end-of-text anchor if it is not.
\A, \Z and \z
Python and PCRE also have anchors that always mean the start and end of the whole text, whatever mode is on. Their letters are the same, but their meaning is not:
| Flavor | Start of text | Very end of text | End, or before a final newline |
|---|---|---|---|
Python re (3.11) |
\A |
\Z (\z is an error before 3.14)
|
$ without multiline mode |
| Python 3.14+ | \A |
\z, and \Z as an alias |
$ without multiline mode |
PCRE2 (PHP, grep -P) |
\A |
\z, or $ with the
D modifier
|
\Z, and $ by default |
| JavaScript | ^ without the m flag |
$ without the m flag |
(none) |
Multiline mode
When the text holds several lines, such as a whole log file read
into one string, you often want "the start of a line". That
is what multiline mode does:
with it switched on, ^ also matches right after every
newline and $ right before every newline. It is turned
on with re.MULTILINE (or re.M) in Python,
the m flag in JavaScript and the
m modifier in PHP. \A and
\Z ignore the mode, which makes them useful when you
need both kinds of anchor in one pattern. The
Flags and Modes page covers the
other modes.
import re
log = """2026-10-02T08:14:03Z noc-fw01 link up on port 7
2026-10-02T08:14:09Z noc-sw02 ERROR fan 2 failed
ERROR forwarder lost its timestamp
2026-10-02T08:15:40Z noc-sw02 WARN fan 2 speed low"""
print(re.findall(r"^ERROR.*", log))
print(re.findall(r"^ERROR.*", log, re.MULTILINE))
print(re.findall(r"\AERROR.*", log, re.MULTILINE))
print(re.findall(r"\S+$", log, re.M))
[] ['ERROR forwarder lost its timestamp'] [] ['7', 'failed', 'timestamp', 'low']
The first search finds nothing, because the text as a whole does not
start with ERROR. Multiline mode finds the one line
that does. \A still means the start of the whole text.
The last line uses $ to pick the final word of every
line. Note that . stops at a newline, so
.* never runs into the next line.
Anchors in grep
Command-line tools such as
grep,
sed and awk read their input one line at a time and match each line
separately. For them ^ and $ are line
anchors already; there is no multiline mode to switch on.
grep -x requires the whole line to match, as if the
pattern were wrapped in ^…$.
printf '%s\n' 'ERROR fan 2 failed' 'noc-sw02 ERROR fan 2 failed' 'ERRORS: 0' > noc.log
grep '^ERROR' noc.log
echo ---
grep 'failed$' noc.log
echo ---
grep -x 'ERROR fan 2 failed' noc.log
ERROR fan 2 failed ERRORS: 0 --- ERROR fan 2 failed noc-sw02 ERROR fan 2 failed --- ERROR fan 2 failed
Note that ^ERROR also prints ERRORS: 0:
the anchor fixes where the match starts, not where the word ends.
That is the job of a word boundary.
Word boundaries
A word boundary, written
\b, is the position between a word character and a
non-word character, or between a word character and the start or end
of the text. A word character is whatever \w matches:
letters, digits and the underscore. \b lets you search
for a whole word without consuming the spaces or punctuation around
it. Its opposite, \B, succeeds at every position that
is not a word boundary.
| Pattern (ignoring case) | Matches | Does not match |
|---|---|---|
error |
ERROR disk full, no errors found,
Terror Energy
|
err: timeout |
\berror\b |
ERROR disk full, error: timeout
|
no errors found, Terror Energy
|
\berror |
ERROR disk full, no errors found
|
Terror Energy |
import re
lines = ["ERROR disk full", "no errors found", "Terror Energy invoice", "error: timeout"]
for line in lines:
plain = re.search(r"error", line, re.IGNORECASE) is not None
word = re.search(r"\berror\b", line, re.IGNORECASE) is not None
print(f"{line:22} plain={plain!s:5} whole word={word}")
print(re.findall(r"\b\w+\b", "INC-42 open"))
ERROR disk full plain=True whole word=True no errors found plain=True whole word=False Terror Energy invoice plain=True whole word=False error: timeout plain=True whole word=True ['INC', '42', 'open']
The last line shows the boundaries from the diagram at work:
\b\w+\b picks out the three "words" of
INC-42 open, because the hyphen and the space are not
word characters. A hostname such as noc-sw02 is two
words to \b for the same reason.
Word boundaries and Unicode
Because \b is defined by \w, it inherits
each flavor's idea of a word character. In Python 3,
\w includes accented letters; in JavaScript it is ASCII
only ([A-Za-z0-9_]), even with the u flag.
So the same pattern finds the French word café in Python
and fails in JavaScript, where the é counts as a
non-word character and there is no boundary after it:
import re
print(re.findall(r"\bcafé\b", "un café noir"))
print(re.findall(r"\bcafé\b", "un café noir", re.ASCII))
['café'] []
console.log("un café noir".match(/\bcafé\b/u));
console.log("un café noir".match(/\bcaf/u)[0]);
null caf
In PHP, \w and \b are ASCII by default;
add the u modifier for UTF-8 text. For names and other
text in many languages, test word boundaries with real non-English
input before you rely on them.
Word boundaries in grep
GNU grep understands \b and has two older, more precise
anchors: \< for the start of a word and
\> for the end. The option
-w (--word-regexp) wraps the whole pattern
in word boundaries, which is often the easiest way to search for a
word in logs.
printf '%s\n' 'ERROR disk full' 'no errors found' 'Terror Energy invoice' 'error: timeout' > app.log
grep -i -w 'error' app.log
echo ---
grep -i '\<error' app.log
ERROR disk full error: timeout --- ERROR disk full no errors found error: timeout
\<, \> and -w are not
part of POSIX, but GNU and BSD grep (and BusyBox for
-w) support them; -w is the most widely
available. In POSIX grep, sed and awk without GNU extensions there
is no portable word boundary at all.
Full matches: anchors for validation
A full match is a match that covers the entire input, from its first character to its last, rather than some part of it. Validation, such as checking a ticket number, a meter ID or a postal code, needs a full match. There are two ways to get one: write the anchors yourself, or call a function that requires the whole string to match.
| Language | Search anywhere | Full match |
|---|---|---|
| Python | re.search(p, s) |
re.fullmatch(p, s) |
| JavaScript | /p/.test(s) |
/^(?:p)$/.test(s) (no m flag)
|
| PHP | preg_match('/p/', $s) |
preg_match('/\A(?:p)\z/', $s) |
| grep | grep 'p' |
grep -x 'p' (per line) |
| HTML forms | (not available) | pattern="p" is always a full match |
Python's re.match sits in between and confuses many
people: it only anchors at the start. It does not care what
follows.
import re
pattern = r"INC-[0-9]{5}"
print("value match fullmatch")
for value in ["INC-31877", "INC-318770", "see INC-31877"]:
starts = re.match(pattern, value) is not None
whole = re.fullmatch(pattern, value) is not None
print(f"{value:14} {starts!s:6} {whole}")
value match fullmatch INC-31877 True True INC-318770 True False see INC-31877 False False
Anchors and alternation
When the pattern contains alternatives, put them in a group before
you anchor. The | operator has the lowest precedence of
all, so ^INC-\d{5}|CHG-\d{4}$ means "starts with an
incident number, or ends with a change number", not "is
exactly one of the two". The
Alternation and Precedence
page explains why; here is the effect:
const careless = /^INC-\d{5}|CHG-\d{4}$/;
const ticket = /^(?:INC-\d{5}|CHG-\d{4})$/;
for (const value of ["INC-31877", "CHG-1042", "INC-31877 reopened", "rollback CHG-1042"]) {
console.log(value.padEnd(20), careless.test(value), ticket.test(value));
}
INC-31877 true true CHG-1042 true true INC-31877 reopened true false rollback CHG-1042 true false
Wrapping a pattern as ^(?:…)$ is also exactly what the
browser does with the pattern attribute of an HTML
input, which it also compiles with the v flag, so
escapes like \- are errors there (see
built-in form validation), which is why you never need ^ and
$ there. That check runs in the browser only and is
easy to bypass, so repeat it on the server: see
input validation
and
Validating Input.