Course outline · 0% complete

0/27 lessons0%

Course overview →

Groups, findall, sub, and a lookahead

lesson 7-2 · ~13 min · 19/27

Pulling pieces out with groups

Parentheses in a pattern create groups, letting you capture the interesting parts of a match separately:

import re

m = re.search(r"(\w+)@(\w+)\.com", "mail ada@example.com now")
m.group(0)   # 'ada@example.com'  the whole match
m.group(1)   # 'ada'
m.group(2)   # 'example'

Two more workhorses:

  • re.findall(pattern, text) returns every match as a list of strings, or a list of tuples when the pattern has groups.
  • re.sub(pattern, replacement, text) replaces every match. In the replacement, \1 refers back to group 1.

Groups are where regex pays its rent: they turn a yes/no matcher into an extraction tool that pulls every (name, score), (date, amount), or (level, message) pair out of a pile of text in two lines.

Extracting prices and masking a card number

Two of the most common regex jobs in one snippet. findall collects every match in the string, and sub rewrites matches, keeping the part captured by a group.

import re

receipt = "coffee $4.50, bagel $2.25, tip $1.00"
print(re.findall(r"\$\d+\.\d{2}", receipt))

card = "card 4242424242421234 on file"
print(re.sub(r"\d{12}(\d{4})", r"****\1", card))

Output

['$4.50', '$2.25', '$1.00']
card ****1234 on file

Both $ and . are special characters in a pattern, so the price pattern escapes them as \$ and \. to mean the literal dollar sign and the literal dot.

The sub call shows a backreference. (\d{4}) captures the last four digits as group 1, and \1 in the replacement text puts them back, so the mask hides twelve digits while preserving the part a customer uses to recognize their own card.

Lookahead: check without consuming

A lookahead (?=...) peeks forward without including that text in the match. The classic use is password rules, several conditions on the same stretch of text:

r"^(?=.*\d)(?=.*[A-Z]).{8,}$"

Read it as: at the start, peek: somewhere ahead there is a digit. Peek again: somewhere ahead there is an uppercase letter. Then actually match 8 or more characters to the end. Each lookahead rewinds to the start after checking, which is how all the rules can apply at once.

Keep regex honest: it shines for flat token patterns like codes, prices, and log lines. The moment you are parsing nested structure, where values contain values that contain values, like the JSON data format coming in unit 8 or the HTML markup of a web page, switch to a real parser built for that format (unit 8 covers json).

Extracting name and score pairs

When a pattern contains capture groups, findall hands back a tuple of those groups for each match, which makes parsing structured text almost mechanical.

import re

log = "mia:82 leo:91 zoe:78"

for name, score in re.findall(r"(\w+):(\d+)", log):
    print(name, int(score) + 10)

Output

mia 92
leo 101
zoe 88

The pattern r"(\w+):(\d+)" puts one capture group on each side of the colon, so every match yields a (name, score) tuple that the for line unpacks directly.

The conversion is not optional. Regex captures are always strings, so score arrives as "82", and int(score) is what makes + 10 arithmetic rather than an error.

Redacting email addresses

One re.sub call replaces every email address in the text with [hidden]. A good-enough pattern for this purpose is word characters, an @, more word characters, a literal dot, then word characters.

import re

text = "contact ada@example.com or leo@test.org today"

print(re.sub(r"\w+@\w+\.\w+", "[hidden]", text))

Output

contact [hidden] or [hidden] today

\w+ matches a run of letters, digits, and underscores, and the dot has to be escaped as \. or it would match any character at all. re.sub replaces every match it finds, so a single call censors both addresses without a loop.

This pattern is deliberately simple, and it will miss real addresses with dots or plus signs in the local part. Validating email properly with a regex is a famously bad idea. Use a pattern like this for redaction and reporting, and a real library when correctness matters.

What findall returns with groups

re.findall(r"(\d+)-(\d+)", "7-2 and 10-4") returns [('7', '2'), ('10', '4')].

The presence of capture groups changes the shape of the result. With groups, findall gives you a tuple of the groups for each match rather than the matched text. Remove the parentheses and findall(r"\d+-\d+", ...) would return ['7-2', '10-4'], the whole matches as plain strings.

PatternResult shape
no groupslist of matched strings
one grouplist of that group's text
two or more groupslist of tuples, one per match

Matching both spellings of a word

In r"colou?r" the ? applies to the u, making it optional, so the single pattern matches both color and colour.

This is the same binding rule from the previous lesson seen in a practical light: a quantifier attaches to the one piece, or one parenthesized group, immediately before it. Handling regional spelling variations with a single optional character is a small but genuinely useful trick when searching text written by many people.