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.

Python Beginner 14 min read Watch the video (4:59)

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

python
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:

PatternMatches
\dAny digit, 09
\wA word character: letter, digit or underscore
\sAny 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:

PatternMeaning
*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:

PatternMeaning
^Start of the string
$End of the string
\bA 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?

python
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?

python
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

python
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

python
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.

python
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

python
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.

python
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

python
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}")
text
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  1domdom

Notice 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

python
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.

  1. 1You want to find a pattern anywhere in a string. Which function?

    Show the answer

    A. re.search

    re.match only ever looks at the start of the string. Reaching for it by name is the single most common regex mistake.

  2. 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. 3re.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.search finds anywhere; re.match only at the start. Reach for search by 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.

More tutorials Subscribe