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 7

Time 5:00–5:50

The night before the story: substitution and splitting

CASE-007

Grep & Co. Data Detective Agency

Case file

The night before the story

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.

7-1. Find and replace: re.sub (8 min)

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

Right now the member IDs and the years turn into asterisks too. Fix the pattern so it matches only phone numbers, and change the replacement to \1-****-\2 so only the middle is hidden. Pick subn to also see how many spots were replaced.

7-2. Groups inside the replacement: \g<1>, \g, \g<0> (10 min)

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 1

In 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 0

re.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

The replacement is in day-month-year order. Change it to \g<y>-\g<m>-\g<d>. Also try putting in \g<0> or \10 and see what happens.

7-3. Replacing with a function, plus count and subn (8 min)

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'
  • The function takes one Match and must return a string. Returning a number gives you a TypeError.
  • The Evidence lab can't run function replacements. Try them in Python yourself.

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]   # 4

Now the article can state the exact number: "four phone numbers were redacted".

7-4. Cutting: re.split (9 min)

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']
  • The \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

Cutting on commas alone leaves semicolons and vertical bars inside the columns. Treat all three separators, plus the spaces around them, as the separator, but don't cut on the - in the phone number.

7-5. Pass count, maxsplit and flags by name (5 min)

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.

Cracking the case (10 min)

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

Typed in as a pattern, the signature can't find its own characters, because it's a regex that looks for runs of a, like the aaa in the case conditions. Switch off every metacharacter so it finds the signature text itself.

"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.

Exercises

  1. Rewrite the member IDs 1001 to 1004 with a # in front, like #1001. With \g<0> it takes a single line.
  2. Predict the result of re.split(r"(\s*)([,;|])(\s*)", "a, b"). With three groups, how many extra items get slotted in?
  3. Use a function replacement to double every number 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.

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