Python regular expressions — practice with a server log: filtering errors, counting login attempts and converting it to CSV (and the IP that knocked three times a second)
The lab technician handed us the log of one morning on the virtual campus with a question: “is anyone trying to get in by guessing passwords?”. A failed login leaves a request to /campus/login in the log with the code 401 (unauthorised), so the question becomes “which IPs have 401s?”. With what we learnt in the theory, it can be answered in a single line:
import re LOG = """\ 10.0.0.7 [12/10/2026 09:15:02] "GET /campus/inicio" 200 5120 "Firefox/131" 10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129" 10.0.0.23 [12/10/2026 09:15:51] "POST /campus/login" 401 312 "Chrome/129" 10.0.0.23 [12/10/2026 09:16:03] "POST /campus/login" 200 1024 "Chrome/129" 172.16.4.150 [12/10/2026 09:20:17] "GET /fp1/tema8.pdf" 200 14040 "Safari/17" 10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131" 192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5" 192.168.1.66 [12/10/2026 09:31:09] "POST /campus/login" 401 312 "curl/8.5" 192.168.1.66 [12/10/2026 09:31:10] "POST /campus/login" 401 312 "curl/8.5" 10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129" -- server restart -- 10.0.0.41 [12/10/2026 10:05:12] "GET /fp1/entregas" 200 2210 "Chrome/129" """ # Which IPs have failed logins (code 401)? One single line: print(re.findall(r'(\S+) .*" 401 ', LOG))
When you run it:
['10.0.0.23', '10.0.0.23', '192.168.1.66', '192.168.1.66', '192.168.1.66']
The pattern (\S+) .*" 401 reads like this: a piece without spaces at the start (the IP, which is kept in the group), a space, anything, and then a quote, a space, 401 and another space. Since the pattern has one group, findall returns only the IP of each matching line. And since the dot does not match newlines, .* cannot jump from one line to the next: each match stays inside its own line.
The answer had a catch. 10.0.0.23 fails twice in 11 seconds and gets in on the third try: someone who mistyped. 192.168.1.66, on the other hand, fails three times in three consecutive seconds, and its browser is called curl: a program, not a person. But the list does not say that: the rest of the line does. To see it you have to cut each line into its fields, and that is what we are going to do.
In this practice we work with the full log in three programs: filtering the errors while checking that each line has the right format, counting requests per code, per hour and per IP (to find our curl), and converting the log to CSV with re.sub, reordering the date and anonymising the IPs. All three are intermediate level and build on the regular expressions theory; we will also use dictionaries to count, so if you need to, review associative containers.
Table of Contents
How to use this article
Each program is complete in a single file. Copy it, save it with the name given and run it from the terminal with python3 name.py. Under each one you have the real output, a drawing or a table with what happens inside, and an explanation piece by piece. Before looking at the output, try to predict which lines of the log will match each pattern: that is exactly what you need to train with regular expressions.
From the theory, make sure you are clear on:
- The difference between
match,searchandfullmatch, and that all three returnNoneif they find nothing. - How groups are numbered (by their opening parenthesis) and what
m.groups()returns. - Why
.*is greedy and what[^"]*does. - That in the replacement of
re.sub,\1,\2… are the text of each group.
All three programs use the same log, twelve lines from one morning on the virtual campus. Each line has this shape: IP, [date time], “METHOD path” in quotes, HTTP code, bytes sent and “browser” in quotes. Line 11 is not a request: it is a mark the server left when it restarted.
LOG = """\ 10.0.0.7 [12/10/2026 09:15:02] "GET /campus/inicio" 200 5120 "Firefox/131" 10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129" 10.0.0.23 [12/10/2026 09:15:51] "POST /campus/login" 401 312 "Chrome/129" 10.0.0.23 [12/10/2026 09:16:03] "POST /campus/login" 200 1024 "Chrome/129" 172.16.4.150 [12/10/2026 09:20:17] "GET /fp1/tema8.pdf" 200 14040 "Safari/17" 10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131" 192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5" 192.168.1.66 [12/10/2026 09:31:09] "POST /campus/login" 401 312 "curl/8.5" 192.168.1.66 [12/10/2026 09:31:10] "POST /campus/login" 401 312 "curl/8.5" 10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129" -- server restart -- 10.0.0.41 [12/10/2026 10:05:12] "GET /fp1/entregas" 200 2210 "Chrome/129" """
No program uses input() or reads files. The log is written inside the code, between triple quotes, so that your output is identical to the one you see here. Reading it from a real file is exactly what we will learn in the next topic. The backslash after the opening quotes ("""\) stops the text from starting with an empty line.
Program 1 · Filtering the errors in the log
filtrar.py
The idea
We want a table with the requests that returned an error (code 400 or higher) and, on top of that, the list of lines that do not have the format of a request. One pattern solves both: if a line matches it whole, it is a request and its groups give us each field; if it does not match, it is an odd line.
Write the pattern piece by piece, copying a log line and replacing each field with its piece of regex. Whatever is not a field (brackets, quotes, spaces) stays as it is, escaped if needed.
The code
import re
LOG = """\
10.0.0.7 [12/10/2026 09:15:02] "GET /campus/inicio" 200 5120 "Firefox/131"
10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129"
10.0.0.23 [12/10/2026 09:15:51] "POST /campus/login" 401 312 "Chrome/129"
10.0.0.23 [12/10/2026 09:16:03] "POST /campus/login" 200 1024 "Chrome/129"
172.16.4.150 [12/10/2026 09:20:17] "GET /fp1/tema8.pdf" 200 14040 "Safari/17"
10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131"
192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5"
192.168.1.66 [12/10/2026 09:31:09] "POST /campus/login" 401 312 "curl/8.5"
192.168.1.66 [12/10/2026 09:31:10] "POST /campus/login" 401 312 "curl/8.5"
10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"
-- server restart --
10.0.0.41 [12/10/2026 10:05:12] "GET /fp1/entregas" 200 2210 "Chrome/129"
"""
# A good line: IP [date time] "METHOD path" code bytes "browser"
PATTERN = (r'(\d{1,3}(?:\.\d{1,3}){3}) ' # 1: IP
r'\[(\d\d/\d\d/\d{4}) (\d\d:\d\d:\d\d)\] ' # 2: date, 3: time
r'"(GET|POST) (\S+)" ' # 4: method, 5: path
r'(\d{3}) (\d+) ' # 6: code, 7: bytes
r'"([^"]*)"') # 8: browser
errors = 0
bad = []
print(f"{'Time':<10}{'IP':<14}{'Code':>6} Request")
for n, line in enumerate(LOG.splitlines(), start=1):
m = re.fullmatch(PATTERN, line) # fullmatch: the WHOLE line has to match
if m is None:
bad.append(n)
continue
ip, date, time, method, path, code, size, browser = m.groups()
if int(code) >= 400: # 4xx: client failure; 5xx: server failure
errors += 1
print(f"{time:<10}{ip:<14}{code:>6} {method} {path}")
print(f"Errors: {errors}. Lines that are not requests: {bad}")
When you run it:
Time IP Code Request 09:15:40 10.0.0.23 401 POST /campus/login 09:15:51 10.0.0.23 401 POST /campus/login 09:22:45 10.0.0.7 404 GET /fp1/tema9.pdf 09:31:08 192.168.1.66 401 POST /campus/login 09:31:09 192.168.1.66 401 POST /campus/login 09:31:10 192.168.1.66 401 POST /campus/login 10:02:33 10.0.0.41 500 GET /fp1/entregas Errors: 7. Lines that are not requests: [11]
The pattern: eight groups, one per field
# A good line: IP [date time] "METHOD path" code bytes "browser"
PATTERN = (r'(\d{1,3}(?:\.\d{1,3}){3}) ' # 1: IP
r'\[(\d\d/\d\d/\d{4}) (\d\d:\d\d:\d\d)\] ' # 2: date, 3: time
r'"(GET|POST) (\S+)" ' # 4: method, 5: path
r'(\d{3}) (\d+) ' # 6: code, 7: bytes
r'"([^"]*)"') # 8: browser
The first thing that stands out is that the pattern is split across five lines. Python automatically joins strings that appear next to each other inside parentheses: r'abc' r'def' is the same as r'abcdef'. That way each piece fits on one line and carries a comment with the number of its groups. Each piece, with its r in front.
Piece by piece:
- IP, group 1:
(\d{1,3}(?:\.\d{1,3}){3}). 1 to 3 digits, and then three times “dot and 1 to 3 digits”. The repetition goes in a non-capturing group(?:…), because we only want to repeat, not keep; a normal group would shift the numbering of all the others and would only keep its last round anyway. - Date and time, groups 2 and 3:
\[(\d\d/\d\d/\d{4}) (\d\d:\d\d:\d\d)\]. The brackets in the log are real brackets, so they are escaped (\[,\]); otherwise the pattern would take them as the start of a set. - Method and path, groups 4 and 5:
"(GET|POST) (\S+)". The alternative goes inside a group so that the|only affectsGETandPOST. The path is “one or more characters that are not spaces”. - Code and bytes, groups 6 and 7:
(\d{3}) (\d+). The code always has 3 digits; the bytes, however many (including0). - Browser, group 8:
"([^"]*)". Here you can see why the theory insisted on[^"]*: “anything that is not a quote” cannot overshoot the closing quote.
Writing the path as "(GET|POST) (.*)" instead of (\S+). With fullmatch it would work on this log, because the rest of the pattern forces it to step back, but with search on a longer line the .* would take pieces that are not the path. Describe each field by what it can contain.
The loop: fullmatch validates and cuts at the same time
errors = 0
bad = []
print(f"{'Time':<10}{'IP':<14}{'Code':>6} Request")
for n, line in enumerate(LOG.splitlines(), start=1):
m = re.fullmatch(PATTERN, line) # fullmatch: the WHOLE line has to match
if m is None:
bad.append(n)
continue
ip, date, time, method, path, code, size, browser = m.groups()
if int(code) >= 400: # 4xx: client failure; 5xx: server failure
errors += 1
print(f"{time:<10}{ip:<14}{code:>6} {method} {path}")
enumerate(LOG.splitlines(), start=1)goes through the lines numbered from 1, so the number matches the one you would see in an editor.- **
re.fullmatch** is the key decision. Withsearch, a line with rubbish in front or behind would match all the same; withfullmatch, the whole line has to have the shape of the pattern. Line 11,-- server restart --, does not start with an IP, somisNone. if m is Nonestores the number of the bad line and **continue** jumps straight to the next one. This protects the line after it:m.groups()onNonewould give theAttributeErrorfrom the theory.m.groups()returns a tuple of 8 strings, which are spread over 8 variables in one go. If the number of variables did not match the number of groups, Python would raise aValueErrorwhen unpacking: a good check that you counted the parentheses right.int(code) >= 400converts the code before comparing. Groups are strings, and comparing"401" >= 400would raise aTypeError.
Let’s check the output: of the 12 lines, 11 are requests. Those with code 400 or higher are five 401s, one 404 and one 500: 5 + 1 + 1 = 7 errors, the 7 rows of the table. The other 4 requests are 200.
The variables you won’t use (date, size, browser) can still be collected in the unpacking. It is clearer than reaching for m.group(3) or m.group(5) by number, and the day you need them they already have a name.
Try it yourself
- Add a line to the log with the
PUTmethod. Does it appear in the table or in the list of bad lines? Change the pattern to acceptGET,POST,PUTandDELETE. - Swap
fullmatchforsearchand add a line with extra text at the end (for example,# check). What changes in the output? Why is it not detected?
Program 2 · Counting requests and spotting login attempts
contar.py
The idea
Now we want numbers: how many requests there are for each code, how many in each hour (with a bar of # to see it at a glance), how many bytes the server has sent and how many failed logins each IP has. If an IP reaches 3, we flag it as suspicious.
To count, a dictionary with get(key, 0) + 1. To pull several fields out of a line, a pattern with groups only on the fields you care about; the rest can be described with .* if there is no risk of it overshooting.
The code
import re
LOG = """\
10.0.0.7 [12/10/2026 09:15:02] "GET /campus/inicio" 200 5120 "Firefox/131"
10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129"
10.0.0.23 [12/10/2026 09:15:51] "POST /campus/login" 401 312 "Chrome/129"
10.0.0.23 [12/10/2026 09:16:03] "POST /campus/login" 200 1024 "Chrome/129"
172.16.4.150 [12/10/2026 09:20:17] "GET /fp1/tema8.pdf" 200 14040 "Safari/17"
10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131"
192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5"
192.168.1.66 [12/10/2026 09:31:09] "POST /campus/login" 401 312 "curl/8.5"
192.168.1.66 [12/10/2026 09:31:10] "POST /campus/login" 401 312 "curl/8.5"
10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"
-- server restart --
10.0.0.41 [12/10/2026 10:05:12] "GET /fp1/entregas" 200 2210 "Chrome/129"
"""
codes = re.findall(r'" (\d{3}) ', LOG) # all the codes at once
per_code = {}
for c in codes:
per_code = per_code.get(c, 0) + 1
print("Requests per code:", dict(sorted(per_code.items())))
per_hour = {}
failed_logins = {}
total_bytes = 0
for line in LOG.splitlines():
m = re.match(r'(\S+) \[\d\d/\d\d/\d{4} (\d\d):.*" (\d{3}) (\d+) ', line)
if not m:
continue # the restart line is not a request
ip, time, code, size = m.groups()
per_hour[time] = per_hour.get(time, 0) + 1
total_bytes += int(size)
if code == "401":
failed_logins[ip] = failed_logins.get(ip, 0) + 1
for time, n in sorted(per_hour.items()):
print(f"{time}:00-{time}:59 {'#' * n} {n}")
print(f"Bytes served: {total_bytes} ({total_bytes / 1024:.1f} KB)")
for ip, n in failed_logins.items():
warning = " <- suspicious" if n >= 3 else ""
print(f"{ip:<14}{n} failed logins{warning}")
When you run it:
Requests per code: {'200': 4, '401': 5, '404': 1, '500': 1}
09:00-09:59 ######### 9
10:00-10:59 ## 2
Bytes served: 24041 (23.5 KB)
10.0.0.23 2 failed logins
192.168.1.66 3 failed logins <- suspicious
All the codes at once with findall
codes = re.findall(r'" (\d{3}) ', LOG) # all the codes at once
per_code = {}
for c in codes:
per_code = per_code.get(c, 0) + 1
print("Requests per code:", dict(sorted(per_code.items())))
re.findall(r'" (\d{3}) ', LOG)is applied to the whole log, not line by line. It looks for “closing quote, space, three digits, space”, which in this format only appears right before the HTTP code. Since there is a group, the list only has the digits: 11 codes, one per request.- The context is what makes the pattern reliable. A bare
\d{3}would also find pieces of the IP, the date or the bytes; with the quote in front, only the code. dict(sorted(per_code.items()))sorts the dictionary by key so the output always comes out in the same order: 4 of200, 5 of401, 1 of404and 1 of500. They add up to 4 + 5 + 1 + 1 = 11.
A pattern with only the groups you need
for line in LOG.splitlines():
m = re.match(r'(\S+) \[\d\d/\d\d/\d{4} (\d\d):.*" (\d{3}) (\d+) ', line)
if not m:
continue # the restart line is not a request
ip, time, code, size = m.groups()
per_hour[time] = per_hour.get(time, 0) + 1
total_bytes += int(size)
if code == "401":
failed_logins[ip] = failed_logins.get(ip, 0) + 1
The pattern in this part is shorter than in program 1, because we only need four fields:
| Piece | Group | What it captures |
|---|---|---|
(\S+) | 1 | The IP: the start of the line, up to the first space |
\[\d\d/\d\d/\d{4} | The date, which is checked but not kept | |
(\d\d): | 2 | Only the hour (no minutes or seconds) |
.* | Anything: minutes, seconds, method and path | |
" (\d{3}) (\d+) | 3 and 4 | The code and the bytes, after the quote |
- **
re.matchis used because the IP is at the start** of the line: here it is exactly what we want. - The
.*is greedy: it swallows the line to the end and steps back until it finds" ddd ddd. In this format there is only one place where that fits, so the result is right. If the browser could contain something like" 200 5, the greedy one would go to that last occurrence: that is why the hint said to use it only “if there is no risk of it overshooting”. - The restart line does not match,
misNoneandcontinueskips it. - Each counter is a different dictionary:
per_hourcounts every request in each hour andfailed_loginsonly the ones with code"401". Note that it is compared with the string"401", becausecodeis a group and has not been converted.
The report
for time, n in sorted(per_hour.items()):
print(f"{time}:00-{time}:59 {'#' * n} {n}")
print(f"Bytes served: {total_bytes} ({total_bytes / 1024:.1f} KB)")
for ip, n in failed_logins.items():
warning = " <- suspicious" if n >= 3 else ""
print(f"{ip:<14}{n} failed logins{warning}")
'#' * nrepeats the character as many times as there are requests: 9 between 9:00 and 9:59, and 2 between 10:00 and 10:59. A one-line bar chart.- The bytes add up to 24041. Check it against the bytes column of the log: 5120 + 312 + 312 + 1024 + 14040 + 0 + 312 + 312 + 312 + 87 + 2210 = 24041. Divided by 1024 they give 23.48, which
.1fwrites as23.5. 10.0.0.23has 2 failures and192.168.1.66has 3, the minimum for the warning. The dictionary remembers the order in which each IP was added, which is why10.0.0.23comes first.
A real detector would also take time into account: three failures in three seconds are not the same as three failures in a week. The times of our three requests (09:31:08, 09:31:09 and 09:31:10) are already in the log; working out the difference between them is a good “Try it yourself”.
Try it yourself
- Add a counter of requests per path (
/campus/login,/fp1/entregas…). You will have to add a group to the pattern; watch out that the numbers of the groups you already use do not change… or usem.groups()and unpack into one more variable. - Make the suspicious IP warning also appear if the IP has any 401 and its browser contains
curl.
Program 3 · Converting the log to CSV with re.sub
reformatear.py
The idea
To open the log in a spreadsheet, we want to turn each request into a CSV line (fields separated by ;) in this order: date and time in ISO format (2026-10-12T09:15:02), the IP anonymised (with the last number set to 0, because a full IP is personal data), the method, the path and the code. Lines that are not requests are discarded and counted.
re.sub with a template can do all the work at once: a pattern that captures each piece you need, in the order it appears in the log, and a template that writes them in the order you want. And re.subn also tells you whether there was a replacement.
The code
import re
LOG = """\
10.0.0.7 [12/10/2026 09:15:02] "GET /campus/inicio" 200 5120 "Firefox/131"
10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129"
10.0.0.23 [12/10/2026 09:15:51] "POST /campus/login" 401 312 "Chrome/129"
10.0.0.23 [12/10/2026 09:16:03] "POST /campus/login" 200 1024 "Chrome/129"
172.16.4.150 [12/10/2026 09:20:17] "GET /fp1/tema8.pdf" 200 14040 "Safari/17"
10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131"
192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5"
192.168.1.66 [12/10/2026 09:31:09] "POST /campus/login" 401 312 "curl/8.5"
192.168.1.66 [12/10/2026 09:31:10] "POST /campus/login" 401 312 "curl/8.5"
10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"
-- server restart --
10.0.0.41 [12/10/2026 10:05:12] "GET /fp1/entregas" 200 2210 "Chrome/129"
"""
PATTERN = (r'(\d+\.\d+\.\d+)\.\d+ ' # 1: IP without its last number
r'\[(\d\d)/(\d\d)/(\d{4}) (\S+)\] ' # 2: day, 3: month, 4: year, 5: time
r'"(\w+) (\S+)" (\d{3}) .*') # 6: method, 7: path, 8: code; .* the rest
TEMPLATE = r"\4-\3-\2T\5;\1.0;\6;\7;\8" # the template decides the order
print("datetime;ip;method;path;code")
discarded = 0
for line in LOG.splitlines():
new, n = re.subn(PATTERN, TEMPLATE, line) # n is 1 if the line matched, 0 if not
if n == 0:
discarded += 1
else:
print(new)
print(f"# {discarded} line(s) discarded")
When you run it:
datetime;ip;method;path;code 2026-10-12T09:15:02;10.0.0.0;GET;/campus/inicio;200 2026-10-12T09:15:40;10.0.0.0;POST;/campus/login;401 2026-10-12T09:15:51;10.0.0.0;POST;/campus/login;401 2026-10-12T09:16:03;10.0.0.0;POST;/campus/login;200 2026-10-12T09:20:17;172.16.4.0;GET;/fp1/tema8.pdf;200 2026-10-12T09:22:45;10.0.0.0;GET;/fp1/tema9.pdf;404 2026-10-12T09:31:08;192.168.1.0;POST;/campus/login;401 2026-10-12T09:31:09;192.168.1.0;POST;/campus/login;401 2026-10-12T09:31:10;192.168.1.0;POST;/campus/login;401 2026-10-12T10:02:33;10.0.0.0;GET;/fp1/entregas;500 2026-10-12T10:05:12;10.0.0.0;GET;/fp1/entregas;200 # 1 line(s) discarded
The pattern and the template
PATTERN = (r'(\d+\.\d+\.\d+)\.\d+ ' # 1: IP without its last number
r'\[(\d\d)/(\d\d)/(\d{4}) (\S+)\] ' # 2: day, 3: month, 4: year, 5: time
r'"(\w+) (\S+)" (\d{3}) .*') # 6: method, 7: path, 8: code; .* the rest
TEMPLATE = r"\4-\3-\2T\5;\1.0;\6;\7;\8" # the template decides the order
This pattern captures exactly what is going to come out and nothing else:
- Group 1,
(\d+\.\d+\.\d+), is the first three numbers of the IP with their dots. The fourth number is described outside the group (\.\d+): it has to be there, but it is not kept, and so it disappears from the result. - Groups 2, 3 and 4 are the day, the month and the year separately, because they have to be reordered.
- Group 5 is the time, with
(\S+): everything up to the next space, which comes after the closing bracket. The greedy one also swallows the]and then gives it back, so that the pattern’s\]can match. - Groups 6, 7 and 8 are the method, the path and the code. This time the method is described with
\w+, which accepts any word. - The **final
.*** matches the rest of the line (bytes and browser). It is essential:re.subonly replaces what matches, and without it the bytes and the browser would stay stuck behind the CSV.
The template r"\4-\3-\2T\5;\1.0;\6;\7;\8" decides the output order:
Let’s check it with line 2 of the log, the first 401. The groups are 10.0.0, 12, 10, 2026, 09:15:40, POST, /campus/login and 401. The template writes group 4 (2026), a hyphen, group 3 (10), a hyphen, group 2 (12), a T, group 5 (09:15:40), a ;, group 1 (10.0.0) followed by .0;, and then groups 6, 7 and 8 separated by ;. Result: 2026-10-12T09:15:40;10.0.0.0;POST;/campus/login;401, which is the second line of the output.
Writing the template without the r in front: "\4-\3-\2T…". Python turns \4, \3… into control characters before they reach re, and the output fills up with invisible characters without any error. sub templates, just like patterns, always with r.
The loop: subn to know whether it matched
print("datetime;ip;method;path;code")
discarded = 0
for line in LOG.splitlines():
new, n = re.subn(PATTERN, TEMPLATE, line) # n is 1 if the line matched, 0 if not
if n == 0:
discarded += 1
else:
print(new)
print(f"# {discarded} line(s) discarded")
re.subnreturns the new line and the number of replacements. Since the pattern describes the whole line, it can only match once:nis 1 if the line was a request and 0 if not.- When it does not match,
subreturns the original line unchanged. That is why printingnewis not enough: the restart line would come out as it is, in the middle of the CSV.n == 0is the clean way to detect it. - Look at the header:
datetime;ip;method;path;code, with no spaces. Column names like that cause fewer problems when the CSV is imported into other programs.
Once the date is in year-month-day format, the lines can be sorted as text and they end up in chronological order: "2026-10-12" comes before "2026-11-01" both alphabetically and on the calendar. With day/month/year it doesn’t work: "12/10" would go after "01/11". We will take advantage of this in the last exercise.
Try it yourself
- Add the bytes as the last column of the CSV. You will have to turn part of the final
.*into a group; which group number does it get? - Change the anonymisation so that it only keeps the first two numbers (
10.0.0.23→10.0.0.0,172.16.4.150→172.16.0.0).
Common mistakes in this practice
- Not escaping the brackets of the log.
[\d\d/…]is a set, not a bracket: the pattern changes its meaning completely. Literal brackets,\[and\]. - **Asking for
groups()without checking forNone.** Any line that does not match (like the restart one) gives anAttributeError. Firstif m is None: continue. - Counting the groups wrong. If you add a parenthesis, all the numbers after it change. Use
(?:…)for parentheses that only group, and comment the number of each group. - Comparing groups with numbers. Groups are strings:
int(code) >= 400, orcode == "401"in quotes. - **Using
searchto validate.** A line with rubbish in front or behind matches all the same. For “the line has this format”,fullmatch. - **Forgetting that
subonly replaces what matches.** If the pattern does not cover the whole line, whatever is left outside is copied as it is into the result.
Watch it step by step in Python Tutor
Python Tutor runs the code line by line and shows the value of each variable after every step. Choose Python 3 and paste this minimal version of program 3, with a single line:
import re
line = '192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5"'
pattern = r'(\S+) \[(\d\d)/(\d\d)/(\d{4}) (\S+)\]'
m = re.search(pattern, line)
groups = m.groups()
iso = re.sub(pattern, r"\4-\3-\2T\5 \1", line)
print(groups)
print(iso)
Expected output:
('192.168.1.66', '12', '10', '2026', '09:31:08')
2026-10-12T09:31:08 192.168.1.66 "POST /campus/login" 401 312 "curl/8.5"
What to watch, step by step:
- After line 5,
mappears as anre.Matchobject. Python Tutor does not draw its groups inside: to see them you have to ask for them. - On line 6,
groupsis drawn as a tuple of five strings: the IP, the day, the month, the year and the time. All in quotes, the numbers too. - On line 7,
isois a new string. Compare it withline, which stays the same:re.subchanges nothing, it returns another text. - Look at
iso: the start of the line has changed (2026-10-12T09:31:08 192.168.1.66) but the rest, from the quote onwards, is the same. The pattern in this example **has no.*at the end**, so only the part that matched has been replaced, and what came after has been copied as it is. It is the last common mistake in the list, live. - Add
.*to the end of the pattern and run it again: now the rest of the line disappears from the result.
Summary
- One pattern per line format: copy a real line and replace each field with its piece of regex. Brackets and other metacharacters from the log, escaped.
- **
fullmatchvalidates and cuts at the same time:** if it returnsNone, the line does not have the format; if not,m.groups()spreads the fields into variables. - Groups only where needed:
(?:…)to repeat without capturing, and a comment with the number of each group. - **
findallover the whole text** is the fastest way to pull one field out of every line, as long as the pattern has enough context not to confuse it with another. - Groups are strings: convert with
int()before comparing or doing maths. - **
re.subwith a template reorders fields;**re.subnalso tells you whether the line matched. The template, withrin front.
Checklist: have you practised it?
- Have you run the three programs and checked the outputs?
- Could you explain why program 1 uses
fullmatchand program 2 usesmatch? - Can you number the eight groups of program 1’s pattern by hand without looking at the drawing?
- Do you know what happens to the restart line in each of the three programs?
- Could you add a column to program 3’s CSV without the group numbers getting out of place?
- Have you tried the “Try it yourself” sections without looking at the theory?
If you said yes to almost everything, you are ready for the regular expressions exercises, where I no longer give you the code: validating fields with fullmatch, finding DNIs (Spanish ID numbers) written any old way in an incident report, merging two logs with different date formats, and three snippets with very common bugs to hunt down. See you there!
Did this article help you? Here is how you can support SergioLearns →
