Intermediate Python 📂 Modules · 4 of 6 46 min read

Python Regular Expressions

Master Python regex from definition to advanced patterns. Learn what a regular expression is, every function in the re module (search, match, findall, sub, compile), and every special character (\d, \w, \s, anchors, quantifiers, groups, lookaheads). Includes two visual diagrams — search vs match vs findall behavior and email regex anatomy — a common pattern library, and six exercises from simple extraction to strong password validation.

Section 01

The Story That Explains Regular Expressions

The Universal Search Warrant
Imagine you're a detective handed a warehouse of a million documents. Someone says: "Find every phone number in there." You could hire 100 people and read every document by hand. Or you could hand them one small piece of paper — a pattern description. It says: "a plus sign, then between 10 and 13 digits, with optional spaces or hyphens." Every person reads their stack looking for anything that matches that shape. Ten minutes later, you have every phone number in the building.

That paper — the description of a shape rather than an exact word — is a regular expression. It's a mini-language for describing patterns of text, not just literal text. And Python's re module is the engine that runs the search.

Regex is the difference between "find the word 'error'" and "find any line that starts with a timestamp, contains 'error' or 'warning', and ends with a stack trace." One is a search box. The other is superpower.

Section 02

What Is a Regular Expression?

A regular expression (regex or regexp) is a sequence of characters that defines a search pattern. Instead of searching for exact text, you describe the shape of the text you want to find.

A regex is written as a string, but each character in that string means something special. Some characters — like letters and digits — match themselves literally. Others — like ., *, +, ? — are metacharacters: instructions to the engine.

🧠 A Regex Says Three Things
What
Which characters to accept — letters, digits, whitespace, anything
Where
Where in the text to match — start, end, word boundary, anywhere
How many
How many times to repeat — once, optional, one or more, exactly N
import re

# The simplest regex: literal text
print(re.search("hello", "say hello world"))
# <re.Match object; span=(4, 9), match='hello'>

# A pattern: any digit, one or more times
print(re.findall(r"\d+", "order 42 shipped 17 items"))
# ['42', '17']

# A pattern: exactly the shape of a UK postcode-ish thing
print(re.search(r"[A-Z]{1,2}\d[A-Z\d]? \d[A-Z]{2}", "Send to SW1A 1AA today"))
# <re.Match object; span=(8, 15), match='SW1A 1AA'>
💡
The r"" Raw String Convention

You'll always see regex patterns written as r"\d+", not "\d+". The r makes it a raw string — Python doesn't interpret backslashes. Without it, "\n" becomes a newline instead of "backslash then n". Raw strings prevent constant double-backslash headaches. Always use them for regex.


Section 03

The re Module — Complete Function Reference (Searching)

The re module has five functions for finding matches. They differ in where they look and what they return.

1. re.search() — Find the First Match Anywhere

import re

text = "The rain 42 falls 17 on 8 plains"

result = re.search(r"\d+", text)
print(result)              # <re.Match; span=(9, 11), match='42'>
print(result.group())      # '42' — the matched text
print(result.start())      # 9 — starting position
print(result.end())        # 11 — ending position (exclusive)

# Returns None if no match — always check!
if re.search(r"xyz", text):
    print("found")
else:
    print("not found")

2. re.match() — Only At The Start

import re

# match() only checks if the pattern matches FROM THE START
print(re.match(r"\d+", "42 items"))    # matches — starts with 42
print(re.match(r"\d+", "order 42"))   # None — starts with 'o'

# Useful for validating input format
if re.match(r"\d{4}-\d{2}-\d{2}$", "2026-07-12"):
    print("valid date format")

3. re.fullmatch() — The ENTIRE String Must Match

import re

# fullmatch() requires the pattern to consume the WHOLE string
print(re.fullmatch(r"\d+", "42"))         # matches
print(re.fullmatch(r"\d+", "42 items"))   # None — extra text after digits

# Cleanest way to validate an entire input string
if re.fullmatch(r"\d{6}", user_input):
    print("valid 6-digit code")

