They differ in where the pattern must match. re.match anchors at the
start of the string (but not the end). re.search scans for the pattern
anywhere in the string. re.fullmatch requires the pattern to match the
entire string. All return a Match object on success or None on failure.
import re
re.match("ab", "abcd") # match — starts with 'ab'
re.match("cd", "abcd") # None — not at the start
re.search("cd", "abcd") # match — found anywhere
re.fullmatch("ab", "abcd") # None — must match the whole string
re.fullmatch("abcd", "abcd")# match
A common bug is using match expecting whole-string validation — it only
anchors the start. Rule of thumb: use search to find, fullmatch to
validate, and match only when you specifically mean "begins with".
Parentheses ( ) create a capture group you retrieve by number
(1-based; group 0 is the whole match). (?P<name>...) creates a named
group you retrieve by name — far more readable. (?:...) groups without
capturing when you only need it for grouping/alternation.
import re
m = re.search(r"(\d{4})-(\d{2})", "2026-06")
m.group(0) # '2026-06' — whole match
m.group(1) # '2026' — first group
m.groups() # ('2026', '06')
m = re.search(r"(?P<year>\d{4})-(?P<month>\d{2})", "2026-06")
m.group("year") # '2026'
m.groupdict() # {'year': '2026', 'month': '06'}
Named groups make patterns self-documenting and resilient to reordering.
Rule of thumb: use (?P<name>...) for anything you'll extract, and
(?:...) when grouping is structural only.
re.compile(pattern) builds a reusable pattern object once, then you call
methods (.search, .match, .findall, .sub) on it. The module-level
functions actually compile internally and cache recent patterns, so the
main win is clarity and reuse — plus a small speedup when a pattern is
used many times in a loop.
import re
DATE = re.compile(r"(?P<year>\d{4})-(?P<month>\d{2})") # compile once
for line in lines:
m = DATE.search(line) # reuse the compiled object
if m:
print(m.group("year"))
It also lets you attach flags (e.g. re.IGNORECASE, re.VERBOSE) in one
place. Rule of thumb: compile patterns used repeatedly or shared across a
module; for one-off use the module functions are fine.
By default quantifiers (*, +, ?, {m,n}) are greedy — they match as
much as possible, then backtrack. Adding a trailing ? makes them
non-greedy (lazy) — they match as little as possible. This matters
hugely when a delimiter can appear multiple times.
import re
text = "<a><b>"
re.search(r"<.*>", text).group() # '<a><b>' — greedy, grabs everything
re.search(r"<.*?>", text).group() # '<a>' — lazy, stops at first '>'
Greedy patterns over-matching is a classic "regex ate too much" bug. Rule of
thumb: when matching content between delimiters, reach for the lazy *?
/ +? (or a negated character class like [^>]*).
re.sub(pattern, repl, string) returns a new string with all matches
replaced. The replacement can reference captured groups with \1 or
\g<name>, or be a function that receives each Match for dynamic
replacement. You should write patterns as raw strings (r"...") so that
backslash escapes like \d and \b reach the regex engine instead of being
interpreted by Python first.
import re
re.sub(r"\s+", " ", "a b\tc") # 'a b c' — collapse whitespace
re.sub(r"(\d{4})-(\d{2})", r"\2/\1", "2026-06") # '06/2026' — reorder groups
re.sub(r"\d+", lambda m: f"[{m.group()}]", "x9") # 'x[9]' — function repl
"\d" # in a normal string this is an invalid escape (warns)
r"\d" # raw string — passes \d straight to the engine
Without r"", "\b" becomes a backspace character, not a word boundary —
a subtle, hard-to-spot bug. Rule of thumb: always prefix regex patterns
with r.
findall returns a list of matches (strings, or tuples if there are multiple
groups). finditer returns a lazy iterator of Match objects, giving you
positions and group access — better for large inputs or when you need match details.
import re
re.findall(r"\d+", "a1b22c333") # ['1', '22', '333']
re.findall(r"(\w)(\d)", "a1b2") # [('a','1'), ('b','2')] — tuples!
for m in re.finditer(r"\d+", "a1b22"):
print(m.group(), m.start(), m.end()) # value + position
Rule of thumb: findall for a quick list of values; finditer when you need spans,
groups per match, or memory-efficient iteration. Watch findall's tuple behavior with
multiple groups.
re.IGNORECASE case-insensitive; re.MULTILINE makes ^/$ match at each
line; re.DOTALL lets . match newlines; re.VERBOSE allows whitespace and
comments in the pattern. Combine with |.
import re
re.findall(r"^\w+", "foo\nbar", re.MULTILINE) # ['foo', 'bar']
re.search(r"a.b", "a\nb", re.DOTALL) # matches across newline
re.findall(r"cat", "Cat CAT", re.IGNORECASE) # ['Cat', 'CAT']
pat = re.compile(r"""
\d{4} # year
-\d{2} # month
""", re.VERBOSE)
Rule of thumb: MULTILINE for line-based ^/$, DOTALL for .-spans-newlines,
VERBOSE for readable complex patterns; combine via re.A | re.B.
^ matches start of string (or line in MULTILINE), $ matches end. \b is
a word boundary (between word/non-word chars); \B is a non-boundary. Anchors
match positions, not characters.
import re
re.search(r"\bcat\b", "the cat sat") # matches 'cat' as a whole word
re.search(r"\bcat\b", "category") # None — 'cat' not a full word
re.findall(r"^\d", "5x", ) # ['5'] — at start
re.sub(r"\s+$", "", "trim ") # 'trim' — trailing whitespace
Rule of thumb: use \b to match whole words (avoiding partial matches), and ^/$
to anchor to string/line edges.
A backreference (\1, or (?P=name)) matches the same text a prior group
captured — useful for finding repeats or matched pairs. They make the regex
context-sensitive within a single match.
import re
re.search(r"(\w+) \1", "the the end") # matches 'the the' — repeated word
re.search(r"<(\w+)>.*</\1>", "<b>hi</b>") # matched open/close tag
re.search(r"(?P<q>['\"]).*?(?P=q)", "'hi'") # same quote char on both ends
Rule of thumb: backreferences match previously-captured text (\1/(?P=name)) —
ideal for duplicate detection and matching paired delimiters.
re.split(pattern, s) splits on a regex, not a fixed substring — so you can
split on variable delimiters. If the pattern has capturing groups, the delimiters
are included in the result.
import re
re.split(r"\s*,\s*", "a, b ,c") # ['a', 'b', 'c'] — flexible spacing
re.split(r"[;,]", "a,b;c") # ['a', 'b', 'c'] — multiple delimiters
re.split(r"(\d)", "a1b2") # ['a', '1', 'b', '2', ''] — keeps captures
Rule of thumb: use re.split for variable/multi-character delimiters; capturing
groups in the pattern keep the separators in the output.
Nested or overlapping quantifiers (e.g. (a+)+) can make the engine try an
exponential number of paths on non-matching input — freezing your program (a ReDoS
risk). Avoid ambiguous nesting and prefer specific character classes.
import re
# DANGEROUS: (a+)+ on "aaaaaaaaaaaaaaaaX" backtracks catastrophically
# re.match(r"(a+)+$", "a" * 30 + "X") # may hang
# safer: unambiguous, no nested quantifier
re.match(r"a+$", "a" * 30 + "X") # fails fast
Rule of thumb: avoid nested quantifiers over overlapping patterns; use atomic groups/
possessive quantifiers (3.11+ (?>...), a++) or precise classes to prevent ReDoS.
Lookarounds match a position based on what follows/precedes without consuming
characters: (?=...) positive lookahead, (?!...) negative lookahead, (?<=...)
positive lookbehind, (?<!...) negative lookbehind.
import re
re.findall(r"\d+(?= dollars)", "5 dollars 10 euros") # ['5'] — followed by 'dollars'
re.findall(r"(?<=\$)\d+", "$5 and $10") # ['5', '10'] — preceded by $
re.sub(r"(?<!^)(?=(\d{3})+$)", ",", "1234567") # '1,234,567' — thousands
Rule of thumb: use lookarounds to assert context (what's around a match) without
including it in the result; lookbehind must be fixed-width in Python's re.
A Match exposes .group()/.groups()/.groupdict() for captured text, and
.start()/.end()/.span() for positions. .group(0) is the whole match. These
let you locate and extract in one pass.
import re
m = re.search(r"(\w+)@(\w+)", "to bob@acme now")
m.group() # 'bob@acme'
m.groups() # ('bob', 'acme')
m.start(), m.end() # (3, 11)
m.span(1) # (3, 6) — span of group 1
Rule of thumb: a Match carries both the captured text (groups) and where it occurred (span/start/end) — use it instead of re-searching for positions.
re.sub(pat, repl, s, count=N) replaces only the first N matches.
re.subn returns a tuple (new_string, number_of_substitutions) so you know how
many replacements happened.
import re
re.sub(r"o", "0", "foo boo", count=1) # 'f0o boo' — only first
re.subn(r"o", "0", "foo boo") # ('f00 b00', 4)
new, n = re.subn(r"\bthe\b", "a", "the cat the dog")
n # 2
Rule of thumb: use count= to cap replacements and subn when you need the
replacement count (e.g. to detect whether anything changed).
Use re.escape() to backslash-escape all special characters, so user input or a
variable is matched literally rather than interpreted as a pattern — preventing
both bugs and injection.
import re
user = "a.b+c"
re.search(re.escape(user), "xa.b+cy") # matches the literal 'a.b+c'
# without escape, '.' and '+' would be metacharacters
pattern = re.compile(re.escape(delimiter))
Rule of thumb: wrap any dynamic/literal text in re.escape() before embedding it in
a pattern — never trust raw input to be regex-safe.
More Standard Library Essentials interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.