Regular expressions in Python: a practical guide with the traps
Regular expressions find and reshape text. Learn groups, greedy versus lazy, a Bangladeshi mobile number check and the backtracking trap that can freeze a server.

A regular expression (regex) describes a pattern of text, so you can find, check or rewrite it. It is excellent for logs, simple validation and clean-up, and easy to misuse for the things it cannot do well. The code and output below were run with Python 3 before publishing.
The building blocks
\da digit,\wa letter, digit or underscore,\swhitespace,.any character except a newline.+one or more,*zero or more,?optional,{4}exactly four.[A-Z]a character from a set,^start,$end.( ... )a group,(?P<name> ... )a named group.- Write patterns as raw strings (
r"...") so the backslashes reach the regex engine.
A tested tour
import re
log = "2026-10-29 ERROR payment failed order=1042 user=amina@example.com"
m = re.search(r"(?P<date>\d{4}-\d{2}-\d{2}) (?P<level>[A-Z]+) (?P<msg>.*?) order=(?P<order>\d+)", log)
print(m.groupdict())
print(re.findall(r"[\w.+-]+@[\w-]+\.[\w.]+", "mail amina@example.com or ravi@mail.test today"))
html = "<b>one</b> and <b>two</b>"
print("greedy:", re.findall(r"<b>.*</b>", html), "| lazy:", re.findall(r"<b>.*?</b>", html))
print(re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", "due 2026-11-03"))
phone = re.compile(r"^(?:\+?88)?01[3-9]\d{8}$")
print([bool(phone.match(x)) for x in ["01712345678", "+8801712345678", "8801712345678", "0171234567", "02112345678"]])
{'date': '2026-10-29', 'level': 'ERROR', 'msg': 'payment failed', 'order': '1042'}
['amina@example.com', 'ravi@mail.test']
greedy: ['<b>one</b> and <b>two</b>'] | lazy: ['<b>one</b>', '<b>two</b>']
due 03/11/2026
[True, True, True, False, False]
Things to notice:
- Named groups turn a log line into a dictionary you can read by name.
- Greedy vs lazy.
.*grabs as much as possible and swallowed both bold tags, while.*?stops at the first</b>. re.subreuses groups (\3/\2/\1) to reorder a date.- The phone check accepts
017...,+88017...and88017..., and rejects a number with too few digits. It is an example for a common Bangladeshi mobile format, not a complete rule for every valid number.
The trap: catastrophic backtracking
Some patterns take exponentially longer on input that almost matches. This is how a single request can freeze a server (ReDoS).
import re, time
def run(pattern, n):
s = "a" * n + "b"
t = time.perf_counter(); re.match(pattern, s); return time.perf_counter() - t
for n in (20, 22, 24):
print(f"(a+)+$ on {n} a's then b: {run(r'(a+)+$', n):.3f} s | a+$: {run(r'a+$', n):.6f} s")
(a+)+$ on 20 a's then b: 0.031 s | a+$: 0.000057 s
(a+)+$ on 22 a's then b: 0.121 s | a+$: 0.000006 s
(a+)+$ on 24 a's then b: 0.473 s | a+$: 0.000008 s
The nested quantifier (a+)+ roughly quadrupled each time we added two letters, while the safe a+$ stayed instant. On a longer string this becomes minutes.
How to stay safe
- Avoid nested quantifiers like
(a+)+,(.*)*and(a|aa)+. - Cap the input length before matching user text.
- Prefer simple tools when they are enough:
str.startswith,split,in, or a real parser for HTML and JSON. - For email, do a light check and send a confirmation message. A regex cannot prove an address works.
- Compile once with
re.compileif you use a pattern many times.
Common mistakes
- Using regex to parse HTML. Use a parser.
- Forgetting the raw string prefix and doubling backslashes by accident.
- Using
re.matchwhen you meantre.search(matchonly checks the start). - Forgetting anchors, so
\d{4}matches inside a longer number. - Writing one huge pattern instead of splitting the work into steps.
Main idea: use named groups, prefer lazy quantifiers where you want the shortest match, anchor your patterns, and never run a pattern with nested quantifiers on untrusted input.
Programming roadmaps series, Part 3 (writing better Python): 2 of 5. Previous: Python virtual environments and packaging: a clean setup.


