Pitfalls
Regular Expressions in Practice
Chapter 9 ยท Performance and Pitfalls
Most patterns are fast and most mistakes are quiet: the pattern runs, returns something, and the something is wrong. This chapter collects the problems that cause the most trouble in practice. First comes the rare but severe one, a pattern that takes minutes to fail. Then come the everyday traps, and finally the question of when a plain string method is the better tool.
Backtracking in one paragraph
When a pattern has a choice to make (a quantifier that can take more or fewer characters, or an alternation), the engine picks one option and remembers the others. If the rest of the pattern fails, it goes back and tries the next option. That is backtracking, and it is what makes patterns flexible. It is harmless when there are few choices. It becomes a problem when the number of ways to split the text grows very fast.
Catastrophic backtracking
The classic cause is a quantifier inside a quantifier, where both can match the same characters. In ^(a+)+$ the inner a+ and the outer + can share a run of letters in many different ways. If the text then fails at the very end (a trailing b), the engine tries every one of those ways before giving up. Each extra character roughly doubles the work.
Four extra characters made the risky pattern many times slower, and the growth continues: a few dozen characters would take longer than you would wait. The safe pattern handles a hundred thousand characters with no trouble. Both patterns accept exactly the same strings. Only the amount of work differs.
This matters most when the text comes from someone else, such as a web form. A user who submits a long string that nearly matches can tie up your program. This is sometimes called a regular expression denial of service.
How to avoid it
- Do not put a repeat around something that already repeats, unless the pieces cannot match the same text.
(a+)+is the warning sign. - Be careful with alternations whose branches overlap inside a repeat, such as
(a|aa)+. - Prefer a specific class over
.*. A pattern that can only match the right characters has fewer choices to explore. - Test with a long near-miss, not only with matching text. The slow case is the one that fails at the end.
- Limit the length of untrusted input before matching it.
Pitfall: greedy matching across a boundary
Chapter 1 introduced greedy and lazy quantifiers. It is worth repeating because it is the most common wrong answer. A greedy .* runs to the last possible end, so it can swallow several items at once. There are two fixes, and the second is usually better:
The lazy form stops at the first closing tag. The negated class [^<]* says what the text is allowed to contain, and so it also gives the engine nothing to backtrack over. A class that excludes the closing delimiter is both clearer and faster.
Pitfall: match is not search
re.match only tries the start of the string. Reaching for it when you meant search gives None and no error message.
Pitfall: the dollar sign and the newline
$ matches at the end of the string and just before a final newline. So a value that looks validated can still carry a newline. For validation, use fullmatch, or \Z, which matches only at the true end.
Pitfall: \d is wider than 0 to 9
For text (str) patterns, \d matches digits from other writing systems too, which may not be what a validator wants. Use [0-9] or the re.ASCII flag when you mean the ordinary digits.
Pitfall: alternation takes the first branch that works
Alternation is tried left to right and the first branch that lets the whole pattern succeed wins, even if a later branch would match more. Put longer alternatives first.
Pitfall: user text inside a pattern
If a pattern is built from text you did not write, characters such as ., + and ( will be read as pattern syntax. re.escape turns them into literals. Without it, the search can match the wrong text, or fail to compile.
When not to use a pattern
A pattern is a tool for text with shape. If you are looking for fixed text, the built-in string methods are shorter, faster and cannot be broken by special characters.
| Situation | Better tool | Why |
|---|---|---|
| Fixed text present or absent | in, startswith, endswith | No syntax to get wrong |
| Replace one fixed piece of text | str.replace | Clear and fast |
| Split on one fixed separator | str.split | No pattern needed |
| Text with variable shape (digits, optional parts) | re | This is what patterns are for |
| Nested or recursive structure (HTML, JSON, source code) | A real parser | A pattern cannot track nesting reliably |
| Untrusted long input | A length limit, then a simple pattern | Bounds the worst case |
Practical habits
- Test with long near-misses, not only with matching text.
- Avoid a repeat of something that itself repeats.
- Prefer a negated class to
.*when you know the closing character. - Use
fullmatchfor validation andsearchfor finding. - Escape any text that comes from outside with
re.escape. - Ask first whether a string method will do.
Challenges
The pattern ^(\d+)+$ accepts strings made only of digits, but it is dangerous on a long string that ends in a non-digit. Write a safe pattern with the same meaning, and show that it returns quickly (under 0.1 seconds) on 5000 ones followed by x. Also check that the old and new patterns agree on the short inputs "", "7", "77", "7a", "a7" and "12 3". Do not run the old pattern on the long input.
Given <p>Hello <i>big</i> and <i>small</i> world, <i>end</i></p>, first show what <i>.*</i> finds with findall and explain the problem. Then write a pattern using a negated class that returns just the three words inside the tags.
Write count_literal(needle, text) that counts how many times a piece of text occurs, using re.findall and re.escape. Test it on the needles 1.5, (a+b) and a+b against Price 1.5, price 1x5, code (a+b) and (a+b) again, 1.5 end, and print each count beside text.count(needle). Then show how many matches the unescaped pattern 1.5 finds, and say which tool you would really use here.
Quick Reference
- Catastrophic backtracking: a repeat inside a repeat over the same characters, such as
(a+)+; work roughly doubles per extra character on failure - Greedy overreach: use
.*?or, better, a negated class like[^<]* - match vs search:
matchonly tries the start of the string - $ and newline:
$allows a final newline; usefullmatchor\Z - \d: matches other scripts' digits; use
[0-9]orre.A - Alternation: first working branch wins; put longer branches first
- re.escape: makes outside text literal inside a pattern
- Not a pattern job: fixed text (string methods) and nested structure (a parser)