Skip to main content

Pattern-matching text with re

Level 2: Level 2: Idiomsmedium12 minregexrefindallsub

Find, extract, and replace text patterns with regular expressions.

Text patterns with re

Raw text arrives messy: log lines like "order 12, item 345", user-typed phone numbers, IDs buried in free-form comments. A regular expression (regex) is a compact pattern language for "text that looks like this," and Python's re module runs those patterns. When a data engineer needs to pull every order ID out of a million log lines, or reject rows whose zip_code field is not five digits, regex does it in one pass instead of a hand-written character loop.

Write patterns as raw strings

Check yourself
You write a word-boundary pattern as an ordinary string, '\bcat\b', with no r prefix. What is the risk?

Always write patterns as raw strings (r"..."). Regex leans on the backslash (\d, \w, \s), and in a normal Python string the backslash is an escape character. r"\d" is the two characters backslash-d, exactly what the regex engine wants; a plain "\d" is fragile because \d is not a valid string escape, and in Python 3.12 and later it triggers a SyntaxWarning.

The pieces you will use most

Table
Two rows quietly cause most regex surprises. * can match zero characters, so a pattern built only from * always succeeds without consuming anything, and + is greedy by default, so it runs to the LAST possible match rather than the first.
PatternMatchesNote
\done digit, 0 to 9\D is the negation: one non-digit
\wone letter, digit, or underscore\W negates it
\sone whitespace characterspace, tab, or newline
.any single characterexcept a newline, unless re.DOTALL
+one or more of what precedes itgreedy: use +? to take as few as possible
*zero or more of what precedes itcan match nothing at all
[abc]any single one of a, b, or c[^abc] means any character EXCEPT those
( )a capture groupits contents come back from .group(1)
Two rows quietly cause most regex surprises. * can match zero characters, so a pattern built only from * always succeeds without consuming anything, and + is greedy by default, so it runs to the LAST possible match rather than the first.

The three workhorse functions

import re

re.findall(r"\d+", "a1 b22")   # ['1', '22']  every match, as a list
re.search(r"\d+", "abc7")       # a Match at '7', or None if nothing matches
re.sub(r"\d", "#", "a1b2")      # 'a#b#'  replace every match

re.findall returns a list of every non-overlapping match, left to right. That is exactly the Apply task: re.findall(r"\d+", "a1b22") gives ["1", "22"], and [] when there are no digits. re.sub(pattern, replacement, text) swaps every match for the replacement string, which is the Practice task: re.sub(r"\d", "#", "a1b2") gives "a#b#".

Pitfalls

\d matches one digit; \d+ matches a whole run. re.findall(r"\d", "a1b22") returns ['1', '2', '2'] (three separate digits), while re.findall(r"\d+", "a1b22") returns ['1', '22']. Reach for + when you want whole numbers, and drop it when you want single characters (as in redaction, where replacing each digit one at a time is fine).

Check yourself
What does re.findall(r'(\w+)@(\w+)', 'a@x b@y') return?

A capture group changes what findall returns. With no group it returns the full match; with one group it returns only that group; with several it returns tuples:

re.findall(r"\w+@\w+", "a@x b@y")     # ['a@x', 'b@y']
re.findall(r"(\w+)@(\w+)", "a@x b@y") # [('a', 'x'), ('b', 'y')]

So do not wrap your whole pattern in () out of habit; it silently reshapes the result.

Check yourself
What does re.findall(r'<.*>', '<a><b>') return?

Interview nuance: quantifiers are greedy by default. .* matches as much as it can, then backtracks. re.findall(r"<.*>", "<a><b>") returns ['<a><b>'], not the two tags you probably wanted. Add ? to make it lazy: re.findall(r"<.*?>", "<a><b>") returns ['<a>', '<b>']. Interviewers use this to check whether you understand that the engine explores text by backtracking, which is also why a careless pattern over adversarial input can blow up to quadratic time.

Worked example (Python)
import re
print(re.findall(r"\d+", "order 12, item 345"))   # ['12', '345']
print(re.sub(r"\d", "#", "PIN 4021"))             # 'PIN ####'

Apply

Your turn

The task this lesson builds to.

Implement find_numbers(text): return a list of every run of digits in text, in order, using re.findall.

For "a1b22" return ["1", "22"]. If there are no digits, return [].

3 hints and 4 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Implement redact_digits(text): return text with every digit replaced by "#", using re.sub.

For "a1b2" return "a#b#"; for "2024" return "####".

3 hints and 3 automated checks are waiting in the workspace.