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 7:30–8:20
CASE-010
Grep & Co. Data Detective Agency
Case file
Client
Detective Kang
Evidence
Three threatening letters addressed to Detective Kang, plus one more that arrived yesterday. Forensics transcribed each of the four onto a single line.
To close the case
Work out which letters came from the same person, and show the evidence with patterns.
On a rainy evening, Detective Kang lays four plastic evidence bags out on the desk. "Threatening letters. Three have been coming since last week, and one showed up yesterday. After the story ran in the paper." The director straightens her glasses. "So there's a copycat mixed in. Evidence leaves patterns. Writing by the same hand has the same habits."
Here's the forensics team's transcript of the four letters, one line each. The body (msg=) is a single short line of English.
#1 2026.10.18 key='K-17' tel=(010)-0000-1018 msg=the server server falls on friday
#2 2026-10-19 key="K-17" tel=010-0000-1019 msg=no one can can read me
#3 2026/10/20 key='K-17' tel=(010)-0000-1020 msg=see you At at the end
#4 2026-10/21 key="K-17' tel=(010-0000-1021 msg=pay now or elseAt a glance, all four look alike. Today you'll track down habits with backreferences: syntax that asks "does exactly the same thing I caught earlier show up again?"
The director points at the first letter. "server server. He typed the same word twice. The second letter has can can." It looks like a typo, but if it's in all three, it's a habit.
In Lesson 5 you learned group numbers. You can call those numbers inside the pattern too. \1 means "exactly the same characters that group 1 just caught". That's called a backreference.
import re
line = "msg=the server server falls on friday"
m = re.search(r"\b(\w+)\s+\1\b", line)
print(m.group()) # server server
print(m.group(1)) # server(\w+) catches server, from that moment \1 stands for the characters server.\1 doesn't mean "use the pattern \w+ once more". That's why it won't match two different words.re.search(r"(\w+)\s+(\w+)", "the server").groups() # ('the', 'server')
re.search(r"(\w+)\s+\1", "the server") # NoneThe \b on each side matters too. Without them, the can can inside can cannot would also count as a "repeated word".
re.search(r"(\w+)\s+\1", "can cannot") # <re.Match object; span=(0, 7), match='can can'>
re.search(r"\b(\w+)\s+\1\b", "can cannot") # NoneEvidence lab
re.search(r"", text)
Return value
Look closely at the lab's result and something's off. The At at in the third letter is missing, because A and a are different characters. Pass re.I (ignore case; you'll meet it properly in Lesson 11) and backreferences compare without regard to case too.
re.findall(r"\b(\w+)\s+\1\b", letters) # ['server', 'can']
re.findall(r"\b(\w+)\s+\1\b", letters, re.I) # ['server', 'can', 'At']
print(re.search(r"(a)\1", "aA", re.I)) # <re.Match object; span=(0, 2), match='aA'>Here letters is a string holding the four letter lines above. The result shows that the habit of typing a word twice appears only in letters 1, 2 and 3.
In Lesson 5 the director put one problem aside. If you choose the separator with [-./], a date with mixed separators, like 2026-10/20, gets through too, because the class makes its choice separately at each spot. Catch the first separator with a backreference and demand the same character in the second spot.
re.fullmatch(r"\d{4}[-./]\d\d[-./]\d\d", "2026-10/21") # <re.Match object; span=(0, 10), match='2026-10/21'>
re.fullmatch(r"\d{4}([-./])\d\d\1\d\d", "2026-10/21") # None
re.fullmatch(r"\d{4}([-./])\d\d\1\d\d", "2026/10/20") # <re.Match object; span=(0, 10), match='2026/10/20'>Named groups (Lesson 6) can be called inside the pattern too. The syntax is (?P=name). Once you have lots of groups, names are easier to read than numbers.
DATE = r"(?P<y>\d{4})(?P<sep>[-./])(?P<m>\d\d)(?P=sep)(?P<d>\d\d)"
m = re.search(DATE, "#3 2026/10/20 key='K-17'")
print(m.groupdict()) # {'y': '2026', 'sep': '/', 'm': '10', 'd': '20'}\2 in place of (?P=sep) works the same.(?P=sep) inside a pattern, \g<sep> in a replacement string (Lesson 7), and m["sep"] on a Match object.Evidence lab
re.search(r"", text)
Return value
Look at the value after key=. Letters 1 and 3 wrap it in single quotes, 'K-17'; letter 2 uses double quotes, "K-17". Only letter 4 opens with a double quote and closes with a single one: "K-17'. Catch the opening quote in a group and demand \1 at the close, and you can tell whether the pair matches.
QUOTED = r'(["\']).*?\1'
for line in letters.splitlines():
m = re.search(QUOTED, line)
print(line[:2], m.group() if m else None)
# #1 'K-17'
# #2 "K-17"
# #3 'K-17'
# #4 None\'. In a regex, \' is just '..*? (a lazy quantifier, Lesson 8). With .*, it would swallow everything from the first quote to the last matching quote in one go.re.search(r'(["\']).*\1', '"a" and "b"').group() # '"a" and "b"'
re.search(r'(["\']).*?\1', '"a" and "b"').group() # '"a"'Evidence lab
re.search(r"", text)
Return value
But put the same pattern into findall and you get a strange result.
re.findall(QUOTED, letters) # ["'", '"', "'"]
[m.group() for m in re.finditer(QUOTED, letters)] # ["'K-17'", '"K-17"', "'K-17'"]It's the rule from Lesson 5: when there's one group, findall returns only that group. And this time you can't switch to (?:...), because then the group \1 refers to would disappear.
Dot's tip
The first time, I collected quote pairs with findall and came back with a mouthful of nothing but quote marks. A backreference needs its group, so when you want the whole thing, use finditer and take m.group(). You could also wrap the whole pattern in one more pair of parentheses, but then the numbers shift and \1 becomes \2.
Now look at the phone numbers after tel=. Letters 1 and 3 put the area code in parentheses, like (010)-0000-1018; letter 2 writes it without them. Letter 4 has only an opening parenthesis: (010-0000-1021. A rule like "if there was an opening parenthesis, there must be a closing one" is written as a conditional pattern.
(?(1)yes|no) means "if group 1 took part in the match, use the yes pattern; otherwise, the no pattern". You can leave out the no part. (?(1)yes) means "if the group was there, require yes; if not, require nothing".
PHONE = r"(\()?\d{2,3}(?(1)\))-\d{3,4}-\d{4}"
for tel in ["(010)-0000-1018", "010-0000-1019", "(010-0000-1021", "010)-0000-1021"]:
print(tel, bool(re.fullmatch(PHONE, tel)))
# (010)-0000-1018 True
# 010-0000-1019 True
# (010-0000-1021 False
# 010)-0000-1021 False(\()?: catch the opening parenthesis as group 1, but it's optional.(?(1)\)): if group 1 was caught, require a closing parenthesis; if not, just move on.With named groups, you put the name in the condition instead of a number. The syntax is (?(name)yes|no). Here's a rule for list numbers: (1) must end with a closing parenthesis, and a number without one, like 2., must end with a period.
ITEM = r"(?P<p>\()?\d+(?(p)\)|\.)"
for s in ["(1)", "2.", "(3.", "4)"]:
print(s, bool(re.fullmatch(ITEM, s)))
# (1) True
# 2. True
# (3. False
# 4) FalseEvidence lab
re.search(r"", text)
Return value
Even after every case condition passes, the lab still highlights 010-0000-1021 on letter 4's line. search and finditer can start a match anywhere, so the engine simply skipped the opening parenthesis and started right after it. Anchor the starting point by putting tel= in front.
re.search(PHONE, "tel=(010-0000-1021").group() # '010-0000-1021'
re.search(r"tel=" + PHONE + r"\b", "tel=(010-0000-1021") # NoneTwo-digit backreference numbers get confusing. Inside a pattern, \10 doesn't mean "group 1 followed by the character 0"; it means group 10. If there's no group 10, it's an error. It isn't read as an octal character either.
re.compile(r"(a)\10")
# re.PatternError: invalid group reference 10 at position 4
p = re.compile(r"(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)\10")
print(p.fullmatch("abcdefghijj")) # <re.Match object; span=(0, 11), match='abcdefghijj'>\1[0].\g<1>0 you used in replacement strings (Lesson 7) doesn't work inside a pattern. In a pattern, \g is an unknown escape and raises an error.\0 or has three digits (\101 is A). We'll come back to this in Lesson 12.print(re.fullmatch(r"(a)\1[0]", "aa0")) # <re.Match object; span=(0, 3), match='aa0'>Write the four habits down as rules and count how many each letter matches. The rule names are the director's Korean labels: 반복 = repeat, 날짜 = date, 따옴표 = quotes, 괄호 = parentheses.
RULES = {
"반복": re.compile(r"\b(\w+)\s+\1\b", re.I),
"날짜": re.compile(r"\d{4}([-./])\d\d\1\d\d"),
"따옴표": re.compile(r'key=(["\']).*?\1'),
"괄호": re.compile(r"tel=(\()?\d{2,3}(?(1)\))-\d{3,4}-\d{4}\b"),
}
for line in letters.splitlines():
hits = [name for name, rx in RULES.items() if rx.search(line)]
print(line[:2], hits)
# #1 ['반복', '날짜', '따옴표', '괄호']
# #2 ['반복', '날짜', '따옴표', '괄호']
# #3 ['반복', '날짜', '따옴표', '괄호']
# #4 []Letters 1, 2 and 3 share all four habits. The writer types a word twice, sticks to a single kind of separator, and never once mismatches a quote or a parenthesis. Letter 4 copied only the look, and has none of the habits.
Detective Kang clicks his tongue. "So number 4 is some prankster who read the paper, and the real ones are the three." The director nods. "The real one is meticulous. Never gets a single pair wrong."
Case closed report — Threatening letters 1, 2 and 3 were sent by the same person (the Backtracker). The repeated words, the consistent date separators and the matching quote and parenthesis pairs all line up. Letter 4 was judged a copycat and handed to Detective Kang separately.
Just as she's about to close the report, the director picks up the original letters again. There were lines the forensics team didn't transcribe. At the very bottom of each of the three real letters, one strange line made of nothing but parentheses and backslashes.
0000. Hint: you can put a quantifier on \1 too.<b>bold</b> <i>x</b>, find only the parts where the opening and closing tag names match. The result should be just <b>bold</b>.<, it must end with >. <kim@example.com> and kim@example.com should pass; <kim@example.com should fail.The Backtracker's trail
(a+)+$
The last line of each of the three. Join them in order. They make one line. If you can read it.
The director's notebook
\1 inside a pattern means exactly the characters group 1 actually caught. It doesn't repeat the pattern.
Repeated word: \b(\w+)\s+\1\b. Matching date separators: \d{4}([-./])\d\d\1\d\d.
Call a named group again with (?P=name). In a replacement it's \g<name>; on a Match it's m['name'].
Quote pairs: (["']).*?\1. findall returns only the quote, so use finditer for the whole match.
Conditionals: (?(1)yes|no), (?(name)yes|no). The no part can be left out: (?(1)yes).
With re.I, backreferences ignore case too. \10 is group 10; group 1 followed by 0 is \1[0].
← Previous
Lesson 9 · The safe's rules: lookaround
Next →
Lesson 11 · The unreadable regex: flags and compiling
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.