4. re.findall() — Every Match as a List of Strings

import re

text = "The rain 42 falls 17 on 8 plains"

print(re.findall(r"\d+", text))
# ['42', '17', '8']

# With groups, findall returns tuples
text = "John: 25, Alice: 30, Bob: 22"
print(re.findall(r"(\w+): (\d+)", text))
# [('John', '25'), ('Alice', '30'), ('Bob', '22')]

5. re.finditer() — Every Match as a Lazy Iterator

import re

text = "The rain 42 falls 17 on 8 plains"

# Yields Match objects one at a time — memory efficient
for m in re.finditer(r"\d+", text):
    print(f"'{m.group()}' at position {m.start()}")

# '42' at position 9
# '17' at position 18
# '8'  at position 24

Section 04

The re Module — Complete Function Reference (Editing)

6. re.sub() — Search and Replace

import re

# Replace all digits with #
print(re.sub(r"\d", "#", "Order 42 for £17.50"))
# Order ## for £##.##

# Use groups in the replacement string
print(re.sub(r"(\w+) (\w+)", r"\2 \1", "Alice Smith"))
# Smith Alice — swap first/last names

# Limit the number of replacements
print(re.sub(r"\d", "#", "1-2-3-4-5", count=2))
# #-#-3-4-5

# Replacement can be a FUNCTION for dynamic logic
def double(match):
    return str(int(match.group()) * 2)

print(re.sub(r"\d+", double, "a 3 b 5 c 7"))
# a 6 b 10 c 14

7. re.subn() — Substitute + Count

import re

# Same as sub, but also returns HOW MANY replacements happened
new_text, count = re.subn(r"\d", "#", "1-2-3-4-5")
print(new_text)    # #-#-#-#-#
print(count)       # 5

8. re.split() — Split By Any Pattern

import re

# Regular string split only takes one literal separator
# re.split can use a PATTERN — any whitespace, any punctuation

print(re.split(r"\s+", "one    two\tthree\nfour"))
# ['one', 'two', 'three', 'four']

print(re.split(r"[,;|]", "apple,banana;cherry|kiwi"))
# ['apple', 'banana', 'cherry', 'kiwi']

# Limit the number of splits
print(re.split(r"-", "a-b-c-d-e", maxsplit=2))
# ['a', 'b', 'c-d-e']

9. re.compile() — Reusable Pre-Compiled Patterns

import re

# If you use the same pattern many times, COMPILE it once
digit_pattern = re.compile(r"\d+")

# Now use it like a regular re function, but faster
print(digit_pattern.findall("a 12 b 34"))    # ['12', '34']
print(digit_pattern.search("only 99 here"))    # Match object
print(digit_pattern.sub("#", "1 to 100"))       # '# to #'

# Best practice for loops or repeated calls
email_re = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
for line in log_lines:
    for email in email_re.findall(line):
        print(email)

Section 05

Diagram — search vs match vs findall

These three functions look similar in signature but do very different things. Once you can visualise the difference, you'll never reach for the wrong one.

Same pattern \d+, same text, three different results
INPUT TEXT The rain 42 falls 17 on 8 plains re.match(r"\d+", text) — Only checks position 0 Position 0 is 'T' — not a digit returns None re.search(r"\d+", text) — Finds FIRST match anywhere Stops at first hit — '42' Match: '42' re.findall(r"\d+", text) — Grabs EVERY match All three hits — complete list ['42', '17', '8']

match asks: "does it start with this?". search asks: "is it in there somewhere?". findall asks: "give me every one."


Section 06

The Match Object — What You Get Back

search, match, fullmatch, and each iteration of finditer return a Match object — a rich container with everything you need about where and what matched.

import re

m = re.search(r"(\w+)@(\w+\.\w+)", "Contact: alice@example.com today")

# The whole match
print(m.group())        # 'alice@example.com' — same as group(0)
print(m.group(0))       # 'alice@example.com'

# Groups by number (1-indexed)
print(m.group(1))       # 'alice' — first ()
print(m.group(2))       # 'example.com' — second ()

