← Back to list

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…

Jesse L · 2026-08-25 01:33 · 52 claps · 7.6 min read paywalled
#python #python-programming #regex #regular-expressions #python3
Open on Medium ↗
Wiki topics: 💻 · Programming 🌐 · Web Development 📚 · Books & Reading 📰 · Journalism & News

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

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