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 4:10–5:00
CASE-006
Grep & Co. Data Detective Agency
Case file
Client
Ms. Jeong, who runs the online store "Market Jeong"
Evidence
The access log of Market Jeong's web server: the record of that same small-hours night when the fake sign-ups poured in.
To close the case
Split a log line into named fields (a dict), and find the IP that got into the admin page (/admin) at 3 a.m.
Ms. Jeong is back again, and this time she's brought the whole laptop. "The fake accounts came in the middle of the night, right? Apparently someone opened the admin screen at that hour. I was asleep then."
The director skims the screen once. "Web server log. About seven fields per line. Call them by number and you'll get lost. Give them names."
203.0.113.7 - - [20/Oct/2026:02:58:40 +0900] "GET /products/42 HTTP/1.1" 200 5123
198.51.100.23 - - [20/Oct/2026:03:01:05 +0900] "POST /login HTTP/1.1" 302 0
203.0.113.66 - - [20/Oct/2026:03:02:11 +0900] "GET /admin HTTP/1.1" 200 8890
203.0.113.66 - - [20/Oct/2026:03:02:19 +0900] "GET /admin/users?page=2 HTTP/1.1" 200 12044
198.51.100.4 - - [20/Oct/2026:09:15:30 +0900] "GET /admin HTTP/1.1" 200 8890
203.0.113.7 - - [20/Oct/2026:09:16:02 +0900] "GET /cart HTTP/1.1" 200 2210From the left, each line holds the visitor's IP, two empty fields (-), the time in square brackets, the request in quotes (method, path, protocol), the response code, and the number of bytes sent.
In Lesson 5 you called groups by number, as in group(1) and group(2). Once there are more than seven groups, there's no way to remember which number is what. To give a group a name, write ?P<name> right after the opening parenthesis.
import re
line = '203.0.113.66 - - [20/Oct/2026:03:02:11 +0900] "GET /admin HTTP/1.1" 200 8890'
m = re.search(r"(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\]", line)
print(m.group("ip")) # 203.0.113.66
print(m["time"]) # 20/Oct/2026:03:02:11 +0900
print(m.group("ip", "time")) # ('203.0.113.66', '20/Oct/2026:03:02:11 +0900')
print(m.groupdict()) # {'ip': '203.0.113.66', 'time': '20/Oct/2026:03:02:11 +0900'}m.group("ip") and m["ip"] are the same. Pass several names and you get a tuple back.groupdict() collects only the named groups and returns them as a {name: text} dictionary. One log line becomes one dict.- is an error.Named groups still have their numbers. The numbering works exactly as in Lesson 5, in the order of the opening parentheses. A name is just a nickname stuck on top of the number.
print(m.group(1)) # 203.0.113.66
print(m[2]) # 20/Oct/2026:03:02:11 +0900
print(m.groups()) # ('203.0.113.66', '20/Oct/2026:03:02:11 +0900')Evidence lab
re.search(r"", text)
Return value
Dot's tip
If you've seen (?<name>...) in other languages, watch out. In Python, leave out the P and you get an "unknown extension ?<n" error. And give the same name twice and it's a "redefinition of group name" error. I only have one name too, you know.
Now give every field in the line a name. The spaces, quotes and square brackets between fields go into the pattern as literal characters.
LINE_RE = r'(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] "(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) (?P<size>\d+)'
m = re.fullmatch(LINE_RE, line)
print(m.groupdict())
# {'ip': '203.0.113.66', 'time': '20/Oct/2026:03:02:11 +0900', 'method': 'GET', 'path': '/admin', 'status': '200', 'size': '8890'}r'...'.[^\]]+ is "characters that aren't a closing square bracket", and [^"]* is "characters that aren't a quote". Lesson 2's negated classes mark where each field ends.fullmatch from Lesson 4. A line with a broken shape becomes None and stands out right away.Once it's a dict, the rest is ordinary Python. Say the six log lines above are stored in a string called access_log.
rows = [re.fullmatch(LINE_RE, l).groupdict() for l in access_log.splitlines()]
print(len(rows)) # 6
print(rows[1]["method"], rows[1]["path"], rows[1]["status"]) # POST /login 302All the values are strings. To add up the byte counts, you have to convert them yourself, as in int(row["size"]).
Evidence lab
re.search(r"", text)
Return value
Besides what it found, a Match object also tells you where it found it, for each group. Pass a group number or name as the argument and you get that group's position.
m = re.fullmatch(LINE_RE, line)
print(m.start("path"), m.end("path")) # 51 57
print(m.span("status")) # (68, 71)
print(line[m.start("path"):m.end("path")]) # /adminAn optional group might not match at all. A query string such as the ?page=2 after a path is one example. A group that didn't match has None for its text and -1 for its position.
P = r"(?P<path>/[^\s?]*)(?:\?(?P<query>\S+))?"
a = re.search(P, "GET /admin HTTP/1.1")
b = re.search(P, "GET /admin/users?page=2 HTTP/1.1")
print(a.group("query"), a.span("query")) # None (-1, -1)
print(b.group("query"), b.span("query")) # page=2 (17, 23)A stray None easily causes errors in string handling. groups() and groupdict() take a default argument: the value to put in place of None.
print(a.groups()) # ('/admin', None)
print(a.groups(default="")) # ('/admin', '')
print(a.groupdict(default="-")) # {'path': '/admin', 'query': '-'}Evidence lab
re.search(r"", text)
Return value
You won't use these often, but they come in handy when debugging.
m = re.search(r"(?P<method>[A-Z]+) (?P<path>\S+)", line)
print(m.lastindex, m.lastgroup) # 2 path
print(m.pos, m.endpos, len(line)) # 0 76 76
print(m.string is line) # True
print(m.re) # re.compile('(?P<method>[A-Z]+) (?P<path>\\S+)')lastindex is the number of the last group to close, and lastgroup is that group's name. If it has no name, or no group matched, you get None.lastindex of ((\d+)\.(\d+)) is 1, not 3.|, as in (?P<a>x)|(?P<b>y), lastgroup tells you which side matched. In Lesson 12 you'll build a tokenizer on exactly this property.pos and endpos are the search range. With the module functions they're always 0 and the length of the string. You'll learn to set the range yourself with re.compile in Lesson 11.string is the original string that was searched, and re is the pattern object that was used.n = re.search(r"((\d+)\.(\d+))", "v3.14")
print(n.lastindex, n.lastgroup) # 1 None
n = re.search(r"(?P<a>x)|(?P<b>y)", "y")
print(n.lastindex, n.lastgroup) # 2 bA Match object is an immutable value, so copy.copy(m) and copy.deepcopy(m) are fine. What you get back is the very same object.
Finally, expand(template) slots the match into a template to build a new string. Every \g<name> in the template is replaced by that group's text. It's exactly the same syntax as the replacement strings in Lesson 7. (요청 below means "request".)
print(m.expand(r"\g<method> 요청: \g<path>")) # GET 요청: /adminNow put the case's condition into the pattern. In the time field [20/Oct/2026:03:02:11 +0900], the two digits after the first : following the date are the hour. Skip the date with \[[^:]+:, write 03 in that spot, and you have the condition "3 a.m.".
03 or :03: and you'll also catch lines where the minutes or seconds are 03. What matters is pinning down the position: which field's 03 you mean./admin\S* so it catches every page that starts with /admin.Evidence lab
re.search(r"", text)
Return value
Here's one possible answer.
(?P<ip>\S+) \S+ \S+ \[[^:]+:(?P<hour>03):[^\]]+\] "GET (?P<path>/admin\S*)Split the time into day, month, year, hour, minute and second, name every piece, then pull each line out like a dict and test the conditions. Thanks to the named groups, the if reads almost like plain English.
TIME_RE = (r'(?P<ip>\S+) \S+ \S+ '
r'\[(?P<day>\d\d)/(?P<mon>\w{3})/(?P<year>\d{4}):(?P<hour>\d\d):(?P<min>\d\d):(?P<sec>\d\d) [^\]]+\] '
r'"(?P<method>[A-Z]+) (?P<path>\S+)')
suspects = set()
for l in access_log.splitlines():
m = re.match(TIME_RE, l)
if m and m["hour"] == "03" and m["path"].startswith("/admin"):
print(m["hour"] + ":" + m["min"], m["ip"], m["path"])
suspects.add(m["ip"])
# 03:02 203.0.113.66 /admin
# 03:02 203.0.113.66 /admin/users?page=2
print(suspects) # {'203.0.113.66'}At 3:02 a.m., 203.0.113.66 opened the admin screen, and eight seconds later it had pulled down page 2 of the member list. The 198.51.100.4 at 9 a.m. was the IP of Ms. Jeong's own office. Ms. Jeong goes pale. "The member list? Then our customers' data…"
Case closed report — On October 20 at 03:02, the external IP 203.0.113.66 logged into the admin page and viewed the member list. Recommended changing the admin password immediately and restricting access by IP. Member data may have been leaked.
LINE_RE and add up the size of every line whose response code (status) is 200. Don't forget int()."2026-10-20" with the named groups (?P<y>\d{4})-(?P<m>\d\d)-(?P<d>\d\d), then predict the result of m.expand(r"\g<d>/\g<m>/\g<y>").re.search(r"(?P<a>\d)(?P<b>x)?", "7"), predict span("b"), groupdict(default="?") and lastgroup, then check.The director's notebook
(?P<name>...) gives a group a name. m.group("name"), m["name"], m.groupdict().
Named groups still have numbers, in the order of the opening parentheses.
start(g), end(g), span(g). A group that didn't match is None, with position -1.
groups(default=...) and groupdict(default=...) put another value in place of None.
lastindex is the number of the last group to close; lastgroup is its name. It tells you which of several |-joined named groups matched.
m.pos, m.endpos, m.string, m.re. m.expand(r"\g<name>") slots the match into a template.
← Previous
Lesson 5 · Dates all over the place: groups and alternation
Next →
Lesson 7 · The night before the story: substitution and splitting
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.