description: "Regular Expressions — re.match is a common source of confusion: it silently anchors to position 0, so a pattern that would obviously match 'somewhere' in…"---
06 · Regular Expressions¶
🎥 Video walkthrough¶
Regular expressions describe patterns in text. Python's re module lets you
search, extract, validate, and replace text based on those patterns instead of
writing manual character-by-character parsing.
Basic pattern syntax¶
| Pattern | Matches |
|---|---|
. |
any character except newline |
\d |
a digit (0-9) |
\w |
a "word" character (letters, digits, underscore) |
\s |
whitespace |
* |
0 or more of the previous token |
+ |
1 or more of the previous token |
? |
0 or 1 of the previous token (also: makes */+ non-greedy) |
{n,m} |
between n and m repetitions |
^ / $ |
start / end of string (or line, with re.MULTILINE) |
[abc] |
any one of a, b, c |
(...) |
a capturing group |
\| |
alternation ("or") |
re.search vs re.match vs re.fullmatch¶
import re
text = "order #4821 shipped"
print(re.match(r"\d+", text)) # None — match() only anchors at the START of the string
print(re.search(r"\d+", text)) # <Match object; span=(7, 11), match='4821'> — searches anywhere
print(re.fullmatch(r"order.*", text)) # matches only if the WHOLE string fits the pattern
re.match is a common source of confusion: it silently anchors to position 0,
so a pattern that would obviously match "somewhere" in the string returns
None if that text isn't right at the start. re.search is usually what you
want.
findall and finditer¶
log = "user=42 action=login user=7 action=logout"
ids = re.findall(r"user=(\d+)", log)
print(ids) # ['42', '7'] — findall returns just the captured groups
for match in re.finditer(r"user=(\d+)", log):
print(match.group(0), "->", match.group(1), "at", match.span())
# user=42 -> 42 at (0, 8)
# user=7 -> 7 at (24, 31)
finditer gives you full Match objects (position, groups) instead of just
the captured strings.
Groups — capturing and naming¶
pattern = r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
match = re.search(pattern, "Event date: 2026-07-18")
print(match.group()) # 2026-07-18 (the whole match)
print(match.group("year")) # 2026
print(match.groupdict()) # {'year': '2026', 'month': '07', 'day': '18'}
Named groups ((?P<name>...)) make code that uses the match far more
readable than relying on numeric group positions.
sub — search and replace¶
messy = "Contact: john@example.com , jane@example.com"
# collapse multiple spaces into one
cleaned = re.sub(r"\s+", " ", messy)
print(cleaned) # Contact: john@example.com , jane@example.com
# redact emails
redacted = re.sub(r"[\w.+-]+@[\w-]+\.[\w.-]+", "[email hidden]", messy)
print(redacted) # Contact: [email hidden] , [email hidden]
sub also accepts a function instead of a replacement string, called once per
match:
def censor(match):
return "*" * len(match.group())
print(re.sub(r"\d{4,}", censor, "card 483920 exp 1225"))
# card ****** exp 1225
Compiling patterns for reuse¶
If you use the same pattern repeatedly (e.g. inside a loop), compile it once.
phone_pattern = re.compile(r"\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}")
texts = ["call 555-123-4567", "or (555) 987 6543", "no number here"]
for t in texts:
match = phone_pattern.search(t)
if match:
print(match.group())
# 555-123-4567
# (555) 987 6543
Greedy vs. non-greedy¶
html = "<b>bold</b> and <i>italic</i>"
greedy = re.findall(r"<.*>", html) # greedy: grabs as much as possible
non_greedy = re.findall(r"<.*?>", html) # non-greedy: stops at the first match
print(greedy) # ['<b>bold</b> and <i>italic</i>']
print(non_greedy) # ['<b>', '</b>', '<i>', '</i>']
Common flags¶
re.search(r"python", "I love Python", re.IGNORECASE) # case-insensitive
re.findall(r"^\d+", "1 apple\n2 pears", re.MULTILINE) # ^ matches start of EACH line
How It Actually Works¶
re is a compiler and a virtual machine, just like CPython itself:
- Parse.
re.compile(r"user=(\d+)")parses the pattern string into a small syntax tree of nodes (literal, character class, group, repeat, branch). - Compile. That tree is lowered to a flat program of opcodes for the
sre(Secret Labs regex engine) VM — things likeLITERAL 117("match 'u'"),IN(character set),MARK(record a group boundary),REPEAT_ONE,BRANCH,JUMP. - Cache.
re.search(pattern_string, ...)without pre-compiling still compiles internally, then caches the compiled object (last 512 distinct patterns) so a loop callingre.searchwith the same literal pattern isn't recompiling every iteration — though pre-compiling is still clearer. - Execute. Matching runs the VM against the subject string with a
backtracking strategy: on a quantifier like
.*, the engine greedily consumes as much as possible, then backs up one character at a time whenever the rest of the pattern fails to match. Group positions are saved and restored asMARKopcodes execute.
This backtracking model is the whole reason for greedy-vs-lazy (.* vs .*?
just change which direction the engine tries first) and for catastrophic
backtracking: a pattern like (a+)+$ against "aaaa...b" forces the engine
to try an exponential number of ways to partition the a's before concluding
failure. re.match vs re.search is not a VM difference — match simply
starts the VM only at position 0, while search retries it at each successive
start position.
Exercise¶
Write a function extract_hashtags(text) that returns a list of unique
hashtags (without the #) found in a piece of text using re.findall, and a
function mask_credit_cards(text) that replaces any 16-digit number
(optionally separated by spaces or dashes into groups of 4) with
"**** **** **** " + last 4 digits, using a named group and re.sub.