Python regular expressions — exercises with validators, DNIs hidden in a report and two logs to merge (and the alert that said “All good”)
After the practice I got excited and wrote a tiny alert script: if the last line of the log had a 500 (server error) as a separate word, it should warn me. I used a word boundary so as not to confuse it with a 15000 in the bytes, just as I learnt in the theory. I tested it with a line that had a crystal-clear 500, and the script answered, very calmly: “All good”.
I went over the pattern ten times: \b500\b, perfect. The fault was right in front of it: I had written "\b500\b" **without the r**. Python turned each \b into a backspace character before the pattern reached re, and my script was looking for “backspace, 500, backspace”, something that appears in no log in the world. No error, no warning: just an alert that was never going to go off. It is the first snippet in “What’s wrong here?”, and the other two are just as silent.
Today we practise with three exercises of increasing difficulty, three snippets to debug and a cheat sheet to take away. These exercises use the regular expressions theory and review the practice. You can do them without those, but it will be harder.
Table of Contents
How to use this article
- Read the statement of a level and don’t look at the solution yet.
- Write your version, run it and compare your output with the expected one.
- If you get stuck, look only at the 🔵 hint and try again.
- When you finish, compare with the solution and pay attention to why it is written that way, not just to whether it gives the same output.
No exercise uses input(): the data is fixed in the code. With regular expressions, matching output is not enough: also test your pattern with texts that should not match. A pattern that accepts everything also “gives the expected output” with the good examples.
Level 1 · Basic: validating the fields of an alert
Statement. Before an alert is created, the system checks that each field has the right format. Write a dictionary PATTERNS with one pattern per field:
- IP: four numbers of 1 to 3 digits separated by dots.
- Time:
hh:mm:sswith hours from00to23and minutes and seconds from00to59. - Code: an HTTP code, from
100to599. - Path: starts with
/and goes on with letters, digits,_,/,.or-.
Check each value from these tests with re.fullmatch and, for comparison, also with re.match, and show a table with ✔ or ✘:
TESTS = [
("IP", "10.0.0.7"), ("IP", "10.0.0"), ("IP", "10.0.0.7 "),
("Time", "09:15:02"), ("Time", "9:15:02"), ("Time", "24:00:00"),
("Code", "404"), ("Code", "4040"), ("Code", "704"),
("Path", "/fp1/tema8.pdf"), ("Path", "fp1/tema8.pdf"),
]
Expected output:
Field Value fullmatch match IP '10.0.0.7' ✔ ✔ IP '10.0.0' ✘ ✘ IP '10.0.0.7 ' ✘ ✔ Time '09:15:02' ✔ ✔ Time '9:15:02' ✘ ✘ Time '24:00:00' ✘ ✘ Code '404' ✔ ✔ Code '4040' ✘ ✔ Code '704' ✘ ✘ Path '/fp1/tema8.pdf' ✔ ✔ Path 'fp1/tema8.pdf' ✘ ✘
For the hours, \d\d is not enough: think of an alternative between “a 0 or a 1 followed by any digit” and “a 2 followed by a digit from 0 to 3”. For the code, the first digit is a set from 1 to 5. And for the IP, repeat a non-capturing group (?:…) three times.
Level 2 · Intermediate: DNIs hidden in an incident report
Statement. Each technician writes the virtual campus incident report their own way, and the DNIs (the Spanish national ID: eight digits and a check letter) appear stuck to the letter, separated by a hyphen or a space, and with a lowercase letter:
INCIDENTS = """\ 09:15 user 12345678Z cannot remember their password 09:31 locked 87654321-x after three attempts 10:02 user 45123456 D asks to reopen the submission 10:40 contact phone: 928451000 (not a DNI) 11:05 new accounts 11111111A and 2222222-J """
Find every DNI (eight digits and a letter, with a hyphen, a space or nothing in between) with a single pattern. For each one, show the text as it appears, the normalised DNI (digits and capital letter, all together) and whether the letter is correct. The correct letter is LETTERS[number % 23], with LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE", as in the strings topic. Finish with a summary.
The phone number (nine digits) and 2222222-J (seven digits) are not DNIs and must not appear.
Expected output:
Found Normalized Letter '12345678Z' 12345678Z correct '87654321-x' 87654321X correct '45123456 D' 45123456D correct '11111111A' 11111111A should be H DNIs: 4, with the correct letter: 3
Two groups, one for the digits and one for the letter, with [- ]? between them. To avoid finding eight digits inside the phone number, surround the pattern with word boundaries \b. And since there are several DNIs and you want their groups, go through the matches with re.finditer.
Level 3 · Challenge: merging two logs with different dates
Statement. Two servers write their logs with different date formats: A uses day-month-year (with / or with -) and B, year-month-day. On top of that, some line was typed by hand and has mixed separators:
SERVER_A = """\ 12/10/2026 09:15:02 10.0.0.7 GET /campus/inicio 200 12-10-2026 09:31:08 192.168.1.66 POST /campus/login 401 12/10-2026 09:40:00 10.0.0.9 GET /fp1 200 """ SERVER_B = """\ 2026-10-12 09:20:17 172.16.4.150 GET /fp1/tema8.pdf 200 2026-10-12 10:02:33 10.0.0.41 GET /fp1/entregas 500 2026/10-12 10:05:12 10.0.0.41 GET /fp1/entregas 200 """
Write a function normalize(line) that returns the line with the date as year-month-day with hyphens, or None if the date is not valid. A day-month-year date is only valid if it uses the same separator twice. Put together the valid lines from both servers, marking their source, sort them chronologically and show them. Then list the rejected ones and count how many requests have an error code (4xx or 5xx).
Expected output:
2026-10-12 09:15:02 10.0.0.7 GET /campus/inicio 200 [A] 2026-10-12 09:20:17 172.16.4.150 GET /fp1/tema8.pdf 200 [B] 2026-10-12 09:31:08 192.168.1.66 POST /campus/login 401 [A] 2026-10-12 10:02:33 10.0.0.41 GET /fp1/entregas 500 [B] Rejected: A: 12/10-2026 09:40:00 10.0.0.9 GET /fp1 200 B: 2026/10-12 10:05:12 10.0.0.41 GET /fp1/entregas 200 Requests: 4, with errors: 2
For “the same separator”, capture the first one in a group and then require a backreference to that group. re.subn with a template reorders the date and, at the same time, tells you whether it matched. Once every date is year-month-day, sort() on the lines leaves them in chronological order.
What’s wrong here?
Three snippets with one bug each. Before reading the explanation, do two things: predict whether it raises an error and, if it doesn’t, what it prints.
Snippet 1
raw.py
import re
line = '10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"'
if re.search("\b500\b", line): # is there a 500 as a separate word?
print("Server error")
else:
print("All good")
*Think before reading on: the line has a 500 as a separate word. What does python3 raw.py print?*
Explanation. No error, but it gets it wrong:
All good
It is my alert from the start. The pattern "\b500\b" is written **without r**, and Python processes the string’s escape sequences before handing it to re. \b is a valid sequence for Python: the backspace character (code 8). So re does not receive “boundary, 500, boundary” but “backspace, 500, backspace”. There is no backspace in the line, re.search returns None and the else prints “All good”.
Trusting that the pattern “works without r” because it worked with \d. \d is not a Python escape and reaches re intact; \b, \1 or \n are, and they are transformed on the way.
Fix. An r in front of the pattern:
import re
line = '10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"'
if re.search(r"\b500\b", line): # with r: \b reaches re as a word boundary
print("Server error")
else:
print("All good")
When you run it:
Server error
If you ever wonder what re actually received, print repr(pattern). repr("\b500\b") shows '\x08500\x08': the backspaces are out in the open.
Snippet 2
avaricia.py
import re line = '10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"' request = re.search(r'"(.*)"', line).group(1) # whatever is between quotes print(request)
*Think before reading on: we want the request between quotes, GET /fp1/entregas. What does it print?*
Explanation. No error, but it grabs half the line:
GET /fp1/entregas" 500 87 "Chrome/129
.* is greedy: after the first quote, it swallows the rest of the line and then steps back just enough for the pattern’s quote to match, which is the last one in the line, the one closing "Chrome/129". Everything in between, two quotes included, goes into group 1.
Testing the pattern with a line that has only one pair of quotes. There "(.*)" works, and the bug shows up with the first real line that has two.
Fix. Describe what can go inside: anything except a quote.
import re line = '10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"' request = re.search(r'"([^"]*)"', line).group(1) # nothing that is a quote print(request)
When you run it:
GET /fp1/entregas
"(.*?)", with the lazy quantifier, fixes it too. [^"]* has one advantage: whatever the rest of the pattern becomes, it can never skip over a quote, whereas the lazy one can end up stretching if what follows does not match the first time.
Snippet 3
match.py
import re
lines = [
'10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"',
'192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5"',
]
for line in lines:
m = re.match(r"\d{3}", line) # the HTTP code is 3 digits
print(m.group() if m else "no code")
Think before reading on: we want the HTTP code of each line (500 and 401). What does it print?
Explanation. No error, and both answers are wrong, each in its own way:
no code 192
re.match only tries at the start of the line. The first line starts with 10.0.0.41: after the 1 and the 0 comes a dot, so there are no three digits in a row at position 0, and match returns None without looking any further. The second starts with 192.168.1.66, and 192 are three digits: match matches the first number of the IP and the program prints 192 as if it were the code.
The second bug is the worst kind: a result that looks right. Even if match is swapped for search, \d{3} would find the first run of three digits in the line, which is not the code either.
Looking for a field only by its shape (\d{3}) when other fields have the same shape. In a log, IPs, dates, bytes and codes are all digits: what sets the code apart is where it is.
Fix. search to look through the whole line, and context around the three digits so that only the code matches: the closing quote and the spaces.
import re
lines = [
'10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"',
'192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5"',
]
for line in lines:
m = re.search(r'" (\d{3}) ', line) # search looks in the whole line; the context pins it down
print(m.group(1) if m else "no code")
When you run it:
500 401
Solutions
Look at this section only once you have tried the exercise. If your code is different but gives the same output (and rejects the same things), it is also a valid solution.
Level 1 solution
validar.py
import re
PATTERNS = {
"IP": r"\d{1,3}(?:\.\d{1,3}){3}",
"Time": r"([01]\d|2[0-3]):[0-5]\d:[0-5]\d", # 00 to 23 : 00 to 59 : 00 to 59
"Code": r"[1-5]\d\d", # HTTP codes go from 100 to 599
"Path": r"/[\w/.-]*", # starts with / and then letters, /, . or -
}
TESTS = [
("IP", "10.0.0.7"), ("IP", "10.0.0"), ("IP", "10.0.0.7 "),
("Time", "09:15:02"), ("Time", "9:15:02"), ("Time", "24:00:00"),
("Code", "404"), ("Code", "4040"), ("Code", "704"),
("Path", "/fp1/tema8.pdf"), ("Path", "fp1/tema8.pdf"),
]
print(f"{'Field':<8}{'Value':<18}{'fullmatch':<11}match")
for field, value in TESTS:
good = re.fullmatch(PATTERNS[field], value) # does the WHOLE value have the format?
almost = re.match(PATTERNS[field], value) # does it START with the format?
print(f"{field:<8}{value!r:<18}{'✔' if good else '✘':<11}{'✔' if almost else '✘'}")
Why it is written this way:
- The patterns are stored in a dictionary by field, so the check is a single line for all of them:
re.fullmatch(PATTERNS[field], value). Adding a new field means adding a key. - The time uses
([01]\d|2[0-3]): “a 0 or a 1 and any digit” (00to19) or “a 2 and a digit from 0 to 3” (20to23). Minutes and seconds,[0-5]\d, go from00to59. That way24:00:00is rejected by its format, without converting anything to a number. - The code is
[1-5]\d\d: the first digit from 1 to 5. That is why704fails, even though it has three digits. - In the path,
[\w/.-]*accepts letters, digits,_, slashes, dots and hyphens. The hyphen goes at the end of the set: placed between two characters it would mean a range. And inside the brackets the dot is an ordinary dot, unescaped. - The
matchcolumn shows why validating withmatchis a mistake: it accepts'10.0.0.7 '(with a space after it) and'4040', because it only checks that the value starts well.fullmatchrequires nothing to be left over. '10.0.0'and'9:15:02'fail with both functions: a piece is missing (a number of the IP, a digit of the hour) from the start.{value!r:<18}prints the value withrepr, in quotes. That way you can see the trailing space of'10.0.0.7 ', which would be invisible with an ordinaryprint.
\d{1,3} accepts 999.999.999.999, which is not a valid IP (each number goes from 0 to 255). Regexes check formats; for ranges of values, the clearest thing is to convert with int() and compare. Mixing both in one pattern is possible, but the result is unreadable.
Level 2 solution
dnis.py
import re
LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE" # the DNI letter is LETTERS[number % 23] (topic 7)
INCIDENTS = """\
09:15 user 12345678Z cannot remember their password
09:31 locked 87654321-x after three attempts
10:02 user 45123456 D asks to reopen the submission
10:40 contact phone: 928451000 (not a DNI)
11:05 new accounts 11111111A and 2222222-J
"""
# 8 digits (group 1), an optional hyphen or space, and the letter (group 2), with boundaries on both sides
DNI = r"\b(\d{8})[- ]?([A-Za-z])\b"
total = correct = 0
print(f"{'Found':<14}{'Normalized':<13}Letter")
for m in re.finditer(DNI, INCIDENTS):
number, letter = m.group(1), m.group(2).upper()
expected = LETTERS[int(number) % 23]
total += 1
if letter == expected:
correct += 1
status = "correct"
else:
status = f"should be {expected}"
print(f"{m.group()!r:<14}{number + letter:<13}{status}")
print(f"DNIs: {total}, with the correct letter: {correct}")
Why it is written this way:
- The pattern
\b(\d{8})[- ]?([A-Za-z])\bhas two groups: the eight digits and the letter. What lies between them ([- ]?) is “a hyphen or a space, or nothing”, and it is not captured because we don’t want it in the normalised DNI. - The boundaries
\bdo the fine work. In928451000, the first eight digits match\d{8}, but the next thing is a0, which is neither a separator nor a letter. The engine tries starting at the next digit, but there is no boundary there (it is in the middle of the number), and the same goes for all the others: the phone number does not match at any position. 2222222-Jhas only seven digits, so\d{8}does not match. Without the first\bit would not match either, but the final\bis the one that prevents, for example,12345678ZZfrom being taken as a DNI with the letterZ.- **
re.finditer** gives oneMatchper DNI, with its groups. Withfindallyou would get(digits, letter)tuples, but not the complete original text (m.group()), which is what the first column shows. - The letter is uppercased with
upper()before comparing.87654321-xis a correct DNI written with a lowercase letter. 11111111Ais found, because it has the format of a DNI, but its letter is wrong: 11111111 % 23 = 18, andLETTERS[18]isH. Once again, the regex checks the shape and the calculation checks the value.- The summary comes from two counters: 4 DNIs found, of which 3 have the correct letter.
total = correct = 0 sets both variables to 0 in one line. It works fine with numbers; with lists, on the other hand, both variables would point to the same list.
Level 3 solution
unificar.py
import re
# Two servers, two date formats (and the odd line typed by hand, wrongly)
SERVER_A = """\
12/10/2026 09:15:02 10.0.0.7 GET /campus/inicio 200
12-10-2026 09:31:08 192.168.1.66 POST /campus/login 401
12/10-2026 09:40:00 10.0.0.9 GET /fp1 200
"""
SERVER_B = """\
2026-10-12 09:20:17 172.16.4.150 GET /fp1/tema8.pdf 200
2026-10-12 10:02:33 10.0.0.41 GET /fp1/entregas 500
2026/10-12 10:05:12 10.0.0.41 GET /fp1/entregas 200
"""
DMY = r"(\d\d)([-/])(\d\d)\2(\d{4}) " # day, separator, month, THE SAME separator, year
YMD = r"\d{4}-\d\d-\d\d " # already year-month-day
def normalize(line):
"""Returns the line with the date as year-month-day, or None if the date makes no sense."""
new, n = re.subn("^" + DMY, r"\4-\3-\1 ", line)
if n == 1:
return new
if re.match(YMD, line):
return line
return None
merged, rejected = [], []
for source, text in [("A", SERVER_A), ("B", SERVER_B)]:
for line in text.splitlines():
new = normalize(line)
if new is None:
rejected.append(f"{source}: {line}")
else:
merged.append(new + " [" + source + "]")
merged.sort() # with the date as year-month-day, alphabetical order is chronological order
for line in merged:
print(line)
print("Rejected:")
for r in rejected:
print(" " + r)
errors = [l for l in merged if re.search(r" [45]\d\d \[", l)] # 4xx and 5xx codes
print(f"Requests: {len(merged)}, with errors: {len(errors)}")
Why it is written this way:
DMYis(\d\d)([-/])(\d\d)\2(\d{4}): group 2 captures the first separator, and\2requires the same text in the second slot.12-10-2026matches (hyphen and hyphen);12/10-2026does not (slash and hyphen). It is exactly what the statement asked for, in a single pattern.normalizeusesre.subn("^" + DMY, r"\4-\3-\1 ", line). The^makes sure the date is at the start of the line. The template writes the year (group 4), the month (group 3) and the day (group 1); the separator (group 2) is not used, because the output always has hyphens. The trailing space is repeated in the template because it was part of what matched.- If
nis 1, the date was day-month-year and it has already been converted. If not, the function checks withre.match(YMD, line)whether the line was already year-month-day; if not that either, it returnsNone. That is how2026/10-12is rejected: it has neither of the two formats. "^" + DMYjoins two strings: the"^"has norbecause it has no backslashes, andDMYwas already written withr. What reachesreis the complete, well-formed pattern.- The valid lines are stored with their source at the end (
" [A]"), and **merged.sort()** sorts them as text. Since they all start with2026-10-12 hh:mm:ss, alphabetical order matches chronological order: the lines from A and B end up interleaved by time. - The last pattern,
r" [45]\d\d \[", counts the errors: a space, a code starting with 4 or 5, two spaces and the bracket of the source. The context avoids confusing the code with other numbers in the line. There are 2: the401from server A and the500from B.
Watch it step by step in Python Tutor
The bug in snippet 2 is much easier to understand by comparing both versions side by side. Python Tutor runs the code line by line and shows the value of each variable. Choose Python 3 and paste this program:
import re line = '"GET /fp1" 200 "Chrome/129"' greedy = re.search(r'"(.*)"', line) lazy = re.search(r'"(.*?)"', line) print(greedy.group(1)) print(lazy.group(1))
Expected output:
GET /fp1" 200 "Chrome/129 GET /fp1
What to watch, step by step:
- After line 3,
lineis a string with four quotes: two around the request and two around the browser. - Lines 4 and 5 create two
re.Matchobjects,greedyandlazy, with the same text and almost the same pattern: only a?changes. - On line 6,
greedy.group(1)starts after the first quote and ends before the last one: the group contains200and the two quotes in the middle. - On line 7,
lazy.group(1)ends at the second quote, the first one that lets the pattern close: justGET /fp1. - Change the pattern of
lazytor'"([^"]*)"'and run it again: the result is the same as with.*?.
Cheat sheet: Python regular expressions
The essentials in 30 seconds
- **
import reand every pattern withrin front:**r"\b\d{3}\b". - To search:
re.search(anywhere). To validate:re.fullmatch(everything). At the start:re.match. - Every match:
re.findall(list of strings) orre.finditer(oneMatcheach). - **Check for
None** before asking form.group(). - **Groups
( )** to cut apart:m.group(1),m.groups(). They are numbered by the opening parenthesis. - **
.*is greedy:** for “between quotes”,"[^"]*"or".*?".
Pattern syntax
| I want… | Pattern | Example that matches |
|---|---|---|
| A digit / non-digit | \d / \D | 7 / a |
| Word character / not | \w / \W | a, 7, _ / . |
| Whitespace / not | \s / \S | space, tab / x |
Any character (except \n) | . | a, 7, . |
| A real dot | \. | . |
| One of several characters | [aeiou], [0-9a-f] | e, 3 |
| Anything except those | [^0-9] | x |
| One thing or another | GET|POST | POST |
| Optional | https? | http, https |
| 0 or more / 1 or more | \d* / \d+ | "" / 404 |
| Exactly n / between n and m | \d{4} / \d{1,3} | 2026 / 16 |
| The minimum (lazy) | .*?, \d+? | |
| Start / end of the text | ^ / $ | |
| Separate word | \b404\b | 404, not 14040 |
| Capturing group | (\d\d) | |
| Non-capturing group | (?:\.\d+){3} | .16.4.150 |
| The same as group 1 | \1 |
Functions of the re module
| I want… | Code | Returns |
|---|---|---|
| Is it anywhere? | re.search(p, t) | Match or None |
| Does it start like this? | re.match(p, t) | Match or None |
| Is it all like this? | re.fullmatch(p, t) | Match or None |
| Every match | re.findall(p, t) | List of strings (or of groups) |
| Go through them with positions | for m in re.finditer(p, t): | One Match per loop |
| Replace | re.sub(p, r"\2/\1", t) | New string |
| Replace and count | re.subn(p, rep, t) | (new string, n) |
| Check a pattern | re.compile(p) | Pattern or re.error |
The Match object
| I want… | Code | Example |
|---|---|---|
| The whole match | m.group() or m.group(0) | '[12/10/2026 09:15:40]' |
| One group | m.group(1) | '12' |
| Several groups | m.group(1, 2, 3) | ('12', '10', '2026') |
| All the groups | m.groups() | Tuple; None for the ones that don’t take part |
| Position | m.start(), m.end(), m.span() | (52, 55) |
| Position of a group | m.span(4) | (22, 30) |
Templates from the exercises
Validate a value (Level 1):
if re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d:[0-5]\d", time):
print("Valid time")
Search while checking for None (snippet 3):
m = re.search(r'" (\d{3}) ', line)
if m:
code = int(m.group(1))
Go through every match with its groups (Level 2):
for m in re.finditer(r"\b(\d{8})[- ]?([A-Za-z])\b", text):
number, letter = m.group(1), m.group(2).upper()
Cut a line into fields (practice):
m = re.fullmatch(PATTERN, line)
if m is None:
continue
ip, date, time = m.groups()
Reorder with a template (Level 3):
new, n = re.subn(r"^(\d\d)([-/])(\d\d)\2(\d{4})", r"\4-\3-\1", line)
if n == 0:
print("It did not have the format")
Most frequent errors
| Symptom | Likely cause |
|---|---|
AttributeError: 'NoneType' object has no attribute 'group' | The pattern found nothing and you didn’t check if m: |
A pattern with \b or \1 finds nothing, with no error | The r in front is missing: Python turned the backslash into an escape |
14040 slips through when looking for 404 | Boundaries are missing: \b404\b |
".*" grabs half the line | Greedy: use "[^"]*" or ".*?" |
re.match gives None for something that is there | match only looks at the start: use search |
A validator accepts '4040' or '10.0.0.7 ' | You validate with match or search: use fullmatch |
10a0b0c7 passes as an IP | Unescaped dot: \. |
findall returns tuples or just one piece | The pattern has groups: findall returns the groups |
| A repeated group keeps only the last piece | (…){3} keeps the last round: use several groups or finditer |
| Group numbers “move” | You added parentheses: use (?:…) for the ones that only group |
re.error: missing ), unterminated subpattern | Unclosed parenthesis in the pattern |
re.sub “doesn’t change” the text | It returns a new one: t = re.sub(…, t) |
Before handing in, ask yourself
- Do all my patterns and templates carry the
rin front? - Do I use
fullmatchto validate andsearchto look for things, and do I check that the result is notNone? - Have I escaped the dots, brackets and parentheses I want to find as they are?
- Have I also tested each pattern with texts that should not match?
If you want to review the topic from the beginning, go back to the regular expressions theory.
Keep goingAll Python (en) articlesDid this article help you? Here is how you can support SergioLearns →
