Groups and Captures

Regular Expressions in Practice

Chapter 2 ยท Groups and Captures

So far a match has been one lump of text. Real work usually needs the pieces: the year, month and day of a date, the user and host of an address. Parentheses turn part of a pattern into a group, and the engine remembers what each group matched. This chapter covers numbered groups, named groups, groups that do not capture, and how findall changes its answer when groups appear.

What was run
As in Regular Expressions in Practice 1, every snippet was executed and the comments show what Python printed.

Numbered Groups

Each pair of parentheses is a group, numbered from 1 by the position of its opening bracket, reading left to right. Group 0 is always the whole match.

import re text = "Booked 2026-11-02 and 2027-01-15." m = re.search(r"(\d{4})-(\d{2})-(\d{2})", text) print(m.group(), m.group(0)) # 2026-11-02 2026-11-02 print(m.group(1), m.group(2), m.group(3)) # 2026 11 02 print(m.groups()) # ('2026', '11', '02') print(m.span(2)) # (12, 14) year, month, day = m.groups() print(year, month, day) # 2026 11 02

.groups() returns every group except group 0 as a tuple, which makes unpacking into variables a one-liner. .span(n) gives the position of one group inside the original string. Captured values are always strings: convert with int() when you need a number.

Nested groups

When groups sit inside one another, count opening brackets from the left. The outer group is numbered before the ones inside it.

m = re.search(r"((\d{3})-(\d{4}))", "call 555-0199 now") print(m.groups()) # ('555-0199', '555', '0199')

Groups That Did Not Take Part

A group followed by ? may match nothing. It still exists, but its value is None, not an empty string. Check for that before using it.

pat = r"(\w+)@(\w+)\.com(\.uk)?" m = re.search(pat, "bob@site.com") print(m.groups(), m.group(3)) # ('bob', 'site', None) None m = re.search(pat, "bob@site.com.uk") print(m.groups()) # ('bob', 'site', '.uk')

Named Groups

Numbers are fragile: add a group at the front and every number after it shifts. A named group, written (?P<name>...), lets you ask for a part by what it means. It still has a number too.

m = re.search(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})", text) print(m.group("year"), m["month"], m.group("day")) # 2026 11 02 print(m.groupdict()) # {'year': '2026', 'month': '11', 'day': '02'} print(m.group(1)) # 2026 (the number still works)

.groupdict() returns a dictionary of name to text, which is ideal for building records. The shorthand m["month"] works the same as m.group("month"). Together with finditer you can turn a line of text into data:

line = "alice=30, bob=25" pat = r"(?P<name>\w+)=(?P<age>\d+)" for m in re.finditer(pat, line): print(m.groupdict()) # {'name': 'alice', 'age': '30'} # {'name': 'bob', 'age': '25'} print({m["name"]: int(m["age"]) for m in re.finditer(pat, line)}) # {'alice': 30, 'bob': 25}

Non-Capturing Groups

Sometimes you need parentheses only to group: to apply a quantifier to several characters, or to limit an alternation. If you do not want the text remembered, write (?:...). It takes no number and does not appear in .groups().

SyntaxGroups?Captures?Use it for
( ... )yesyes, numberedpieces you want back
(?P<name> ... )yesyes, named and numberedpieces with a meaning
(?: ... )yesnorepeating or choosing, nothing to keep
people = "Dr. Lee met Ms. Wu and Mr. Fox" print(re.findall(r"(?:Mr|Ms|Dr)\. (\w+)", people)) # ['Lee', 'Wu', 'Fox'] print(re.findall(r"(Mr|Ms|Dr)\. (\w+)", people)) # [('Dr', 'Lee'), ('Ms', 'Wu'), ('Mr', 'Fox')]

How findall Reacts to Groups

This is the rule that surprises people. What findall returns depends on how many capturing groups the pattern has:

Capturing groupsfindall returns
nonea list of the whole matches
exactly onea list of that group's text
two or morea list of tuples, one item per group
print(re.findall(r"\d{4}-\d{2}-\d{2}", text)) # ['2026-11-02', '2027-01-15'] print(re.findall(r"(\d{4})-\d{2}", text)) # ['2026', '2027'] print(re.findall(r"(\d{4})-(\d{2})-(\d{2})", text)) # [('2026', '11', '02'), ('2027', '01', '15')] hosts = "hosts 10.0.0.1 and 192.168.1.20" print(re.findall(r"(?:\d{1,3}\.){3}\d{1,3}", hosts)) # ['10.0.0.1', '192.168.1.20'] print(re.findall(r"(\d{1,3}\.){3}\d{1,3}", hosts)) # ['0.', '1.']

The last two lines are the classic accident. Adding a capturing group just to repeat it changes the result to the last repetition of that group ('0.' and '1.'), not the address. Use (?:...) when the parentheses are only there for repeating. A repeated capturing group always keeps only its final repetition:

print(re.fullmatch(r"(\d)+", "123").group(1)) # 3

Reusing a Group Inside the Pattern

A backreference such as \1 means "the same text group 1 matched". It lets a pattern find repetition, like a word typed twice in a row.

print(re.findall(r"\b(\w+) \1\b", "this is is a test test of the the code")) # ['is', 'test', 'the']
A habit worth forming
Name any group you will read later, make any group you will not read non-capturing, and remember that every captured value is a string until you convert it.

Challenges

Exercise 1

Given s = "Smith, John; Doe, Jane", use findall with two capturing groups to get the surname and first name of each person as tuples, then build the list ['John Smith', 'Jane Doe'] from them.

๐Ÿ“„ View solution
Exercise 2

Write parse_time(t) using named groups h, m and an optional s for strings like 9:05 and 14:30:59. Return a dictionary of integers (seconds default to 0) or None if the string does not fit. Test it on "9:05", "14:30:59", "9:5" and "1:2:3". Hint: the optional seconds need a non-capturing group around the colon.

๐Ÿ“„ View solution
Exercise 3

For urls = "http://a.com/x https://b.org/y/z ftp://c.net", predict and then run four things: findall with a capturing protocol group and a capturing host group; the same with the protocol group made non-capturing; the same with both groups named; and a finditer loop printing each groupdict(). Explain why the shape of the findall result changes.

๐Ÿ“„ View solution

Quick Reference

  • Group: ( ... ) numbered from 1 by opening bracket; group 0 is the whole match
  • Read back: m.group(n), m.groups(), m.span(n)
  • Named: (?P<name> ... ), then m["name"] or m.groupdict()
  • Non-capturing: (?: ... ) groups without remembering
  • Unused group: value is None
  • findall: strings for 0 or 1 groups, tuples for 2 or more
  • Backreference: \1 repeats what group 1 matched
Coming next
Regular Expressions in Practice 3 covers alternation and flags: choosing between patterns with the pipe, ignoring case, matching across lines, and writing long patterns readably.