Introduction to Regular Expressions in Python
Pattern matching without the fear. The dozen symbols that cover ninety percent of real use, plus groups, and the one function everyone reaches for first by mistake.
A regular expression describes the shape of text rather than its exact contents. "Three digits, a dash, four digits." "Anything, an @, anything, a dot, two or more letters."
Regex has a reputation for being unreadable. That reputation is earned by people writing 200-character monsters. The dozen symbols below cover almost everything you will ever actually need.
#The re module
import re
text = "Contact Dom at dom@codingwithdom.com or call 555-0134."
email = re.search(r"[\w.-]+@[\w.-]+\.\w+", text)
print(email.group()) # dom@codingwithdom.com#The symbols worth memorising
Character classes — match one character of a kind:
| Pattern | Matches |
|---|---|
\d | Any digit, 0–9 |
\w | A word character: letter, digit or underscore |
\s | Any whitespace: space, tab, newline |
. | Any character at all (except a newline) |
[aeiou] | Any one character from the set |
[^aeiou] | Any one character not in the set |
[a-z] | Any character in the range |
Capitalise to invert: \D is any non-digit, \S any non-whitespace.
Quantifiers — how many of the previous thing:
| Pattern | Meaning |
|---|---|
* | Zero or more |
+ | One or more |
? | Zero or one (optional) |
{3} | Exactly three |
{2,4} | Between two and four |
{2,} | Two or more |
Anchors — where in the text:
| Pattern | Meaning |
|---|---|
^ | Start of the string |
$ | End of the string |
\b | A word boundary |
\b is the one people forget and then wonder why searching for cat also matched "concatenate". r"\bcat\b" matches the word, not the fragment.
#The four functions you will use
#re.search — is it in there anywhere?
if re.search(r"\d{3}-\d{4}", text):
print("Found a phone number")Returns a match object, or None if there is nothing. Since None is falsy, you can use it directly in an if.
#re.match — does it start with this?
re.match(r"Contact", text) # matches
re.match(r"Dom", text) # None — 'Dom' is not at position 0#re.findall — give me all of them
prices = re.findall(r"\$\d+\.\d{2}", "Sale: $19.99, was $34.50")
print(prices) # ['$19.99', '$34.50']Returns a plain list of strings. \$ is an escaped dollar sign — unescaped, $ means end-of-string.
#re.sub — find and replace
messy = "Too many spaces"
print(re.sub(r"\s+", " ", messy)) # 'Too many spaces'
redacted = re.sub(r"\d{3}-\d{4}", "[REDACTED]", text)Collapsing runs of whitespace with re.sub(r"\s+", " ", s) is worth committing to memory. You will use it constantly when cleaning scraped or user-pasted text.
#Groups — pull the pieces out
Parentheses capture part of a match so you can retrieve it separately.
log = "2024-03-15 ERROR Database connection failed"
m = re.search(r"(\d{4})-(\d{2})-(\d{2}) (\w+) (.+)", log)
print(m.group(0)) # the whole match
print(m.group(1)) # 2024
print(m.group(4)) # ERROR
print(m.groups()) # ('2024', '03', '15', 'ERROR', 'Database connection failed')Group zero is always the entire match; numbering starts at one for your own groups.
#Named groups are much better
pattern = r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2}) (?P<level>\w+) (?P<message>.+)"
m = re.search(pattern, log)
print(m.group("level")) # ERROR
print(m.groupdict()) # {'year': '2024', 'month': '03', ...}(?P<name>...) is ugly to type and worth every character. Six months later, m.group("level") still means something; m.group(4) does not — and if you add a group in the middle, every number after it silently shifts.
#Greedy versus lazy
By default, quantifiers grab as much as they possibly can.
html = "<b>bold</b> and <i>italic</i>"
re.findall(r"<.+>", html) # ['<b>bold</b> and <i>italic</i>']
re.findall(r"<.+?>", html) # ['<b>', '</b>', '<i>', '</i>']The first pattern matched from the very first < to the very last >, because .+ is greedy. Adding ? makes it lazy — stop at the first thing that satisfies the pattern.
Whenever a regex "matches too much", greediness is your suspect.
#A practical validator
import re
PATTERNS = {
"email": r"^[\w.+-]+@[\w-]+\.[\w.]+$",
"phone": r"^\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$",
"zip": r"^\d{5}(-\d{4})?$",
"username": r"^[a-zA-Z][a-zA-Z0-9_]{2,15}$",
}
def validate(field, value):
pattern = PATTERNS.get(field)
if pattern is None:
raise ValueError(f"No rule for field '{field}'")
return re.match(pattern, value) is not None
tests = [
("email", "dom@codingwithdom.com"),
("email", "not-an-email"),
("phone", "(555) 123-4567"),
("phone", "555.123.4567"),
("zip", "90210-1234"),
("username", "1domdom"),
]
for field, value in tests:
mark = "PASS" if validate(field, value) else "FAIL"
print(f"{mark} {field:<9} {value}")PASS email dom@codingwithdom.com
FAIL email not-an-email
PASS phone (555) 123-4567
PASS phone 555.123.4567
PASS zip 90210-1234
FAIL username 1domdomNotice how each pattern is anchored with ^ and $. Without the anchors, "totally not an email dom@x.co lol" would pass the email check, because search-style matching only needs the pattern to appear somewhere. For validation, always anchor both ends.
The username rule reads: start with a letter, then two to fifteen more letters, digits or underscores. So 1domdom fails on the very first character, exactly as intended.
#Compile it when you use it repeatedly
EMAIL = re.compile(r"^[\w.+-]+@[\w-]+\.[\w.]+$")
for address in big_list_of_addresses:
if EMAIL.match(address):
...re.compile parses the pattern once instead of on every call. For a handful of matches it makes no difference; inside a loop over a large file, it does.
Check yourself
3 questions on what you just read. No score is kept — it is only here to catch the bits that did not stick.
-
1You want to find a pattern anywhere in a string. Which function?
Show the answer
A.
re.searchre.matchonly ever looks at the start of the string. Reaching for it by name is the single most common regex mistake. -
2Why must a validation pattern be anchored with
^and$?Show the answer
A. Without them the pattern only has to appear somewhere, so junk around it still passes.
Unanchored,
"totally not an email dom@x.co lol"sails through an email check. For validation, pin both ends. -
3
re.findall(r"<.+>", "<b>bold</b>")returns one long match instead of the tags. Why?Show the answer
A.
+is greedy, so it runs to the last>it can find.Add
?to make the quantifier lazy:<.+?>. Whenever a regex "matches too much", greediness is your first suspect.
#Key takeaways
- Always write patterns as raw strings:
r"...". re.searchfinds anywhere;re.matchonly at the start. Reach forsearchby default.- Anchor validation patterns with
^and$or they will accept junk around the edges. - Named groups (
(?P<name>...)) survive contact with future you. - Quantifiers are greedy; add
?to make them lazy when a match swallows too much.
That closes out the Python track. Next stop: the PHP and web track, where you will build something with a URL.
Finished this one?
The challenge above has no posted solution — that is deliberate. Get it working, then come back for the next lesson.