Python with AI Tutorial · Chapter 29 of 48
Python regex (regular expressions) is pattern matching for text, provided by the built-in re module. Instead of checking for one fixed word, you describe a shape: “three letters, a dash, four digits” or “anything between angle brackets”. With search, findall, sub and fullmatch you can find, extract, replace and validate text in a few lines.
Regex has a reputation for looking like noise, and that reputation is partly earned. The trick is to learn about a dozen metacharacters, always write patterns as raw strings (r"...") so backslashes survive, and test each pattern on a few real lines before trusting it. This chapter follows that approach with invoices, emails, GST numbers and log files.
Finding the first match with re.search
re.search scans the whole string and returns a Match object for the first hit, or None if nothing matches. The match object tells you what was found and where.
import re
text = "Invoice INV-2026-0451 was issued on 31-03-2026 for Rs 12,500."
m = re.search(r"INV-\d{4}-\d{4}", text)
print(m)
print(m.group(), m.start(), m.end())
print(bool(re.search(r"paid", text)))
Output: <re.Match object; span=(8, 21), match='INV-2026-0451'> INV-2026-0451 8 21 False
Here \d means any digit and {4} means exactly four of them. Because None is falsy, if re.search(...) is the idiomatic way to ask “does this text contain the pattern?”
| Metacharacter | Matches | Example |
|---|---|---|
. |
Any character except newline | a.c matches abc, a-c |
\d / \D |
Digit / non-digit | \d{2} matches 31 |
\w / \W |
Letter, digit or underscore / anything else | \w+ matches INV_2026 |
\s / \S |
Whitespace / non-whitespace | \s+ matches spaces and tabs |
[abc] / [^abc] |
One character in the set / not in the set | [A-Z]{5} matches AAPFU |
^ / $ |
Start / end of string (or line with MULTILINE) | ^ERROR |
* / + / ? |
0 or more / 1 or more / 0 or 1 | colou?r matches color, colour |
{n} / {n,m} |
Exactly n / between n and m | \d{4,6} |
( ) |
Capture group | (\d{2})-(\d{2}) |
(?P<name> ) |
Named capture group | (?P<year>\d{4}) |
a|b |
Either a or b | INR|Rs |
\b |
Word boundary | \bQ1\b |
Extracting every match with re.findall
re.findall returns a list of every non-overlapping match. It is the fastest way to pull all numbers, codes or emails out of a block of text, but the first attempt below shows why you must look at the result before using it.
import re
report = "Q1 sales 420000, Q2 sales 465000, refunds 12500 and 3200."
print(re.findall(r"\d+", report))
print(re.findall(r"\b\d{4,}\b", report))
amounts = [int(n) for n in re.findall(r"\b\d{4,}\b", report)]
print("Total mentioned:", sum(amounts))
Output: ['1', '420000', '2', '465000', '12500', '3200'] ['420000', '465000', '12500', '3200'] Total mentioned: 901200
The plain \d+ also grabbed the 1 and 2 from Q1 and Q2. Adding a minimum length and word boundaries fixed it. Expect two or three rounds like this with any new pattern.
Capture groups: pulling out the parts
Parentheses capture pieces of the match so you can use them separately. Numbered groups start at 1 (group(0) is the whole match); named groups with (?P<name>...) make the code self-documenting.
import re
line = "Priya Sharma <priya.sharma@neotech.in> joined 2019-07-01"
m = re.search(r"<([\w.]+)@([\w.]+)>", line)
print(m.group(0))
print("User:", m.group(1), "Domain:", m.group(2))
d = re.search(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})", line)
print(d.group("year"), d["month"])
print(d.groupdict())
Output:
<priya.sharma@neotech.in>
User: priya.sharma Domain: neotech.in
2019 07
{'year': '2019', 'month': '07', 'day': '01'}
Replacing with re.sub
re.sub(pattern, replacement, text) replaces every match. The replacement can refer back to captured groups with \1, \2 and so on, which makes reformatting dates or masking sensitive digits a one-liner.
import re
messy = "Contact: +91 80770 90260 or 080-2345-6789 "
print(re.sub(r"\s+", " ", messy).strip())
card = "Paid with card 4111 1111 1111 1234"
print(re.sub(r"\d{4}(?= \d{4})", "****", card))
print(re.sub(r"(\d{2})-(\d{2})-(\d{4})", r"\3-\2-\1", "Due 31-03-2026, paid 12-05-2026"))
Output: Contact: +91 80770 90260 or 080-2345-6789 Paid with card **** **** **** 1234 Due 2026-03-31, paid 2026-05-12
The card example uses a lookahead, (?= \d{4}): it requires a following group of four digits to be present but does not consume it, so only the first three groups are masked and the last four digits stay visible.
Validating with re.fullmatch
search is happy if the pattern appears anywhere. For validation you want fullmatch, which succeeds only when the entire string fits. Compiling a pattern once with re.compile is tidier and faster when you check thousands of rows.
import re
GSTIN = re.compile(r"\d{2}[A-Z]{5}\d{4}[A-Z][1-9A-Z]Z[0-9A-Z]")
PAN = re.compile(r"[A-Z]{5}\d{4}[A-Z]")
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
for g in ["27AAPFU0939F1ZV", "27aapfu0939f1zv", "27AAPFU0939F1Z"]:
print(g, "valid" if GSTIN.fullmatch(g) else "invalid")
print(bool(PAN.fullmatch("AAPFU0939F")))
print(bool(EMAIL.fullmatch("accounts@neotech.in")), bool(EMAIL.fullmatch("accounts@neotech")))
Output: 27AAPFU0939F1ZV valid 27aapfu0939f1zv invalid 27AAPFU0939F1Z invalid True True False
r prefix. In a normal string "\b" is a backspace character and "\d" triggers a SyntaxWarning on Python 3.12. Write every pattern as r"..." and the backslashes reach the regex engine unchanged.Splitting text and using flags
re.split breaks a string on a pattern rather than a single character, which handles inconsistent delimiters in one pass. Flags change how matching works: re.IGNORECASE for case-insensitive, re.MULTILINE so ^ and $ work per line.
import re
raw = "Laptop;Mouse, Keyboard |Monitor"
print(re.split(r"\s*[;,|]\s*", raw))
log = """ERROR disk full
info backup done
Error timeout on node 3"""
errors = re.findall(r"^error.*$", log, flags=re.IGNORECASE | re.MULTILINE)
print(errors)
print(len(errors), "error lines")
Output: ['Laptop', 'Mouse', 'Keyboard', 'Monitor'] ['ERROR disk full', 'Error timeout on node 3'] 2 error lines
Greedy versus lazy matching
Quantifiers are greedy by default: .* grabs as much as it can while still letting the pattern succeed. Adding ? makes it lazy, stopping at the first opportunity. This is the difference between one giant match and a clean list.
import re
html = "<b>Total</b>: <b>4,599</b>"
print(re.findall(r"<b>.*</b>", html))
print(re.findall(r"<b>(.*?)</b>", html))
for m in re.finditer(r"<b>(.*?)</b>", html):
print(m.group(1), "at", m.span())
Output: ['<b>Total</b>: <b>4,599</b>'] ['Total', '4,599'] Total at (0, 12) 4,599 at (14, 26)
finditer yields match objects one at a time, so you get positions and groups together. It is also memory-friendly on very large files because nothing is collected into a list.
Ask for a validated pattern with a plain-English breakdown.
Write a Python regex that validates Indian GST numbers (GSTIN): 2-digit state code, 10-character PAN, 1 entity code, the letter Z, then a check character. Explain each part of the pattern on its own line, then show a test loop over 5 valid and 5 invalid examples using re.fullmatch.
Turn a messy text export into structured rows.
I have bank statement lines like "05/09/2026 UPI/PRIYA SHARMA/RENT SEPT 25,000.00 Dr" and "04/09/2026 NEFT/ACME LTD/INV-104 1,25,000.00 Cr". Write a Python function using named regex groups that extracts date, channel, counterparty, description, amount (as float, handling Indian comma grouping) and Dr/Cr, and returns a list of dicts. Include 3 test lines and print the result with json.dumps(indent=2).
Common mistakes
- Writing patterns without the
rprefix, so\band\dare mangled before the regex engine sees them. - Using
re.matchwhen you meanre.search;matchonly checks the start of the string. - Validating with
searchinstead offullmatch, which accepts “27AAPFU0939F1ZV extra text”. - Forgetting that
.does not match a newline unless you passre.DOTALL. - Reaching for regex to parse real HTML or JSON. Use a parser for those; regex is for flat text.
Exercise
Given text = "Orders: ORD-1001 (2 items), ORD-1002 (5 items), ORD-1017 (1 item)", use one regex with two capture groups to build a dictionary mapping each order id to its item count as an integer.
Show answer
import re
text = "Orders: ORD-1001 (2 items), ORD-1002 (5 items), ORD-1017 (1 item)"
pairs = re.findall(r"(ORD-\d+) \((\d+) items?\)", text)
orders = {order_id: int(count) for order_id, count in pairs}
print(orders) # {'ORD-1001': 2, 'ORD-1002': 5, 'ORD-1017': 1}
Related chapters
FAQ
What is the difference between re.match, re.search and re.fullmatch?
match checks only at the start of the string, search finds the pattern anywhere, and fullmatch requires the entire string to fit the pattern. Use fullmatch for validation.
Why do I need r before a regex string in Python?
The r makes a raw string, so backslashes are passed through unchanged. Without it, \b becomes a backspace and \d raises a SyntaxWarning in Python 3.12.
How do I make a Python regex case-insensitive?
Pass flags=re.IGNORECASE (or re.I) to the function, or put (?i) at the start of the pattern. Combine flags with the | operator.
Working with spreadsheets too? Ready-made Excel, Google Sheets and Power BI templates are at NextGenTemplates.com.
Chapter 29 of 48 · Python with AI: all 48 chapters



