Regular Expressions (the re module)
Reviewed & published by Brayan K
A regular expression is a tiny pattern language for describing text — "any digit", "an email", "a word at the end of a line". Python's re module turns those patterns into powerful search-and-replace tools.
Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
Once you can read and write regex, validating phone numbers, pulling dates out of log files, or cleaning messy data becomes a few lines instead of a hundred.
What You'll Learn in This Lesson
- The core functions: search, match, findall, sub, split
- Metacharacters and character classes (\d, \w, \s, [...])
- Quantifiers: *, +, ?, {n}, and greedy vs lazy
- Capture groups and named groups
- Why raw strings (r'...') matter for patterns
- Real validation: emails, phone numbers, dates
1 What Is a Regular Expression?
A regular expression (regex) is a string that describes a pattern of text. Instead of asking "does this string contain the exact word cat?", regex lets you ask "does this string contain three digits, then a dash, then four digits?".
🔍 Real problems regex solves every day:
- Validating that a user typed a real email address
- Extracting all the dates or prices from a block of text
- Finding and replacing patterns (e.g. masking credit-card numbers)
- Splitting a log line into fields without fragile string slicing
Everything starts with importing the module — it ships with Python, nothing to install:
import re
text = "Call me on 555-123-4567 tomorrow."
match = re.search(r"\d{3}-\d{3}-\d{4}", text)
print(match.group()) # 555-123-4567That pattern \d{3}-\d{3}-\d{4} reads as: "three digits, a dash, three digits, a dash, four digits". You just described a US phone number in 17 characters.
2 Raw Strings — The First Rule of Regex
Regex patterns are full of backslashes (\d, \w, \b). But in a normal Python string, \ is the escape character — "\n" means newline, "\t" means tab. That clash causes endless confusion.
The fix is the raw string prefix r:
pattern1 = "\d+" # works, but you have to double every backslash
pattern2 = r"\d+" # raw string — write the pattern exactly as you mean it
# In a raw string, r"\n" is a backslash followed by n — NOT a newline.3 The Five Functions You'll Actually Use
The re module has many functions, but 95% of real work uses just five:
| Function | What it does |
|---|---|
| re.search(p, s) | Find the first match anywhere in s → Match object or None |
| re.match(p, s) | Match only at the START of s → Match object or None |
| re.findall(p, s) | Return a list of every match (or every group) |
| re.sub(p, repl, s) | Replace every match with repl → new string |
| re.split(p, s) | Split s wherever the pattern matches → list |
import re
log = "ERROR 2024-01-15 disk full; ERROR 2024-01-16 timeout"
print(re.search(r"ERROR", log).start()) # 0 (index of first ERROR)
print(re.findall(r"\d{4}-\d{2}-\d{2}", log)) # ['2024-01-15', '2024-01-16']
print(re.sub(r"ERROR", "WARN", log)) # both ERRORs become WARN
print(re.split(r";\s*", log)) # split on '; 'import re
text = "Order #4521 shipped, order #889 pending, order #10023 delivered"
# Find every order number (a # followed by digits)
order_ids = re.findall(r"#(\d+)", text)
print("Order IDs:", order_ids) # ['4521', '889', '10023']
# Find the first one anywhere in the string
first = re.search(r"#(\d+)", text)
print("First match:", first.group()) # #4521
print("Just the digits:", first.group(1)) # 4521
# match() only looks at the START — this fails because text starts with 'O'
print("match at start:", re.match(r"#(\d+)", text)) # None
# ✅ Expected output:
# Order IDs: ['4521', '889', '10023']
# First match: #4521
# Just the digits: 4521
# match at start: None🧱 The Building Blocks of a Pattern
Character classes — shortcuts for common groups
| \d | Any digit, 0–9 |
| \w | Any word character: letter, digit, or underscore |
| \s | Any whitespace: space, tab, newline |
| \D \W \S | The OPPOSITE — non-digit, non-word, non-space |
| . | Any character except newline |
| [aeiou] | Any one character listed in the brackets |
| [a-z] | Any lowercase letter (a range) |
| [^0-9] | Any character NOT a digit (^ inside [] means 'not') |
Quantifiers — how many times
| * | Zero or more (greedy) |
| + | One or more |
| ? | Zero or one (optional) |
| {3} | Exactly 3 |
| {2,4} | Between 2 and 4 |
| {2,} | 2 or more |
Anchors — where in the string
^abc # string must START with abc
abc$ # string must END with abc
\bcat\b # 'cat' as a whole word (\b = word boundary)
# matches "the cat sat" but NOT "category"4 Greedy vs Lazy — The Classic Gotcha
By default quantifiers are greedy: they grab as much as possible. Add a ? to make them lazy — grab as little as possible. This trips up everyone the first time:
import re
html = "<b>bold</b> and <i>italic</i>"
# Greedy: .* eats everything between the FIRST < and the LAST >
print(re.findall(r"<.*>", html)) # ['<b>bold</b> and <i>italic</i>']
# Lazy: .*? stops at the first > it can
print(re.findall(r"<.*?>", html)) # ['<b>', '</b>', '<i>', '</i>']5 Capture Groups — Pulling Out the Pieces
Parentheses (...) create a capture group. After a match you can pull out each group individually — perfect for parsing structured text.
import re
date = "2024-01-15"
m = re.search(r"(\d{4})-(\d{2})-(\d{2})", date)
print(m.group(0)) # 2024-01-15 (the whole match)
print(m.group(1)) # 2024 (first group)
print(m.group(2)) # 01
print(m.group(3)) # 15
print(m.groups()) # ('2024', '01', '15')
# Named groups are even clearer:
m = re.search(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})", date)
print(m.group("month")) # 01
print(m.groupdict()) # {'year': '2024', 'month': '01', 'day': '15'}Named groups ((?P<name>...)) make patterns self-documenting and let you reference fields by name instead of counting parentheses.
📖 Worked Example: Parsing a Log File
This is what regex is genuinely great at: turning lines of text that follow a shape into structured data. Read it once before you write anything — every piece of the pattern is one of the building blocks you have just met.
Two new-but-small things appear here. re.VERBOSE lets you spread a pattern across several lines and comment each part, which is the difference between a maintainable pattern and write-only line noise. And re.compile builds the pattern once instead of on every loop iteration.
import re
LOG = """2024-01-15 09:12:03 ERROR db connection refused
2024-01-15 09:12:44 WARN api slow response 1420ms
2024-01-15 09:13:01 ERROR api timeout after 30s
2024-01-15 09:15:22 INFO web deploy finished
-- end of log --"""
# re.VERBOSE lets you spread a pattern over lines and comment each piece.
# Whitespace inside the pattern is ignored, so you write \s+ where you mean a gap.
LINE = re.compile(r"""
(?P<date>\d{4}-\d{2}-\d{2}) # 2024-01-15
\s+
(?P<time>\d{2}:\d{2}:\d{2}) # 09:12:03
\s+
(?P<level>[A-Z]+) # ERROR / WARN / INFO
\s+
(?P<service>\w+) # db / api / web
\s+
(?P<message>.+) # everything left on the line
""", re.VERBOSE)
errors = 0
for line in LOG.splitlines():
m = LINE.match(line) # match() anchors at the start of the line
if m is None: # the "-- end of log --" line matches nothing
continue # ALWAYS check for None before using the match
row = m.groupdict() # named groups come back as a plain dict
if row["level"] == "ERROR":
errors += 1
print(f"{row['time']} {row['service']:4} {row['message']}")
print("Errors found:", errors)
# re.sub replaces every match; \d+ms means "one or more digits then 'ms'"
print(re.sub(r"\d+ms", "[REDACTED]", LOG.splitlines()[1]))
# ✅ Expected output:
# 09:12:03 db connection refused
# 09:13:01 api timeout after 30s
# Errors found: 2
# 2024-01-15 09:12:44 WARN api slow response [REDACTED]🎯 Your Turn: Invoice IDs and Due Dates
Three blanks, one concept each: a character class, a group name, and the method that hands back every named group at once. Fill them in and run it — the expected output is at the bottom of the code so you can check yourself.
import re
# 🎯 YOUR TURN — replace the three ___ blanks
text = "Invoice INV-2024-0093 due 2024-03-01, invoice INV-2023-0871 due 2023-12-15"
# 1) Every invoice ID looks like INV- then 4 digits, a dash, then 4 more digits.
ids = re.findall(r"INV-___{4}-___{4}", text) # 👉 the shorthand for "any digit"
print("Invoice IDs:", ids)
# 2) Pull the first due date apart into named pieces.
m = re.search(r"(?P<year>\d{4})-(?P<___>\d{2})-(?P<day>\d{2})", text) # 👉 name the middle group
print("First due year:", m.group("year"))
print("All parts:", m.___()) # 👉 the method that returns ALL named groups as a dict
# 3) Redact the invoice IDs before sharing the text.
print(re.sub(r"INV-\d{4}-\d{4}", "INV-****-****", text))
# ✅ Expected output:
# Invoice IDs: ['INV-2024-0093', 'INV-2023-0871']
# First due year: 2024
# All parts: {'year': '2024', 'month': '03', 'day': '01'}
# Invoice INV-****-**** due 2024-03-01, invoice INV-****-**** due 2023-12-151) \d — so the pattern is r"INV-\d{4}-\d{4}". Note the r prefix: without it Python would read \d as an escape sequence rather than passing it to the regex engine.
2) month — the middle piece of a YYYY-MM-DD date.
3) groupdict — m.groupdict(). Use m.groups() instead and you get an unnamed tuple, ('2024', '03', '01').
If Invoice IDs comes back empty, check you kept the literal INV- in front — the digit class alone matches nothing here because it needs the surrounding text to anchor it.
Real-World Example: Cleaning and Validating a Sign-Up Form
Here's the kind of validation code that runs behind every registration form. Notice how each rule is a small, readable pattern:
import re
def validate_signup(email, username, phone):
errors = []
# Email: something@something.tld (simplified but practical)
if not re.match(r"^[\w.+-]+@[\w-]+\.[\w.-]+$", email):
errors.append("Invalid email address")
# Username: 3-16 chars, letters/digits/underscore only
if not re.match(r"^\w{3,16}$", username):
errors.append("Username must be 3-16 letters, digits or _")
# Phone: optional +, then 10-15 digits, spaces/dashes allowed
digits = re.sub(r"[\s-]", "", phone) # strip spaces and dashes first
if not re.match(r"^\+?\d{10,15}$", digits):
errors.append("Invalid phone number")
return errors
print(validate_signup("ada@example.com", "ada_lovelace", "+1 555-123-4567"))
# [] -> all valid!
print(validate_signup("not-an-email", "x", "12"))
# ['Invalid email address', 'Username must be 3-16 letters, digits or _', 'Invalid phone number']A note on email regex
The "perfect" email regex is famously gigantic. In real apps, a simple pattern like the one above plus sending a confirmation email is the practical standard. Don't try to validate every RFC edge case with regex alone.
import re
review = "Loved it! 5/5 stars. Shipping took 3 days. Price was $24.99."
# Pull out the rating (number/number)
rating = re.search(r"(\d+)/(\d+)", review)
print("Rating:", rating.group(1), "out of", rating.group(2))
# Pull out the price (a $ then digits.digits)
price = re.search(r"\$(\d+\.\d{2})", review)
print("Price:", price.group(1))
# Mask every number in the text with 'X'
masked = re.sub(r"\d", "X", review)
print("Masked:", masked)
# Split the review into words (one or more non-word chars as separators)
words = re.split(r"\W+", review)
print("Word count:", len([w for w in words if w]))
# ✅ Expected output:
# Rating: 5 out of 5
# Price: 24.99
# Masked: Loved it! X/X stars. Shipping took X days. Price was $XX.XX.
# Word count: 13⚙️ re.compile and Useful Flags
If you reuse a pattern, compile it once into a Pattern object. It's faster and reads better in loops:
import re
# Compile once, use many times
ip_pattern = re.compile(r"\d{1,3}(?:\.\d{1,3}){3}")
logs = ["192.168.0.1 GET /", "10.0.0.5 POST /login", "no ip here"]
for line in logs:
m = ip_pattern.search(line)
print(m.group() if m else "(no IP)")
# Flags change matching behaviour:
text = "Hello WORLD hello world"
print(re.findall(r"hello", text, re.IGNORECASE)) # ['Hello', 'hello']Common flags: re.IGNORECASE (case-insensitive), re.MULTILINE (^/$ match each line), re.DOTALL (. also matches newlines).
🏆 Mini-Challenge: Clean Up a Social Post
Outline only — you write all four patterns. Take one messy social post and pull out its hashtags, its email addresses and its launch date, then produce a copy with the emails hidden. This is real work: it is exactly what a moderation or analytics pipeline does to every incoming message.
One deliberate trap: hello@site.co.uk has two dots after the @. Make sure your pattern still catches it.
import re
post = "Big news! Launch is on 2024-06-01. #python #release #opensource. Email press@example.com or hello@site.co.uk"
# 🎯 MINI-CHALLENGE: pull four things out of one social post
# 1. tags — every hashtag WITHOUT the # sign.
# Pattern idea: a # followed by one or more word characters,
# with the word characters in a capture group so findall returns
# just them.
# 2. emails — findall with a practical email pattern:
# [\w.+-]+ @ [\w.-]+ \. \w+ (no spaces in the real pattern)
# 3. date — re.search for 4 digits, dash, 2 digits, dash, 2 digits.
# Remember search returns None if nothing matches, so print
# date.group() only when date is truthy.
# 4. safe — re.sub the same email pattern with "[email hidden]"
#
# Then print, in this order: Tags, Emails, Date, Safe
#
# ✅ Expected output:
# Tags: ['python', 'release', 'opensource']
# Emails: ['press@example.com', 'hello@site.co.uk']
# Date: 2024-06-01
# Safe: Big news! Launch is on 2024-06-01. #python #release #opensource. Email [email hidden] or [email hidden]
# your code heretags = re.findall(r"#(\w+)", post)
emails = re.findall(r"[\w.+-]+@[\w.-]+\.\w+", post)
date = re.search(r"\d{4}-\d{2}-\d{2}", post)
print("Tags:", tags)
print("Emails:", emails)
print("Date:", date.group() if date else "none")
print("Safe:", re.sub(r"[\w.+-]+@[\w.-]+\.\w+", "[email hidden]", post))The hashtag pattern captures (\w+) in a group, which is why findall returns the words without the # — when a pattern has exactly one group, findall gives you that group instead of the whole match.
🧩 Reorder Challenge
These lines extract all hashtags from a tweet and print them lowercased — but they're scrambled. Put them in the correct order:
A) print(tag.lower())
B) import re
C) tags = re.findall(r"#(\w+)", tweet)
D) for tag in tags:
E) tweet = "Loving #Python and #RegEx today! #CODING"Correct order: B → E → C → D → A
import re
tweet = "Loving #Python and #RegEx today! #CODING"
tags = re.findall(r"#(\w+)", tweet)
for tag in tags:
print(tag.lower())
# python
# regex
# codingImport first, define the data, run findall to collect the captures, then loop. The capture group (\w+) means findall returns just the word, without the #.
🧠 Quick Recall — Predict the Output
Read each snippet and predict what prints before revealing the answer.
import re
print(re.findall(r"\d+", "a1b22c333"))['1', '22', '333'] — \d+ grabs each run of one-or-more digits as a separate match.
import re
print(re.sub(r"\s+", "_", "hello big world"))hello_big_world — \s+ matches each run of whitespace (even multiple spaces) and replaces the whole run with a single underscore.
import re
print(bool(re.match(r"cat", "the cat")))False — re.match only checks the START of the string, and "the cat" starts with "the". Use re.search to find it anywhere.
❓ Frequently Asked Questions
You can now describe text patterns like a pro!
You've learned the five core re functions, character classes, quantifiers, greedy vs lazy matching, and capture groups. Regex is a skill you sharpen by using it — keep a cheat sheet handy and test patterns on small strings first.
Practice quiz
What is the difference between re.match and re.search?
- match scans the whole string; search only the start
- They are identical
- match checks only the START of the string; search scans the whole string
- search only works on numbers
Answer: match checks only the START of the string; search scans the whole string. re.match only matches at the very start of the string, while re.search scans the whole string for the first match anywhere.
Why write regex patterns as raw strings like r'\d+'?
- So backslashes are treated literally and reach re unchanged
- They run faster
- Raw strings allow Unicode
- It is required by Python syntax
Answer: So backslashes are treated literally and reach re unchanged. Regex uses many backslashes; a raw string (r'...') tells Python to treat them literally instead of as escape sequences like .
What does re.findall(r'\d+', 'a1b22c333') return?
- ['1', '2', '3']
- ['a1b22c333']
- [1, 22, 333]
- ['1', '22', '333']
Answer: ['1', '22', '333']. \d+ grabs each run of one-or-more digits as a separate match, giving the list of strings ['1', '22', '333'].
What does bool(re.match(r'cat', 'the cat')) evaluate to?
- True
- False
- None
- It raises an error
Answer: False. re.match only checks the START of the string, and 'the cat' starts with 'the', so the result is False. Use re.search to find it anywhere.
By default, quantifiers like .* are:
- Greedy (match as much as possible)
- Lazy (match as little as possible)
- Disabled
- Case-insensitive
Answer: Greedy (match as much as possible). Quantifiers are greedy by default, grabbing as much as possible. Add ? (as in .*?) to make them lazy.
What does the lazy pattern r'<.*?>' match in '<b>bold</b>'?
- The whole string at once
- Only the first letter
- Each tag separately: '<b>', '</b>'
- Nothing
Answer: Each tag separately: '<b>', '</b>'. The lazy .*? stops at the first > it can, so it matches each tag individually rather than spanning from the first < to the last >.
How do you create a capture group in a regex?
- Square brackets [ ]
- Parentheses ( )
- Curly braces { }
- Angle brackets < >
Answer: Parentheses ( ). Parentheses (...) create a capture group; you then retrieve each piece with .group(1), .group(2), etc., or via re.findall.
What does re.sub(r'\s+', '_', 'hello big world') return?
- 'hello___big___world'
- 'hello big world'
- '_hello_big_world_'
- 'hello_big_world'
Answer: 'hello_big_world'. \s+ matches each run of whitespace (even multiple spaces) and replaces the whole run with one underscore, giving 'hello_big_world'.
When re.search finds no match, what does it return?
- An empty string
- None
- An empty Match object
- It raises ValueError
Answer: None. re.search (and re.match) return None when nothing matches, so always check before calling .group() to avoid an AttributeError.
What is the benefit of re.compile(pattern)?
- It validates the pattern only
- It makes the regex case-insensitive
- It builds a reusable Pattern object that is faster for repeated use
- It converts the pattern to a string
Answer: It builds a reusable Pattern object that is faster for repeated use. re.compile turns a pattern into a reusable Pattern object — faster and clearer when you apply the same pattern many times.
Continue this course
- Previous: Checkpoint: Python Essentials
- Next: Dates & Times (datetime) — Work with dates, times, durations and formatting
- Quick reference: Python cheat sheet › Standard Library · Regex cheat sheet › Character Classes
Frequently asked questions
What is the re module in Python?
re is Python's built-in regular expression module. It lets you search, match, split and replace text using pattern strings — no installation needed, just import re.
What is the difference between re.match and re.search?
re.match only checks for a match at the very start of the string, while re.search scans the whole string and returns the first match found anywhere.
Why should I use raw strings (r'...') for regex patterns?
Regex uses backslashes heavily (\d, \w, \b). In a normal Python string, \d could be misinterpreted as an escape sequence. Prefixing with r tells Python to treat backslashes literally, so the pattern reaches re unchanged.
What does re.compile do and when should I use it?
re.compile turns a pattern string into a reusable Pattern object. If you apply the same pattern many times (e.g. in a loop or across files), compiling once is faster and reads more clearly.
How do I capture parts of a match?
Wrap the part you want in parentheses to create a capture group. Then call .group(1), .group(2), etc. on the Match object, or use re.findall to get a list of all captured groups.