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.

The text INC-42 open has 11 characters and 12 positions, numbered 0 to 11. The start anchors caret and backslash A succeed only at position 0, the end anchors dollar and backslash z only at position 11. A word boundary succeeds at positions 0, 3, 4, 6, 7 and 11, wherever a word character meets a non-word character or the edge of the text. \b \b \b \b \b \b I N C - 4 2 ␣ o p e n 0 1 2 3 4 5 6 7 8 9 10 11 ^ \A $ \z positions (between characters)
Anchors test the gaps between characters. ^ 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.