datarekha

Regular expressions

The re module essentials — search vs findall, named groups, and the patterns you'll actually use to parse log lines and clean data.

10 min read Intermediate Python Lesson 22 of 41

What you'll learn

  • re.search, re.match, re.findall, and when each fits
  • The character classes and quantifiers you will use every day
  • Numbered and named capture groups, and compiling hot-path patterns
  • Parsing a log line into a structured record with re.sub

Before you start

Regular expressions have a reputation for being write-only — easy to scribble, impossible to read back. The way out of that reputation is to notice that a small subset handles the overwhelming majority of real work. Learn the half-dozen patterns that actually recur, always write them as raw strings, and keep regex101.com open in another tab for anything fiddly. When a pattern starts sprouting five nested groups and a lookahead, that is the signal to put the regex down and write a small parser instead.

Use raw strings, always

import re

pattern = r"\d{4}-\d{2}-\d{2}"       # YYYY-MM-DD

The r prefix tells Python “do not interpret the backslashes.” Without it, \b silently becomes a backspace character, and in modern Python an unrecognised escape like \d raises a DeprecationWarning on its way to a future SyntaxError. Raw strings sidestep all of it, so every pattern in this lesson is written r"...".

Here are the tokens that earn their keep — the ones worth knowing cold before anything else:

\ddigit [0-9]\wword char\swhitespace.any but newline*0 or more+1 or more?0 or 1{n,m}between n and m

search vs match vs findall

Four functions cover nearly everything:

re.search(pat, s)     # first match ANYWHERE in s, or None
re.match(pat, s)      # match must start at index 0 (rarely what you want)
re.findall(pat, s)    # a list of all non-overlapping matches
re.finditer(pat, s)   # an iterator of Match objects (best for large inputs)

Watch the first three on a real string:

import re

text = "Order 1234 shipped on 2026-05-27. Refund 5678 issued on 2026-05-28."

# search — the first match anywhere.
m = re.search(r"\d{4}-\d{2}-\d{2}", text)
print("first date:", m.group())     # the matched text
print("position:", m.span())        # (start, end)

# findall — every match, as a list.
print("all dates:", re.findall(r"\d{4}-\d{2}-\d{2}", text))

# match — only succeeds at the very start of the string.
print("match at 0:", re.match(r"Order", text))     # works
print("match at 0:", re.match(r"shipped", text))   # None — not at index 0
first date: 2026-05-27
position: (22, 32)
all dates: ['2026-05-27', '2026-05-28']
match at 0: <re.Match object; span=(0, 5), match='Order'>
match at 0: None

Notice that re.match(r"shipped", ...) returned None even though “shipped” is right there in the text — match only looks at index 0. That is why re.search is the right default; reach for match only when you truly mean “must start with”, and even then a leading ^ in the pattern reads more clearly.

Greedy versus non-greedy

The single sharpest edge in everyday regex is greediness. A * or + grabs as much as it possibly can, and adding a ? after it flips that to as little as possible. The difference is dramatic:

import re

html = "<b>hello</b> and <b>world</b>"

# Greedy — .* runs from the first <b> all the way to the LAST </b>.
print(re.findall(r"<b>.*</b>", html))

# Non-greedy — .*? stops at the FIRST </b>.
print(re.findall(r"<b>.*?</b>", html))
['<b>hello</b> and <b>world</b>']
['<b>hello</b>', '<b>world</b>']

The greedy pattern swallowed the entire string as one match; the non-greedy one found the two tags separately. Any time you mean “from X to the next Y”, you want the ? — otherwise the match quietly runs across boundaries you never intended.

A couple more building blocks round out the toolkit. Anchors pin a match to a position: ^ and $ mark the start and end (of the string, or of each line under re.MULTILINE), and \b marks a word boundary — r"\bcat\b" matches “cat” but not “category” or “concatenate”. Character classes group choices: [abc] is “a, b, or c”, [^abc] is “anything but”, and [a-z] is a range.

Groups — extracting the pieces

Parentheses create a capture group, and after a match you pull each group out by number — .group(1), .group(2), and so on, with .group(0) being the whole match:

import re

s = "Order 1234 placed by user_id=42 on 2026-05-27."

m = re.search(r"Order (\d+) placed by user_id=(\d+) on (\d{4}-\d{2}-\d{2})", s)
if m:
    print("order:  ", m.group(1))
    print("user:   ", m.group(2))
    print("date:   ", m.group(3))
    print("all:    ", m.groups())     # a tuple of every group
order:   1234
user:    42
date:    2026-05-27
all:     ('1234', '42', '2026-05-27')

This works, but counting parentheses to remember “was the user group 2 or 3?” gets old fast. Named groups fix that — (?P<name>...) labels a group, and you read it back by name:

import re

s = "Order 1234 placed by user_id=42 on 2026-05-27."

pattern = r"Order (?P<order>\d+) placed by user_id=(?P<user>\d+) on (?P<date>\d{4}-\d{2}-\d{2})"
m = re.search(pattern, s)

if m:
    print(m.groupdict())          # a dict of every named group
    print("order:", m["order"])   # dict-style access
{'order': '1234', 'user': '42', 'date': '2026-05-27'}
order: 1234

(?P<name>...) is a few extra characters, and it is a kindness to whoever reads the regex next — for anything headed to production, name the groups.

A real example — parsing a log line

This is the regex use case that turns up at every job: lines of semi-structured text you need as records. Notice the re.compile, which builds the pattern once for reuse in the loop:

import re

log_lines = [
    "2026-05-27 14:30:12 INFO  request_id=abc-123 user_id=42 latency_ms=187 path=/api/users",
    "2026-05-27 14:30:13 ERROR request_id=def-456 user_id=99 latency_ms=12   path=/api/orders",
    "2026-05-27 14:30:14 WARN  request_id=ghi-789 user_id=42 latency_ms=2103 path=/api/checkout",
]

log_re = re.compile(
    r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+"
    r"(?P<level>INFO|WARN|ERROR|DEBUG)\s+"
    r"request_id=(?P<request_id>\S+)\s+"
    r"user_id=(?P<user_id>\d+)\s+"
    r"latency_ms=(?P<latency_ms>\d+)\s+"
    r"path=(?P<path>\S+)"
)

for line in log_lines:
    m = log_re.search(line)
    if m:
        record = m.groupdict()
        record["latency_ms"] = int(record["latency_ms"])
        record["user_id"] = int(record["user_id"])
        print(record)
{'ts': '2026-05-27 14:30:12', 'level': 'INFO', 'request_id': 'abc-123', 'user_id': 42, 'latency_ms': 187, 'path': '/api/users'}
{'ts': '2026-05-27 14:30:13', 'level': 'ERROR', 'request_id': 'def-456', 'user_id': 99, 'latency_ms': 12, 'path': '/api/orders'}
{'ts': '2026-05-27 14:30:14', 'level': 'WARN', 'request_id': 'ghi-789', 'user_id': 42, 'latency_ms': 2103, 'path': '/api/checkout'}

Three habits make that robust. re.compile builds the pattern once when you will reuse it (the re module also caches recent patterns, but being explicit is clearer). The \s+ between fields tolerates one space or several — see how the 12 line had extra padding and still parsed. And the numeric groups are cast to int afterward, because a regex always hands you strings.

Substitution — re.sub

re.sub(pattern, replacement, string) replaces matches, and the replacement can reference captured groups with \1, \2, or \g<name>:

import re

text = "Charge 4532-1234-5678-9010 declined. Retry 4111-2222-3333-4444."

# Keep the first and last four digits, mask the middle.
masked = re.sub(r"\b(\d{4})-\d{4}-\d{4}-(\d{4})\b", r"\1-****-****-\2", text)
print(masked)
Charge 4532-****-****-9010 declined. Retry 4111-****-****-4444.

You can also pass a function as the replacement — re.sub(pat, fn, s) calls fn(match) for each hit — which is the move when the replacement depends on what was matched.

Knowing when not to reach for it

Regex is a sharp tool, and sharp tools are for the right cuts. If the task is “split on commas”, use .split(","). If it is “does this start with http”, use .startswith(). The moment you find yourself stacking lookaheads and nested groups, stop and write a small parser — it will be more readable and far easier to debug. And for anything non-trivial, paste the pattern and the text into regex101.com first; its live, token-by-token explanation beats running re.findall and squinting at the result.

In one breath

  • Always write patterns as raw strings (r"...").
  • re.search (anywhere) is the default; re.findall returns all matches; re.match only checks the start.
  • */+ are greedy; add ? for non-greedy when matching “up to the next” delimiter.
  • Use named groups (?P<name>...) and read them with .groupdict() / m["name"].
  • re.compile once for hot loops; cast numeric groups to int; reach for .split() / .startswith() when regex is overkill.

Practice

Quick check

0/3
Q1Why use `r"\d+"` instead of `"\d+"`?
Q2What is the difference between `.*` and `.*?`?
Q3Why use named groups `(?P<name>...)` instead of positional ones?

What’s next

You have pulled timestamps out of strings; next we make them mean something. datetime turns those strings into values you can compare, add days to, and convert across time zones without losing your mind.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Related lessons

Explore further

Glossary terms
Cheat sheets
Skip to content