Regular expressions you can see
·
Course overview
1
The missing payment: finding text as it is
2
The hit-and-run car: character classes
3
The torn phone book: quantifiers
4
The fake sign-ups: anchors and validation
5
Dates all over the place: groups and alternation
6
The 3 a.m. login: named groups and Match objects
7
The night before the story: substitution and splitting
8
The Backtracker's challenge: greedy, lazy and possessive
9
The safe's rules: lookaround
10
Same modus operandi: backreferences and conditionals
11
The unreadable regex: flags and compiling
12
Unmasking the Backtracker: review and a tokenizer
Time 3:20–4:10
CASE-005
Grep & Co. Data Detective Agency
Case file
Client
Ms. Jeong, owner of the online store "Market Jeong"
Evidence
Market Jeong's order records. Each part-timer wrote the dates in a different shape.
To close the case
Pull the year, month and day out of every order separately and tidy them into one shape.
The day after the fake sign-up case, Ms. Jeong is back. This time she starts with a sigh. "We blocked the sign-ups. But when I tried to sort the order records by date, it turns out my three part-timers all wrote dates differently." The director takes a sip of coffee. "Part 1 taught you how to find things. Now it's time to learn how to pull things out of what you find."
Here are the order records.
#1001 2026-10-20 ship=done
#1002 2026.10.20 ship=wait
#1003 2026/10/19 ship=done
#1004 20261019 ship=done
#1005 2026-10/20 ship=waitJust finding the dates isn't enough. To tidy them up you need "2026", "10" and "20" each on its own. Today's tool is parentheses: groups.
In Lesson 3 you saw that a quantifier attaches only to the single piece right before it. Wrap several characters in parentheses ( ) and they become one piece.
re.fullmatch(r"ha+", "hahaha") # None
re.fullmatch(r"(ha)+", "hahaha") # <re.Match object; span=(0, 6), match='hahaha'>A shape like an IP address, where "digits and a period" repeats three times, is also written with a quantifier on a group.
re.search(r"(\d{1,3}\.){3}\d{1,3}", "ip=203.0.113.66")
# <re.Match object; span=(3, 15), match='203.0.113.66'>(\d{1,3}\.){3} is "1 to 3 digits and a period", three times. The last number has no period after it, so a separate \d{1,3} is tacked on.
| means "the left side or the right side". But | has the lowest precedence of anything in a regex. Unless parentheses fence it in, it splits the whole pattern in two.
re.fullmatch(r"ship=done|wait", "wait") # <re.Match object; span=(0, 4), match='wait'>
re.fullmatch(r"ship=(done|wait)", "wait") # None
re.fullmatch(r"ship=(done|wait)", "ship=wait") # <re.Match object; span=(0, 9), match='ship=wait'>ship=done|wait is "ship=done or wait". If you want "ship= followed by done or wait", wrap only the part being chosen in parentheses.
Be especially careful when mixing it with anchors. ^a|b$ isn't "a string that is just a or just b"; it's "^a or b$".
re.findall(r"^a|b$", "a.b") # ['a', 'b']
re.search(r"^ok|no$", "say no") # <re.Match object; span=(4, 6), match='no'>say no doesn't start with ok, yet it matched. For the intended meaning, write ^(ok|no)$.
The alternatives are tried from left to right, and the first one that succeeds wins. It doesn't pick the longest.
re.search(r"10|100", "100") # <re.Match object; span=(0, 2), match='10'>
re.search(r"100|10", "100") # <re.Match object; span=(0, 3), match='100'>-|\.|/, is the same as the class [-./]. In cases like this, the class is shorter and faster.Evidence lab
re.search(r"", text)
Return value
Parentheses have a second job besides grouping: they remember separately the text matched inside them. This is called a capturing group, and groups are numbered 1, 2, 3 … in the order their opening parentheses appear.
Say the order records above are stored in a string called orders.
m = re.search(r"(\d{4})-(\d\d)-(\d\d)", orders)
print(m.group(0)) # 2026-10-20
print(m.group(1)) # 2026
print(m.group(2), m.group(3)) # 10 20
print(m.groups()) # ('2026', '10', '20')
print(m.group(1, 2)) # ('2026', '10')
print(m[1], m[0]) # 2026 2026-10-20group(0) (or just group()) is the whole match. Groups start at 1.groups() gives you everything from group 1 to the last as a tuple.group(1, 2), and you get a tuple back.m[1] is short for m.group(1).Nested parentheses follow the same rule: count in the order the opening parentheses ( appear.
((\d{4})-(\d\d))-(\d\d)
12 3 4m = re.search(r"((\d{4})-(\d\d))-(\d\d)", "2026-10-20")
print(m.groups()) # ('2026-10', '2026', '10', '20')Evidence lab
re.search(r"", text)
Return value
Often you only want to group, with no need to remember anything: when the parentheses are there to set the reach of |, or to hang a quantifier on. For that, use (?:...). It groups, but gets no number.
m = re.search(r"(?:\d{4})-(\d\d)", "2026-10")
print(m.group(1)) # 10
print(re.fullmatch(r"(?:ab)+", "abab").groups()) # ()The first pair of parentheses got no number, so (\d\d) became group 1. Capture only what you need and the numbering stays simple; you also dodge the findall trap in the next section.
When the pattern has capturing groups, findall changes its behavior.
| Number of capturing groups | What findall returns |
|---|---|
| 0 | a list of whole-match strings |
| 1 | a list of strings from that group |
| 2 or more | a list of tuples of the groups |
re.findall(r"\d{4}[-./]\d\d[-./]\d\d", orders)
# ['2026-10-20', '2026.10.20', '2026/10/19', '2026-10/20']
re.findall(r"(\d{4})[-./]\d\d[-./]\d\d", orders)
# ['2026', '2026', '2026', '2026']
re.findall(r"(\d{4})[-./](\d\d)[-./](\d\d)", orders)
# [('2026', '10', '20'), ('2026', '10', '20'), ('2026', '10', '19'), ('2026', '10', '20')]When you want the year, month and day, a list of tuples is handy. The problem is that parentheses you only meant for grouping become groups too.
re.findall(r"\d{4}(-|\.|/)\d\d(-|\.|/)\d\d", orders)
# [('-', '-'), ('.', '.'), ('/', '/'), ('-', '/')]You wanted dates and got back only the separators. When you need the whole match, change those parentheses to (?:...), or use finditer to get Match objects and call group().
re.findall(r"\d{4}(?:-|\.|/)\d\d(?:-|\.|/)\d\d", orders)
# ['2026-10-20', '2026.10.20', '2026/10/19', '2026-10/20']
[m.group() for m in re.finditer(r"\d{4}(-|\.|/)\d\d(-|\.|/)\d\d", orders)]
# ['2026-10-20', '2026.10.20', '2026/10/19', '2026-10/20']Dot's tip
If a findall result comes out strangely short, or turns into tuples, count the parentheses in your pattern first. Very often, parentheses you only meant for grouping have quietly become groups. That's also why running (ha)+ through findall in Lesson 3 gave you nothing but 'ha'.
Evidence lab
re.search(r"", text)
Return value
If a group doesn't take part in the match, its slot is None. That happens when a group with ? matched zero times, or when the other side of a | was chosen.
m = re.fullmatch(r"(\d{4})-(\d\d)(-(\d\d))?", "2026-10")
print(m.groups()) # ('2026', '10', None, None)
print(m.group(3)) # None
print(re.search(r"(a)|(b)", "b").groups()) # (None, 'b')Put a quantifier on a group so it matches several times, and the group keeps only the last one.
m = re.fullmatch(r"(\d)+", "2026")
print(m.group(0), m.group(1)) # 2026 6
re.findall(r"(\d{1,3}\.){3}\d{1,3}", "a=203.0.113.66 b=198.51.100.7")
# ['113.', '100.']
re.findall(r"(?:\d{1,3}\.){3}\d{1,3}", "a=203.0.113.66 b=198.51.100.7")
# ['203.0.113.66', '198.51.100.7'](\d)+ matched four times, but group 1 kept only the last 6. With the IP addresses, too, the group kept only the third repetition, 113., and findall returned that group. When you use the IP pattern from 5-1 with findall, (?:...) is the answer.
The three separators -, . and / are handled by [-./], and the separator-less 20261019 by ?. Capture the year, month and day separately.
The loop below prints the dates in Korean style: 년 is "year", 월 is "month", 일 is "day".
DATE = r"(\d{4})[-./]?(\d\d)[-./]?(\d\d)"
for m in re.finditer(DATE, orders):
y, mo, d = m.groups()
print(f"{y}년 {int(mo)}월 {int(d)}일")
# 2026년 10월 20일
# 2026년 10월 20일
# 2026년 10월 19일
# 2026년 10월 19일
# 2026년 10월 20일To pull out the order number as well, add one more group. With four groups, findall returns four-item tuples.
re.findall(r"#(\d+) " + DATE, orders)
# [('1001', '2026', '10', '20'), ('1002', '2026', '10', '20'), ('1003', '2026', '10', '19'), ('1004', '2026', '10', '19'), ('1005', '2026', '10', '20')]Evidence lab
re.search(r"", text)
Return value
Case closed report — Split the dates of all 5 orders into year, month and day. However, #1005 was entered with mixed separators and needs checking by staff.
Ms. Jeong takes the tidy table and points at the last line. "#1005 says 2026-10/20. That one just passed too?"
re.fullmatch(DATE, "2026-10/20")
# <re.Match object; span=(0, 10), match='2026-10/20'>She's right. The two [-./]? know nothing about each other. The current pattern has no way of saying "if you used - up front, use - at the back too". The director writes a line in her notebook. "There's a way to call back later what you caught earlier. That's Lesson 10." For today, a human eye checks this one entry, and that's that.
re.findall(r"ship=(done|wait)", orders). How does it change if you switch to (?:done|wait)?re.search(r"(\d{4})-(\d\d)(-(\d\d))?", "2026-10 delivery"), what is groups()?03:11:02 as groups, and check the result of m.group(1, 3).The director's notebook
( ) wraps things into one piece and remembers the matched text. Add quantifiers, as in (ab)+.
| has the lowest precedence. ^a|b$ means '^a or b$'. Alternatives are tried from the left.
Group numbers follow the order of opening parentheses. group(0) is the whole match, groups() a tuple, m[1] is group(1).
(?:...) only groups; it gets no number.
findall: 0 groups gives whole matches, 1 gives that group, 2 or more give tuples. Need the whole match? Use (?:...) or finditer.
A group that didn't match is None; a repeated group like (\d)+ keeps only the last one.
← Previous
Lesson 4 · The fake sign-ups: anchors and validation
Next →
Lesson 6 · The 3 a.m. login: named groups and Match objects
Get news about new lessons
We'll email you when this course gets new lessons or a follow-up course opens.
R
ReflexKR
Learn Python and Reflex by doing. Every example was run and verified on Reflex 0.10.0.
ReflexKR is free, with no banner ads. If it helped you, you can support it with a coffee. ☕ Buy us a coffee
Affiliate disclosure© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
Questions & answers
Ask anything about this lesson. Questions from every language version of this page appear together.