Expresiones regulares en Python — teoría con los logs de un servidor, grupos que trocean cada línea y patrones avariciosos (y el 404 que no era un 404)

Para una práctica de redes nos dieron el log de acceso de un servidor web: cientos de líneas, una por petición, con la IP, la fecha, la ruta pedida y el código HTTP de la respuesta. La primera tarea era sencilla: «saca las peticiones que dieron 404», es decir, las de páginas que no existían. Con lo que sabía de strings, lo tuve claro: if "404" in linea. Lo probé con dos líneas:

import re

lineas = [
    '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 linea in lineas:
    print("¿Contiene '404'?", "404" in linea)   # búsqueda exacta: ¿aparece ese texto?
    m = re.search(r'" (\d{3}) ', linea)         # búsqueda por patrón: comilla, espacio, 3 cifras, espacio
    print("Código HTTP:", m.group(1))

Al ejecutarlo:

¿Contiene '404'? True
Código HTTP: 404
¿Contiene '404'? True
Código HTTP: 200

Las dos líneas dicen que contienen '404', pero la segunda es un 200: una petición que fue perfectamente. El 404 estaba escondido dentro del tamaño de la respuesta, 14040 bytes. in busca un texto exacto y lo encuentra en cualquier sitio, también donde no debe. Lo que yo quería decir era otra cosa: «tres cifras que van justo después de las comillas y un espacio». Eso no es un texto: es un patrón. Y para escribir patrones existen las expresiones regulares, que es justo lo que usa la segunda parte del programa.

Hoy aprendemos a leer y escribir patrones (caracteres, clases, conjuntos, cuantificadores y anclas), la diferencia entre **match, search y fullmatch, cómo los grupos recortan los trozos que te interesan y cómo las referencias** (\1) obligan a repetir lo ya encontrado. También veremos por qué .* es avaricioso y por qué todos los patrones se escriben con una **r delante. El ejemplo que nos acompaña es el log de un servidor**: líneas con IPs, fechas, horas, rutas y códigos, que es exactamente el tipo de texto para el que se inventaron las expresiones regulares. Si vienes de strings en Python, vas a reconocer los índices y los slices en cuanto aparezca la palabra span. Cuando acabes, la práctica de expresiones regulares te espera con tres programas completos sobre un log entero.

¿Qué es una expresión regular?

Una expresión regular (o regex) es un string que describe una forma de texto: «tres cifras», «una fecha dd/mm/aaaa», «algo entre comillas». En lugar de preguntar «¿está este texto?», preguntas «¿hay algo con esta forma?». En Python se usan con el módulo **re**, que viene con el lenguaje: basta con import re.

El caso más sencillo es un patrón formado solo por caracteres normales, que se buscan tal cual. Para eso sirve **re.search(patron, texto)**:

import re

linea = '10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131"'

m = re.search(r"404", linea)     # el patrón más simple: caracteres normales, buscados tal cual
print(m)                         # un objeto Match: qué ha encontrado y dónde
print(m.group())                 # el texto encontrado
print(m.start(), m.end())        # empieza en la 52 y acaba ANTES de la 55
print(m.span())                  # las dos posiciones juntas, en una tupla
print(linea[m.start():m.end()])  # son posiciones de slice, como en el tema 7

print(re.search(r"500", linea))  # si no lo encuentra, devuelve None
if re.search(r"Firefox", linea): # None cuenta como falso: así se pregunta "¿está?"
    print("Visita desde Firefox")
print(re.findall(r"0", linea))   # findall: una LISTA con todo lo que encaja

Esto es lo que imprime:

<re.Match object; span=(52, 55), match='404'>
404
52 55
(52, 55)
404
None
Visita desde Firefox
['0', '0', '0', '0', '0', '0', '0', '0']

Lo que devuelve re.search merece calma:

  • Si encuentra el patrón, devuelve un **objeto Match**: una «ficha» con lo que ha encontrado y dónde. Al imprimirlo, Python resume esa ficha: span=(52, 55) y match='404'.
  • **m.group()** devuelve el texto encontrado. Aquí es '404', igual que el patrón, porque el patrón no tenía nada especial. En cuanto el patrón tenga comodines, group() será la única forma de saber qué encajó exactamente.
  • **m.start() y m.end() son las posiciones de inicio y de fin, y m.span() las da juntas. Son posiciones de slice**, como las del tema anterior: el 404 ocupa las posiciones 52, 53 y 54, y end() vale 55 porque es la raya después del último carácter. Por eso linea[m.start():m.end()] reconstruye exactamente el texto encontrado.
  • Si no encuentra nada, re.search devuelve **None**. Y como None cuenta como falso en un if, la forma natural de preguntar «¿está?» es if re.search(…):.
  • La última línea presenta a **re.findall**, que no devuelve un Match sino una lista con todos los trozos que encajan. Hay 8 ceros en la línea: tres en la IP 10.0.0.7, dos en la fecha (10 y 2026), uno en la hora, el del 404 y el 0 del tamaño. findall es la herramienta más cómoda para ver qué hace un patrón, y la vamos a usar mucho en este artículo.

¿por qué los patrones llevan una r delante, como r"404"? Es el raw string que viste en el tema de strings: desactiva las secuencias de escape de Python. Con "404" no hace falta, pero en cuanto aparezca una barra (\d, \b…) sí, y por eso se escribe siempre. Tiene su propio apartado más abajo, con el fallo que provoca olvidarla.

El error más frecuente: usar el resultado sin comprobarlo

Como re.search puede devolver None, llamar a .group() sin mirar antes es la forma más rápida de romper un programa:

import re

linea = '10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"'
m = re.search(r"404", linea)   # en esta línea no hay ningún 404
print("Buscando 404...")
print(m.group())               # m vale None, y None no tiene group()

Salida:

Buscando 404...
Traceback (most recent call last):
  File "error_none.py", line 6, in <module>
    print(m.group())               # m vale None, y None no tiene group()
          ^^^^^^^
AttributeError: 'NoneType' object has no attribute 'group'

'NoneType' object has no attribute 'group' se traduce como *»has pedido group() a algo que es None«*. El fallo no está en la línea que marca Python, sino en que el patrón no encontró nada y nadie lo comprobó.

Encadenar re.search(…).group() en una sola línea. Funciona mientras el patrón encuentre algo, y revienta con el primer texto que no encaje. Guarda el resultado en una variable y comprueba if m: antes de usarlo.

Clases de caracteres: \d, \w, \s y sus contrarias

Los patrones se vuelven útiles cuando dejan de describir caracteres concretos y empiezan a describir tipos de caracteres. Para los más habituales hay abreviaturas, que empiezan por la barra invertida:

ClaseEncaja conSu contraria
\dUn dígito: 0, 1, …, 9\D: cualquier cosa que no sea un dígito
\wUn carácter de palabra: letra, dígito o _\W: lo que no es de palabra (espacios, signos…)
\sUn espacio en blanco: espacio, tabulador, salto de línea\S: cualquier cosa que no sea un espacio en blanco

La regla para recordar las contrarias es sencilla: la misma letra en mayúscula significa «lo contrario». Probémoslas con un trozo de una línea del log:

import re

peticion = '"GET /fp1/tema8.pdf" 200 14040'

print(re.findall(r"\d", peticion))    # \d: un dígito, cada uno por separado
print(re.findall(r"\d+", peticion))   # \d+: uno o más dígitos seguidos (+ se explica luego)
print(re.findall(r"\w+", peticion))   # \w: letra, dígito o guion bajo
print(re.findall(r"\s", peticion))    # \s: espacio, tabulador o salto de línea
print(re.findall(r"\W", peticion))    # en mayúscula, lo contrario: lo que NO es \w
print(re.findall(r"\D+", peticion))   # trozos sin ningún dígito

Al ejecutarlo:

['1', '8', '2', '0', '0', '1', '4', '0', '4', '0']
['1', '8', '200', '14040']
['GET', 'fp1', 'tema8', 'pdf', '200', '14040']
[' ', ' ', ' ']
['"', ' ', '/', '/', '.', '"', ' ', ' ']
['"GET /fp', '/tema', '.pdf" ', ' ']

Línea a línea:

  • \d encuentra cada dígito por separado: diez listas de un carácter. El patrón describe un dígito, así que cada coincidencia mide uno. Fíjate en que también aparecen el 1 de fp1 y el 8 de tema8: para \d un dígito es un dígito, esté donde esté.
  • \d+ encuentra grupos de dígitos seguidos. El + significa «una o más veces» y lo veremos enseguida; de momento quédate con que convierte «un dígito» en «un número entero de cualquier longitud».
  • \w+ encuentra las «palabras»: GET, fp1, tema8, pdf, 200 y 14040. La barra, el punto, las comillas y los espacios no son caracteres de palabra, así que hacen de frontera.
  • \s encuentra los tres espacios.
  • \W encuentra justo lo que \w dejaba fuera: comillas, barras, el punto y los espacios.
  • \D+ encuentra los trozos sin ningún dígito. Mira el primero, '"GET /fp': se corta justo antes del 1 de fp1.

Cuando no tengas claro qué hace un patrón, pásalo por re.findall con un texto de prueba y míralo. Es el equivalente regex de imprimir una variable para ver qué vale.

Caracteres especiales, escapes, conjuntos y alternativas

Algunos caracteres no se buscan tal cual: tienen un significado especial dentro de un patrón. Se llaman metacaracteres y son estos:

.  ^  $  *  +  ?  {  }  [  ]  \  |  (  )

Si quieres buscar uno de ellos como carácter normal, hay que escaparlo con una barra invertida delante: \. es un punto de verdad, \[ un corchete, \( un paréntesis. El más traicionero es el punto, porque sin escapar encaja con cualquier carácter (menos el salto de línea) y casi nunca da error:

import re

texto = "10.0.0.7 10a0b0c7 172.16.4.150"

print(re.findall(r"10.0.0.7", texto))           # el punto suelto es "cualquier carácter"
print(re.findall(r"10\.0\.0\.7", texto))        # \. es un punto de verdad
print(re.findall(r"[aeiou]", "Safari Firefox")) # [ ]: UNO de los caracteres de dentro
print(re.findall(r"[0-9a-f]+", "id=3fa9 x=zz")) # rangos: de 0 a 9 y de a a f
print(re.findall(r"[^0-9 ]+", "GET 200 POST 401"))   # ^ al principio de [ ]: cualquiera MENOS esos
print(re.findall(r"GET|POST", "GET /a POST /b PUT /c"))   # |: una alternativa u otra
print(re.search(r"\.htm|\.html", "index.html").group())   # gana la PRIMERA que encaja
print(re.search(r"\.html|\.htm", "index.html").group())   # la larga primero

Salida:

['10.0.0.7', '10a0b0c7']
['10.0.0.7']
['a', 'a', 'i', 'i', 'e', 'o']
['d', '3fa9']
['GET', 'POST']
['GET', 'POST']
.htm
.html

Vamos por partes:

  • r"10.0.0.7" encuentra la IP… y también 10a0b0c7. Cada punto del patrón ha aceptado una letra. Con un patrón así, un validador de IPs daría por buena cualquier cosa.
  • r"10\.0\.0\.7", con los puntos escapados, solo encuentra la IP de verdad.
  • Los corchetes forman un conjunto: [aeiou] encaja con un solo carácter, que tiene que ser uno de los de dentro. En "Safari Firefox" encuentra las seis vocales una a una.
  • Dentro de un conjunto se pueden poner rangos con guion: [0-9a-f] es «una cifra o una letra de la a a la f» (las cifras hexadecimales). La salida tiene una sorpresa: además de 3fa9 aparece 'd', la d de id. Para el patrón, una d es una cifra hexadecimal válida, aunque tú no la estuvieras buscando.
  • Un **^ justo después del corchete** niega el conjunto: [^0-9 ]+ es «uno o más caracteres que no sean cifras ni espacios». Por eso solo quedan GET y POST.
  • La barra vertical **| es la alternativa**: GET|POST encaja con una cosa o con la otra. PUT no está entre las opciones, así que no aparece.

Las dos últimas líneas esconden una regla importante: la alternativa se prueba de izquierda a derecha y gana la primera que encaja, no la más larga. Con \.htm|\.html, en "index.html" la primera opción ya encaja con .htm, y Python no sigue mirando: la l se queda fuera. Si pones la opción larga delante, \.html|\.htm, sale el .html completo.

Olvidar escapar el punto en IPs, nombres de archivo o dominios (ulpgc.es). El patrón «funciona» con tus pruebas, porque los puntos encajan con puntos, y también deja pasar textos que no deberían. Cada punto literal, \..

Dentro de unos corchetes, casi todos los metacaracteres pierden su poder: [.] es un punto normal y [*+?] son esos tres signos. Las excepciones son el ^ al principio, el guion entre dos caracteres y la propia barra invertida.

Cuantificadores: cuántas veces

Hasta ahora cada pieza del patrón encajaba con un carácter. Los cuantificadores van detrás de una pieza e indican cuántas veces puede repetirse:

CuantificadorSignificaEjemploEncaja con
?0 o 1 vez (opcional)https?http y https
*0 o más veces10*1, 10, 100…
+1 o más veces\d+7, 404, 14040…
{n}Exactamente n veces\d{4}2026
{n,m}Entre n y m veces\d{1,3}7, 16, 150
{n,}n o más veces\d{3,}200, 14040…

Un detalle que se olvida mucho: el cuantificador afecta solo a la pieza que tiene justo delante. En https?, la ? hace opcional la s, no la palabra entera.

import re

texto = "http://campus.ulpgc.es https://www.ulpgc.es 10.0.0.7 172.16.4.150"

print(re.findall(r"https?://", texto))                         # ?: la s, 0 o 1 veces
print(re.findall(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", texto)) # {1,3}: entre 1 y 3 cifras
print(re.findall(r"\d{3}", "200 14040 87"))                    # {3}: exactamente 3 (y sigue desde ahí)
print(re.findall(r"\d{3,}", "200 14040 87"))                   # {3,}: 3 o más
print(re.findall(r"10*", "1 10 100 1000"))                     # *: el 1 y detrás 0 o más ceros
print(re.findall(r"10+", "1 10 100 1000"))                     # +: el 1 y detrás 1 o más ceros

Al ejecutarlo:

['http://', 'https://']
['10.0.0.7', '172.16.4.150']
['200', '140']
['200', '14040']
['1', '10', '100', '1000']
['10', '100', '1000']
  • https?:// encuentra las dos direcciones, con y sin s.
  • El patrón de IP repite cuatro veces «de 1 a 3 cifras» separadas por puntos escapados. Encuentra 10.0.0.7 (con números de una cifra) y 172.16.4.150 (con números de dos y de tres), pero no las direcciones web.
  • \d{3} en "200 14040 87" da ['200', '140']. Después de encontrar 140, la búsqueda continúa desde donde terminó: quedan 40 (solo dos cifras) y 87 (otras dos), y ninguno llega a tres. findall no solapa coincidencias.
  • \d{3,} encuentra los números de 3 cifras o más, así que 14040 sale entero.
  • 10* y 10+ muestran la diferencia entre * y +: con *, el 1 solo (cero ceros) ya vale; con +, hace falta al menos un 0 detrás.

Si dudas entre * y +, pregúntate si «ninguna vez» es aceptable. \d* encaja incluso con un texto vacío (cero dígitos), y eso provoca coincidencias vacías muy desconcertantes. Para «un número», casi siempre \d+.

Avaricioso o perezoso: hasta dónde llega .*

Los cuantificadores tienen una personalidad que conviene conocer: son avariciosos (greedy). Intentan encajar tantas repeticiones como puedan, y solo ceden caracteres si el resto del patrón no encaja. Con .*, «cualquier cosa, todas las veces que se pueda», eso significa comerse la línea entera. Es la tercera trampa clásica del tema:

import re

linea = '10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131"'

print(re.search(r'".*"', linea).group())    # avaricioso: se estira hasta la ÚLTIMA comilla
print(re.search(r'".*?"', linea).group())   # perezoso (*?): se para en la PRIMERA que sirve
print(re.findall(r'".*?"', linea))          # todos los trozos entre comillas
print(re.findall(r'"[^"]*"', linea))        # lo mismo, sin perezosos: "nada que sea una comilla"

html = "<b>404</b> Not Found"
print(re.search(r"<.*>", html).group())     # de la primera < a la última >
print(re.search(r"<.*?>", html).group())    # solo la primera etiqueta
print(re.search(r"\d+?", "14040").group())  # +?: lo mínimo, un solo dígito

Salida:

"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

La primera línea quería «lo que hay entre comillas» y se ha llevado desde la primera comilla hasta la última, con el código, el tamaño y el navegador dentro. Este dibujo explica qué ha hecho el motor de búsqueda:

Avaricioso (.*) contra perezoso (.*?)El texto "GET /fp1/tema9.pdf" 404 0 "Firefox/131" en casillas. El patrón ".*" se estira hasta la última comilla y abarca 40 caracteres; ".*?" se para en la primera comilla de cierre y abarca 20.Avaricioso (.*) contra perezoso (.*?).* se come todo hasta el final de la línea……y devuelve lo justo para que la comilla encaje"GET␣/fp1/tema9.pdf"␣404␣0␣"Firefox/131"".*" avaricioso: 40 caracteres, hasta la ÚLTIMA comilla".*?" perezoso: 20 caracteres, hasta la PRIMERA comilla*? avanza de uno en uno y para en cuanto la comilla encaja
Los dos patrones empiezan en la misma comilla; la diferencia es dónde terminan. *? (o [^"]*) es casi siempre lo que quieres.

Paso a paso, con ".*":

  1. La primera " del patrón encaja con la comilla que abre la petición.
  2. .* se come todo lo que queda de la línea, hasta el final. Para el punto, las comillas son caracteres como cualquier otro.
  3. Ahora el patrón necesita una " y ya no queda texto. El motor retrocede (backtracking): devuelve un carácter, el último de la línea. Es una comilla, así que encaja, y la búsqueda termina con 40 caracteres.

Si añades una **? detrás del cuantificador** (*?, +?, ??, {n,m}?), se vuelve perezoso (lazy): encaja lo mínimo posible y solo avanza si el resto del patrón no encaja. ".*?" empieza sin comer nada, prueba si viene la comilla de cierre, y si no, avanza un carácter y vuelve a probar. Se para en la primera comilla que sirve: 20 caracteres, solo la petición.

El resto de la salida lo confirma:

  • findall con el perezoso encuentra los dos trozos entre comillas por separado.
  • "[^"]*" da exactamente lo mismo sin cuantificadores perezosos: «una comilla, cualquier cantidad de caracteres que no sean comillas, y otra comilla». Como el conjunto no puede saltarse una comilla, no hay forma de que se pase.
  • Con HTML pasa igual: <.*> va de la primera < a la última > y se lleva <b>404</b> entero; <.*?> se queda en la primera etiqueta, <b>.
  • \d+? en "14040" encaja con un solo dígito: el perezoso se conforma con el mínimo, que para + es una vez.

Usar .* para «lo que haya entre dos marcas» cuando la marca de cierre puede repetirse en la línea. Funciona en las pruebas con una sola pareja de comillas y falla con la primera línea real. Usa .*? o, mejor todavía, un conjunto negado como [^"]*, que además deja claro qué no puede aparecer dentro.

El avaricioso no «se equivoca»: hace exactamente lo que dice el patrón. ".*" significa «una comilla, lo que sea, una comilla», y la línea entera desde la primera comilla cumple esa descripción. El problema es que el patrón no dice lo que tú querías decir.

Anclas y fronteras: dónde puede estar

Las anclas no encajan con ningún carácter: encajan con una posición. Sirven para decir dónde tiene que estar el patrón:

AnclaPosición
^Principio del texto (fuera de unos corchetes)
$Final del texto
\A, \ZPrincipio y final del texto, sin excepciones
\bFrontera de palabra: entre un carácter de \w y uno que no lo es (o el principio/final)
\BCualquier posición que no sea una frontera de palabra

Esta es la herramienta que le faltaba a mi "404" in linea. Comparemos el mismo 404 con cada ancla:

import re

PATRONES = [
    r"404",       # en cualquier sitio
    r"^404",      # ^: solo al principio
    r"404$",      # $: solo al final
    r"\b404\b",   # \b: frontera de palabra a los dos lados
]

print(f"{'Texto':<16}" + "".join(f"{p:<9}" for p in PATRONES))
for t in ["404 Not Found", "bytes 14040", "error 404", "404"]:
    fila = f"{t!r:<16}"
    for p in PATRONES:
        fila += f"{str(bool(re.search(p, t))):<9}"
    print(fila)

Al ejecutarlo:

Texto           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     
  • Sin anclas, 404 aparece en los cuatro textos, también en bytes 14040: es exactamente el fallo del principio.
  • ^404 solo acepta los textos que empiezan por 404, y 404$ los que terminan en 404. '404' a secas cumple las dos cosas a la vez.
  • \b404\b exige una frontera de palabra a cada lado: antes del 4 y después del último 4 no puede haber otra letra o cifra. En 14040 el 404 está pegado a un 1 por delante y a un 0 por detrás, así que no hay frontera: False. En los demás, el 404 está rodeado de espacios o de los extremos del texto: True.

\b…\b es la forma más corta de decir «como palabra suelta». Sirve para números (\b500\b), palabras (\bERROR\b) o códigos. Es la diferencia entre encontrar el código 500 y encontrar el 500 de «15000 bytes».

En la tabla de anclas, \A y \Z parecen iguales a ^ y $. Lo son mientras no uses la opción re.MULTILINE, que hace que ^ y $ funcionen en cada línea de un texto con varias. En FP1 no la necesitamos, pero si la ves en algún código, ya sabes por qué existe.

match, search y fullmatch

Con re.search hemos buscado en cualquier sitio. El módulo re tiene otras dos funciones con la misma forma, función(patron, texto), que solo cambian dónde dejan encajar al patrón:

FunciónPreguntaEquivale a
re.match(p, t)¿El texto empieza con el patrón?re.search("^" + p, t)
re.search(p, t)¿Está el patrón en algún sitio?
re.fullmatch(p, t)¿El texto entero tiene la forma del patrón?re.search("^" + p + "$", t)

Las tres devuelven un Match o None. Veámoslas con el mismo patrón de tres cifras:

import re

CODIGO = r"\d{3}"


def ver(m):
    """El texto encontrado, o None si no se encontró nada."""
    return m.group() if m else "None"


print(f"{'Texto':<17}{'match':<8}{'search':<8}fullmatch")
for t in ["404", "404 Not Found", "Error 404", "4040"]:
    print(f"{t!r:<17}"
          f"{ver(re.match(CODIGO, t)):<8}"     # ¿EMPIEZA por el patrón?
          f"{ver(re.search(CODIGO, t)):<8}"    # ¿está en ALGÚN sitio?
          f"{ver(re.fullmatch(CODIGO, t))}")   # ¿es TODO el texto, de principio a fin?

Esto es lo que imprime:

Texto            match   search  fullmatch
'404'            404     404     404
'404 Not Found'  404     404     None
'Error 404'      None    404     None
'4040'           404     404     None
match, search y fullmatch con el mismo patrón \d{3}Dos textos, 'Error 404' y '4040', y el patrón de tres cifras. match solo prueba en la posición 0: falla con 'Error 404' y da '404' con '4040'. search prueba en todas las posiciones: da '404' en los dos. fullmatch exige que encaje el texto entero: da None en los dos.match, search y fullmatch con el mismo patrón \d{3}'Error 404''4040're.match¿empieza así?Error␣404None4040'404're.search¿está en algún sitio?Error␣404'404'4040'404're.fullmatch¿es todo así?Error␣404None4040None
Las tres funciones usan el mismo patrón; lo que cambia es DÓNDE le dejan encajar. Para validar un formato, fullmatch.

Fila a fila:

  • '404': las tres lo encuentran. Empieza por tres cifras, las contiene y es exactamente tres cifras.
  • '404 Not Found': match y search lo encuentran, pero fullmatch no: después de las tres cifras sobra " Not Found".
  • 'Error 404': match falla porque el texto empieza por E. match solo prueba en la posición 0 y no sigue buscando. search, en cambio, prueba en la 0, en la 1, en la 2… hasta encontrarlo en la 6.
  • '4040': el caso más traicionero. match y search dicen que sí (las tres primeras cifras encajan), y solo fullmatch detecta que sobra un 0.

La regla práctica que sale de aquí:

  • Para buscar algo dentro de un texto, **search** (o findall/finditer si hay varios).
  • Para validar un formato (¿esto es una hora?, ¿esto es una IP?), **fullmatch**. Con match, '4040' pasaría por un código de tres cifras.
  • **match casi solo tiene sentido cuando el formato de lo que buscas está al principio** de cada línea, como la IP en un log.

Usar re.match creyendo que busca en todo el texto. Con la IP al principio, re.match(r"\d{3}", linea) no encuentra el código HTTP: o devuelve None, o devuelve los tres primeros números de la IP. Es el tercer fragmento de los ejercicios.

Grupos: recortar los trozos que te interesan

Hasta aquí, una coincidencia era un bloque: lo tomas entero o nada. Los paréntesis forman grupos, y sirven para dos cosas: aplicar un cuantificador a varias piezas a la vez y, sobre todo, guardar por separado el trozo de texto que encaja con cada grupo. Esto es lo que convierte una regex en una herramienta para trocear líneas:

import re

linea = '10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129"'
patron = r"\[(\d\d)/(\d\d)/(\d{4}) (\d\d:\d\d:\d\d)\]"   # 4 paréntesis = 4 grupos

m = re.search(patron, linea)
print(m.group())                 # grupo 0: todo lo que ha encajado
print(m.group(1))                # cada grupo, por su número: el día
print(m.group(4))                # la hora
print(m.groups())                # todos los grupos en una tupla
dia, mes, anio = m.group(1, 2, 3)
print(f"{anio}-{mes}-{dia}")     # ya son strings normales
print(m.span(4))                 # dónde está la hora dentro de la línea
print(linea[m.start(4):m.end(4)])

Al ejecutarlo:

[12/10/2026 09:15:40]
12
09:15:40
('12', '10', '2026', '09:15:40')
2026-10-12
(22, 30)
09:15:40

El patrón tiene cuatro paréntesis, así que hay cuatro grupos. Así se leen:

Un patrón se lee pieza a piezaEl patrón \[(\d\d)/(\d\d)/(\d{4}) (\d\d:\d\d:\d\d)\] dividido en piezas: los escapes \[ y \] encajan con los corchetes, cuatro grupos entre paréntesis numerados del 1 al 4 encajan con 12, 10, 2026 y 09:15:40, y las barras y el espacio encajan tal cual.Un patrón se lee pieza a piezapatron = 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)\][1grupo12/2grupo10/3grupo2026␣4grupo09:15:40]escape: el carácter tal cualgrupo: se guarda y se numeraliteralm.group(0) → '[12/10/2026 09:15:40]'m.group(3) → '2026'
Cada paréntesis abre un grupo, numerado de izquierda a derecha; lo que encaja con él se recupera con m.group(n).
  • Los grupos se numeran contando los paréntesis que abren, de izquierda a derecha, empezando en 1. El grupo 1 es el día, el 2 el mes, el 3 el año y el 4 la hora entera.
  • **m.group(0)** (o m.group() sin nada) es toda la coincidencia, con los corchetes incluidos.
  • **m.group(n)** devuelve el texto del grupo n, y con varios números, m.group(1, 2, 3), devuelve una tupla. Por eso se puede desempaquetar en dia, mes, anio.
  • **m.groups()** devuelve todos los grupos en una tupla, sin el 0. Es lo más cómodo cuando quieres repartirlos en variables, como haremos en la práctica.
  • Los grupos son strings, aunque sean cifras: m.group(3) es '2026', no 2026. Si quieres operar, int(m.group(3)).
  • **m.span(4)**, m.start(4) y m.end(4) dan la posición del grupo 4 dentro de la línea completa, no dentro de la coincidencia. Por eso linea[22:30] es la hora.

Cuando un patrón tenga muchos paréntesis, numéralos con un comentario al lado de cada trozo (# 1: IP, # 2: fecha…). En la práctica lo haremos con un patrón de ocho grupos, y sin esa ayuda es muy fácil equivocarse de número.

Grupos opcionales, repetidos y sin captura

Tres detalles de los grupos que la teoría de FP1 pregunta mucho:

import re

patron = r"(GET|POST) (/\w+)(/\w+)?"   # el grupo 3 lleva ? detrás: es opcional
for peticion in ["GET /campus", "POST /campus/login"]:
    m = re.search(patron, peticion)
    print(m.groups())                  # si un grupo no participa, vale None

m = re.fullmatch(r"(\d{1,3}\.){3}(\d{1,3})", "172.16.4.150")
print(m.group(1), m.group(2))          # un grupo repetido guarda solo su ÚLTIMA vuelta

m = re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", "172.16.4.150")
print(m.group(), m.groups())           # (?: ) agrupa para repetir, pero no captura nada

Salida:

('GET', '/campus', None)
('POST', '/campus', '/login')
4. 150
172.16.4.150 ()
  • (GET|POST) agrupa la alternativa: el | dentro de un grupo solo separa las opciones de ese grupo. Sin paréntesis, GET|POST /\w+ significaría «GET» o «POST /algo«.
  • El grupo 3, (/\w+)?, lleva una ? detrás: es opcional. En "GET /campus" no participa, y entonces **vale None**. El patrón sigue encajando; simplemente, ese grupo no tiene texto.
  • (\d{1,3}\.){3} repite un grupo tres veces. Encaja con 172.16.4., pero el grupo 1 solo guarda la última vuelta: '4.'. Las anteriores (172. y 16.) se pierden. Si necesitas cada número, escribe cuatro grupos o usa finditer.
  • **(?:…) es un grupo sin captura**: agrupa para poder repetir ((?:\d{1,3}\.){3}), pero no guarda nada ni cuenta en la numeración. Por eso groups() devuelve una tupla vacía.

Usa (?:…) siempre que los paréntesis sean solo para repetir o para la alternativa. Así los números de los grupos que sí te interesan no se desplazan, y el m.groups() no se llena de trozos que no vas a usar.

Referencias: \1 repite lo ya encontrado

Dentro del propio patrón, **\1 significa «el mismo texto** que capturó el grupo 1″ (y \2, el del grupo 2, etc.). No repite el patrón del grupo, sino el texto concreto que encajó. Es lo que se necesita para exigir que una fecha use el mismo separador dos veces:

import re

SIN = r"\d\d[-/]\d\d[-/]\d{4}"     # cada separador puede ser - o /, por libre
CON = r"\d\d([-/])\d\d\1\d{4}"     # \1: el MISMO texto que capturó el grupo 1

print(f"{'Fecha':<12}{'SIN':<8}CON")
for fecha in ["12/10/2026", "12-10-2026", "12/10-2026", "12-10/2026"]:
    print(f"{fecha:<12}{bool(re.fullmatch(SIN, fecha))!s:<8}{bool(re.fullmatch(CON, fecha))}")

# Palabras repetidas: una palabra, un espacio y otra vez la misma
print(re.findall(r"\b(\w+) \1\b", "GET GET /login 200 200"))

Al ejecutarlo:

Fecha       SIN     CON
12/10/2026  True    True
12-10-2026  True    True
12/10-2026  True    False
12-10/2026  True    False
['GET', '200']
  • SIN acepta - o / en cada hueco por libre, así que da por buenas las cuatro fechas, incluidas 12/10-2026 y 12-10/2026, que nadie escribiría a propósito.
  • CON captura el primer separador en el grupo 1 con ([-/]) y luego exige \1: el mismo carácter otra vez. Si el grupo capturó un guion, solo vale un guion; si capturó una barra, solo una barra. Las fechas mezcladas se rechazan.
  • \b(\w+) \1\b busca palabras repetidas: una palabra en el grupo 1, un espacio y la misma palabra otra vez. En "GET GET /login 200 200" encuentra el GET duplicado y el 200 duplicado. Como el patrón tiene un grupo, findall devuelve el texto del grupo, no la coincidencia entera: es una regla de findall que conviene recordar.

Escribir "\1" sin la r delante. Python lo convierte, antes de que llegue a re, en el carácter de código 1 (invisible), y la referencia desaparece sin dar ningún error. Es justo el siguiente apartado.

Raw strings: por qué todos los patrones llevan una r

En el tema de strings viste que la barra invertida empieza las secuencias de escape: \n es un salto de línea y \t un tabulador. El problema es que las expresiones regulares también usan la barra (\d, \b, \1…), y **Python procesa el string antes que re**. Si Python reconoce una secuencia de escape, la sustituye, y re recibe otra cosa:

import re

print(len("\b"), repr("\b"))           # sin r: \b es UN carácter, el retroceso (backspace)
print(len(r"\b"), repr(r"\b"))         # con r: dos caracteres, la barra y la b

linea = "GET /login 200"
print(re.search("\bGET\b", linea))     # busca retroceso + GET + retroceso: None
print(re.search(r"\bGET\b", linea))    # busca la palabra GET
print(re.search("\\bGET\\b", linea))   # sin r hay que doblar cada barra
print(repr("\1"), repr(r"\1"))         # sin r, \1 es el carácter de código 1, no el grupo 1

Salida:

1 '\x08'
2 '\\b'
None
<re.Match object; span=(0, 3), match='GET'>
<re.Match object; span=(0, 3), match='GET'>
'\x01' '\\1'
  • "\b" sin r mide 1: Python lo ha convertido en el carácter de retroceso (backspace, código 8, que repr muestra como '\x08'). Con r"\b", el string tiene los dos caracteres, la barra y la b, que es lo que re necesita para entender «frontera de palabra».
  • Por eso re.search("\bGET\b", linea) devuelve **None**: re está buscando retroceso + GET + retroceso, y en el log no hay ningún retroceso. No hay error, solo un resultado equivocado.
  • Sin r también funciona si doblas cada barra ("\\bGET\\b"), porque \\ es una barra de verdad. Funciona, pero es ilegible.
  • "\1" sin r es el carácter de código 1. Una referencia escrita así nunca puede encajar.

Hay barras que Python no reconoce como escape, como \d, y en esos casos el patrón funciona por casualidad (las versiones recientes de Python avisan con un SyntaxWarning). Que funcione a veces es lo peor que puede pasar, porque te acostumbras a no poner la r hasta el día en que usas \b.

La regla es tan simple que no merece la pena pensarla: **todo patrón, con r delante**. r"…", siempre, aunque no lleve barras.

finditer, sub y subn

Quedan tres funciones del módulo para cerrar el repertorio:

  • **re.finditer(p, t) recorre todas** las coincidencias, como findall, pero en lugar de strings da un **Match por cada una**, con sus grupos y posiciones.
  • **re.sub(p, reemplazo, t) devuelve un string nuevo** en el que cada coincidencia se ha sustituido por reemplazo. Dentro del reemplazo, \1, \2… son el texto de cada grupo: así se reordenan trozos.
  • **re.subn(p, reemplazo, t) hace lo mismo y devuelve una tupla: el string nuevo y el número de sustituciones**.
import re

linea = '10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129"'

for m in re.finditer(r"\d+", linea[:30]):   # un Match por cada número, con su posición
    print(m.group(), m.span())

print(re.sub(r"(\d\d)/(\d\d)/(\d{4})", r"\3-\2-\1", linea))   # fecha a año-mes-día
print(re.sub(r"(\d+\.\d+\.\d+)\.\d+", r"\1.0", linea))        # el último número de la IP, a 0
nueva, cambios = re.subn(r"\d", "#", "10.0.0.23")             # subn: también cuenta los cambios
print(nueva, cambios)

Al ejecutarlo:

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 sobre los 30 primeros caracteres de la línea da diez números, cada uno con su span(). (0, 2) es el 10 del principio de la IP; (17, 21), el año. Con findall tendrías los números, pero no sabrías dónde está cada uno.
  • El primer sub usa tres grupos para la fecha y el reemplazo r"\3-\2-\1" los escribe en otro orden: año, mes y día, separados por guiones. Es el formato ISO, el que ordena bien las fechas al ordenar texto. El resto de la línea no se toca, porque solo se reemplaza lo que encaja.
  • El segundo sub anonimiza la IP: captura los tres primeros números en el grupo 1 y los vuelve a escribir seguidos de .0. El último número desaparece.
  • subn cambia cada dígito de "10.0.0.23" por # y además cuenta los cambios: 6, uno por cifra.

Esperar que re.sub cambie la variable. Igual que los métodos de string, devuelve un string nuevo: linea = re.sub(…, linea), con la asignación delante.

Cuando el patrón está mal escrito

Si el patrón tiene un error de sintaxis (un paréntesis sin cerrar, un cuantificador sin nada delante), re lanza la excepción **re.error**. Se puede capturar como cualquier otra excepción:

import re

try:
    re.compile(r"(\d{3}")          # falta cerrar el paréntesis
except re.error as e:
    print("Patrón mal escrito:", e)

Salida:

Patrón mal escrito: missing ), unterminated subpattern at position 0

re.compile traduce el patrón a la estructura interna que usa el módulo, y es ahí donde se detecta el fallo. El mensaje dice qué falta (missing )) y en qué posición del patrón empieza el problema: el paréntesis de la posición 0. También puedes usar re.compile para guardar un patrón ya traducido y llamar a sus métodos (patron.search(linea)), algo útil cuando el mismo patrón se usa miles de veces; para FP1, las funciones del módulo son suficientes.

Míralo paso a paso en Python Tutor

Python Tutor ejecuta el código línea a línea y dibuja cada variable con su valor. Con las expresiones regulares es útil para ver que **un Match es un objeto y que los grupos son strings hasta que los conviertes. Elige Python 3** y pega este programa:

import re

linea = '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})', linea)
metodo = m.group(1)
ruta = m.group(2)
codigo = int(m.group(3))
print(metodo, ruta, codigo, m.span(3))

Salida esperada:

GET /fp1/tema9.pdf 404 (52, 55)

Qué observar, paso a paso:

  1. Después de la línea 4, m no se dibuja como un número o un texto: aparece como un objeto re.Match. Es la ficha con la coincidencia y sus grupos.
  2. En la línea 5, metodo vale "GET": el grupo 1, la alternativa (GET|POST).
  3. En la línea 6, ruta vale "/fp1/tema9.pdf". \S+ es avaricioso y al principio se come también la comilla de cierre (una comilla no es un espacio). Como detrás el patrón exige ", devuelve ese último carácter y la ruta termina justo antes de la comilla.
  4. En la línea 7, fíjate en la diferencia: codigo aparece sin comillas, como el número 404, porque int() ha convertido el string del grupo 3.
  5. En la salida, m.span(3) da (52, 55): las mismas posiciones que el 404 del primer ejemplo de re.search, porque la línea empieza igual.

Resumen

ConceptoLo esencial
RegexUn string que describe una forma de texto; módulo re
Clases\d dígito, \w de palabra, \s espacio en blanco; en mayúscula, lo contrario
Punto y escapes. cualquier carácter; \. un punto de verdad; escapa todos los metacaracteres
Conjuntos[abc], [a-f0-9], [^…] negado; encajan con un carácter
Alternativaa|b; gana la primera que encaja, así que la larga primero
Cuantificadores? * + {n} {n,m} {n,}; afectan solo a la pieza de delante
AvariciaPor defecto, lo máximo; con ? detrás (.*?), lo mínimo; o [^"]*
Anclas^ principio, $ final, \b frontera de palabra
Funcionesmatch (al principio), search (en algún sitio), fullmatch (todo)
Matchgroup(n), groups(), start, end, span; None si no encuentra
Grupos( ) capturan y se numeran por su paréntesis de apertura; (?: ) no captura
Referencias\1 en el patrón: el mismo texto del grupo 1; en sub, reordenan
Más funcionesfindall (lista), finditer (Match a Match), sub, subn
Raw stringsTodos los patrones con r"…", o \b y \1 dejan de funcionar

Tres ideas para llevarte:

  1. Un patrón dice exactamente lo que dice, no lo que tú querías. 404 encuentra el 404 de 14040 y ".*" se come hasta la última comilla. Antes de usar un patrón, pruébalo con findall en textos que no deberían encajar.
  2. **Para validar, fullmatch; para buscar, search; para trocear, grupos.** Comprueba siempre que el resultado no es None antes de pedirle group().
  3. **r delante, siempre.** Es la única regla del tema que no tiene excepciones.

En la práctica de expresiones regulares aplicarás todo esto a un log completo: filtrar los errores con un patrón de ocho grupos, contar peticiones por código, por hora y por IP (y detectar a quien intenta adivinar contraseñas), y convertir el log a CSV con re.sub. Después, los ejercicios te pondrán a prueba. Si ya has visto las expresiones regulares en Java, vas a reconocer casi toda la sintaxis: lo que cambia de un lenguaje a otro son las funciones, no los patrones. Y en el siguiente tema, los archivos de texto: el log dejará de estar escrito dentro del programa y lo leeremos de un fichero de verdad.

Siguiente paso: prácticaExpresiones regulares en Python — práctica con el log de un servidor: filtrar errores, contar intentos de acceso y convertirlo a CSV (y la IP que llamaba tres veces por segundo)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

4 comentarios

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *