Regular Expressions › Grouping and Capturing

Michael BrenndoerferUpdated March 18, 20254 min read
Language AI HandbookAll chapters
Part 1 of 67Text as Data
Chapter 3Regular Expressions › Grouping and Capturing
Reading section 7 of 21 · Grouping and Capturing · 4 minSection 7 of 21 · 4 min

Book contents

Section 7 of 21 · 4 min read

Regular Expressions › Grouping and CapturingOne section · Sections in this chapter7 / 21 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Parentheses serve two purposes in regex: grouping elements together and capturing matched text for later use. Grouping lets you apply quantifiers to multi-character sequences or create alternations. Capturing stores the matched text so you can reference it later in the pattern or in replacement strings.

The distinction between these two uses is important. When you write (https?)://, the parentheses group http and s? so the alternation is clear, and they also capture the matched protocol as group 1. If you do not need the captured content, you should use a non-capturing group (?:https?):// instead. Using capturing groups unnecessarily has two costs: it slightly slows down the match because the engine must track the captured span, and it pollutes the results of findall() with content you do not want.

Basic Groups

In[27]:
Code
# Grouping for repetition
text = "abcabcabc"
pattern = r"(abc)+"
match = re.search(pattern, text)

# Grouping for alternation
colors = "The car is red, the bike is blue, the bus is green"
pattern = r"is (red|blue|green)"
matches = re.findall(pattern, colors)
Out[28]:
Console
Grouping for repetition:
  Text: 'abcabcabc'
  Pattern: (abc)+
  Full match: 'abcabcabc'
  Captured group: 'abc'

Grouping for alternation:
  Pattern: is (red|blue|green)
  Captured colors: ['red', 'blue', 'green']

When you use findall() with groups, it returns only the captured group contents, not the full match. This is often what you want when extracting specific parts of a pattern. The behavior change of findall() based on whether groups are present is a common source of confusion for new regex users. If your pattern contains no groups, findall() returns a list of the full matched strings. If it contains one group, it returns a list of the group's content. If it contains multiple groups, it returns a list of tuples. Understanding this contract up front will save significant debugging time.

Multiple Groups

In[29]:
Code
# Extract date components
text = "Meeting on 2024-01-15 and 2024-02-20"
pattern = r"(\d{4})-(\d{2})-(\d{2})"

# findall returns tuples when there are multiple groups
dates = re.findall(pattern, text)

# Using match objects for more control
for match in re.finditer(pattern, text):
    full = match.group(0)
    year = match.group(1)
    month = match.group(2)
    day = match.group(3)
Out[30]:
Console
Text: 'Meeting on 2024-01-15 and 2024-02-20'
Pattern: (\d{4})-(\d{2})-(\d{2})

findall with groups returns tuples:
  [('2024', '01', '15'), ('2024', '02', '20')]

Using match objects:
  Full match: '2024-01-15'
    Year:  2024
    Month: 01
    Day:   15

  Full match: '2024-02-20'
    Year:  2024
    Month: 02
    Day:   20

Named Groups

Named groups make patterns more readable and self-documenting:

In[31]:
Code
# Named groups with (?P<name>...)
pattern = r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
text = "Event date: 2024-03-15"

match = re.search(pattern, text)
Out[32]:
Console
Pattern with named groups:
  (?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})

Accessing by name:
  Year:  2024
  Month: 03
  Day:   15

As dictionary:
  {'year': '2024', 'month': '03', 'day': '15'}

Named groups are especially valuable in complex patterns where numbered groups become confusing. Consider a pattern for parsing log lines that has a dozen groups: numbering them and remembering which number corresponds to which field is error-prone. Named groups make the pattern self-documenting. The match.groupdict() method returns a dictionary mapping each group name to its matched content, which you can pass directly into a data processing function without any manual unpacking.

In practice, teams that maintain regex-heavy code often adopt a convention of always using named groups in production patterns, reserving numbered groups only for throwaway scripts. This makes code review much easier, because reviewers can understand what a substitution like r"\g<year>-\g<month>-\g<day>" is doing without reverse-engineering the pattern.

Non-Capturing Groups

Sometimes you need grouping for structure but don't want to capture the content. Use (?:...):

In[33]:
Code
# Capturing group - captures "http" or "https"
capturing = re.findall(
    r"(https?)://(\w+\.\w+)", "Visit https://example.com or http://test.org"
)

# Non-capturing group - only captures the domain
non_capturing = re.findall(
    r"(?:https?)://(\w+\.\w+)", "Visit https://example.com or http://test.org"
)
Out[34]:
Console
Capturing group (https?):
  Returns: [('https', 'example.com'), ('http', 'test.org')]

Non-capturing group (?:https?):
  Returns: ['example.com', 'test.org']

Non-capturing groups keep your results clean when you only care about specific parts of the pattern. Use the rule: if you do not intend to reference the group's content, make it non-capturing.