Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Text & Patterns  ›  Lesson

sub, split, finditer & Flags

Substitution and Splitting 22 minRewriting text with patterns instead of just reading it
You're building a piece ofQuarry — the extractor
This piece — redact(): Masks secrets, emails and IP addresses before a human sees the line.
Scenario A report quoting last night's auth failures is about to be pasted into a public ticket. Every email, internal IP and leaked password in it has to be masked first.
Your task
Write redact(line) that hides secrets (password=/token=/secret= values become [hidden], any key case), masks emails to ***@domain, and masks IPv4 addresses to their first two numbers plus x.x. Example: redact("login failed user=maya ip=10.0.4.17") → "login failed user=maya ip=10.0.x.x".

sub, split, finditer & Flags

In short: re.sub(pattern, replacement, text) returns a new string with every match replaced — the replacement can be a template using \1, \g<name> or \g<0> when it only needs to rearrange captured text, or a function when the new text must be computed. re.split cuts on a pattern instead of one fixed separator, and keeps the separators only if the pattern captures a group. re.finditer yields one match at a time, with its span — the primitive sub and split are built from. Flags (re.IGNORECASE, re.MULTILINE, re.VERBOSE, combined with |) change how the whole pattern reads, and re.escape makes untrusted text safe to drop into one.

A line before and after re.sub: an email, an IP and a secret value each boxed in red and arrowed down to their green masked replacement, the surrounding words unchanged

Quarry's report is about to leave the building. Operators paste it into tickets and chat threads, and the lines it quotes are full of things that should never travel that far — customer emails, internal IPs, and a password some debug statement logged in plain text. This lesson teaches the extractor to rewrite text, not just read it: redact(), the last thing every line passes through before a human sees it.

Mask a leaked phone number before it ships in a ticket

re.sub finds every match and swaps it for the replacement. A first attempt at masking a 10-digit phone number:

broken — fix it

One phone number is masked, the other leaks in plain sight. Run it, see which one survives, then widen the pattern so both are caught.

Masking a credit-card-shaped number is different: you want to keep the last four digits, and that has to be computed, not just rearranged. Pass a function instead of a string, and re.sub calls it once per match with the match object:

python

A replacement string can only reorder captured text and add fixed text around it:

python

There's one sharp edge here, worth meeting on purpose. \1 followed by digits doesn't mean "group 1, then some digits":

broken — fix it

This should turn took=2s into took=2000ms, but it prints something odd. Run it, then fix the replacement string.

\g<1> always works, so reach for it whenever a group reference is followed by a digit.

Both forms are really the same machine underneath. Here's sub written out by hand with re.finditer, which yields a match object for each match, left to right. Step through it and watch out grow: the text between matches is copied as-is, and each match's span is swapped for the replacement.

step through it
1import re
2text = "took=2s wait=1.5s ok"
3out = ""
4last = 0
5for m in re.finditer(r"(\d+(?:\.\d+)?)s\b", text):
6 out += text[last:m.start()]
7 out += str(round(float(m.group(1)) * 1000)) + "ms"
8 last = m.end()
9out += text[last:]
10print(out)

finditer is the right tool whenever you want matches and their positions, one at a time, without building the full replaced string.

Split a messy export on whatever separator someone used

A legacy exporter writes component lists with any of ,, ; or |, with or without spaces. str.split only cuts on one fixed separator — re.split cuts on anything a pattern matches:

python

The second line shows the one real surprise: if the pattern contains a capturing group, the separators are kept in the output, interleaved with the pieces. Use (?:...) when you need grouping but not the separators in the result.

maxsplit caps the number of cuts — exactly what a log line needs: four leading fields, then a message that must stay whole even though it contains spaces.

What does this print?
import re
print(re.split(r" ", "2026-09-14 08:15:02 WARN [auth] login failed user=maya", maxsplit=3)[-1])

