Python regular expressions — theory with a server log, groups that cut each line apart and greedy patterns (and the 404 that wasn’t a 404)
For a networking assignment we were given the access log of a web server: hundreds of lines, one per request, with the IP, the date, the requested path and the HTTP code of the response. The first task was simple: “pull out the requests that returned 404”, that is, the ones for pages that did not exist. With what I knew about strings, it was obvious: if "404" in line. I tried it with two lines:
import re
lines = [
'10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131"',
'172.16.4.150 [12/10/2026 09:20:17] "GET /fp1/tema8.pdf" 200 14040 "Safari/17"',
]
for line in lines:
print("Contains '404'?", "404" in line) # exact search: does that text appear?
m = re.search(r'" (\d{3}) ', line) # pattern search: quote, space, 3 digits, space
print("HTTP code:", m.group(1))
This is what it printed:
Contains '404'? True HTTP code: 404 Contains '404'? True HTTP code: 200
Both lines claim to contain '404', but the second one is a 200: a request that went perfectly well. The 404 was hiding inside the size of the response, 14040 bytes. in looks for an exact text and finds it anywhere, including where it shouldn’t. What I actually meant was something else: “three digits that come right after the quotes and a space”. That is not a text: it is a pattern. And to write patterns there are regular expressions, which is exactly what the second half of the program uses.
Today we learn to read and write patterns (characters, classes, sets, quantifiers and anchors), the difference between **match, search and fullmatch, how groups cut out the pieces you care about, and how backreferences** (\1) force something already found to repeat. We will also see why .* is greedy and why every pattern is written with an **r in front. The example that comes with us is a server log**: lines with IPs, dates, times, paths and codes, which is exactly the kind of text regular expressions were invented for. If you are coming from Python strings, you will recognise indices and slices as soon as the word span shows up. When you finish, the regular expressions practice is waiting with three complete programs over a whole log.
Table of Contents
What is a regular expression?
A regular expression (or regex) is a string that describes a shape of text: “three digits”, “a dd/mm/yyyy date”, “something between quotes”. Instead of asking “is this text here?”, you ask “is there something with this shape?”. In Python they are used through the **re** module, which comes with the language: import re is all it takes.
The simplest case is a pattern made only of ordinary characters, which are searched as they are. That is what **re.search(pattern, text)** is for:
import re
line = '10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131"'
m = re.search(r"404", line) # the simplest pattern: ordinary characters, searched as they are
print(m) # a Match object: what it found and where
print(m.group()) # the text found
print(m.start(), m.end()) # starts at 52 and ends BEFORE 55
print(m.span()) # both positions together, in a tuple
print(line[m.start():m.end()]) # they are slice positions, as in topic 7
print(re.search(r"500", line)) # if it is not found, it returns None
if re.search(r"Firefox", line): # None counts as false: this is how you ask "is it there?"
print("Visit from Firefox")
print(re.findall(r"0", line)) # findall: a LIST with everything that matches
This is what it prints:
<re.Match object; span=(52, 55), match='404'> 404 52 55 (52, 55) 404 None Visit from Firefox ['0', '0', '0', '0', '0', '0', '0', '0']
What re.search returns deserves a calm look:
- If it finds the pattern, it returns a **
Matchobject**: a “record card” with what it found and where. When printed, Python summarises that card:span=(52, 55)andmatch='404'. - **
m.group()** returns the text found. Here it is'404', the same as the pattern, because the pattern had nothing special in it. As soon as the pattern has wildcards,group()will be the only way to know what exactly matched. - **
m.start()andm.end()are the start and end positions, andm.span()gives both together. They are slice** positions, like the ones in the previous topic: the404takes up positions 52, 53 and 54, andend()is 55 because it is the line after the last character. That is whyline[m.start():m.end()]rebuilds exactly the text found. - If nothing is found,
re.searchreturns **None**. And sinceNonecounts as false in anif, the natural way to ask “is it there?” isif re.search(…):. - The last line introduces **
re.findall**, which does not return aMatchbut a list with every piece that matches. There are 8 zeros in the line: three in the IP10.0.0.7, two in the date (10and2026), one in the time, the one in404and the0of the size.findallis the handiest tool to see what a pattern does, and we will use it a lot in this article.
Why do patterns carry an r in front, as in r"404"? It is the raw string you saw in the strings topic: it switches off Python’s escape sequences. With "404" it makes no difference, but as soon as a backslash appears (\d, \b…) it does, so it is always written. It has its own section further down, with the bug that forgetting it causes.
The most common error: using the result without checking it
Since re.search can return None, calling .group() without looking first is the fastest way to break a program:
import re
line = '10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"'
m = re.search(r"404", line) # there is no 404 in this line
print("Looking for 404...")
print(m.group()) # m is None, and None has no group()
Output:
Looking for 404...
Traceback (most recent call last):
File "error_none.py", line 6, in <module>
print(m.group()) # m is None, and None has no group()
^^^^^^^
AttributeError: 'NoneType' object has no attribute 'group'
'NoneType' object has no attribute 'group' translates as *”you asked for group() from something that is None“*. The fault is not in the line Python points at, but in the fact that the pattern found nothing and nobody checked.
Chaining re.search(…).group() on a single line. It works as long as the pattern finds something, and it blows up with the first text that does not match. Store the result in a variable and check if m: before using it.
Character classes: \d, \w, \s and their opposites
Patterns become useful when they stop describing specific characters and start describing kinds of characters. For the most common ones there are shorthands, which start with a backslash:
| Class | Matches | Its opposite |
|---|---|---|
\d | A digit: 0, 1, …, 9 | \D: anything that is not a digit |
\w | A word character: letter, digit or _ | \W: anything that is not a word character (spaces, signs…) |
\s | A whitespace character: space, tab, newline | \S: anything that is not whitespace |
The rule to remember the opposites is simple: the same letter in capitals means “the opposite”. Let’s try them on a piece of a log line:
import re request = '"GET /fp1/tema8.pdf" 200 14040' print(re.findall(r"\d", request)) # \d: one digit, each one separately print(re.findall(r"\d+", request)) # \d+: one or more digits in a row (+ comes later) print(re.findall(r"\w+", request)) # \w: letter, digit or underscore print(re.findall(r"\s", request)) # \s: space, tab or newline print(re.findall(r"\W", request)) # in capitals, the opposite: whatever is NOT \w print(re.findall(r"\D+", request)) # pieces without any digit
When you run it:
['1', '8', '2', '0', '0', '1', '4', '0', '4', '0'] ['1', '8', '200', '14040'] ['GET', 'fp1', 'tema8', 'pdf', '200', '14040'] [' ', ' ', ' '] ['"', ' ', '/', '/', '.', '"', ' ', ' '] ['"GET /fp', '/tema', '.pdf" ', ' ']
Line by line:
\dfinds each digit separately: ten one-character strings. The pattern describes one digit, so each match is one long. Notice that the1infp1and the8intema8appear too: for\da digit is a digit, wherever it is.\d+finds runs of digits. The+means “one or more times” and we will see it shortly; for now, remember that it turns “one digit” into “a whole number of any length”.\w+finds the “words”:GET,fp1,tema8,pdf,200and14040. The slash, the dot, the quotes and the spaces are not word characters, so they act as borders.\sfinds the three spaces.\Wfinds exactly what\wleft out: quotes, slashes, the dot and the spaces.\D+finds the pieces without a single digit. Look at the first one,'"GET /fp': it stops right before the1offp1.
When you are not sure what a pattern does, run it through re.findall with a test text and look. It is the regex version of printing a variable to see its value.
Special characters, escapes, sets and alternatives
Some characters are not searched as they are: they have a special meaning inside a pattern. They are called metacharacters, and these are they:
. ^ $ * + ? { } [ ] \ | ( )
If you want to find one of them as an ordinary character, you have to escape it with a backslash in front: \. is a real dot, \[ a bracket, \( a parenthesis. The sneakiest one is the dot, because unescaped it matches any character (except a newline) and almost never raises an error:
import re text = "10.0.0.7 10a0b0c7 172.16.4.150" print(re.findall(r"10.0.0.7", text)) # a bare dot means "any character" print(re.findall(r"10\.0\.0\.7", text)) # \. is a real dot print(re.findall(r"[aeiou]", "Safari Firefox")) # [ ]: ONE of the characters inside print(re.findall(r"[0-9a-f]+", "id=3fa9 x=zz")) # ranges: 0 to 9 and a to f print(re.findall(r"[^0-9 ]+", "GET 200 POST 401")) # ^ at the start of [ ]: anything EXCEPT those print(re.findall(r"GET|POST", "GET /a POST /b PUT /c")) # |: one alternative or the other print(re.search(r"\.htm|\.html", "index.html").group()) # the FIRST one that matches wins print(re.search(r"\.html|\.htm", "index.html").group()) # the long one first
Output:
['10.0.0.7', '10a0b0c7'] ['10.0.0.7'] ['a', 'a', 'i', 'i', 'e', 'o'] ['d', '3fa9'] ['GET', 'POST'] ['GET', 'POST'] .htm .html
Piece by piece:
r"10.0.0.7"finds the IP… and also10a0b0c7. Each dot in the pattern has accepted a letter. With a pattern like that, an IP validator would accept anything.r"10\.0\.0\.7", with the dots escaped, only finds the real IP.- Square brackets make a set:
[aeiou]matches one single character, which has to be one of those inside. In"Safari Firefox"it finds the six vowels one by one. - Inside a set you can write ranges with a hyphen:
[0-9a-f]is “a digit or a letter fromatof” (the hexadecimal digits). The output holds a surprise: besides3fa9,'d'appears, thedofid. For the pattern, adis a valid hexadecimal digit, even though you weren’t looking for it. - A **
^right after the bracket** negates the set:[^0-9 ]+is “one or more characters that are not digits nor spaces”. That is why onlyGETandPOSTremain. - The vertical bar **
|is the alternative**:GET|POSTmatches one thing or the other.PUTis not among the options, so it does not appear.
The last two lines hide an important rule: alternatives are tried from left to right and the first one that matches wins, not the longest. With \.htm|\.html, in "index.html" the first option already matches .htm, and Python does not keep looking: the l is left out. If you put the long option first, \.html|\.htm, you get the whole .html.
Forgetting to escape the dot in IPs, file names or domains (ulpgc.es). The pattern “works” with your tests, because dots match dots, and it also lets through texts it shouldn’t. Every literal dot, \..
Inside square brackets, almost every metacharacter loses its power: [.] is an ordinary dot and [*+?] are those three signs. The exceptions are ^ at the start, a hyphen between two characters and the backslash itself.
Quantifiers: how many times
Until now, each piece of the pattern matched one character. Quantifiers go after a piece and say how many times it can repeat:
| Quantifier | Means | Example | Matches |
|---|---|---|---|
? | 0 or 1 time (optional) | https? | http and https |
* | 0 or more times | 10* | 1, 10, 100… |
+ | 1 or more times | \d+ | 7, 404, 14040… |
{n} | Exactly n times | \d{4} | 2026 |
{n,m} | Between n and m times | \d{1,3} | 7, 16, 150 |
{n,} | n or more times | \d{3,} | 200, 14040… |
A detail that is often forgotten: the quantifier only affects the piece right in front of it. In https?, the ? makes the s optional, not the whole word.
import re
text = "http://campus.ulpgc.es https://www.ulpgc.es 10.0.0.7 172.16.4.150"
print(re.findall(r"https?://", text)) # ?: the s, 0 or 1 times
print(re.findall(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", text)) # {1,3}: between 1 and 3 digits
print(re.findall(r"\d{3}", "200 14040 87")) # {3}: exactly 3 (and carries on from there)
print(re.findall(r"\d{3,}", "200 14040 87")) # {3,}: 3 or more
print(re.findall(r"10*", "1 10 100 1000")) # *: the 1 followed by 0 or more zeros
print(re.findall(r"10+", "1 10 100 1000")) # +: the 1 followed by 1 or more zeros
When you run it:
['http://', 'https://'] ['10.0.0.7', '172.16.4.150'] ['200', '140'] ['200', '14040'] ['1', '10', '100', '1000'] ['10', '100', '1000']
https?://finds both addresses, with and without thes.- The IP pattern repeats “1 to 3 digits” four times, separated by escaped dots. It finds
10.0.0.7(with one-digit numbers) and172.16.4.150(with two- and three-digit ones), but not the web addresses. \d{3}on"200 14040 87"gives['200', '140']. After finding140, the search carries on from where it stopped: what is left is40(only two digits) and87(another two), and neither reaches three.findallnever overlaps matches.\d{3,}finds numbers with 3 digits or more, so14040comes out whole.10*and10+show the difference between*and+: with*, a lone1(zero zeros) is enough; with+, at least one0is needed after it.
If you are torn between * and +, ask yourself whether “zero times” is acceptable. \d* even matches an empty text (zero digits), and that produces very confusing empty matches. For “a number”, nearly always \d+.
Greedy or lazy: how far .* goes
Quantifiers have a personality worth knowing: they are greedy. They try to match as many repetitions as they can, and they only give characters back if the rest of the pattern does not match. With .*, “anything, as many times as possible”, that means swallowing the whole line. It is the third classic trap of the topic:
import re line = '10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131"' print(re.search(r'".*"', line).group()) # greedy: stretches to the LAST quote print(re.search(r'".*?"', line).group()) # lazy (*?): stops at the FIRST one that works print(re.findall(r'".*?"', line)) # every piece between quotes print(re.findall(r'"[^"]*"', line)) # the same, without lazy: "nothing that is a quote" html = "<b>404</b> Not Found" print(re.search(r"<.*>", html).group()) # from the first < to the last > print(re.search(r"<.*?>", html).group()) # only the first tag print(re.search(r"\d+?", "14040").group()) # +?: the minimum, a single digit
Output:
"GET /fp1/tema9.pdf" 404 0 "Firefox/131" "GET /fp1/tema9.pdf" ['"GET /fp1/tema9.pdf"', '"Firefox/131"'] ['"GET /fp1/tema9.pdf"', '"Firefox/131"'] <b>404</b> <b> 1
The first line wanted “whatever is between quotes” and took from the first quote to the last one, with the code, the size and the browser inside. This drawing explains what the search engine did:
Step by step, with ".*":
- The first
"of the pattern matches the quote that opens the request. .*swallows everything left in the line, up to the end. For the dot, quotes are characters like any other.- Now the pattern needs a
"and there is no text left. The engine steps back (backtracking): it gives back one character, the last one in the line. It is a quote, so it matches, and the search ends with 40 characters.
If you add a **? after the quantifier** (*?, +?, ??, {n,m}?), it becomes lazy: it matches as little as possible and only moves on if the rest of the pattern does not match. ".*?" starts by swallowing nothing, checks whether the closing quote comes next, and if not, moves one character and checks again. It stops at the first quote that works: 20 characters, just the request.
The rest of the output confirms it:
findallwith the lazy version finds both quoted pieces separately."[^"]*"gives exactly the same without lazy quantifiers: “a quote, any number of characters that are not quotes, and another quote”. Since the set cannot skip over a quote, there is no way for it to overshoot.- HTML works the same way:
<.*>goes from the first<to the last>and takes the whole<b>404</b>;<.*?>stops at the first tag,<b>. \d+?on"14040"matches a single digit: the lazy one settles for the minimum, which for+is once.
Using .* for “whatever is between two marks” when the closing mark can repeat in the line. It works in tests with a single pair of quotes and fails on the first real line. Use .*? or, even better, a negated set like [^"]*, which also makes it clear what cannot appear inside.
The greedy quantifier is not “wrong”: it does exactly what the pattern says. ".*" means “a quote, anything, a quote”, and the whole line from the first quote fits that description. The problem is that the pattern does not say what you meant to say.
Anchors and boundaries: where it can be
Anchors do not match any character: they match a position. They are for saying where the pattern has to be:
| Anchor | Position |
|---|---|
^ | Start of the text (outside square brackets) |
$ | End of the text |
\A, \Z | Start and end of the text, no exceptions |
\b | Word boundary: between a \w character and one that is not (or the start/end) |
\B | Any position that is not a word boundary |
This is the tool my "404" in line was missing. Let’s compare the same 404 with each anchor:
import re
PATTERNS = [
r"404", # anywhere
r"^404", # ^: only at the start
r"404$", # $: only at the end
r"\b404\b", # \b: word boundary on both sides
]
print(f"{'Text':<16}" + "".join(f"{p:<9}" for p in PATTERNS))
for t in ["404 Not Found", "bytes 14040", "error 404", "404"]:
row = f"{t!r:<16}"
for p in PATTERNS:
row += f"{str(bool(re.search(p, t))):<9}"
print(row)
When you run it:
Text 404 ^404 404$ \b404\b '404 Not Found' True True False True 'bytes 14040' True False False False 'error 404' True False True True '404' True True True True
- Without anchors,
404appears in all four texts, includingbytes 14040: exactly the bug from the start. ^404only accepts texts that start with 404, and404$the ones that end with 404. A bare'404'meets both at once.\b404\brequires a word boundary on each side: there can be no other letter or digit before the4or after the last4. In14040the404is glued to a1in front and a0behind, so there is no boundary:False. In the others, the404is surrounded by spaces or by the ends of the text:True.
\b…\b is the shortest way to say “as a separate word”. It works for numbers (\b500\b), words (\bERROR\b) or codes. It is the difference between finding the 500 code and finding the 500 in “15000 bytes”.
In the anchors table, \A and \Z look the same as ^ and $. They are, as long as you do not use the re.MULTILINE option, which makes ^ and $ work on every line of a multi-line text. We don’t need it in FP1, but if you see it in some code, now you know why it exists.
match, search and fullmatch
With re.search we have searched anywhere. The re module has two other functions with the same shape, function(pattern, text), that only change where they let the pattern match:
| Function | Question | Same as |
|---|---|---|
re.match(p, t) | Does the text start with the pattern? | re.search("^" + p, t) |
re.search(p, t) | Is the pattern anywhere? | |
re.fullmatch(p, t) | Does the whole text have the shape of the pattern? | re.search("^" + p + "$", t) |
All three return a Match or None. Let’s see them with the same three-digit pattern:
import re
CODE = r"\d{3}"
def show(m):
"""The text found, or None if nothing was found."""
return m.group() if m else "None"
print(f"{'Text':<17}{'match':<8}{'search':<8}fullmatch")
for t in ["404", "404 Not Found", "Error 404", "4040"]:
print(f"{t!r:<17}"
f"{show(re.match(CODE, t)):<8}" # does it START with the pattern?
f"{show(re.search(CODE, t)):<8}" # is it ANYWHERE?
f"{show(re.fullmatch(CODE, t))}") # is it the WHOLE text, start to end?
This is what it prints:
Text match search fullmatch '404' 404 404 404 '404 Not Found' 404 404 None 'Error 404' None 404 None '4040' 404 404 None
Row by row:
'404': all three find it. It starts with three digits, it contains them, and it is exactly three digits.'404 Not Found':matchandsearchfind it, butfullmatchdoes not: after the three digits," Not Found"is left over.'Error 404':matchfails because the text starts withE.matchonly tries position 0 and does not keep looking.search, on the other hand, tries position 0, 1, 2… until it finds it at 6.'4040': the sneakiest case.matchandsearchsay yes (the first three digits match), and onlyfullmatchnotices there is a0left over.
The practical rule that comes out of this:
- To look for something inside a text, **
search** (orfindall/finditerif there are several). - To validate a format (is this a time?, is this an IP?), **
fullmatch**. Withmatch,'4040'would pass as a three-digit code. - **
matchreally only makes sense when the shape you are looking for is at the start** of each line, like the IP in a log.
Using re.match thinking it searches the whole text. With the IP at the start, re.match(r"\d{3}", line) does not find the HTTP code: it either returns None or returns the first three digits of the IP. It is the third snippet in the exercises.
Groups: cutting out the pieces you care about
So far, a match was a single block: you take it whole or not at all. Parentheses create groups, and they do two jobs: apply a quantifier to several pieces at once and, above all, keep separately the piece of text that matches each group. This is what turns a regex into a tool to cut lines apart:
import re
line = '10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129"'
pattern = r"\[(\d\d)/(\d\d)/(\d{4}) (\d\d:\d\d:\d\d)\]" # 4 parentheses = 4 groups
m = re.search(pattern, line)
print(m.group()) # group 0: everything that matched
print(m.group(1)) # each group, by its number: the day
print(m.group(4)) # the time
print(m.groups()) # all the groups in a tuple
day, month, year = m.group(1, 2, 3)
print(f"{year}-{month}-{day}") # they are ordinary strings now
print(m.span(4)) # where the time is inside the line
print(line[m.start(4):m.end(4)])
When you run it:
[12/10/2026 09:15:40]
12
09:15:40
('12', '10', '2026', '09:15:40')
2026-10-12
(22, 30)
09:15:40
The pattern has four parentheses, so there are four groups. This is how they read:
- Groups are numbered by counting opening parentheses, from left to right, starting at 1. Group 1 is the day, 2 the month, 3 the year and 4 the whole time.
- **
m.group(0)** (orm.group()with nothing) is the whole match, brackets included. - **
m.group(n)** returns the text of groupn, and with several numbers,m.group(1, 2, 3), it returns a tuple. That is why it can be unpacked intoday, month, year. - **
m.groups()** returns all the groups in a tuple, without group 0. It is the handiest option when you want to spread them into variables, as we will do in the practice. - Groups are strings, even when they are digits:
m.group(3)is'2026', not2026. If you want to do maths,int(m.group(3)). - **
m.span(4)**,m.start(4)andm.end(4)give the position of group 4 inside the whole line, not inside the match. That is whyline[22:30]is the time.
When a pattern has many parentheses, number them with a comment next to each piece (# 1: IP, # 2: date…). In the practice we will do it with an eight-group pattern, and without that help it is very easy to get a number wrong.
Optional, repeated and non-capturing groups
Three details about groups that FP1 theory asks about a lot:
import re
pattern = r"(GET|POST) (/\w+)(/\w+)?" # group 3 has ? after it: it is optional
for request in ["GET /campus", "POST /campus/login"]:
m = re.search(pattern, request)
print(m.groups()) # if a group does not take part, it is None
m = re.fullmatch(r"(\d{1,3}\.){3}(\d{1,3})", "172.16.4.150")
print(m.group(1), m.group(2)) # a repeated group only keeps its LAST round
m = re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", "172.16.4.150")
print(m.group(), m.groups()) # (?: ) groups to repeat, but captures nothing
Output:
('GET', '/campus', None)
('POST', '/campus', '/login')
4. 150
172.16.4.150 ()
(GET|POST)groups the alternative: a|inside a group only separates the options of that group. Without parentheses,GET|POST /\w+would mean “GET” or “POST /something“.- Group 3,
(/\w+)?, has a?after it: it is optional. In"GET /campus"it does not take part, and then **it isNone**. The pattern still matches; that group simply has no text. (\d{1,3}\.){3}repeats a group three times. It matches172.16.4., but group 1 only keeps the last round:'4.'. The earlier ones (172.and16.) are lost. If you need each number, write four groups or usefinditer.- **
(?:…)is a non-capturing** group: it groups so you can repeat ((?:\d{1,3}\.){3}), but it keeps nothing and does not count in the numbering. That is whygroups()returns an empty tuple.
Use (?:…) whenever the parentheses are only there to repeat or for an alternative. That way the numbers of the groups you do care about do not shift, and m.groups() does not fill up with pieces you won’t use.
Backreferences: \1 repeats what was already found
Inside the pattern itself, **\1 means “the same text** that group 1 captured” (and \2, the text of group 2, and so on). It does not repeat the group’s pattern, but the specific text that matched. It is exactly what you need to require that a date uses the same separator twice:
import re
LOOSE = r"\d\d[-/]\d\d[-/]\d{4}" # each separator can be - or /, independently
STRICT = r"\d\d([-/])\d\d\1\d{4}" # \1: the SAME text that group 1 captured
print(f"{'Date':<12}{'LOOSE':<8}STRICT")
for date in ["12/10/2026", "12-10-2026", "12/10-2026", "12-10/2026"]:
print(f"{date:<12}{bool(re.fullmatch(LOOSE, date))!s:<8}{bool(re.fullmatch(STRICT, date))}")
# Repeated words: a word, a space and the same word again
print(re.findall(r"\b(\w+) \1\b", "GET GET /login 200 200"))
When you run it:
Date LOOSE STRICT 12/10/2026 True True 12-10-2026 True True 12/10-2026 True False 12-10/2026 True False ['GET', '200']
LOOSEaccepts-or/in each slot independently, so it accepts all four dates, including12/10-2026and12-10/2026, which nobody would write on purpose.STRICTcaptures the first separator in group 1 with([-/])and then requires\1: the same character again. If the group captured a hyphen, only a hyphen will do; if it captured a slash, only a slash. Mixed dates are rejected.\b(\w+) \1\blooks for repeated words: a word in group 1, a space and the same word again. In"GET GET /login 200 200"it finds the duplicatedGETand the duplicated200. Since the pattern has a group,findallreturns the text of the group, not the whole match: afindallrule worth remembering.
Writing "\1" without the r in front. Python turns it, before it reaches re, into the character with code 1 (invisible), and the backreference disappears without any error. That is precisely the next section.
Raw strings: why every pattern carries an r
In the strings topic you saw that the backslash starts escape sequences: \n is a newline and \t a tab. The problem is that regular expressions also use the backslash (\d, \b, \1…), and **Python processes the string before re does**. If Python recognises an escape sequence, it replaces it, and re receives something else:
import re
print(len("\b"), repr("\b")) # without r: \b is ONE character, the backspace
print(len(r"\b"), repr(r"\b")) # with r: two characters, the backslash and the b
line = "GET /login 200"
print(re.search("\bGET\b", line)) # looks for backspace + GET + backspace: None
print(re.search(r"\bGET\b", line)) # looks for the word GET
print(re.search("\\bGET\\b", line)) # without r every backslash must be doubled
print(repr("\1"), repr(r"\1")) # without r, \1 is the character with code 1, not group 1
Output:
1 '\x08' 2 '\\b' None <re.Match object; span=(0, 3), match='GET'> <re.Match object; span=(0, 3), match='GET'> '\x01' '\\1'
"\b"withoutris 1 character long: Python has turned it into the backspace character (code 8, whichreprshows as'\x08'). Withr"\b", the string has both characters, the backslash and theb, which is whatreneeds to understand “word boundary”.- That is why
re.search("\bGET\b", line)returns **None**:reis looking for backspace + GET + backspace, and there is no backspace in the log. No error, just a wrong result. - Without
rit also works if you double every backslash ("\\bGET\\b"), because\\is a real backslash. It works, but it is unreadable. "\1"withoutris the character with code 1. A backreference written that way can never match.
Some backslashes are not escapes Python recognises, like \d, and in those cases the pattern works by chance (recent versions of Python warn you with a SyntaxWarning). Working sometimes is the worst thing that can happen, because you get used to leaving out the r until the day you use \b.
The rule is so simple it is not worth thinking about: **every pattern, with r in front**. r"…", always, even when it has no backslashes.
finditer, sub and subn
Three more functions of the module complete the toolbox:
- **
re.finditer(p, t)goes through every** match, likefindall, but instead of strings it gives **oneMatchper match**, with its groups and positions. - **
re.sub(p, replacement, t)returns a new string** in which every match has been replaced byreplacement. Inside the replacement,\1,\2… are the text of each group: that is how pieces get reordered. - **
re.subn(p, replacement, t)does the same and returns a tuple: the new string and the number of replacements**.
import re
line = '10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129"'
for m in re.finditer(r"\d+", line[:30]): # one Match per number, with its position
print(m.group(), m.span())
print(re.sub(r"(\d\d)/(\d\d)/(\d{4})", r"\3-\2-\1", line)) # date to year-month-day
print(re.sub(r"(\d+\.\d+\.\d+)\.\d+", r"\1.0", line)) # the last number of the IP, set to 0
new, changes = re.subn(r"\d", "#", "10.0.0.23") # subn: also counts the changes
print(new, changes)
When you run it:
10 (0, 2) 0 (3, 4) 0 (5, 6) 23 (7, 9) 12 (11, 13) 10 (14, 16) 2026 (17, 21) 09 (22, 24) 15 (25, 27) 40 (28, 30) 10.0.0.23 [2026-10-12 09:15:40] "POST /campus/login" 401 312 "Chrome/129" 10.0.0.0 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129" ##.#.#.## 6
finditerover the first 30 characters of the line gives ten numbers, each with itsspan().(0, 2)is the10at the start of the IP;(17, 21), the year. Withfindallyou would have the numbers, but you would not know where each one is.- The first
subuses three groups for the date, and the replacementr"\3-\2-\1"writes them in a different order: year, month and day, separated by hyphens. It is the ISO format, the one that sorts dates correctly when you sort text. The rest of the line is untouched, because only what matches is replaced. - The second
subanonymises the IP: it captures the first three numbers in group 1 and writes them again followed by.0. The last number disappears. subnreplaces each digit of"10.0.0.23"with#and also counts the changes: 6, one per digit.
Expecting re.sub to change the variable. Just like string methods, it returns a new string: line = re.sub(…, line), with the assignment in front.
When the pattern is badly written
If the pattern has a syntax error (an unclosed parenthesis, a quantifier with nothing in front), re raises the **re.error** exception. It can be caught like any other exception:
import re
try:
re.compile(r"(\d{3}") # the parenthesis is never closed
except re.error as e:
print("Badly written pattern:", e)
Output:
Badly written pattern: missing ), unterminated subpattern at position 0
re.compile translates the pattern into the internal structure the module uses, and that is where the fault is detected. The message says what is missing (missing )) and the position in the pattern where the problem starts: the parenthesis at position 0. You can also use re.compile to keep an already translated pattern and call its methods (pattern.search(line)), which is useful when the same pattern is used thousands of times; for FP1, the module’s functions are enough.
Watch it step by step in Python Tutor
Python Tutor runs the code line by line and draws each variable with its value. With regular expressions it is useful to see that **a Match is an object and that the groups are strings until you convert them. Choose Python 3** and paste this program:
import re
line = '10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0'
m = re.search(r'"(GET|POST) (\S+)" (\d{3})', line)
method = m.group(1)
path = m.group(2)
code = int(m.group(3))
print(method, path, code, m.span(3))
Expected output:
GET /fp1/tema9.pdf 404 (52, 55)
What to watch, step by step:
- After line 4,
mis not drawn as a number or a text: it appears as anre.Matchobject. It is the card with the match and its groups. - On line 5,
methodis"GET": group 1, the alternative(GET|POST). - On line 6,
pathis"/fp1/tema9.pdf".\S+is greedy and at first it also swallows the closing quote (a quote is not a space). Since the pattern then requires", it gives back that last character and the path ends right before the quote. - On line 7, notice the difference:
codeappears without quotes, like the number404, becauseint()converted the string from group 3. - In the output,
m.span(3)gives(52, 55): the same positions as the404in the firstre.searchexample, because the line starts the same way.
Summary
| Concept | The essentials |
|---|---|
| Regex | A string that describes a shape of text; re module |
| Classes | \d digit, \w word character, \s whitespace; in capitals, the opposite |
| Dot and escapes | . any character; \. a real dot; escape every metacharacter |
| Sets | [abc], [a-f0-9], [^…] negated; they match one character |
| Alternative | a|b; the first one that matches wins, so the long one first |
| Quantifiers | ? * + {n} {n,m} {n,}; they only affect the piece in front |
| Greediness | By default, the maximum; with ? after (.*?), the minimum; or [^"]* |
| Anchors | ^ start, $ end, \b word boundary |
| Functions | match (at the start), search (anywhere), fullmatch (everything) |
| Match | group(n), groups(), start, end, span; None if nothing is found |
| Groups | ( ) capture and are numbered by their opening parenthesis; (?: ) does not capture |
| Backreferences | \1 in the pattern: the same text as group 1; in sub, they reorder |
| More functions | findall (list), finditer (Match by Match), sub, subn |
| Raw strings | Every pattern with r"…", or \b and \1 stop working |
Three ideas to take with you:
- A pattern says exactly what it says, not what you meant.
404finds the 404 in 14040, and".*"swallows up to the last quote. Before using a pattern, test it withfindallon texts that should not match. - **To validate,
fullmatch; to search,search; to cut apart, groups.** Always check that the result is notNonebefore asking it forgroup(). - **
rin front, always.** It is the only rule in the topic with no exceptions.
In the regular expressions practice you will apply all of this to a complete log: filter the errors with an eight-group pattern, count requests per code, per hour and per IP (and catch whoever is trying to guess passwords), and convert the log to CSV with re.sub. After that, the exercises will put you to the test. If you have already seen regular expressions in Java, you will recognise almost all of the syntax: what changes from one language to another are the functions, not the patterns. And in the next topic, text files: the log will stop living inside the program and we will read it from a real file.
Did this article help you? Here is how you can support SergioLearns →

One Comment