# All groups as a tuple
print(m.groups())       # ('alice', 'example.com')

# Position info
print(m.start())        # 9 — index where match begins
print(m.end())          # 26 — index where match ends
print(m.span())         # (9, 26) — both as tuple

# NAMED groups — much easier to read
m2 = re.search(r"(?P<user>\w+)@(?P<domain>\w+\.\w+)", "alice@example.com")
print(m2.group("user"))      # 'alice'
print(m2.group("domain"))    # 'example.com'
print(m2.groupdict())         # {'user': 'alice', 'domain': 'example.com'}
🔑
Always Check for None Before .group()

If re.search finds nothing, it returns None. Calling None.group() throws AttributeError. Idiom: m = re.search(...); if m: use(m.group()). Or use the walrus operator: if m := re.search(...): use(m.group()).


Section 07

Special Characters — Character Classes

A character class matches any one character from a set. Python's re gives you shortcuts for the most common sets, plus square brackets for custom ones.

PatternMeaningExample Match
.Any character except newlinea, 5, @, space...
\dAny digit (0-9)0, 1, 2, ... 9
\DAny non-digita, @, space
\wWord character (letter, digit, underscore)a, Z, 5, _
\WNon-word character@, space, !
\sWhitespace (space, tab, newline)' ', '\t', '\n'
\SNon-whitespacea, 5, @
[abc]Any one of a, b, or ca, b, c
[a-z]Any lowercase lettera, b, ... z
[A-Z0-9]Any uppercase letter or digitA, ... Z, 0, ... 9
[^abc]Anything EXCEPT a, b, or cd, e, @, 1
import re

# . matches any single character
print(re.findall(r"c.t", "cat cot cut c@t c t"))
# ['cat', 'cot', 'cut', 'c@t', 'c t']

# \d for digits
print(re.findall(r"\d\d\d", "call 999 or 112"))
# ['999', '112']

# Custom class — only vowels
print(re.findall(r"[aeiou]", "hello world"))
# ['e', 'o', 'o']

# NEGATED class — everything BUT vowels
print(re.findall(r"[^aeiou\s]", "hello world"))
# ['h', 'l', 'l', 'w', 'r', 'l', 'd']

Section 08

Special Characters — Anchors

Anchors don't match characters — they match positions in the string. They're how you say "at the start" or "at a word boundary."

AnchorMeaningExample
^Start of string (or line with re.MULTILINE)^Hello
$End of string (or line with re.MULTILINE)world$
\bWord boundary (between \w and \W)\bcat\b matches "cat" but not "cats"
\BNon-word boundary\Bcat\B matches "cat" inside "concatenate"
\AStart of the string (always, ignores multiline)\AHello
\ZEnd of the string (always, ignores multiline)bye\Z
import re

# ^ anchors to the start
print(re.search(r"^Hello", "Hello world"))    # Match
print(re.search(r"^Hello", "Say Hello"))      # None

# $ anchors to the end
print(re.search(r"world$", "Hello world"))    # Match

# \b — word boundary is CRUCIAL for whole-word matching
print(re.findall(r"\bcat\b", "cat cats concatenate"))
# ['cat'] — only the standalone word

print(re.findall(r"cat", "cat cats concatenate"))
# ['cat', 'cat', 'cat'] — every occurrence, including inside words

Section 09

Special Characters — Quantifiers

A quantifier says how many times the preceding character, class, or group should repeat.

QuantifierMeaningExample
*Zero or morea* matches "", "a", "aaaa"
+One or morea+ matches "a", "aa" (not "")
?Zero or one (optional)colou?r matches "color" and "colour"
{n}Exactly n times\d{4} matches "2026"
{n,}At least n times\d{3,} matches 3+ digits
{n,m}Between n and m times\d{2,4} matches 2-4 digits
*?, +?, ??Lazy versions — match as FEW as possible<.+?> non-greedy match
import re

# + one or more digits
print(re.findall(r"\d+", "a1 b22 c333"))
# ['1', '22', '333']

