The Story That Explains Regular Expressions
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.
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.
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'>
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.
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
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)
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.
\d+, same text, three different results
match asks: "does it start with this?".
search asks: "is it in there somewhere?".
findall asks: "give me every one."
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'}
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()).
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.
| Pattern | Meaning | Example Match |
|---|---|---|
. | Any character except newline | a, 5, @, space... |
\d | Any digit (0-9) | 0, 1, 2, ... 9 |
\D | Any non-digit | a, @, space |
\w | Word character (letter, digit, underscore) | a, Z, 5, _ |
\W | Non-word character | @, space, ! |
\s | Whitespace (space, tab, newline) | ' ', '\t', '\n' |
\S | Non-whitespace | a, 5, @ |
[abc] | Any one of a, b, or c | a, b, c |
[a-z] | Any lowercase letter | a, b, ... z |
[A-Z0-9] | Any uppercase letter or digit | A, ... Z, 0, ... 9 |
[^abc] | Anything EXCEPT a, b, or c | d, 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']
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."
| Anchor | Meaning | Example |
|---|---|---|
^ | Start of string (or line with re.MULTILINE) | ^Hello |
$ | End of string (or line with re.MULTILINE) | world$ |
\b | Word boundary (between \w and \W) | \bcat\b matches "cat" but not "cats" |
\B | Non-word boundary | \Bcat\B matches "cat" inside "concatenate" |
\A | Start of the string (always, ignores multiline) | \AHello |
\Z | End 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
Special Characters — Quantifiers
A quantifier says how many times the preceding character, class, or group should repeat.
| Quantifier | Meaning | Example |
|---|---|---|
* | Zero or more | a* matches "", "a", "aaaa" |
+ | One or more | a+ 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
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.
Special Characters — Groups & Alternation
| Pattern | Meaning | Example |
|---|---|---|
(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|b | Alternation — a OR b | cat|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'
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.
\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\bEach 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.
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)
Practical Pattern Library
| What | Pattern |
|---|---|
| Email (simple) | r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" |
| URL (http/https) | r"https?://[^\s]+" |
| US phone number | r"\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}" |
| IPv4 address | r"\b(?:\d{1,3}\.){3}\d{1,3}\b" |
| ISO date (YYYY-MM-DD) | r"\d{4}-\d{2}-\d{2}" |
| Hex color code | r"#[0-9A-Fa-f]{6}\b" |
| Whitespace collapse target | r"\s+" |
| HTML tag | r"<[^>]+>" |
| Hashtag | r"#\w+" |
| Mention (@user) | r"@\w+" |
| Positive integer | r"\b\d+\b" |
| Signed decimal | r"[-+]?\d*\.?\d+" |
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
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
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
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
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
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
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)
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.
Common Pitfalls
r, Python interprets backslash escapes before the
regex engine sees the pattern. "\n" becomes an actual newline,
not \n. Always write patterns as r"...".
<.+> matches from the first < to the LAST
> — everything in between. Use <.+?> or
<[^>]+> to stop at the first close.
"192.168.1.1", the pattern \d+.\d+ would match
"192.168" because . matches the dot AND any other char.
Escape it: \d+\.\d+.
(a+)+ or (.*)* can take exponential
time on certain inputs — your regex hangs forever. Avoid nesting quantifiers
inside quantified groups.
re.search returns None when nothing matches.
Calling .group() on it raises AttributeError.
Always if m := re.search(...): or explicit None-check.
BeautifulSoup. Don't parse
JSON — use json. Don't parse CSV — use csv. Regex
shines on flat text patterns, not nested/structured formats.
Golden Rules
r"\d+",
never "\d+". Prevents backslash interpretation bugs. Consistency
here saves hours across a project.
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.
\. matches
only .; unescaped . matches any character.
IPs, decimals, and domain names all break subtly without this.
(?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.
re.compile pays for itself after just a few reuses and lets you
spread the same pattern across a codebase as a named object.
.+? not
.+. The greedy default is right for whole tokens but wrong for
pair-delimited content.
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.