A line with x separators splits into min(x, maxsplit) + 1 pieces. One warning: maxsplit=0 does not mean "no cuts" — it means "no limit", the same as leaving it out.

Scrub a ticket with flags, then run the whole chain

Flags change how the whole pattern behaves, passed as flags= and combined with |:

Flag Effect
re.IGNORECASE / re.I letters match either case
re.MULTILINE / re.M ^ and $ match at every line, not just the string's ends
re.VERBOSE / re.X whitespace and # comments in the pattern are ignored
python

Without re.IGNORECASE, the capitalised Password= would sail straight through. When the text to search for isn't yours — a username, a search term an operator typed — re.escape makes it literal, so . and + in it don't get read as regex syntax:

python

Redaction is a chain of substitutions, and a rule that looks right alone can be wrong in company, because each rule runs on the output of the one before:

python

\w+ stops at the first dot, so only 10 is hidden and .0.4.17 leaks — and now the IP rule can't recognise the leftover either, because three numbers aren't four. Run the widest, most dangerous rule first, with a wide value pattern (\S+), and test the whole chain together, because that is where surprises live.

Quick practice

What does this print?
import re
print([m.span() for m in re.finditer(r"\d+", "a1 bb22 c333")])
What does this print?
import re
block = "INFO start\nWARN disk 91%\nwarn disk 97%\nINFO done"
print(re.findall(r"^warn .*$", block, re.I | re.M))
What does this print?
import re
print(re.split(r"\d+", "a1b22c", maxsplit=0), re.split(r"\d+", "a1b22c", maxsplit=1))

🎯 Your turn

Write redact(line), the last stop before a human sees a line. Apply three rules, in this order:

  1. Secrets — password=, token= or secret= (any letter case) followed by a value: keep the key exactly as written, replace the value with [hidden].
  2. Emails — replace everything before the @ with ***, keep the domain.
  3. IPv4 addresses — keep the first two numbers, replace the last two with x.
  • redact("login failed user=maya ip=10.0.4.17") → "login failed user=maya ip=10.0.x.x"
  • redact("reset sent to maya.k@quarry.dev") → "reset sent to ***@quarry.dev"
  • redact("retry Password=hunter2 token=abc123") → "retry Password=[hidden] token=[hidden]"

Hint — one re.sub per rule. Capture what you keep and put it back with \1 or \g<name>; use re.IGNORECASE for the secret keys.

Then press ▶ Run and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upSquash the whitespace

Write squash_spaces(text) that turns every run of whitespace (spaces, tabs, newlines) into a single space and strips the ends.

regex-groupsre.sub
DrillSplit on any separator

Operators type component lists with any of , ; or | as separators, with or without spaces around them. Write split_fields(text) returning the list of names, with surrounding spaces removed. An empty or blank string returns [].

regex-groupsre.split
BuildDay-first dates to ISO

A legacy exporter writes dates as DD/MM/YYYY. Write iso_dates(text) that rewrites every such date to YYYY-MM-DD, leaving everything else alone.

regex-groupsbackreferencere.sub
BossEvery duration in milliseconds

Write normalise_durations(text) that rewrites every duration written in seconds — a whole or decimal number immediately followed by s and then a word boundary — into whole milliseconds. 2s → 2000ms, 1.5s → 1500ms, 0.25s → 250ms. Durations already in ms stay as they are.

regex-groupsre.subbackreference
CapstoneHighlight the watched words

Write highlight(text, words) that wraps every whole-word, case-insensitive occurrence of any of words in square brackets, keeping the original casing. Words can contain regex characters such as . or +, which must match literally. With an empty words list, return text unchanged.

regex-groupsregex-flagsre.escapere.sub
ExplainA function or a replacement string?

re.sub accepts either a replacement string like r"\1.\2.x.x" or a function. Explain how you decide which to use, and give one example from Quarry's redaction or duration work where each is the right choice.

redact.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
redact(line) → strThe line with secrets, emails and IP addresses masked.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.