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
CASE-004
Grep & Co. Data Detective Agency
Case file
Client
Ms. Jeong, owner of the online store "Market Jeong"
Evidence
The store's sign-up records, plus one Python function that checks the user ID rules.
To close the case
Filter out every ID that breaks the rules, and work out how the checker was fooled.
Around lunchtime, Ms. Jeong comes in hugging her laptop. "I set clear rules for user IDs, I really did. But weird IDs keep getting through." The director studies the laptop screen for a long while, then says only this: "The rules are fine. You're asking the wrong question."
Here are Market Jeong's ID rules, as written in the store's spec, and the checking function.
아이디 규칙: 영문 소문자로 시작하고, 소문자·숫자·밑줄(_)만 써서 4~16자In English: ID rules: start with a lowercase English letter, use only lowercase letters, digits and underscores (_), 4 to 16 characters long.
import re
ID = r"[a-z][a-z0-9_]{3,15}"
def is_valid_id(uid):
return re.search(ID, uid) is not NoneAnd the sign-up records.
2026-10-18 14:02:51 signup id=jeong_fan
2026-10-19 03:11:02 signup id=ok_id!!!
2026-10-19 03:11:04 signup id=_admin
2026-10-19 03:11:05 signup id=ABC123
2026-10-19 03:11:07 signup id=a1
2026-10-19 09:40:13 signup id=minsu99First, let's see what Ms. Jeong's checker lets through.
print(is_valid_id("ok_id!!!")) # True ← has exclamation marks, passes anyway
print(is_valid_id("_admin")) # True ← starts with an underscore, passes anyway
print(is_valid_id("ABC123")) # Falsere.search asks, "Is there a part somewhere in this string that fits the rule?" ok_id!!! contains ok_id, and _admin contains admin, so both succeed.
re.search(ID, "ok_id!!!") # <re.Match object; span=(0, 5), match='ok_id'>
re.search(ID, "!!!ok_id") # <re.Match object; span=(3, 8), match='ok_id'>Input validation has to ask a different question: "Does the whole string fit the rule?" For that, the pattern needs to say "this is the start, this is the end".
Outside brackets, ^ means the start of the string and $ means the end of the string. Symbols like these are called anchors.
re.search(r"^id", "id=kim") # <re.Match object; span=(0, 2), match='id'>
re.search(r"^id", "my id=kim") # None
re.search(r"kim$", "id=kim") # <re.Match object; span=(3, 6), match='kim'>
re.search(r"kim$", "id=kim!") # None^id is still just the two characters id.[^...]. At the very front inside brackets, ^ means negation; outside brackets, it means the start.Put ^ and $ at the front and back of the checker's pattern, and the part that fits the rule has to run from the very beginning to the very end of the string.
Evidence lab
re.search(r"", text)
Return value
Even without writing anchors yourself, you can choose where to match by picking the function.
| Function | What it asks | Written with anchors |
|---|---|---|
re.search | Is there a fitting part anywhere? | (no anchors) |
re.match | Is there a fitting part starting at the very front? | \Apattern |
re.fullmatch | Does the whole thing fit? | \Apattern\Z |
\A and \Z are anchors similar to ^ and $; you'll see the difference in 4-4.
re.match(ID, "ok_id!!!") # <re.Match object; span=(0, 5), match='ok_id'>
re.match(ID, "!!!ok_id") # None
re.fullmatch(ID, "ok_id!!!") # None
re.fullmatch(ID, "ok_id") # <re.Match object; span=(0, 5), match='ok_id'>re.match doesn't check whether the whole thing matches. As long as the front part fits, it succeeds, so it lets ok_id!!! through.re.fullmatch. It's safe even if you forget to add anchors to the pattern, and it also dodges the trap in the next section.Evidence lab
re.search(r"", text)
Return value
$ has one hidden rule. It matches not only at the true end of the string but also right before a final newline. So ^abc$ lets "abc\n", with a newline stuck on the end, pass too.
re.search(r"^abc$", "abc\n") # <re.Match object; span=(0, 3), match='abc'>
re.fullmatch(r"abc", "abc\n") # None
re.search(r"\Aabc\Z", "abc\n") # None\A matches only at the very beginning of the string, and \Z only at the very end. No newline exception.\z. In Python it's exactly the same as \Z; it was added to match the notation regexes use in other languages.re.search(r"\Aabc\z", "abc") # <re.Match object; span=(0, 3), match='abc'>
re.search(r"\Aabc\z", "abc\n") # NoneDot's tip
Text you read from a file line by line often has a newline stuck on the end. Check it with ^...$ and it pretends not to see that newline and waves it through. Then the ID gets saved with a newline on the end. When you validate, use fullmatch, or else \A...\Z.
There's still homework left over from Lesson 3: the accident where the phone pattern started matching at 234, right in the middle of 1234-5678-9012. This time we don't need the start and end of the whole string. We need an anchor that marks where a single number begins and ends inside a longer text.
\b is a word boundary. It matches between a \w (letter, digit, underscore) and a \W (any other character), or at the start or end of the string when a \w is right there. \B is the opposite: a spot that is not a boundary. Neither takes up any characters.
re.findall(r"\bid\b", "id=kim userid=lee id_card") # ['id']
re.findall(r"\Bid\b", "id=kim userid=lee id_card") # ['id']
re.findall(r"\bid\B", "id=kim userid=lee id_card") # ['id']All three results are ['id'], but they found it in different places.
\bid\b : the id in id=kim, with a boundary on both sides\Bid\b : the id in userid, with no boundary in front\bid\B : the id in id_card, where the next character is an underscore (\w), so there's no boundary after itPut \b on both ends of the phone pattern and it can no longer start in the middle of a number. Between 1 and 2 there's no boundary, because both are \w.
re.findall(r"\b\d{2,3}[-. ]?\d{3,4}[-. ]?\d{4}\b", phonebook)
# ['010-0000-1234', '010 0000 9876', '02-0000-5678', '010.0000.4321']One thing to be careful about: in Python, Hangul counts as \w too. So a number glued right onto Korean text has no boundary in front of it. (전화 means "phone".)
re.findall(r"\b\d{2,3}-\d{3,4}-\d{4}\b", "전화010-0000-1234") # []
re.findall(r"\b\d{2,3}-\d{3,4}-\d{4}\b", "전화 010-0000-1234") # ['010-0000-1234']Evidence lab
re.search(r"", text)
Return value
When a single string holds several lines, like the sign-up records, ^ matches only at the start of the first line. To pull out the date at the front of every line, pass the re.MULTILINE flag (re.M for short). Then ^ and $ match at the start and end of every line.
re.findall(r"^\d{4}-\d\d-\d\d", signups)
# ['2026-10-18']
re.findall(r"^\d{4}-\d\d-\d\d", signups, re.MULTILINE)
# ['2026-10-18', '2026-10-19', '2026-10-19', '2026-10-19', '2026-10-19', '2026-10-19']Even with re.M, \A and \Z still match only at the beginning and end of the whole string.
re.findall(r"^\d", "1\n2", re.M) # ['1', '2']
re.findall(r"\A\d", "1\n2", re.M) # ['1']All the flags are covered properly in Lesson 11. For today, just remember that there's a switch that turns ^ and $ into per-line anchors.
Evidence lab
re.search(r"", text)
Flags
Return value
Swap the checker's re.search for re.fullmatch. The pattern stays the same.
def is_valid_id(uid):
return re.fullmatch(r"[a-z][a-z0-9_]{3,15}", uid) is not None
for line in signups.splitlines():
uid = line.split("id=")[1]
if not is_valid_id(uid):
print(line)
# 2026-10-19 03:11:02 signup id=ok_id!!!
# 2026-10-19 03:11:04 signup id=_admin
# 2026-10-19 03:11:05 signup id=ABC123
# 2026-10-19 03:11:07 signup id=a1All four filtered entries break the rules. ok_id!!! has exclamation marks, _admin starts with an underscore, ABC123 has capitals, and a1 is too short.
Case closed report — The ID checker used re.search, so "fits partly" counted as a pass. Switched it to re.fullmatch and blocked 4 sign-ups that broke the rules.
But the director runs her finger down the timestamps of the four printed lines. 03:11:02, 03:11:04, 03:11:05, 03:11:07. "Four in five seconds. At three in the morning. That's no human hand." Ms. Jeong's face goes stiff. The fake accounts all arrived together, in the small hours of the same night. Who sent them, nobody knows yet.
That's the end of Part 1. You've learned how to write down "what to find": a single character, a single position, how many times, and where the boundaries are. In Part 2 you'll learn to pull the pieces you need out of what you find, as you track down this pre-dawn visitor.
re.match(r"\d{4}", "2026-10-19") and re.match(r"\d{4}", "#2026"), then run them.re.findall(r"\bcat\b", "cat concat cats") find, and which does re.findall(r"\Bcat", "cat concat cats") find?The director's notebook
^ is the start, $ is the end. Anchors match positions, not characters.
re.search looks anywhere, re.match only at the very front, re.fullmatch the whole thing. Validate input with fullmatch.
$ also matches before a final newline. To check the end with no newline exception, use \Z (from 3.14, \z is the same).
\b is the boundary between \w and \W; \B is a spot that isn't a boundary. Hangul is \w too.
With re.M, ^ $ match at every line. \A \Z still refer to the whole string.
← Previous
Lesson 3 · The torn phone book: quantifiers
Next →
Lesson 5 · Dates all over the place: groups and alternation
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.