Exercises — Regular expressions — re module, patterns, groups, findall, sub
Every pattern below is written as a raw string r"..." so Python passes the backslash straight to the regex engine (see Escape characters and raw strings). Assume import re at the top of every snippet.
Level 1 — Recognition
Goal: read a pattern and say, in plain words, what shape it describes.
The figure below shows the four Level-1 atoms as stamps: each presses down on exactly one character. Notice . presses on any character while [^0-9] refuses digits.

Exercise 1.1 — Name the atom
What does each of these match? One short sentence each.
r"\d" , r"\w" , r"." , r"[^0-9]"
Recall Solution 1.1
r"\d"— one single digit, i.e. one character from0–9.r"\w"— one "word" character. In plain ASCII terms it is a letter, a digit, or the underscore_. Careful: by default Python treats regex strings as Unicode, so\walso matches accented letters and non-Latin scripts (e.g.é,ü,字). To restrict it to the pure ASCII set[A-Za-z0-9_], pass there.ASCIIflag or write the class out explicitly.r"."— any single character except a newline. The dot is a wildcard for exactly one char.r"[^0-9]"— the^inside a class means "not". So: one character that is not a digit.
What it looks like: think of each atom as a single stamp that presses down on exactly one letter of the text (see the figure above).
Exercise 1.2 — Quantifier meaning
Translate to English: r"a*", r"a+", r"a?", r"a{2,4}".
Recall Solution 1.2
A quantifier repeats the previous atom (here a).
r"a*"— zero or moreas (so it can match the empty string too!).r"a+"— one or moreas (needs at least one).r"a?"— zero or onea(optional singlea).r"a{2,4}"— between 2 and 4as in a row.
Mnemonic (from parent): Star = Sometimes (0+), Plus = Present (1+), Question = Quitable (0/1).
Level 2 — Application
Goal: write a pattern that captures a described shape.
Exercise 2.1 — All integers
Write one call that returns every whole number as a string from "room 7, floor 12, unit 305". Expected: ['7', '12', '305'].
Recall Solution 2.1
re.findall(r"\d+", "room 7, floor 12, unit 305")
# -> ['7', '12', '305']Why \d+ and not \d? \d alone matches one digit, so you'd get ['3','0','5', ...] — every digit split apart. The + says "grab a whole run of consecutive digits as one match", clumping 3,0,5 into '305'.
Exercise 2.2 — A simple time stamp
Write a pattern that matches a time like 14:30 (two digits, colon, two digits) and pull the hour and minute out separately from "alarm at 14:30 sharp". Then improve it so it rejects impossible times like 99:99.

