R

ReflexKR

Reflex Docs

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

Regular expressions you can see
Lesson 10

Time 7:30–8:20

Same modus operandi: backreferences and conditionals

CASE-010

Grep & Co. Data Detective Agency

Case file

Same modus operandi

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 else

At 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?"

10-1. Finding what you caught earlier, again: \1 (10 min)

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
  • Once (\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")               # None

The \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")   # None

Evidence lab

re.search(r"", text)

Return value

Right now the pattern catches any two words. Replace the second \w+ with \1 to find only the spots where the same word appears twice.

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.

10-2. Homework from Lesson 5: matching the same separator (8 min)

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'}
  • Named groups have numbers too, so writing \2 in place of (?P=sep) works the same.
  • How you call the same group depends on where you are: (?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

This is the date pattern from Lesson 5. Wrap the first [-./] in parentheses and replace the second [-./] with \1 to knock out dates with mixed separators.

10-3. Opening and closing quotes (7 min)

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
  • Both kinds of quote have to fit inside a Python string, so it's written \'. In a regex, \' is just '.
  • There's a reason the middle uses .*? (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

Right now the pattern picks the opening and closing quotes separately, so mismatched pairs get through. Catch the opening one in a group and replace the closing one with \1. Once it passes, check the findall result too.

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.

10-4. If the group was caught, this; otherwise, that: conditional patterns (10 min)

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) False

Evidence lab

re.search(r"", text)

Return value

Right now \(? and \)? don't know about each other, so a single parenthesis gets through. Catch the opening one with (\()? and change the closing one to (?(1)\)).

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")  # None

10-5. Which group is \10? (5 min)

Two-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'>
  • If you want "group 1 followed by the character 0", separate the 0 by putting it in a class: \1[0].
  • The \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.
  • A character is read as octal only when it starts with \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'>

Cracking the case (10 min)

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.

Exercises

  1. Write a pattern that finds the same digit repeated four times in a row, like 0000. Hint: you can put a quantifier on \1 too.
  2. In <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>.
  3. Write a conditional pattern for emails: if it starts with <, 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.

We collect: your email address · Purpose: news about new lessons or the launch of this course · Kept until: you ask us to remove it. We never use it for anything else or share it. Reply to any notice email and we'll delete it right away.

Questions & answers

Ask anything about this lesson. Questions from every language version of this page appear together.

Shown as plain text, without links or formatting.

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
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex