Python Regex: A Practical Tutorial for People Who’ve Been Avoiding It
Hi everyone, welcome back. Today, I will be going over regular expressions in Python — what they are, how the re library works, and every…
Python Regex: A Practical Tutorial for People Who’ve Been Avoiding It
Hi everyone, welcome back. Today, I will be going over regular expressions in Python — what they are, how the re library works, and every function you'll actually use, with examples.
Every code example below was run before publishing, and the outputs shown are the real ones. Regex tutorials are unusually prone to examples that don’t actually produce what the author claims, so I checked all of them.
I’ll write this assuming you know a little Python but have avoided regex because it looks like someone fell asleep on the keyboard. That reaction is reasonable. It’s also a skill that pays back the two hours it takes to learn, more than almost anything else of comparable size.
Photo by Fotis Fotopoulos on Unsplash
What Is Regex?
A regular expression is a small language for describing patterns in text.
Ordinary string searching answers “does this exact text appear?” Regex answers questions like:
- Does this look like an email address?
- Find every date in this document
- Replace all runs of multiple spaces with a single space
- Pull the timestamp, severity, and message out of each log line
You describe a shape rather than a literal string, and the regex engine finds everything matching that shape.
Here’s the whole idea in one example:
import re
re.findall(r"\d+", "a1 b22 c333")
# ['1', '22', '333']
\d means "any digit." + means "one or more of those." Together: find every run of digits. Three lines in, you've done something that would take a nested loop otherwise.
The Building Blocks
You need maybe fifteen symbols. Here are the ones that matter.
Character classes — what kind of character:
\d— any digit (0-9)\w— any word character (letter, digit, or underscore)\s— any whitespace (space, tab, newline).— any character at all[abc]— specifically a, b, or c[a-z]— any lowercase letter[^abc]— anything except a, b, or c
Capital versions invert them: \D is any non-digit, \W any non-word character, \S any non-whitespace.
Quantifiers — how many:
*— zero or more+— one or more?— zero or one (optional){3}— exactly three{2,5}— between two and five{3,}— three or more
Anchors — where:
^— start of the string$— end of the string\b— a word boundary
Grouping:
(...)— group things together and capture what matched|— or
So \d{3}-\d{4} reads as: three digits, a hyphen, four digits. A phone number.
The One Python-Specific Thing You Must Know: Raw Strings
Before any functions, this trips up everyone.
Always write your patterns as raw strings — a string with r in front of it:
r"\d+" # do this
"\\d+" # not this
Why: backslash means something special in both Python strings and regex. Without the r, Python processes the backslash first and regex never sees what you intended. The r prefix tells Python to leave the string alone.
Both of these happen to work:
re.findall("\\d+", "a1b2") # ['1', '2']
re.findall(r"\d+", "a1b2") # ['1', '2']
But the second is readable and the first is a bug waiting to happen. Just always use r.
The Core Functions
1. re.search() — find the first match anywhere
The one you’ll use most.
re.search(r"world", "hello world")
# <re.Match object; span=(6, 11), match='world'>
re.search(r"python", "hello world")
# None
It returns a match object if found, None if not. Since None is falsy, this reads naturally:
if re.search(r"error", log_line, re.IGNORECASE):
print("Found an error")
Why it works: search scans the entire string. It’s the sensible default when you just want to know whether a pattern occurs.
2. re.match() and re.fullmatch() — anchored versions
re.match() only checks the beginning of the string:
re.match(r"hello", "hello world") # matches
re.match(r"world", "hello world") # None
re.fullmatch() requires the entire string to match:
re.fullmatch(r"\d{3}", "123") # matches
re.fullmatch(r"\d{3}", "1234") # None
Why it matters: re.match is the single most common source of confusion for beginners, who expect it to behave like search. If you want validation, use fullmatch. If you want to find something, use search. match is rarely the right answer.
3. Working with the match object
Once you have a match, you extract pieces from it using groups — the parts in parentheses.
m = re.search(r"(\d{3})-(\d{4})", "Call 555-1234 now")
m.group(0) # '555-1234' the whole match
m.group(1) # '555' first group
m.group(2) # '1234' second group
m.groups() # ('555', '1234')
m.span() # (5, 13) where it was found
Named groups make this far more readable, and you should use them once a pattern has more than two groups:
m = re.search(r"(?P<area>\d{3})-(?P<num>\d{4})", "Call 555-1234")
m.group("area") # '555'
m.groupdict() # {'area': '555', 'num': '1234'}
Why it works: m.group(3) tells a future reader nothing. m.group("area") tells them everything, and the pattern won't silently break when someone adds a group in the middle.
4. re.findall() — get every match as a list
re.findall(r"\d+", "a1 b22 c333")
# ['1', '22', '333']
There’s a behavior here that surprises people. If your pattern contains groups, findall returns the groups rather than the whole match:
re.findall(r"(\w+)@", "bob@x.com amy@y.com")
# ['bob', 'amy'] — just the group
re.findall(r"(\d)(\d)", "12 34 56")
# [('1', '2'), ('3', '4'), ('5', '6')] - tuples
Why it matters: this is a common source of “why is my output shaped wrong.” If you want the whole match while still using parentheses for grouping, use a non-capturing group: (?:...).
5. re.finditer() — every match, with position information
Same as findall, but yields match objects instead of strings:
for m in re.finditer(r"\d+", "a1 b22 c333"):
print(m.group(), m.span())
# 1 (1, 2)
# 22 (4, 6)
# 333 (8, 11)
Why you’d use it: when you need positions, groups, or you’re processing a large file and don’t want every match held in memory at once.
6. re.sub() — find and replace
Probably the most immediately useful function here.
re.sub(r"\s+", " ", "too many spaces")
# 'too many spaces'
You can reference captured groups in the replacement with \1, \2:
re.sub(r"(\w+)@(\w+)", r"\2 at \1", "bob@example")
# 'example at bob'
Limit how many replacements happen:
re.sub(r"a", "X", "banana", count=1)
# 'bXnana'
And re.subn() tells you how many it changed:
re.subn(r"a", "X", "banana")
# ('bXnXnX', 3)
The powerful version: the replacement can be a function, receiving each match and returning what to substitute.
def upper_it(match):
return match.group(0).upper()
re.sub(r"\b\w{4}\b", upper_it, "this is a test word")
# 'THIS is a TEST WORD'
Why it works: this turns re.sub from find-and-replace into arbitrary text transformation. Any logic you can write in a function can be applied to every match.
7. re.split() — split on a pattern
str.split() only handles a fixed delimiter. re.split() handles a pattern:
re.split(r",\s*", "a, b,c, d")
# ['a', 'b', 'c', 'd']
Note that it cleaned up inconsistent spacing, which plain split(",") would not.
If your pattern has groups, the delimiters get included in the output:
re.split(r"(\d)", "a1b2c")
# ['a', '1', 'b', '2', 'c']
And you can limit it:
re.split(r",", "a,b,c,d", maxsplit=2)
# ['a', 'b', 'c,d']
8. re.compile() — reuse a pattern
If you’re using a pattern repeatedly, compile it once:
pattern = re.compile(r"\d{4}-\d{2}-\d{2}")
pattern.search("due 2026-03-15 ok").group()
# '2026-03-15'
pattern.findall("2026-01-01 and 2026-12-25")
# ['2026-01-01', '2026-12-25']
Why you’d use it: mild performance benefit in a loop, but the real reason is readability. A compiled pattern with a good variable name at the top of your file documents itself far better than the same cryptic string repeated in six places.
Flags — Changing How Matching Works
Flags go as the last argument, or into re.compile().
**re.IGNORECASE** — case insensitive:
re.findall(r"cat", "Cat CAT cat", re.IGNORECASE)
# ['Cat', 'CAT', 'cat']
**re.MULTILINE** — ^ and $ match at each line, not just string start and end:
re.findall(r"^\w+", "one\ntwo\nthree", re.MULTILINE)
# ['one', 'two', 'three']
**re.DOTALL** — makes . match newlines too:
re.search(r"a.b", "a\nb") # None
re.search(r"a.b", "a\nb", re.DOTALL) # matches
**re.VERBOSE** — lets you write readable, commented patterns:
pattern = re.compile(r"""
(\d{3}) # area code
- # separator
(\d{4}) # number
""", re.VERBOSE)
pattern.search("555-1234").groups()
# ('555', '1234')
Why it works: re.VERBOSE is dramatically underused. Any pattern longer than about 20 characters becomes maintainable with it, and the alternative is a wall of symbols nobody will ever dare modify.
Greedy vs Lazy — The Classic Trap
By default, quantifiers grab as much as possible:
re.search(r"<.+>", "<a><b>").group()
# '<a><b>' not what you wanted
Add ? after the quantifier to make it lazy — grab as little as possible:
re.search(r"<.+?>", "<a><b>").group()
# '<a>' better
Why it matters: this catches everyone at least once. If your pattern is matching far more than you expected, greediness is almost always the reason.
Lookahead and Lookbehind
These match based on surrounding context without including it in the result.
# lookahead: digits followed by " dollars"
re.findall(r"\d+(?= dollars)", "5 dollars and 10 euros")
# ['5']
# lookbehind: prices preceded by a dollar sign
re.findall(r"(?<=\$)\d+\.\d{2}", "cost $5.99 and $12.50")
# ['5.99', '12.50']
Why you’d use them: when you need to locate something by its context but only want the thing itself.
Practical Recipes
Extracting email addresses:
text = "Contact bob@example.com or amy.smith@test.co.uk today"
re.findall(r"[\w.+-]+@[\w-]+\.[\w.]+", text)
# ['bob@example.com', 'amy.smith@test.co.uk']
Parsing a log line:
log = "2026-03-15 14:23:01 ERROR Database timeout"
m = re.match(r"(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) (\w+) (.*)", log)
m.groups()
# ('2026-03-15', '14:23:01', 'ERROR', 'Database timeout')
Validating a US ZIP code:
zip_re = r"\d{5}(-\d{4})?"
bool(re.fullmatch(zip_re, "45324")) # True
bool(re.fullmatch(zip_re, "45324-1234")) # True
bool(re.fullmatch(zip_re, "4532")) # False
Cleaning messy whitespace:
messy = "Name: John Smith , Age: 42 "
re.sub(r"\s+", " ", messy).strip()
# 'Name: John Smith , Age: 42'
Things That Will Bite You
1. Escaping user input. If you build a pattern from text a user typed, escape it first — otherwise their punctuation becomes regex syntax:
re.escape("price: $5.99 (sale)")
# 'price:\\ \\$5\\.99\\ \\(sale\\)'
2. Catastrophic backtracking. Certain patterns — nested quantifiers like (a+)+b — can take exponentially long on inputs that don't match. On a public web server, that's a denial-of-service vulnerability with a name: ReDoS. Avoid nesting quantifiers inside groups.
3. Don’t parse HTML with regex. HTML is nested and regex fundamentally can’t handle arbitrary nesting. Use a parser like BeautifulSoup. Same for JSON, XML, and CSV — all have proper libraries.
4. Sometimes plain string methods are better. If you’re checking for a fixed substring, "error" in line is faster, clearer, and harder to get wrong than a regex. Reach for regex when the pattern is genuinely variable.
5. Test your patterns somewhere visual. regex101.com shows you exactly what each part of your pattern is doing and highlights matches live. It’s the single best learning tool for this, and it’ll save you enormous frustration.
Conclusion
This concludes my tutorial on Python regex. The short version: use raw strings always, search to find, fullmatch to validate, findall for a list of results, finditer when you need positions, sub for replacement, split for pattern-based splitting, and compile when you'll reuse a pattern. Named groups and re.VERBOSE are what make a complicated pattern survivable six months later.
The thing that changed regex from frustrating to useful for me was realizing the syntax is small — about fifteen symbols carry nearly all the weight. It looks impenetrable because it’s dense, not because there’s a lot of it. Dense and complicated aren’t the same thing.
And regex transfers everywhere. The pattern syntax is nearly identical in JavaScript, Java, Go, Ruby, and your text editor’s find-and-replace box. Learn it once in Python and you’ve quietly picked up a skill that follows you into every language and every tool you’ll use for the rest of your career.
I hope this helps. If you have any questions or comments, please let me know. Thanks for reading!
메타데이터
- post_id
- 459c94b8e649
- slug
- python-regex-a-practical-tutorial-for-people-whove-been-avoiding-it-459c94b8e649
- url
- https://medium.com/@liu-111/python-regex-a-practical-tutorial-for-people-whove-been-avoiding-it-459c94b8e649
- canonical_url
- https://medium.com/@liu-111/python-regex-a-practical-tutorial-for-people-whove-been-avoiding-it-459c94b8e649
- author_url
- https://medium.com/@liu-111
- status
- ok
- fetched_at
- 2026-09-16 22:29:10