Command Palette

Search for a command to run...

Hectal
PHASE 3Beginner ~30 min· topic 6 of 7

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 r before 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.

main.pywhole filepython
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).

main.pywhole filepython
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"))
search, match and fullmatch: where must it match?diagram
Rendering diagram…

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:.

main.pywhole filepython
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.

main.pywhole filepython
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().

main.pywhole filepython
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.

main.pywhole filepython
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 own

07Flags, 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.

main.pywhole filepython
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.com

08Catastrophic 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. 1

    Extract the order ids

    In the first example, change the pattern in findall so it also matches lower-case ids such as a-9. There are two ways: write the character class [A-Za-z], or add the flag re.IGNORECASE. Try both.

  2. 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. 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

search, match, fullmatch, findall, sub, split New tab
Sign in to run this example in your browser.

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']
None
Groups, named groups, greedy and lazy, flags New tab
Sign in to run this example in your browser.

Expected 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']
A compiled, commented pattern New tab
Sign in to run this example in your browser.

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\table
Which function do I need?diagram
Rendering diagram…

Break 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.

main.pywhole filepython
import re

print(re.findall("\d+", "a1b22"))
terminal
$ python main.py
── what you'll see ──
main.py:3: SyntaxWarning: invalid escape sequence '\d'
print(re.findall("\d+", "a1b22"))
['1', '22']

Break #2

Use a match without checking it

Search for digits in text that has none, then call .group().

main.pywhole filepython
import re

m = re.search(r"\d+", "no digits here")
print(m.group())
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 4, in <module>
print(m.group())
^^^^^^^
AttributeError: 'NoneType' object has no attribute '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 re is 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 everything re does. The regex package 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, \d and \s on a str match Unicode characters by default: \d also matches Arabic-Indic digits, for instance, and \w matches letters of every script. Add re.ASCII when you mean plain ASCII digits and letters, especially for security checks. Also remember that $ matches before a trailing newline, so use fullmatch for strict validation.

  • ▸

    Replacement details that cause real bugs. In re.sub, \10 is read as group 10 if it exists, so write \g<1>0 for "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, so re.split and re.sub on it behave in surprising ways; require at least one character with +. Capture groups also change re.split: re.split(r"(,)", "a,b") keeps the separators in the result.

Remember this

  1. 1

    Patterns are written in raw strings: put an r before the opening quote, as in r"\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 (\d means "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 a SyntaxWarning (a warning message, not a crash). **Make r"..." a habit for every pattern.**

  2. 2

    The building blocks. Most characters match themselves. . matches any one character except a newline. \d is a digit, \w a letter, digit or underscore, \s whitespace; 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 \b a 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. 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 with group(1). A named group (?P<year>\d{4}) is read as group("year"), and (?:...) groups without capturing. | means "or": cat|dog.

  4. 4

    Choose the function that matches your question. re.search(p, s) finds the first match anywhere in s. 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.finditer returns them one at a time with positions, re.sub(p, repl, s) replaces them, and re.split(p, s) cuts at them. Searching functions return a match object (or None), which you must check before using. Calling .group() on None is the classic crash.

  5. 5

    The re module comes with Python, and you load it with import re (Phase 6 explains import). The "engine" is the part of re that does the matching.

  6. 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. 7

    Flags change the rules: re.IGNORECASE ignores case, re.MULTILINE makes ^ and $ work per line, re.DOTALL lets . match newlines and re.VERBOSE lets you spread a pattern over lines with comments.

  8. 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

01

Why should regex patterns be raw strings?

02

What is the difference between search, match and fullmatch?

03

What is the difference between greedy and lazy matching? Give an example.

04

What is catastrophic backtracking and how do you defend against it?

Practice

01

Find all the whole numbers in "3 apples, 12 pears and 105 plums".

02

Check which of "9876543210", "98765" and "98765abcde" are exactly ten digits.

03

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, sub and split.

  • 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 None before using it.

  • Done when you can recognise a pattern with nested quantifiers and say how to defend against catastrophic backtracking.