Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Text & Patterns  ›  Lesson

Groups, Named Captures & Lookaround

Groups and Captures 24 minMaking a pattern hand back the pieces it matched
You're building a piece ofQuarry — the extractor
This piece — extract_fields(): Hands back the date, time, level, component and message as separate values.
Scenario The report wants to count warnings per component. The extractor must hand back the level and the component as separate values, not a yes or no.
Your task
Write extract_fields(line) returning a dict with keys date, time, level, component and message for a valid Quarry log line, or None for anything else. Example: extract_fields("2026-09-14 08:15:02 WARN [auth] login failed user=maya") → {"date": "2026-09-14", "time": "08:15:02", "level": "WARN", "component": "auth", "message": "login failed user=maya"}.

Groups, Named Captures & Lookaround

In short: wrap part of a pattern in ( ) and the engine remembers what that part matched — .group(n) reads it back by position, .groups() returns all of them, and (?P<name>...) lets you read one back by name with .groupdict(), which survives someone editing the pattern later. (?:...) groups without capturing, for when you need parentheses but not the text. Lookaround — (?=...), (?!...), (?<=...), (?<!...) — checks what sits around a match without consuming it, but a failing lookahead doesn't stop the engine; it makes a quantifier give a character back and try again, which is where lookaround bugs live.

A log line split into boxes — date, time, level, component, message — each arrow going to a named key in m.groupdict(), matching the pattern's (?P<name>...) group

Quarry's gate can now say yes, this is a log line. That's a start, but a yes/no isn't data. The report at the end of the pipeline wants the level, the component and the message as separate values it can count, filter and store — and groups are how a pattern hands those pieces back.

Read every key=value in a support ticket into a dict

A ticket tail like user=maya ip=10.0.4.17 took=312ms is full of pairs. Capture the key and the value, and findall with two groups returns a list of tuples — which feeds straight into dict():

python

No groups, one group, two groups — three different return shapes from the same findall. That's worth knowing cold, because the number of groups in your pattern silently changes what comes back.

A quantifier after a group repeats the whole group — which finally lets you repeat something bigger than one character:

What does this print?
import re
m = re.fullmatch(r"(ab)+", "ababab")
print(m.group(1))

That surprises nearly everyone once: a group is a single slot, and each repetition overwrites it.

Name the pieces of a slow-request alert

An alert line says took=2s or took=312ms — a number, then a unit. Reading group(1) and group(2) works, but add one more group near the front of the pattern later and every number after it silently shifts. A named group fixes that for good:

python

.groupdict() is exactly the shape the extractor wants to hand to the rest of Quarry: the pattern now documents itself, and a reader sees (?P<unit>...) and knows instantly what that part is for.

Sometimes you need parentheses only to bundle an alternation, not to capture text. (?:...) groups without capturing, so it takes no number and never shows up in .groups():

What does this print?
import re
print(re.findall(r"(?:WARN|ERROR) \[(\w+)\]", "WARN [auth] x ERROR [db] y INFO [web] z"))

Quantifiers take as much as they can and give back only when forced to. Fine for \d+, which stops at the first non-digit anyway — a trap for .*, which can match anything:

python

Greedy .* runs to the end of the line, then backs off one character at a time until a " fits — landing on the last one. Lazy .*? (the ? after a quantifier) takes as little as possible and grows only when forced. Neither is "the fast one": a greedy .* before a separator at position p in a string of length n backs off n − p steps to get there; a lazy .*? creeps forward p steps. Choose by which match you want. When it matters, the real fix is a class that can't cross the boundary at all — ([^"]*) can never run past the closing quote, so there's nothing to back off from.

Count real customers, not load-test bots

A dashboard counts customer visits from cust= ids in access logs, but load tests leave fake ids tagged (test) behind, like cust=loadbot9(test). Lookaround checks a condition without consuming it, so you can require cust= just before and reject (test) just after, keeping neither in the match:

Syntax Name Passes when the text…
(?=...) lookahead ahead matches ...
(?!...) negative lookahead ahead does not match
(?<=...) lookbehind behind matches ...
(?<!...) negative lookbehind behind does not match