# {n,m} range
print(re.findall(r"\d{2,3}", "1 22 333 4444"))
# ['22', '333', '444']  — NOT '4444' because {2,3} caps at 3

# GREEDY vs LAZY — this is the classic regex gotcha
text = "<b>bold</b> and <i>italic</i>"

# GREEDY .+ grabs as MUCH as possible
print(re.findall(r"<.+>", text))
# ['<b>bold</b> and <i>italic</i>']  — one huge match!

# LAZY .+? grabs as LITTLE as possible
print(re.findall(r"<.+?>", text))
# ['<b>', '</b>', '<i>', '</i>']  — each tag separately
⚠️
The Greedy Trap

By default, quantifiers are greedy — they consume as much as they can. Add a ? after them to make them lazy — the minimum that still lets the whole pattern match. HTML tags, quoted strings, and JSON extraction all break without lazy quantifiers.


Section 10

Special Characters — Groups & Alternation

PatternMeaningExample
(abc)Capturing group — save what matched(\d+) → group(1)
(?:abc)Non-capturing group — group but don't save(?:https?://)
(?P<name>abc)Named group — access by name(?P<year>\d{4})
a|bAlternation — a OR bcat|dog|fish
(?=abc)Positive lookahead — followed by abc\d(?=px) digit before "px"
(?!abc)Negative lookahead — NOT followed by abc\d(?!px)
import re

# Capturing group — pull out just the year from a date
m = re.search(r"(\d{4})-\d{2}-\d{2}", "Born: 1990-03-15")
print(m.group(1))       # '1990'

# Non-capturing group — group for structure, not for extraction
print(re.findall(r"(?:https?|ftp)://\S+", "visit https://a.com or ftp://b.com"))
# ['https://a.com', 'ftp://b.com']  — the "https/ftp" isn't in group 1

# Alternation — cat OR dog OR fish
print(re.findall(r"\b(cat|dog|fish)\b", "I have a cat, a dog, and a fish."))
# ['cat', 'dog', 'fish']

# Named groups — MUCH more readable than numbers
pattern = r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
m = re.search(pattern, "Date: 2026-07-12")
print(m.group("year"))    # '2026'
print(m.group("month"))   # '07'
print(m.group("day"))     # '12'

# Lookahead — match digits followed by "px" but don't include "px"
print(re.findall(r"\d+(?=px)", "width: 100px, height: 200em"))
# ['100']  — not '200' because it's followed by 'em'

Section 11

Diagram — Anatomy of an Email Regex

Let's dissect a real-world pattern: matching an email address. This one regex uses character classes, quantifiers, escapes, and anchors — every family of special characters at once.

Breaking down \b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b
\b [A-Za-z0-9._%+-]+ @ [A-Za-z0-9.-]+ \. [A-Za-z]{2,} \b Word boundary Username: letters, digits, ._%+- (1 or more) Literal @ sign — required Domain name: letters, digits, . or - Escaped dot — a LITERAL '.', not "any char" TLD: 2+ letters (com, org, co.uk...) Matches: alice@example.com | bob.smith+news@my-site.co.uk

Each colored region does one job. Purple — anchors. Blue — user part. Red — literal separators. Green — domain. Amber — TLD. Every real regex reads like this: a sentence of small, focused pieces.


Section 12

Flags — Modify How Patterns Match

import re

text = "Hello World\nGoodbye World"

# re.IGNORECASE (or re.I) — case-insensitive
print(re.findall(r"hello", text, re.IGNORECASE))
# ['Hello']

# re.MULTILINE (re.M) — ^ and $ match line boundaries, not just string ends
print(re.findall(r"^\w+", text, re.MULTILINE))
# ['Hello', 'Goodbye']  — both line starts

# re.DOTALL (re.S) — . matches newlines too
print(re.findall(r"Hello.+World", text, re.DOTALL))
# ['Hello World\nGoodbye World']  — . now crosses the \n

