Advanced · Lesson 61 of 94

Regular expressions

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

A regular expression is a pattern that describes a set of strings. \d{4}-\d{2}-\d{2} describes dates like 2026-09-08; [A-Z][a-z]+ describes a capitalized word. The re module compiles a pattern and applies it to text: does the text contain a match, where, and what were the pieces. Regexes are compact, powerful, and easy to write badly. The goal of this lesson is to make you fluent in the ninety percent of syntax that covers real work and honest about the cases where a regex is the wrong choice.

Syntax Matches Example → matches
abc those literal characters cat → cat
. any character except newline c.t → cat, cut
\d \w \s digit, word character, whitespace \d\d → 42
[aeiou] [^0-9] one of these / anything but these [A-Z] → Q
* + ? 0 or more, 1 or more, 0 or 1 go+d → god, good
{n} {n,m} exactly n, between n and m \d{4} → 2026
^ $ start and end of the string (or line with M) ^# → a comment line
(...) a group, captured (\d+)-(\d+) → two numbers
(?P<name>...) a named group (?P<year>\d{4})
a|b alternation cat|dog
\. \( a literal special character \.py$

The functions differ in where they look. re.search finds the first match anywhere. re.match matches only at the start, and re.fullmatch requires the whole string to match, which is what validation wants. re.findall returns every match as a list of strings, or of tuples when the pattern has groups. re.finditer yields match objects one at a time. re.sub replaces matches, and the replacement can refer to groups. Always write patterns as raw strings, r"...", so backslashes reach the regex engine intact, and compile a pattern you use repeatedly with re.compile.

Groups are how you extract. Parentheses capture the part they enclose, and match.group(1) returns it; (?P<year>\d{4}) names it, so match["year"] reads well and survives reordering.

Regular expressions: 'Order 42 shipped 2026-09-08' → r'(?P<id>\\d+).*?(?P<date>\\d{4}-\\d{2}-\\d{2})' → match['id' → 'date'
Regular expressions · Figure 1 · Open for full size

Quantifiers are greedy by default, taking as much as they can, so <.+> on <a><b> matches the whole thing; <.+?> is lazy and stops at the first >. Flags change behavior: re.IGNORECASE, re.MULTILINE for ^/$ per line, and re.VERBOSE to spread a pattern over commented lines, which is how any pattern longer than a line should be written.

Regexes are the wrong tool more often than beginners expect. Checking whether a string starts with a prefix is startswith. Splitting on a fixed separator is split. Parsing HTML, JSON, or a programming language is a parser's job, and a regex will break on the first nested or escaped case. Validating an email address fully is famously impossible with a regex; check the shape loosely and send a confirmation. The rule: reach for re when you need a pattern, a class of characters, or a repeat; reach for string methods when you need a fixed substring; reach for a real parser when the text has a grammar.

Regular expressions: str methods: in, startswith, split, replace → a parser: json, html.parser, ast → re: pattern, class, repeat, groups
Regular expressions · Figure 2 · Open for full size

Code

The search family: search, match, fullmatch, findall, and finditer on the same text, showing what each one looks at.

# search_family.py
import re

text = "Order 42 shipped 2026-09-08; order 7 shipped 2026-09-10"
date = r"\d{4}-\d{2}-\d{2}"

print(re.search(date, text).group())
print(re.match(date, text))
print(re.match(r"Order \d+", text).group())
print(re.fullmatch(r"\d{4}-\d{2}-\d{2}", "2026-09-08") is not None, re.fullmatch(date, "2026-9-8") is not None)
print(re.findall(date, text))
print([(m.start(), m.group()) for m in re.finditer(r"\d+", text)][:3])
Output
2026-09-08
None
Order 42
True False
['2026-09-08', '2026-09-10']
[(6, '42'), (17, '2026'), (22, '09')]

Groups, named groups, and findall with groups returning tuples. A compiled pattern with VERBOSE keeps a longer regex readable.

# groups.py
import re

log = """2026-09-08 14:30:15 ERROR db: connection timed out
2026-09-08 14:30:16 INFO  web: request served in 12ms
2026-09-08 14:31:02 ERROR web: upstream 502"""

line = re.compile(r"""
    (?P<date>\d{4}-\d{2}-\d{2})\s+
    (?P<time>\d{2}:\d{2}:\d{2})\s+
    (?P<level>[A-Z]+)\s+
    (?P<component>\w+):\s+
    (?P<message>.*)
""", re.VERBOSE)

for m in line.finditer(log):
    if m["level"] == "ERROR":
        print(m["time"], m["component"], "->", m["message"])

print(re.findall(r"(\w+): (\w+)", "a: 1, b: 2"))
m = re.search(r"(\d+)ms", log)
print(m.group(1), int(m.group(1)) * 2, m.groups())
Output
14:30:15 db -> connection timed out
14:31:02 web -> upstream 502
[('a', '1'), ('b', '2')]
12 24 ('12',)

Substitution, greedy versus lazy, and flags. sub can use a group reference or a function; the lazy +? stops at the first closing tag.

# substitute.py
import re

print(re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", "due 2026-09-08 and 2026-12-01"))
print(re.sub(r"\s+", " ", "too   many    spaces").strip())
print(re.sub(r"\d+", lambda m: str(int(m.group()) * 2), "3 apples, 10 pears"))

html = "<b>bold</b> and <i>italic</i>"
print(re.findall(r"<.+>", html))
print(re.findall(r"<(.+?)>", html))

print(re.findall(r"^\w+", "first line\nsecond line\nthird", re.MULTILINE))
print(re.search(r"python", "I like PYTHON", re.IGNORECASE).group())
Output
due 08/09/2026 and 01/12/2026
too many spaces
6 apples, 20 pears
['<b>bold</b> and <i>italic</i>']
['b', '/b', 'i', '/i']
['first', 'second', 'third']
PYTHON

Validation with fullmatch, and the right tool for the wrong jobs. The string-method versions are shorter and clearer; the loose email check is honest about what it can promise.

# right_tool.py
import re

for candidate in ["ab12", "AB-12", "ab123", "ab1"]:
    print(candidate, bool(re.fullmatch(r"[a-z]{2}\d{2}", candidate)))

path = "reports/2026/q3.csv"
print(path.endswith(".csv"), path.split("/")[-1], "2026" in path)

loose_email = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
for email in ["ada@example.com", "not an email", "a@b.c"]:
    print(email, "looks like an email:", bool(loose_email.match(email)))
Output
ab12 True
AB-12 False
ab123 False
ab1 False
True q3.csv True
ada@example.com looks like an email: True
not an email looks like an email: False
a@b.c looks like an email: True

Try it yourself

Create extract.py with a block of text containing several phone numbers in different formats, some dates, and a few URLs. Write one VERBOSE pattern with named groups for phone numbers, extract them with finditer, normalize them to digits only with sub, and print each before and after. Then write a fullmatch validator for a product code format like AB-1234, and write down in a comment one job in your text that you deliberately did not use a regex for.

Common mistakes

What's next

You can find and shape text by pattern. Next you will make your programs tell you what they are doing in production, replacing print with the logging module.

Course overview