Python's lookbehind must be fixed-width — (?<=cust=) is fine, (?<=\w+=) is refused.

broken — fix it

The output includes a leftover test-account fragment. Run it, work out why the engine produced it, then fix the pattern so test accounts are excluded completely.

That's the sentence worth remembering: a failing lookaround doesn't make the engine give up — it makes a quantifier give a character back and try again. Whenever a negative condition follows + or *, ask what happens when it gives one back.


Building extract_fields, piece by piece

Last lesson's gate pattern, with every useful part named — the \[ and \] stay outside the component group, because the brackets are punctuation, not data:

python

Adjacent string literals join automatically, so a long pattern can be split across lines with a comment-sized piece on each. re.compile turns the pattern into a reusable object with the same methods — LOG.fullmatch(...) — which avoids re-parsing the pattern on every call.

Watch the groups fill in. This trace does by hand what the engine does for the first three named groups: walk the line, close each group's span when the separator after it arrives.

step through it
1line = "2026-09-14 08:15:02 WARN [auth] login failed"
2spans = {}
3start = 0
4for name in ["date", "time", "level"]:
5 end = line.index(" ", start)
6 spans[name] = line[start:end]
7 start = end + 1
8rest = line[start:]
9print(spans, rest)

Quick practice

What does this print?
import re
m = re.fullmatch(r"(?P<level>INFO|WARN) (?P<msg>.+)", "NOTICE all good")
print(m)
What does this print?
import re
print(re.findall(r"(\d+)(?=ms)", "took=312ms wait=2s"))
What does this print?
import re
tail = "retries=3 user=maya retries=9"
print(dict(re.findall(r"(\w+)=(\S+)", tail)))

🎯 Your turn

Write extract_fields(line). For a valid Quarry log line it returns a dict with the keys date, time, level, component and message; for anything else it returns None:

  • extract_fields("2026-09-14 08:15:02 WARN [auth] login failed user=maya") → {"date": "2026-09-14", "time": "08:15:02", "level": "WARN", "component": "auth", "message": "login failed user=maya"}
  • extract_fields("Traceback (most recent call last):") → None

Hint — compile the named-group pattern once at module level, fullmatch the line, and return m.groupdict() only if m is not None.

Then press ▶ Run and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upHours, minutes, seconds

Write split_time(stamp) that takes a time like "08:15:02" and returns [8, 15, 2] — three ints — using one pattern with three groups. If the text is not exactly HH:MM:SS, return None.

regex-groupscapture-group
DrillEvery key=value as a dict

Write key_values(message) returning a dict of every key=value pair in the message. A key is one or more word characters; a value is everything up to the next whitespace. Words without = are ignored.

regex-groupsre.findalldictionaries
BuildThe first duration, in milliseconds

Write first_duration_ms(message) that finds the first took= value and returns it in milliseconds as an int. Values are a whole number followed by ms or s: took=312ms → 312, took=2s → 2000. Return None if there is no took= value.

regex-groupsnamed-group
BossEvery user except the bots

Write human_users(line) returning every username that follows user=, in order, excluding bots, which log as user=name(bot). Usernames are word characters. Use a lookbehind for user= and a negative lookahead for (bot) — and make sure a bot name never comes back shortened.

regex-groupslookaheadlookbehind
CapstoneParse a gateway request line

Quarry's web gateway logs requests like GET /api/files?id=42 200 18ms. Write parse_request(line) returning {"method", "path", "query", "status", "ms"} where status and ms are ints and query is the text after ? or None when there is no ?. Methods are GET, POST, PUT or DELETE. Return None for anything else.

regex-groupsnamed-groupnon-capturing-group
ExplainNamed groups versus numbers

Your extractor pattern has five groups and the code reads them as m.group(3) and m.group(4). A colleague adds a group for milliseconds after the time. Explain what breaks, why no error is raised, and how named groups prevent it.

extract_fields.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
extract_fields(line) → dictThe line's named fields, or None if it isn't a Quarry log line.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.