Topic 3.6
Regular Expressions with re
In one line
A regular expression (regex) is a small language for describing a pattern of text, such as "four digits, a dash, two digits". The re module finds, checks, extracts and replaces text by pattern, using raw strings so backslashes reach the regex engine untouched.
Think of it like this
A wildcard search for a lost word. "It starts with a capital, then some letters, then a number." You do not know the exact word; you know its shape. A regular expression writes such a shape in a few symbols, and the re module slides it along your text to find everything with that shape: phone numbers, dates, order ids, email-like text.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Regular expression (regex)
- A pattern written in a small special language that describes a shape of text.
- Raw string
- A string with an
rbefore the quote, in which backslashes are kept as they are:r"\d". - Metacharacter
- A character with a special meaning in a pattern, such as
.,*,+,?,[,(,\,^,$. - Quantifier
- A symbol that says how many times to repeat the thing before it.
- Group
- Part of a pattern in round brackets whose matched text can be read back afterwards.
- Match object
- What a successful search returns. It holds the matched text, its position and the groups.
- Greedy and lazy
- Greedy takes as much text as possible. Lazy (with
?) takes as little as possible. - Flag
- An option, such as
re.IGNORECASE, that changes how a pattern matches. - Backtracking
- The engine's way of trying another way to match when the first way fails.
Step by step
01Why raw strings
Python reads the backslash in a normal string first. "\n" is one newline character, but r"\n" is two characters: a backslash and the letter n, which is what the regex engine wants to see (it knows \n as "a newline" itself). For \d, the difference is the warning: Python does not know the escape \d, and says so.
print(len("\n"), len(r"\n")) # 1 2
print(r"C:\new\table") # C:\new\table (nothing is turned into a newline)02search, match and fullmatch: where must it match?
All three look for the pattern but differ in where it must sit. Use search to find something anywhere, match only when the text must start with it, and fullmatch when the whole text must have the shape. fullmatch is the right tool for validation, because match accepts extra text after the pattern.
Printing a match object shows the matched text and where it was found (span). None means no match.
The first line, import re, loads the regular-expression toolbox (Phase 6 explains import).
import re
text = "Order A-1042 shipped on 2026-10-09"
print(re.search(r"\d{4}-\d{2}-\d{2}", text))
print(re.match(r"shipped", text)) # None: text does not start with it
print(re.fullmatch(r"[A-Z]-\d+", "A-1042"))03Reading a match, and checking for None
A match object has .group() (the text), .start(), .end() and .span(). If nothing matched, you get None and None.group() raises AttributeError (Python's "this value has no such tool" error). Always test first: if m: or if m is not None:.
import re
m = re.search(r"\d+", "order 1042 shipped")
if m:
print(m.group(), m.span()) # 1042 (6, 10)
else:
print("no digits")04findall, sub and split
findall gives you all matches as a list. sub replaces every match; in the replacement text, \1, \2 refer to captured groups, which lets you rearrange parts of the match. split cuts the text wherever the pattern matches, more flexible than str.split.
When a pattern has groups, findall returns the groups (as tuples if there are several) rather than the whole match. That surprises many people.
import re
text = "A-1042 and B-77 on 2026-10-09"
print(re.findall(r"[A-Z]-\d+", text)) # ['A-1042', 'B-77']
print(re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", text))
print(re.split(r"[,.] ?", "a, b,c. d")) # ['a', 'b', 'c', 'd']05Groups and named groups
Round brackets capture. m.groups() returns them all and m.group(1) one of them. Numbers are fragile when a pattern changes, so use named groups for anything with more than one or two: (?P<year>\d{4}) is read back as m["year"] or in a dictionary with m.groupdict().
import re
pattern = r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
m = re.search(pattern, "Shipped 2026-10-09 today")
print(m.group("year")) # 2026
print(m.groupdict()) # {'year': '2026', 'month': '10', 'day': '09'}06Greedy and lazy
By default, + and * are greedy: they take as much as possible and then give back only if the rest of the pattern fails. In the HTML example, .+ runs to the end of the text, then backs up to the last >. Adding ? makes it lazy: it takes the fewest characters that let the whole pattern match.
Parsing real HTML with regular expressions is a bad idea whatever the quantifier. Nested and irregular markup needs an HTML parser. Use regexes for flat, predictable text.
import re
html = "<b>bold</b> and <i>italic</i>"
print(re.findall(r"<.+>", html)) # one big match
print(re.findall(r"<.+?>", html)) # each tag on its own07Flags, compile and verbose patterns
Flags can be combined with | (for example re.IGNORECASE | re.MULTILINE). ^ and $ normally mean the start and end of the whole text; with re.MULTILINE they mean the start and end of each line. re.VERBOSE ignores spaces and # comments inside the pattern, so a long pattern can be laid out and explained.
re.compile returns a reusable pattern object. The re module also caches recent patterns itself, so compiling is mainly about naming and reuse rather than speed.
import re
pattern = re.compile(r"""
(?P<user>[\w.]+) # the name before the @
@
(?P<host>[\w.]+\.\w+) # a domain such as example.com
""", re.VERBOSE)
m = pattern.fullmatch("ana@example.com")
print(m["user"], m["host"]) # ana example.com08Catastrophic backtracking: patterns that can freeze a program
When part of a pattern fails, the engine backtracks: it undoes a choice and tries another. Normally this is quick. But a pattern with a repetition inside a repetition, such as (a+)+$, or with overlapping alternatives, such as (a|aa)+$, can offer an enormous number of ways to split the same text. On input that almost matches but fails at the very end, the engine tries them all, and the time can double with every extra character. A string of 30 or 40 characters can then run for minutes and block the whole program.
This is a real security issue called ReDoS (regular-expression denial of service: making a service unusable by keeping it busy). If a web service runs such a pattern on text sent by a stranger, one request can freeze a server. The danger is in your patterns, not in the re module itself, and Python's re has no built-in time limit.
How to stay safe. (1) Do not put a quantifier on a group that already contains a quantifier. (2) Avoid alternatives that can match the same text. (3) Make patterns specific: [0-9]{4} rather than .*. (4) Limit the length of untrusted input before matching it. (5) Never compile a pattern that a user typed. (6) Python 3.11 added possessive quantifiers (a++) and atomic groups ((?>...)) that forbid backtracking into a part. (7) For untrusted input at scale, use a library with a time limit (the third-party regex package has a timeout argument) or a linear-time engine such as RE2. (8) Test new patterns against long near-miss input, in a separate process with a time limit, not in your main program. This course does not run a slow pattern for you; the point is to recognise the shape.
Try it yourself
- 1
Extract the order ids
In the first example, change the pattern in
findallso it also matches lower-case ids such asa-9. There are two ways: write the character class[A-Za-z], or add the flagre.IGNORECASE. Try both. - 2
Greedy against lazy
Run the HTML example with
<.+>and<.+?>. Then try<[^>]+>, which says "anything except a closing bracket". This third form is often the clearest way to avoid greediness. - 3
Rearrange with groups
Use
re.sub(r"(\w+), (\w+)", r"\2 \1", "Lovelace, Ada")to turn "Last, First" into "First Last". Try a name with a middle name and see what the pattern misses.
Code & diagrams
Expected output
<re.Match object; span=(24, 34), match='2026-10-09'>
2026-10-09 24 34 (24, 34)
match at start: True
match 'shipped': None
<re.Match object; span=(0, 6), match='A-1042'>
None
['A-1042', 'B-77']
Order A-1042 shipped on <date>, order B-77 on <date>.
['a', 'b', 'c', 'd']
NoneExpected output
('2026', '10', '09') 2026 2026-10-09
2026 {'year': '2026', 'month': '10', 'day': '09'}
Shipped 09/10/2026
[('2026', '10', '09'), ('2027', '01', '02')]
['<b>bold</b> and <i>italic</i>']
['<b>', '</b>', '<i>', '</i>']
['Hello', 'HELLO', 'hello']
[]
['one', 'two', 'three']Expected output
ana@example.com user=ana host=example.com
bad@ not an email
x.y@mail.org user=x.y host=mail.org
1 2
C:\new\tableBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget the r before the pattern
Run a pattern containing \d in a normal string.
import re
print(re.findall("\d+", "a1b22"))Break #2
Use a match without checking it
Search for digits in text that has none, then call .group().
import re
m = re.search(r"\d+", "no digits here")
print(m.group())Myth vs fact
Myth
re.match finds a pattern anywhere in the text.
Fact
It only matches at the start. Use re.search to look anywhere, and re.fullmatch to require the whole string to match.
Myth
Regular expressions are the best way to validate emails and parse HTML.
Fact
Simple checks are fine, but the real email rules and nested HTML are far too irregular for one pattern. Use a proper parser or a well-tested library, or check by sending a confirmation message.
Myth
Raw strings are a different type of string.
Fact
r"\d" is an ordinary str. The r only changes how Python reads backslashes in the source code.
Myth
Long regex patterns are always fast because they are compiled.
Fact
A pattern with nested repetitions can take exponential time on certain inputs, however it was compiled.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
CPython's
reis a backtracking engine implemented in C (_sre). It is feature-rich (lookahead, lookbehind, backreferences, conditionals), but its worst case is exponential. Backreferences and lookarounds are the reason linear-time engines like RE2 do not support everythingredoes. Theregexpackage adds timeouts, fuzzy matching, Unicode properties and possessive quantifiers. - ▸
Python 3.11 added atomic groups
(?>...)and possessive quantifiers*+,++,?+,{m,n}+. Both tell the engine "once this part has matched, do not go back into it", which removes a whole class of catastrophic cases when used deliberately. - ▸
\w,\dand\son astrmatch Unicode characters by default:\dalso matches Arabic-Indic digits, for instance, and\wmatches letters of every script. Addre.ASCIIwhen you mean plain ASCII digits and letters, especially for security checks. Also remember that$matches before a trailing newline, so usefullmatchfor strict validation. - ▸
Replacement details that cause real bugs. In
re.sub,\10is read as group 10 if it exists, so write\g<1>0for "group 1 then a zero". The replacement can be a function that receives the match and returns the new text (re.sub(r"\d", lambda m: str(int(m[0]) * 2), s)), which is how you do computed rewrites. A pattern that can match the empty string (a*) matches at every position, sore.splitandre.subon it behave in surprising ways; require at least one character with+. Capture groups also changere.split:re.split(r"(,)", "a,b")keeps the separators in the result.
Remember this
- 1
Patterns are written in raw strings: put an
rbefore the opening quote, as inr"\d+". In a normal string Python treats a backslash as the start of its own escape ("\n"is a newline). The regex language also uses backslashes (\dmeans "a digit"). A raw string tells Python to leave backslashes alone, so the pattern arrives unchanged. Without it,"\d"is an invalid escape and recent Python versions print aSyntaxWarning(a warning message, not a crash). **Maker"..."a habit for every pattern.** - 2
The building blocks. Most characters match themselves.
.matches any one character except a newline.\dis a digit,\wa letter, digit or underscore,\swhitespace; the capital versions (\D,\W,\S) mean the opposite.[abc]is any one of a, b, c;[a-z]a range;[^abc]anything but those.^and$mean the start and the end, and\ba word boundary. To match a symbol literally, put a backslash before it (\.is a real dot);re.escape(text)does this for a whole string. - 3
Quantifiers say how many:
*zero or more,+one or more,?zero or one,{4}exactly four,{2,5}two to five. Groups use round brackets:(\d{4})captures the text it matches so you can read it afterwards withgroup(1). A named group(?P<year>\d{4})is read asgroup("year"), and(?:...)groups without capturing.|means "or":cat|dog. - 4
Choose the function that matches your question.
re.search(p, s)finds the first match anywhere ins.re.match(p, s)only matches at the start.re.fullmatch(p, s)requires the whole string to match, which is what you want for validation.re.findall(p, s)returns all matches as a list,re.finditerreturns them one at a time with positions,re.sub(p, repl, s)replaces them, andre.split(p, s)cuts at them. Searching functions return a match object (orNone), which you must check before using. Calling.group()onNoneis the classic crash. - 5
The
remodule comes with Python, and you load it withimport re(Phase 6 explainsimport). The "engine" is the part ofrethat does the matching. - 6
Greedy quantifiers take as much as they can; lazy ones (a
?after the quantifier:*?,+?) take as little as they can. On"<b>bold</b> and <i>italic</i>", the pattern<.+>is greedy and swallows everything from the first<to the last>, while<.+?>stops at the first>and finds each tag. - 7
Flags change the rules:
re.IGNORECASEignores case,re.MULTILINEmakes^and$work per line,re.DOTALLlets.match newlines andre.VERBOSElets you spread a pattern over lines with comments. - 8
re.compile(pattern, flags)builds the pattern once and returns a pattern object with the same methods (search,findall, ...). Use it when a pattern is used many times or is long enough to deserve a name. And a safety topic: some patterns can take a huge amount of time on some inputs (the time can double with every extra character). This is catastrophic backtracking, covered in its own step below.
Explain it without notes
Why should regex patterns be raw strings?
What is the difference between search, match and fullmatch?
What is the difference between greedy and lazy matching? Give an example.
What is catastrophic backtracking and how do you defend against it?
Practice
Find all the whole numbers in "3 apples, 12 pears and 105 plums".
Check which of "9876543210", "98765" and "98765abcde" are exactly ten digits.
Turn "Lovelace, Ada" into "Ada Lovelace" using re.sub and groups.
Trade-offs
- ↔
Power against readability. A regex can say in one line what takes ten lines of string methods, but a regex nobody can read is a liability. Use
re.VERBOSE, named groups and comments, and prefer plain methods when they are enough. - ↔
Flexibility against safety. Backtracking engines support rich patterns but have a bad worst case. For text from strangers, keep patterns simple and inputs short.
Done when you can
Done when you can explain why patterns are raw strings.
Done when you can choose between
search,match,fullmatch,findall,subandsplit.Done when you can read and write groups, named groups, character classes and quantifiers.
Done when you can explain greedy against lazy and fix an over-greedy pattern.
Done when you can check a match for
Nonebefore using it.Done when you can recognise a pattern with nested quantifiers and say how to defend against catastrophic backtracking.