re Module Complexity¶
The re module provides regular expression matching operations.
Throughout, n is the length of a pattern, m the length of the subject
string, k the number of matches, c the number of capturing groups, and g
the total length of the text captured or matched.
Pattern Compilation and Matching¶
| Operation | Time | Space | Notes |
|---|---|---|---|
re.compile(pattern) |
O(n) | O(n) | n = pattern length |
pattern.match(string) |
O(m) typical, O(exp) worst | O(m) | Worst case from backtracking |
pattern.fullmatch(string) |
O(m) typical, O(exp) worst | O(m) | Match entire string |
pattern.search(string) |
O(m) typical, O(exp) worst | O(m) | Searches full string |
pattern.findall(string) |
O(m) typical, O(exp) worst | O(k + g) | A list of k copies, so the matched text counts too |
pattern.finditer(string) |
O(m) typical, O(exp) worst | O(1) | Lazy: the whole scan still costs O(m), spread across the steps |
pattern.sub(repl, string) |
O(m) typical, O(exp) worst | O(m) | Match + build output |
pattern.subn(repl, string) |
O(m) typical, O(exp) worst | O(m) | Like sub, returns (newstr, count) |
pattern.split(string) |
O(m) typical, O(exp) worst | O(m) | Split by pattern |
re.match(pattern, string) |
O(n + m) typical, O(exp) worst | O(m) | Compiles on cache miss; cached up to ~512 |
re.search(pattern, string) |
O(n + m) typical, O(exp) worst | O(m) | Compiles on cache miss; cached up to ~512 |
re.escape(string) |
O(n) | O(n) | Escape special regex characters |
re.purge() |
O(cache) | O(1) | Empties the compiled-pattern caches and the replacement-template cache |
re.error |
- | - | Exception for invalid patterns |
*Note: "O(exp)" denotes exponential time in m from catastrophic backtracking; typical cases are much better.
Match Objects¶
A match records positions into the subject string. Reading a position is constant; asking for the text at that position copies it.
| Operation | Time | Space | Notes |
|---|---|---|---|
match.span(), match.start(), match.end() |
O(1) | O(1) | Two integers, already held |
match.group(i) |
O(g) | O(g) | Slices the subject, so it copies the captured text; a group spanning the whole subject is returned unchanged |
match.groups() |
O(c + g) | O(c + g) | One slice per capturing group |
match.groupdict() |
O(c + g) | O(c + g) | The same slices, in a dict, for the named groups only |
match.expand(template) |
O(t + g) | O(t + g) | t = template length |
match.lastindex, match.re, match.string |
O(1) | O(1) | Attribute access |
When only the positions matter, span() avoids building the substring at all.
Pattern Caching¶
| Operation | Time | Space | Notes |
|---|---|---|---|
re.match(pattern, s) |
O(n + m) | O(n+m) | Compiles on cache miss; cached up to ~512 |
compiled = re.compile(p) |
O(n) | O(n) | Explicit compilation |
compiled.match(s) |
O(m) typical, O(exp) worst | O(m) | Uses the already-compiled pattern |
CPython caches the last ~512 compiled patterns automatically, dropping one entry when a new pattern overflows it rather than emptying the cache. From Python 3.12 the entry dropped is the least recently used, and a 256-entry FIFO sits in front as a fast path. On 3.10 and 3.11 a hit left the entry where it was, so the one dropped was the oldest inserted however often it had been used.
Common Operations¶
Basic Pattern Matching¶
import re
# Compile pattern once - O(n) where n = pattern length
pattern = re.compile(r'\d+') # O(n)
# Use compiled pattern - O(m) per match, m = string length
text = "Number: 12345"
match = pattern.search(text) # O(m)
if match:
print(match.group()) # O(g) - the matched text is copied out
Finding All Matches¶
import re
pattern = re.compile(r'\w+')
text = "Hello world from Python"
# Find all - O(m) where m = text length
matches = pattern.findall(text) # O(m)
# Result: ['Hello', 'world', 'from', 'Python']
# Lazy iteration - O(1) memory, but the scan still costs O(m) overall:
# each step scans forward to the next match
for match in pattern.finditer(text):
print(match.group())
Substitution¶
import re
pattern = re.compile(r'\d+')
text = "Numbers: 10, 20, 30"
# Replace all - O(m)
result = pattern.sub('X', text) # O(m)
# Result: "Numbers: X, X, X"
# Replace with function - O(m) for matching, O(f) for replacements
def replace_func(match):
return str(int(match.group()) * 2)
result = pattern.sub(replace_func, text) # O(m + f)
# Result: "Numbers: 20, 40, 60"
Splitting¶
import re
pattern = re.compile(r',\s*')
text = "apple, banana, cherry"
# Split by pattern - O(m)
parts = pattern.split(text) # O(m)
# Result: ['apple', 'banana', 'cherry']
Grouping and Extraction¶
import re
pattern = re.compile(r'(\d+)-(\w+)')
text = "123-abc"
# Extract groups - O(m)
match = pattern.search(text) # O(m)
if match:
full = match.group(0) # O(g) - copies the matched text
num = match.group(1) # O(g) - copies the first group
word = match.group(2) # O(g) - copies the second group
# Positions instead of text - O(1), nothing is copied
start, end = match.span(1)
# Get all groups - O(c + g) for c capturing groups of total length g
groups = match.groups() # All groups as tuple
Pattern Complexity¶
Simple Patterns (Linear Matching)¶
import re
# Simple patterns - O(m) matching
pattern = re.compile(r'hello')
text = "hello world" * 1000
match = pattern.search(text) # O(m) - linear scan
Complex Patterns (Potential Exponential)¶
import re
# Be careful with patterns that can cause backtracking
# This pattern can cause exponential backtracking on non-matches
pattern = re.compile(r'(a+)+b')
text = 'a' * 25 # No 'b' at end
# This can be very slow!
# match = pattern.search(text) # Potentially exponential!
Catastrophic Backtracking Examples¶
The blow-up needs a subject that fails to match. (a+)+$ against a string
of nothing but a succeeds on the first attempt and returns immediately; add
one character that cannot match and the same pattern has to try every way of
splitting the as between the two quantifiers.
import re
import sys
# AVOID: nested quantifiers, on input that does not match
bad_pattern = re.compile(r'(a+)+b')
bad_pattern.search('a' * 8) # fine at this size
# bad_pattern.search('a' * 24) # seconds, and doubling with each extra 'a'
# BETTER: remove the nesting, so there is nothing to redistribute
good_pattern = re.compile(r'a+b')
good_pattern.search('a' * 24)
# Python 3.11+: keep the nesting but forbid the backtracking
if sys.version_info >= (3, 11):
atomic = re.compile(r'(?>a+)+b')
possessive = re.compile(r'(a++)+b')
atomic.search('a' * 24)
possessive.search('a' * 24)
Performance Tips¶
Compile Patterns Once¶
import re
lines = ["no digits here", "order 66", "still nothing"]
matched = []
# Bad: looks the pattern up in the cache on every call
for line in lines:
if re.search(r'\d+', line): # cache hit, but still a lookup
matched.append(line)
# Good: compile once - O(n)
pattern = re.compile(r'\d+') # O(n)
for line in lines:
if pattern.search(line): # O(m) per line
matched.append(line)
Use Lazy Iteration¶
import re
pattern = re.compile(r'\w+')
text = " ".join(f"word{i}" for i in range(10000))
longest = 0
# Bad: materializes every match - O(k + g) memory for k matches
all_matches = pattern.findall(text) # All in memory at once
# Good: lazy iteration - O(1) memory, one match alive at a time
for match in pattern.finditer(text):
longest = max(longest, match.end() - match.start())
Anchors for Efficiency¶
An unanchored search retries at every position in the subject, so a pattern
that cannot match anywhere costs O(m) attempts. From Python 3.11 a leading ^
lets the engine give up after the first attempt, which is where the saving is -
not in backtracking within a match. Before 3.11 the anchor bought nothing: the
retry loop ran anyway, and the assertion simply failed at each position, which
made the anchored form the slower of the two.
import re
unanchored = re.compile(r'start.*end')
anchored = re.compile(r'^start.*end')
# Matches at position 0: nothing to save, and the anchor is not free
text = "start middle end"
unanchored.search(text) # O(m)
anchored.search(text) # O(m)
# No match anywhere: on 3.11+ the anchor stops the retry at every offset
haystack = "x" * 100_000
unanchored.search(haystack) # one attempt per position
anchored.search(haystack) # one attempt, then done - O(1) from 3.11
Special Considerations¶
Raw Strings¶
import re
# Use raw strings to avoid double escaping
pattern = re.compile(r'\d+') # O(n)
# NOT: re.compile('\\d+') # confusing
# For file paths
pattern = re.compile(r'C:\\Users\\.*') # Windows paths
Groups and Performance¶
import re
# Capturing groups have slight overhead
pattern = re.compile(r'(\d+)') # With group
text = "12345"
match = pattern.search(text) # O(m) + group overhead
# Non-capturing group - slightly faster
pattern = re.compile(r'(?:\d+)') # Non-capturing
match = pattern.search(text) # O(m)
Version Notes¶
- Python 3.x:
remodule is standard - Python 2.x: Similar but with different Unicode handling
- Python 3.11+: atomic groups
(?>...)and possessive quantifiers*+,++,?+- the direct fix for catastrophic backtracking, since they keep the pattern's meaning and forbid the redistribution that causes it - Python 3.11+: a
searchfor a pattern anchored with^stops after the first attempt instead of retrying at every position; before that, anchoring made an unmatchable search slower rather than faster - Python 3.12+: the compiled-pattern cache became a true LRU - a hit now re-records the entry as most recently used - and gained a 256-entry FIFO in front of it as a fast path of compiled patterns
Related Modules¶
Best Practices¶
✅ Do:
- Compile patterns once, reuse them
- Use raw strings (r'...')
- Use
pattern.finditer()for lazy matching - Test patterns on realistic data
- Use anchors (^, $) to limit search - on Python 3.11+ this ends it after one attempt
❌ Avoid:
- Nested quantifiers (a+)+; on Python 3.11+ make them atomic instead
- Alternation with overlap (
foo|fo) - Recompiling patterns in loops
- Greedy quantifiers where not needed (use .*?)
- Testing without considering catastrophic backtracking
- Calling
group()whenspan()would do - it copies the matched text
Further Reading¶
- CPython Internals: re - Deep dive into CPython's re implementation