# re.VERBOSE (re.X) — allow whitespace and # comments in the pattern
pattern = re.compile(r"""
    \b                      # word boundary
    [A-Za-z0-9._%+-]+       # username
    @                       # separator
    [A-Za-z0-9.-]+          # domain
    \.[A-Za-z]{2,}          # TLD
    \b
""", re.VERBOSE)

# Combine multiple flags with |
re.findall(r"^error", log, re.IGNORECASE | re.MULTILINE)

Section 13

Practical Pattern Library

WhatPattern
Email (simple)r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"
URL (http/https)r"https?://[^\s]+"
US phone numberr"\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}"
IPv4 addressr"\b(?:\d{1,3}\.){3}\d{1,3}\b"
ISO date (YYYY-MM-DD)r"\d{4}-\d{2}-\d{2}"
Hex color coder"#[0-9A-Fa-f]{6}\b"
Whitespace collapse targetr"\s+"
HTML tagr"<[^>]+>"
Hashtagr"#\w+"
Mention (@user)r"@\w+"
Positive integerr"\b\d+\b"
Signed decimalr"[-+]?\d*\.?\d+"

Section 14

Exercises

Six problems arranged from simple to advanced. Try each yourself before checking the solution. Every solution uses only the concepts from earlier sections.

Exercise 1 — Extract All Numbers

📝
Problem

Given "Order 42 for £17.50 delivered on day 3", extract every number as a list of strings. Include both integers and decimals.

import re

text = "Order 42 for £17.50 delivered on day 3"

# Solution — \d+ for integer part, optional (\.\d+)? for decimal
numbers = re.findall(r"\d+(?:\.\d+)?", text)
print(numbers)
# ['42', '17.50', '3']

Exercise 2 — Validate a UK Postcode

📝
Problem

Write a function that returns True if the input is a valid UK postcode format like "SW1A 1AA" or "M1 1AE", False otherwise. Structure: 1-2 letters, 1 digit, optional letter/digit, space, 1 digit, 2 letters.

import re

def is_uk_postcode(text):
    pattern = r"^[A-Z]{1,2}\d[A-Z\d]? \d[A-Z]{2}$"
    return re.fullmatch(pattern, text) is not None

print(is_uk_postcode("SW1A 1AA"))    # True
print(is_uk_postcode("M1 1AE"))      # True
print(is_uk_postcode("invalid"))     # False
print(is_uk_postcode("SW1A 1AAA"))   # False

Exercise 3 — Extract Hashtags and Mentions

📝
Problem

From a social-media post like "Loving @python on #Sunday with @friends #100DaysOfCode", return two lists: mentions and hashtags. Without the leading symbols in the results.

import re

post = "Loving @python on #Sunday with @friends #100DaysOfCode"

mentions = re.findall(r"@(\w+)", post)     # the () captures just the name
hashtags = re.findall(r"#(\w+)", post)

print(f"Mentions: {mentions}")
print(f"Hashtags: {hashtags}")
# Mentions: ['python', 'friends']
# Hashtags: ['Sunday', '100DaysOfCode']

Exercise 4 — Parse Log Timestamps and Levels

📝
Problem

Given log lines like "[2026-07-12 14:30:15] INFO User logged in", extract the timestamp, log level, and message as three separate fields. Use named groups.

import re

log_line = "[2026-07-12 14:30:15] INFO User logged in"

pattern = re.compile(r"""
    \[(?P<time>\d{4}-\d{2}-\d{2}\ \d{2}:\d{2}:\d{2})\]
    \s+
    (?P<level>\w+)
    \s+
    (?P<msg>.+)
""", re.VERBOSE)

m = pattern.match(log_line)
if m:
    print(m.group("time"))    # 2026-07-12 14:30:15
    print(m.group("level"))   # INFO
    print(m.group("msg"))     # User logged in

Exercise 5 — Redact Sensitive Data

📝
Problem

Given "Contact alice@x.com or call 07123456789. My email is bob@y.com", redact all emails to [EMAIL] and all UK phone numbers (starting with 07 followed by 9 digits) to [PHONE].

import re

text = "Contact alice@x.com or call 07123456789. My email is bob@y.com"

