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 5:00–5:50
CASE-007
Grep & Co. Data Detective Agency
Case file
Client
Reporter Oh, who is covering the leak
Evidence
A customer list sent by an anonymous tipster. It looks like Market Jeong's member list. Every line uses different separators.
To close the case
Make it printable in tomorrow morning's paper: hide the middle of each phone number, rewrite the dates as 2026-10-20, and split every line into columns.
9 p.m. The office door flies open. Reporter Oh, out of breath, flips open his laptop. "We got a tip. It's Market Jeong's customer list. The story runs tomorrow morning, but I can't print people's personal data as it is."
The director peers at the screen. It's the same member list from 3 a.m. that you found in Lesson 6. "The finding part is over. Today we change things and cut things."
id, name; phone | joined
1001,kim,010-0000-5678,20/10/2026
1002; lee; 010-0000-1234; 19/10/2026
1003 | park | 010-0000-9012 | 03/09/2026
1004, choi;010-0000-3456 |28/02/2026
# (a+)+$There's something odd on the very last line, but Reporter Oh is racing a deadline and doesn't give it a second look.
re.sub(pattern, replacement, original) returns a new string in which every match of the pattern has been replaced. The original stays untouched, because Python strings can't be changed in place.
import re
re.sub(r"\d{4}", "****", "010-0000-5678") # '010-****-****'There are two runs of four digits, so the last block got hidden too. To keep the first three digits and the last four and hide only the middle, capture the parts you want to keep in groups and call them back in the replacement. \1 means "whatever group 1 captured".
re.sub(r"(\d{3})-\d{4}-(\d{4})", r"\1-****-\2", "010-0000-5678") # '010-****-5678'The replacement contains backslashes too, so write it as r"...", just like the pattern.
Evidence lab
re.search(r"", text)
Return value
The dates in the list are written day/month/year, like 20/10/2026. The paper needs them as 2026-10-20. All it takes is putting the groups back in a different order.
re.sub(r"(\d\d)/(\d\d)/(\d{4})", r"\3-\2-\1", "20/10/2026") # '2026-10-20'
re.sub(r"(\d\d)/(\d\d)/(\d{4})", r"\g<3>-\g<2>-\g<1>", "20/10/2026") # '2026-10-20'\3 and \g<3> mean the same thing. The named groups from Lesson 6 are called with \g<name>. You don't have to remember the order, so there's less room for mistakes.
re.sub(r"(?P<d>\d\d)/(?P<m>\d\d)/(?P<y>\d{4})", r"\g<y>-\g<m>-\g<d>", "20/10/2026") # '2026-10-20'\g<0> is the whole match. Use it when you want to wrap what you found instead of removing it.
re.sub(r"\d{3}-\d{4}-\d{4}", r"[\g<0>]", "1001,kim,010-0000-5678") # '1001,kim,[010-0000-5678]'Be careful when you want a digit right after a group number. \10 doesn't mean "group 1 followed by 0"; it means group 10. In that case, close off the number with angle brackets: \g<1>0.
re.sub(r"(\d+)", r"\g<1>0", "kim 5") # 'kim 50'
re.sub(r"(\d+)", r"\10", "kim 5") # re.PatternError: invalid group reference 10 at position 1In the replacement, \n still becomes a newline character. An escaped letter with no meaning, like \q, is an error.
re.sub(r",", r"\n", "a,b") # 'a\nb'
re.sub(r",", r"\q", "a,b") # re.PatternError: bad escape \q at position 0re.PatternError is the name used since Python 3.13; it's the same exception as the old name re.error. We'll look at it closely in Lesson 12.
Evidence lab
re.search(r"", text)
Return value
Instead of a replacement string, you can pass a function. Each time re.sub finds a match, it hands that Match object to your function and replaces the match with whatever string the function returns. Let's keep only the first letter of each name and put an asterisk for every other letter. A fixed template can't do that.
def hide(m):
name = m.group(1)
return "," + name[0] + "*" * (len(name) - 1) + ","
re.sub(r",([a-z]+),", hide, "1003,park,010-0000-9012,03/09/2026") # '1003,p***,010-0000-9012,03/09/2026'
re.sub(r"[a-z]+", lambda m: m[0].upper(), "kim and lee") # 'KIM AND LEE'TypeError.count sets how many spots to replace, counting from the start. The default, 0, means "all of them". re.subn returns a tuple of the new string and the number of replacements. Say the list at the top is stored in a string called leak.
re.sub(r"\d{3}-\d{4}-\d{4}", "***", leak, count=1).splitlines()[1:3]
# ['1001,kim,***,20/10/2026', '1002; lee; 010-0000-1234; 19/10/2026']
re.subn(r"\d{3}-\d{4}-\d{4}", "***", leak)[1] # 4Now the article can state the exact number: "four phone numbers were redacted".
Each line of the list uses different separators. Commas, semicolons and vertical bars are all mixed together, with random spaces around them. str.split only takes a single separator. re.split cuts on a pattern.
re.split(r"\s*[,;|]\s*", "a, b;c |d") # ['a', 'b', 'c', 'd']
re.split(r"\s*[,;|]\s*", "a, b;c |d", maxsplit=2) # ['a', 'b', 'c |d']\s* on each side of the separator swallows the spaces as part of the separator, so the columns come out clean.maxsplit=2 cuts at most twice and leaves the rest as it is in the last column.If the pattern has a capturing group, the separators it cut on are slotted into the result list as well. That's handy when you need to record which separator was used. If you don't need them, use (?:...).
re.split(r"\s*([,;|])\s*", "a, b;c |d") # ['a', ',', 'b', ';', 'c', '|', 'd']You can even cut on empty matches that take up no characters. \b only matches at word boundaries, so the string is cut between every word and space. There are boundaries at both ends too, which produces empty strings.
re.split(r"\b", "a b") # ['', 'a', ' ', 'b', '']Evidence lab
re.search(r"", text)
Return value
The fourth positional slot of re.sub is count. If you absent-mindedly put re.I fourth to add a flag, it goes in as a count, not a flag, because the actual value of re.I is 2.
re.sub("a", "b", "aAaA", re.I) # 'bAbA' ← case isn't ignored; only two get replaced
re.sub("a", "b", "aAaA", flags=re.I) # 'bbbb'This mistake is so common that, starting with Python 3.13, passing count, maxsplit or flags as positional arguments raises a DeprecationWarning. The first line above warns 'count' is passed as positional argument. The same goes for maxsplit and flags in re.split. Always name them: count=1, maxsplit=2, flags=re.I.
Dot's tip
If you say 'sit', I sit. If you just shout the number 2 at me, I have no idea what you want. Functions are the same. re.sub("a", "b", s, re.I) hears 'replace only twice'. Call it by its name: flags=re.I.
Chain the steps: hide, fix the dates, then cut. Skip the header and the last line, and cut only the customer rows.
masked = re.sub(r"(\d{3})-\d{4}-(\d{4})", r"\1-****-\2", leak)
fixed = re.sub(r"(?P<d>\d\d)/(?P<m>\d\d)/(?P<y>\d{4})", r"\g<y>-\g<m>-\g<d>", masked)
for row in fixed.splitlines()[1:-1]:
print(re.split(r"\s*[,;|]\s*", row))
# ['1001', 'kim', '010-****-5678', '2026-10-20']
# ['1002', 'lee', '010-****-1234', '2026-10-19']
# ['1003', 'park', '010-****-9012', '2026-09-03']
# ['1004', 'choi', '010-****-3456', '2026-02-28']Reporter Oh takes the table and starts to get up, but the director raises a hand. "Wait. The last line."
print(fixed.splitlines()[-1]) # # (a+)+$That's not list data. It's a regex someone left there on purpose. To search for these exact characters, you have to switch off every metacharacter, one by one, as you learned in Lesson 1.
print(re.escape("(a+)+$")) # \(a\+\)\+\$
re.search(r"\(a\+\)\+\$", leak) # <re.Match object; span=(177, 183), match='(a+)+$'>Evidence lab
re.search(r"", text)
Return value
"What is that?" Reporter Oh asks. The director is silent for a long moment before she answers. "A backtracking trap. Any regex engine that reads this goes back over the same paths countless times until it grinds to a halt. Keep it out of tomorrow's story."
After Reporter Oh leaves, the director takes a faded photo frame out of her drawer. It's a picture from the agency's first year. She stares at it for a long time, then lays it face down on the desk.
Case closed report — Hid the middle digits of the four phone numbers in the leaked list, normalized the dates to year-month-day, and handed it to the reporter. Found a deliberately planted regex, (a+)+$, at the end of the list. Origin unknown. Needs further investigation.
1001 to 1004 with a # in front, like #1001. With \g<0> it takes a single line.re.split(r"(\s*)([,;|])(\s*)", "a, b"). With three groups, how many extra items get slotted in?"price 1500 and 300". Don't forget that the function has to return a string.The director's notebook
re.sub(pattern, replacement, original) returns a new string. The original stays as it is.
In the replacement, \1 = \g<1>, a name is \g<name>, the whole match is \g<0>. If a digit follows, write \g<1>0 (\10 is group 10).
In the replacement, \n is a newline; an unknown escape like \q is an error.
Function replacement: a function that takes a Match and returns a string. count limits the number of replacements; re.subn returns (result, count).
re.split cuts on a pattern. maxsplit limits the cuts; with a capturing group, the separators end up in the result too.
Pass count, maxsplit and flags as keywords. Positional use warns with DeprecationWarning since 3.13, and re.I silently becomes count=2.
← Previous
Lesson 6 · The 3 a.m. login: named groups and Match objects
Next →
Lesson 8 · The Backtracker's challenge: greedy, lazy and possessive
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.