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.

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 **Match object**: a “record card” with what it found and where. When printed, Python summarises that card: span=(52, 55) and match='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() and m.end() are the start and end positions, and m.span() gives both together. They are slice** positions, like the ones in the previous topic: the 404 takes up positions 52, 53 and 54, and end() is 55 because it is the line after the last character. That is why line[m.start():m.end()] rebuilds exactly the text found.
  • If nothing is found, re.search returns **None**. And since None counts as false in an if, the natural way to ask “is it there?” is if re.search(…):.
  • The last line introduces **re.findall**, which does not return a Match but a list with every piece that matches. There are 8 zeros in the line: three in the IP 10.0.0.7, two in the date (10 and 2026), one in the time, the one in 404 and the 0 of the size. findall is 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:

ClassMatchesIts opposite
\dA digit: 0, 1, …, 9\D: anything that is not a digit
\wA word character: letter, digit or _\W: anything that is not a word character (spaces, signs…)
\sA 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:

  • \d finds each digit separately: ten one-character strings. The pattern describes one digit, so each match is one long. Notice that the 1 in fp1 and the 8 in tema8 appear too: for \d a 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, 200 and 14040. The slash, the dot, the quotes and the spaces are not word characters, so they act as borders.
  • \s finds the three spaces.
  • \W finds exactly what \w left 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 the 1 of fp1.

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 also 10a0b0c7. 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 from a to f” (the hexadecimal digits). The output holds a surprise: besides 3fa9, 'd' appears, the d of id. For the pattern, a d is 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 only GET and POST remain.
  • The vertical bar **| is the alternative**: GET|POST matches one thing or the other. PUT is 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:

QuantifierMeansExampleMatches
?0 or 1 time (optional)https?http and https
*0 or more times10*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 the s.
  • The IP pattern repeats “1 to 3 digits” four times, separated by escaped dots. It finds 10.0.0.7 (with one-digit numbers) and 172.16.4.150 (with two- and three-digit ones), but not the web addresses.
  • \d{3} on "200 14040 87" gives ['200', '140']. After finding 140, the search carries on from where it stopped: what is left is 40 (only two digits) and 87 (another two), and neither reaches three. findall never overlaps matches.
  • \d{3,} finds numbers with 3 digits or more, so 14040 comes out whole.
  • 10* and 10+ show the difference between * and +: with *, a lone 1 (zero zeros) is enough; with +, at least one 0 is 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:

Greedy (.*) versus lazy (.*?)The text "GET /fp1/tema9.pdf" 404 0 "Firefox/131" in boxes. The pattern ".*" stretches to the last quote and covers 40 characters; ".*?" stops at the first closing quote and covers 20.Greedy (.*) versus lazy (.*?).* eats everything up to the end of the line……and gives back just enough for the quote to match"GET␣/fp1/tema9.pdf"␣404␣0␣"Firefox/131"".*" greedy: 40 characters, up to the LAST quote".*?" lazy: 20 characters, up to the FIRST quote*? moves one character at a time and stops as soon as the quote matches
Both patterns start at the same quote; the difference is where they end. *? (or [^"]*) is nearly always what you want.

Step by step, with ".*":

  1. The first " of the pattern matches the quote that opens the request.
  2. .* swallows everything left in the line, up to the end. For the dot, quotes are characters like any other.
  3. 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:

  • findall with 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:

AnchorPosition
^Start of the text (outside square brackets)
$End of the text
\A, \ZStart and end of the text, no exceptions
\bWord boundary: between a \w character and one that is not (or the start/end)
\BAny 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, 404 appears in all four texts, including bytes 14040: exactly the bug from the start.
  • ^404 only accepts texts that start with 404, and 404$ the ones that end with 404. A bare '404' meets both at once.
  • \b404\b requires a word boundary on each side: there can be no other letter or digit before the 4 or after the last 4. In 14040 the 404 is glued to a 1 in front and a 0 behind, so there is no boundary: False. In the others, the 404 is 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:

FunctionQuestionSame 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
match, search and fullmatch with the same pattern \d{3}Two texts, 'Error 404' and '4040', and the three-digit pattern. match only tries position 0: it fails with 'Error 404' and gives '404' with '4040'. search tries every position: it gives '404' in both. fullmatch requires the whole text to match: it gives None in both.match, search and fullmatch with the same pattern \d{3}'Error 404''4040're.matchdoes it start so?Error␣404None4040'404're.searchis it anywhere?Error␣404'404'4040'404're.fullmatchis it all so?Error␣404None4040None
The three functions use the same pattern; what changes is WHERE they let it match. To validate a format, fullmatch.

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': match and search find it, but fullmatch does not: after the three digits, " Not Found" is left over.
  • 'Error 404': match fails because the text starts with E. match only 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. match and search say yes (the first three digits match), and only fullmatch notices there is a 0 left over.

The practical rule that comes out of this:

  • To look for something inside a text, **search** (or findall/finditer if there are several).
  • To validate a format (is this a time?, is this an IP?), **fullmatch**. With match, '4040' would pass as a three-digit code.
  • **match really 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:

A pattern is read piece by pieceThe pattern \[(\d\d)/(\d\d)/(\d{4}) (\d\d:\d\d:\d\d)\] split into pieces: the escapes \[ and \] match the brackets, four groups in parentheses numbered 1 to 4 match 12, 10, 2026 and 09:15:40, and the slashes and the space match as they are.A pattern is read piece by piecepattern = r"\[(\d\d)/(\d\d)/(\d{4}) (\d\d:\d\d:\d\d)\]"\[(\d\d)/(\d\d)/(\d{4})␣(\d\d:\d\d:\d\d)\][1group12/2group10/3group2026␣4group09:15:40]escape: the character itselfgroup: kept and numberedliteralm.group(0) → '[12/10/2026 09:15:40]'m.group(3) → '2026'
Each parenthesis opens a group, numbered from left to right; whatever it matches is retrieved with m.group(n).
  • 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)** (or m.group() with nothing) is the whole match, brackets included.
  • **m.group(n)** returns the text of group n, and with several numbers, m.group(1, 2, 3), it returns a tuple. That is why it can be unpacked into day, 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', not 2026. If you want to do maths, int(m.group(3)).
  • **m.span(4)**, m.start(4) and m.end(4) give the position of group 4 inside the whole line, not inside the match. That is why line[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 is None**. The pattern still matches; that group simply has no text.
  • (\d{1,3}\.){3} repeats a group three times. It matches 172.16.4., but group 1 only keeps the last round: '4.'. The earlier ones (172. and 16.) are lost. If you need each number, write four groups or use finditer.
  • **(?:…) 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 why groups() 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']
  • LOOSE accepts - or / in each slot independently, so it accepts all four dates, including 12/10-2026 and 12-10/2026, which nobody would write on purpose.
  • STRICT captures 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\b looks for repeated words: a word in group 1, a space and the same word again. In "GET GET /login 200 200" it finds the duplicated GET and the duplicated 200. Since the pattern has a group, findall returns the text of the group, not the whole match: a findall rule 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" without r is 1 character long: Python has turned it into the backspace character (code 8, which repr shows as '\x08'). With r"\b", the string has both characters, the backslash and the b, which is what re needs to understand “word boundary”.
  • That is why re.search("\bGET\b", line) returns **None**: re is looking for backspace + GET + backspace, and there is no backspace in the log. No error, just a wrong result.
  • Without r it also works if you double every backslash ("\\bGET\\b"), because \\ is a real backslash. It works, but it is unreadable.
  • "\1" without r is 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, like findall, but instead of strings it gives **one Match per match**, with its groups and positions.
  • **re.sub(p, replacement, t) returns a new string** in which every match has been replaced by replacement. 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
  • finditer over the first 30 characters of the line gives ten numbers, each with its span(). (0, 2) is the 10 at the start of the IP; (17, 21), the year. With findall you would have the numbers, but you would not know where each one is.
  • The first sub uses three groups for the date, and the replacement r"\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 sub anonymises the IP: it captures the first three numbers in group 1 and writes them again followed by .0. The last number disappears.
  • subn replaces 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:

  1. After line 4, m is not drawn as a number or a text: it appears as an re.Match object. It is the card with the match and its groups.
  2. On line 5, method is "GET": group 1, the alternative (GET|POST).
  3. On line 6, path is "/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.
  4. On line 7, notice the difference: code appears without quotes, like the number 404, because int() converted the string from group 3.
  5. In the output, m.span(3) gives (52, 55): the same positions as the 404 in the first re.search example, because the line starts the same way.

Summary

ConceptThe essentials
RegexA 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
Alternativea|b; the first one that matches wins, so the long one first
Quantifiers? * + {n} {n,m} {n,}; they only affect the piece in front
GreedinessBy default, the maximum; with ? after (.*?), the minimum; or [^"]*
Anchors^ start, $ end, \b word boundary
Functionsmatch (at the start), search (anywhere), fullmatch (everything)
Matchgroup(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 functionsfindall (list), finditer (Match by Match), sub, subn
Raw stringsEvery pattern with r"…", or \b and \1 stop working

Three ideas to take with you:

  1. A pattern says exactly what it says, not what you meant. 404 finds the 404 in 14040, and ".*" swallows up to the last quote. Before using a pattern, test it with findall on texts that should not match.
  2. **To validate, fullmatch; to search, search; to cut apart, groups.** Always check that the result is not None before asking it for group().
  3. **r in 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.

Next step: practicePython 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)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *