Matching Patterns with re
Compile patterns once, choose between match and search, name your groups, and keep greedy quantifiers in check.
re.compile(pattern, flags=0).search(string) -> re.Match | NoneThe re module matches text against a pattern. Reach for it when the structure is genuinely irregular; for fixed delimiters str.split, str.startswith and str.removeprefix are faster to write and to read.
compile, and the module-level shorthand
re.compile(pattern, flags=0) returns a re.Pattern with .search(), .match(), .fullmatch(), .findall(), .finditer() and .sub() on it. The module-level re.search(pattern, string) is the shorthand for re.compile(pattern).search(string); it caches recent compiles, so the difference matters mostly for readability and for keeping a long pattern out of a loop body. Always write patterns as raw strings — r"\d+", not "\d+".
match, search, fullmatch
Per the docs, match() checks for a match "only at the beginning" of the string; search() "scans through string looking for the first location" that matches. Neither requires the whole string — that is fullmatch(), which is the one you want for validation. All three return a re.Match or None.
Groups, and what each function returns
Use named groups: (?P<name>...), read back as m.group("name") or the shorthand m["name"], with m.groupdict() for all of them at once. findall returns strings when there are no groups, the group when there is exactly one, and tuples when there are more. finditer yields Match objects instead, so you keep .span() and the group names.
Greedy quantifiers
.* takes the longest run that still allows a match, so "/(.*)=" runs to the last = on the line. .*? is the lazy form and stops at the first. Better still, match a character class that excludes the delimiter: [^=]*.
Example
When to use it
- Pull method, path and status out of access-log lines into dicts.
- Validate that a reference code matches a fixed format before writing it to the database.
- Redact card numbers or tokens from a string before it reaches the logs.
- Extract every key=value pair from a free-form settings string.
More examples
re.VERBOSE for a pattern you will read again
Use VERBOSE for anything longer than a line. It costs nothing at match time, and it is the difference between a pattern a colleague can change and one they will rewrite from scratch. Note that whitespace you want to match must then be written \s or escaped.
import re
# re.VERBOSE (re.X) ignores whitespace and # comments inside the pattern, so a
# pattern can have one group per line with a note on each.
LOG = re.compile(r"""
^(?P<ip>\d{1,3}(?:\.\d{1,3}){3})\s # client address
(?P<method>[A-Z]+)\s # HTTP method
(?P<path>\S+)\s # request target
(?P<status>\d{3})$ # response status
""", re.VERBOSE)
m = LOG.fullmatch("10.0.0.7 GET /health 200")
print(m.groupdict())
# {'ip': '10.0.0.7', 'method': 'GET', 'path': '/health', 'status': '200'}
# (?:...) is a NON-capturing group: it repeats without adding to groupdict().sub with named backreferences
The usual job is redacting secrets before text reaches a log. Use \g<name> rather than \1 whenever the replacement sits far from the pattern, and prefer the function form of repl over building a replacement string with string formatting, which reintroduces escaping bugs.
import re
CARD = re.compile(r"(?P<prefix>\d{4})[ -]?\d{4}[ -]?\d{4}[ -]?(?P<last4>\d{4})")
# In the replacement, \g<name> refers to a named group; \1 and \g<1> refer to
# numbered ones. count=0 (the default) replaces every occurrence.
redacted = CARD.sub(r"\g<prefix>********\g<last4>", "card 4111 1111 1111 1234 on file")
print(redacted) # card 4111********1234 on file
# repl can also be a function taking the Match, for anything conditional:
# CARD.sub(lambda m: mask(m["prefix"], m["last4"]), text)fullmatch for validation
Every format check should use fullmatch, or match with an explicit $ anchor. This is the single most common regex bug in validation code: the pattern looks right, the tests with valid input pass, and anything with trailing characters sails through.
import re
SKU = re.compile(r"[A-Z]{1,3}-\d{3,5}")
def is_sku(value: str) -> bool:
# fullmatch, not match: match("A-100xx") SUCCEEDS, because match only
# anchors the start of the string and ignores the trailing junk.
return SKU.fullmatch(value) is not None
print(is_sku("A-100"), is_sku("A-100xx"), bool(SKU.match("A-100xx")))
# True False True
Discussion