# Chain two sub() calls
redacted = re.sub(r"[\w.+-]+@[\w-]+\.[\w.-]+", "[EMAIL]", text)
redacted = re.sub(r"\b07\d{9}\b", "[PHONE]", redacted)

print(redacted)
# Contact [EMAIL] or call [PHONE]. My email is [EMAIL]

Exercise 6 — Strong Password Validator

📝
Problem (Advanced)

Validate a password that must be at least 8 characters, contain at least one uppercase letter, one lowercase letter, one digit, and one special character from !@#$%^&*. Use lookaheads to check all conditions in a single pattern.

import re

def is_strong(pw):
    pattern = re.compile(r"""
        ^                    # start
        (?=.*[a-z])          # at least one lowercase
        (?=.*[A-Z])          # at least one uppercase
        (?=.*\d)             # at least one digit
        (?=.*[!@#$%^&*])    # at least one special
        .{8,}                # 8+ characters total
        $                    # end
    """, re.VERBOSE)
    return bool(pattern.match(pw))

print(is_strong("Passw0rd!"))   # True
print(is_strong("weakpass"))    # False (no uppercase, digit, or special)
print(is_strong("Ab1!"))        # False (too short)
print(is_strong("ALLCAPS123!")) # False (no lowercase)
Why This Pattern Is Elegant

Each (?=.*x) is a lookahead — it checks that condition exists somewhere ahead without consuming characters. All four lookaheads apply to the same string. Then .{8,} matches the actual length requirement. Without lookaheads, you'd need four separate checks.


Section 15

Common Pitfalls

⚠️
Forgetting Raw Strings
"\d" vs r"\d"
Without the r, Python interprets backslash escapes before the regex engine sees the pattern. "\n" becomes an actual newline, not \n. Always write patterns as r"...".
🚫
The Greedy Trap
.+ grabs too much
<.+> matches from the first < to the LAST > — everything in between. Use <.+?> or <[^>]+> to stop at the first close.
🔑
Unescaped Dot
. matches ANY char
In "192.168.1.1", the pattern \d+.\d+ would match "192.168" because . matches the dot AND any other char. Escape it: \d+\.\d+.
🔥
Catastrophic Backtracking
Nested quantifiers → hang
Patterns like (a+)+ or (.*)* can take exponential time on certain inputs — your regex hangs forever. Avoid nesting quantifiers inside quantified groups.
🔦
Not Checking for None
.group() on None fails
re.search returns None when nothing matches. Calling .group() on it raises AttributeError. Always if m := re.search(...): or explicit None-check.
🌟
Regex For The Wrong Job
HTML, JSON, CSV
Don't parse HTML with regex — use BeautifulSoup. Don't parse JSON — use json. Don't parse CSV — use csv. Regex shines on flat text patterns, not nested/structured formats.

Section 16

Golden Rules

🔑 Regular Expressions — Non-Negotiable Rules
1
Always write patterns as raw strings: r"\d+", never "\d+". Prevents backslash interpretation bugs. Consistency here saves hours across a project.
2
Use re.fullmatch for validation, re.search for extraction, re.findall for lists. Each has a purpose. Using match when you want search silently returns None for anything not at position 0.
3
Escape dots when you mean a literal dot. \. matches only .; unescaped . matches any character. IPs, decimals, and domain names all break subtly without this.
4
Prefer named groups over numbered ones. (?P<year>\d{4}) and m.group("year") reads a hundred times better than m.group(1). It also survives when you insert a new group and every number shifts.
5
Compile patterns you use more than once. re.compile pays for itself after just a few reuses and lets you spread the same pattern across a codebase as a named object.
6
Make quantifiers lazy when matching between delimiters. HTML tags, quoted strings, JSON-in-text — all need .+? not .+. The greedy default is right for whole tokens but wrong for pair-delimited content.
7
For complex patterns, use re.VERBOSE and add comments. A ten-part regex on one line is unreadable. Split it across lines with re.X and comment each part — future-you and code reviewers will thank you.