Recall Solution 2.2
Basic version (shape only):
m = re.search(r"(\d{2}):(\d{2})", "alarm at 14:30 sharp")
m.group(0) # '14:30' whole match
m.group(1) # '14' group 1 = hour
m.group(2) # '30' group 2 = minute
m.groups() # ('14', '30')Why parentheses? Each ( ) is a capturing group — a little labelled box on your rule card. The colon : between them is a literal character (it matches an actual colon). group(0) is always the entire match; group(1), group(2) are the boxes, numbered left to right by their opening (.
Limitation: (\d{2}):(\d{2}) accepts nonsense like 99:99 — it only checks the shape (two digits each), not that the hour is 00–23 or the minute 00–59.
Range-guarded version: we enumerate the valid digit ranges with alternation |. Look at the figure: the hour splits into "0–9", "10–19", "20–23", the minute into "00–59".
pat = r"([01]\d|2[0-3]):([0-5]\d)"
re.search(pat, "alarm at 14:30 sharp").groups() # ('14', '30')
re.search(pat, "bad 99:99 time") # -> None (rejected!)[01]\d— hours00–19(a0or1, then any digit).2[0-3]— hours20–23.[0-5]\d— minutes00–59(first digit 0–5, second any digit). Regex checks shape, so ranges must be spelled out as digit classes; it cannot do arithmetic like "≤ 23".
Level 3 — Analysis
Goal: predict the exact output, including the surprising cases.
Exercise 3.1 — findall with two groups
What does this return?
re.findall(r"(\w+)=(\w+)", "x=1;y=2;z=30")Recall Solution 3.1
[('x', '1'), ('y', '2'), ('z', '30')]Why tuples, not strings? The rule: when the pattern has 2+ capturing groups, findall returns each match as a tuple of its groups — not the whole match. Here each match x=1 yields the tuple ('x','1'). (With 0 groups you'd get whole matches; with exactly 1 group you'd get just that group's string.)
Exercise 3.2 — Greedy vs lazy
Predict both outputs:
re.findall(r"\".*\"", 'say "hi" then "bye"')
re.findall(r"\".*?\"", 'say "hi" then "bye"')The figure below shows what the cursor does: the greedy red arrow shoots to the last quote then backtracks; the two lazy black arrows each stop at the first close quote.

Recall Solution 3.2
re.findall(r"\".*\"", 'say "hi" then "bye"') # -> ['"hi" then "bye"']
re.findall(r"\".*?\"", 'say "hi" then "bye"') # -> ['"hi"', '"bye"']Why the greedy one swallows the middle: .* is greedy — it first grabs everything to the far right, then backtracks left just enough to let the final " match. So it matches from the first quote all the way to the last quote, eating hi" then "bye. Look at the figure: the greedy red arrow shoots to the end, then walks back.
Why lazy splits into two: .*? (the ? after *) tries the smallest number of characters first. From the first " it takes nothing, checks for a closing ", finds it right after hi, and stops — giving "hi". Then it continues from bye and does the same. See Greedy vs Backtracking algorithms for the backtracking machinery.
Level 4 — Synthesis
Goal: combine groups, backreferences, and sub.
Exercise 4.1 — Reformat a date
The log has dates as 05/12/2024 (day/month/year). Turn them into 2024-12-05 (ISO order, dashes) using one re.sub. Test on "due 05/12/2024 ok".
Recall Solution 4.1
re.sub(r"(\d{2})/(\d{2})/(\d{4})", r"\3-\2-\1", "due 05/12/2024 ok")
# -> 'due 2024-12-05 ok'Step by step:
- WHAT: capture three boxes —
(\d{2})day,(\d{2})month,(\d{4})year. The/are literal slashes between them. - WHY groups: we need to reorder the pieces, so each piece must be remembered separately.
- WHAT the replacement means: in
r"\3-\2-\1",\3= year,\2= month,\1= day — these are backreferences to the captured boxes. We glue them with literal dashes. re.subwalks the string, and for every match it substitutes the rebuilt text — non-matching parts ("due "," ok") are left untouched.
Exercise 4.2 — Collapse whitespace
Replace any run of one-or-more whitespace characters with a single space in "a\t b\n\nc". Expected: "a b c".
Recall Solution 4.2
re.sub(r"\s+", " ", "a\t b\n\nc")
# -> 'a b c'Why \s+? \s matches one whitespace char (space, tab \t, newline \n). The + grabs a whole run as one match, so "\n\n" (two newlines) becomes one replacement, not two. Replacing each run with a single " " normalises the spacing. Contrast with String methods — split, join, format: " ".join(s.split()) does the same job without regex.
Level 5 — Mastery
Goal: build a small real-world parser end to end.
Exercise 5.1 — Parse a log line
Given the line
2024-05-12 14:30:07 ERROR user=42 msg=disk full
extract the date, level, user id, and message using named groups (so you read them by name, not by number). Return them as a dict-like lookup.
Recall Solution 5.1
Named groups use the syntax (?P<name>...) — the ?P<name> right after the opening ( labels the box with a name instead of just a number. You then read it with m.group("name") or m.groupdict().
line = "2024-05-12 14:30:07 ERROR user=42 msg=disk full"
pat = (r"^(?P<date>\d{4}-\d{2}-\d{2}) \S+ "
r"(?P<level>\w+) user=(?P<uid>\d+) msg=(?P<msg>.+)$")
m = re.search(pat, line)
m.group("date") # '2024-05-12'
m.group("level") # 'ERROR'
m.group("uid") # '42'
m.group("msg") # 'disk full'
m.groupdict() # {'date': '2024-05-12', 'level': 'ERROR', 'uid': '42', 'msg': 'disk full'}Reading the pattern left to right:
^— anchor to start of string, so we parse from the very beginning.(?P<date>\d{4}-\d{2}-\d{2})— named box date: 4 digits, dash, 2, dash, 2.\S+— a space, then\S+= a run of non-whitespace characters (defined in the callout just above this exercise; this eats the14:30:07time we don't want to capture), then a space. Using\S+instead of a group means we skip it cleanly.(?P<level>\w+)— named box level: the wordERROR.user=(?P<uid>\d+)— literaluser=, then named box uid captures the digits.msg=(?P<msg>.+)$— literalmsg=, then named box msg =.+grabs the rest of the line, and$pins us to end of string.
Why .+ at the end (greedy is fine here): the message may contain spaces (disk full), so we want greedy "grab to the line's end" behaviour, with $ stopping exactly at line end.
The newline caveat: by default . does not match a newline character. So if a message itself spans multiple lines (e.g. a stack trace after msg=), .+$ silently stops at the first line break and the parse loses the rest. To let . swallow newlines too, pass the ==re.DOTALL== flag: re.search(pat, line, re.DOTALL). (And remember from the L5 trap below: ^/$ line behaviour is controlled by a different flag, re.MULTILINE.)
Exercise 5.2 — Count matches efficiently in a loop
You must count how many times a \d+-shaped number appears across a list of 1,000,000 log lines. Show the fast, clean way and say why it is faster.
Recall Solution 5.2
num = re.compile(r"\d+") # parse the pattern ONCE
total = 0
for line in lines:
total += len(num.findall(line))Why re.compile? Every call to re.findall(r"\d+", line) re-parses the pattern string into an internal machine (a state machine — see Finite State Machines). Doing that a million times is wasteful. re.compile parses it once into a reusable pattern object; then num.findall(line) reuses the compiled machine. Same result, far less repeated work — and it reads cleaner.
For example, on ["a1b2", "33", "no digits"] the counts are 2 + 1 + 0 = 3.
Self-test recall
Recall Quick reveal cards
When does findall return tuples? ::: When the pattern has 2+ capturing groups — one tuple of group strings per match.
How do you make .* lazy, and what changes? ::: Add ? → .*?; the engine tries the smallest count first instead of the largest.
What does \3 mean inside a re.sub replacement? ::: A backreference to the text captured by group 3.
How do you name a capturing group and read it back? ::: Write (?P<name>...) in the pattern, read with m.group("name") or m.groupdict().
Which flag lets . match a newline? ::: re.DOTALL.
Which flag makes ^/$ match at each line boundary? ::: re.MULTILINE.
What does \S match? ::: One character that is NOT whitespace (the opposite of \s).
How do you restrict \w/\d to ASCII only? ::: Pass the re.ASCII flag (or spell the class out, e.g. [A-Za-z0-9_]).
Why compile a pattern used in a loop? ::: It parses the pattern into its state machine once, so each reuse skips re-parsing.
Why does re.match return None for a pattern sitting mid-string? ::: match only tries position 0; use search to scan anywhere.
Connections
- Regular expressions — re module, patterns, groups, findall, sub (index 1.3.11) — the parent note.
- Greedy vs Backtracking algorithms — the machinery behind greedy vs lazy in Exercise 3.2.
- Finite State Machines — what
re.compilebuilds internally (Exercise 5.2). - Python Intermediate — File I/O and parsing logs — real use of Exercise 5.1.
- String methods — split, join, format — the non-regex alternative in Exercise 4.2.
- Escape characters and raw strings — why every pattern is
r"...".