Saltar al contenido
expresiones-regulares.py · devschool

Expresiones regulares en Python

Lección 20 de 31 · 14 min de lectura · Actualizado el

En esta lección
  1. Cuándo usar expresiones regulares
  2. Primeros pasos con el módulo re
  3. Las funciones del módulo re
  4. Los bloques del patrón
  5. Grupos
  6. Flags: modificar el comportamiento
  7. Ejemplos prácticos
  8. Errores frecuentes
  9. Resumen

Una expresión regular (o regex) es un patrón que describe cómo es un texto: “cinco dígitos seguidos”, “una palabra que empieza por mayúscula”, “algo con una arroba en medio”. Con ese patrón puedes comprobar si un dato tiene el formato correcto, buscar y extraer trozos de un texto largo o reemplazar partes de forma inteligente. En esta lección aprenderás a usarlas en Python con el módulo re, desde los símbolos básicos hasta ejemplos reales como validar un código postal o un DNI.

Cuándo usar expresiones regulares

Para búsquedas sencillas no las necesitas: "hola" in texto, texto.startswith("ES") o texto.replace("a", "b") son más claros y más rápidos (los tienes en Cadenas). Las expresiones regulares merecen la pena cuando lo que buscas no es un texto fijo sino un formato:

  • Validar datos de un formulario: código postal, teléfono, matrícula, email.
  • Extraer información de textos: todas las fechas de un documento, los precios de un ticket.
  • Limpiar datos: quitar espacios repetidos, normalizar formatos.

Primeros pasos con el módulo re

Todo lo relacionado con expresiones regulares está en el módulo re, que viene con Python. La función más usada es re.search, que busca el patrón en cualquier parte del texto:

import re

texto = "Mi pedido llegará al código postal 39011 el martes"
resultado = re.search(r"\d\d\d\d\d", texto)

print(resultado)           # <re.Match object; span=(35, 40), match='39011'>
print(resultado.group())   # 39011
print(resultado.start(), resultado.end())   # 35 40

\d significa “un dígito”, así que \d\d\d\d\d son cinco dígitos seguidos. Si encuentra el patrón, re.search devuelve un objeto Match con la información de la coincidencia; si no, devuelve None. Por eso lo normal es usarlo en un if:

if re.search(r"\d{5}", "Sin código"):
    print("Tiene código postal")
else:
    print("No hay código postal")
# No hay código postal

Por qué la r delante del patrón

Fíjate en que los patrones se escriben como r"...". Es una cadena raw (cruda): Python no interpreta las barras invertidas como caracteres especiales. Sin la r, "\b" se convertiría en un carácter de retroceso antes de llegar a re, y el patrón no haría lo que esperas.

print(len("\b"), len(r"\b"))   # 1 2

Consejo: escribe siempre los patrones con r"...", aunque no tengan barras. Te ahorrarás errores muy difíciles de ver.

Las funciones del módulo re

FunciónQué haceDevuelve
re.search(p, t)Busca la primera coincidencia en cualquier parteMatch o None
re.match(p, t)Busca solo al principio del textoMatch o None
re.fullmatch(p, t)Exige que todo el texto encajeMatch o None
re.findall(p, t)Todas las coincidenciasLista de textos
re.finditer(p, t)Todas las coincidencias, una a unaIterador de Match
re.sub(p, nuevo, t)Reemplaza las coincidenciasTexto nuevo
re.split(p, t)Corta el texto por el patrónLista de textos

La diferencia entre las tres primeras es importante cuando validas datos:

patron = r"\d{5}"
for dato in ["39011", "39011-B", "CP 39011"]:
    print(dato.ljust(9),
          bool(re.search(patron, dato)),
          bool(re.match(patron, dato)),
          bool(re.fullmatch(patron, dato)))
# 39011     True True True
# 39011-B   True True False
# CP 39011  True False False

Para validar un dato completo, usa re.fullmatch: solo acepta el texto si encaja entero con el patrón.

findall, finditer, sub y split

ticket = "Pan 1.20, leche 0.95, aceite 9.75 y huevos 2.40"

print(re.findall(r"\d+\.\d+", ticket))
# ['1.20', '0.95', '9.75', '2.40']

for m in re.finditer(r"\d+\.\d+", ticket):
    print(m.group(), "en la posición", m.start())
# 1.20 en la posición 4
# 0.95 en la posición 16
# 9.75 en la posición 29
# 2.40 en la posición 43

print(re.sub(r"\d+\.\d+", "X.XX", ticket))
# Pan X.XX, leche X.XX, aceite X.XX y huevos X.XX

print(re.split(r",\s*|\s+y\s+", ticket))
# ['Pan 1.20', 'leche 0.95', 'aceite 9.75', 'huevos 2.40']

Usa findall cuando solo quieres los textos y finditer cuando además necesitas la posición o los grupos de cada coincidencia.

Los bloques del patrón

Caracteres normales y metacaracteres

La mayoría de caracteres se representan a sí mismos: el patrón r"gato" busca exactamente “gato”. Pero hay unos cuantos metacaracteres con significado especial:

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

Si quieres buscar uno de ellos de forma literal, ponle una barra delante: \. es un punto de verdad, \$ un símbolo de dólar, \( un paréntesis.

print(re.findall(r"3.5", "3.5 y 3,5 y 305"))    # ['3.5', '3,5', '305']
print(re.findall(r"3\.5", "3.5 y 3,5 y 305"))   # ['3.5']

Sin la barra, el punto significa “cualquier carácter” (excepto el salto de línea), así que encaja con la coma y con el 0.

Clases de caracteres

Una clase entre corchetes representa un solo carácter de un conjunto:

PatrónEncaja con
[aeiou]Una vocal minúscula
[0-9]Un dígito (rango)
[a-zA-Z]Una letra sin tildes
[^0-9]Cualquier cosa que no sea un dígito (el ^ dentro niega)
\d / \DUn dígito / un no dígito
\w / \WUn carácter de palabra (letras, incluidas ñ y tildes, dígitos y _) / lo contrario
\s / \SUn espacio en blanco (espacio, tabulador, salto de línea) / lo contrario
frase = "Año 2026: ¡Olé, España!"
print(re.findall(r"[aeiouáéíóú]", frase))   # ['o', 'é', 'a', 'a']
print(re.findall(r"\w+", frase))            # ['Año', '2026', 'Olé', 'España']
print(re.findall(r"[^\w\s]", frase))        # [':', '¡', ',', '!']

En Python 3, \w reconoce letras de cualquier idioma, así que ñ y las vocales con tilde cuentan como letras.

Cuantificadores

Los cuantificadores indican cuántas veces se repite lo que tienen justo delante:

CuantificadorSignificado
*0 o más veces
+1 o más veces
?0 o 1 vez (opcional)
{n}Exactamente n veces
{n,m}Entre n y m veces
{n,}n o más veces
print(re.findall(r"\d+", "Mesa 4, sillas 12, taburetes 150"))   # ['4', '12', '150']
print(re.findall(r"\d{2,3}", "Mesa 4, sillas 12, taburetes 150"))   # ['12', '150']
print(re.findall(r"colou?r", "color, colour, colr"))   # ['color', 'colour']
print(bool(re.fullmatch(r"\d{3} ?\d{3} ?\d{3}", "942 123 456")))   # True

En el último ejemplo, ? hace opcional cada espacio, así que el teléfono valdría escrito con o sin espacios.

Codiciosos y no codiciosos

Por defecto, * y + son codiciosos (greedy): intentan abarcar todo lo posible. A veces eso no es lo que quieres:

html = "<b>Oferta</b> y <b>Envío gratis</b>"
print(re.findall(r"<b>.*</b>", html))    # ['<b>Oferta</b> y <b>Envío gratis</b>']
print(re.findall(r"<b>.*?</b>", html))   # ['<b>Oferta</b>', '<b>Envío gratis</b>']

Con .* el patrón se extiende hasta el último </b>. Añadiendo ? detrás del cuantificador (*?, +?) se vuelve perezoso: se queda con lo mínimo posible y para en el primer </b>.

Anclas

Las anclas no representan caracteres, sino posiciones:

AnclaPosición
^Inicio del texto
$Final del texto
\bBorde de palabra (entre una letra y algo que no lo es)
print(re.findall(r"\bsol\b", "sol, soleado, girasol y sol"))   # ['sol', 'sol']
print(bool(re.search(r"^Hola", "Hola, Ana")))    # True
print(bool(re.search(r"Ana$", "Hola, Ana")))     # True

\bsol\b solo encuentra la palabra “sol” completa, no dentro de “soleado” ni “girasol”.

Alternativas con |

La barra vertical significa “o”: r"lunes|martes" encaja con cualquiera de los dos.

print(re.findall(r"tarjeta|efectivo|bizum", "Pagos: bizum, efectivo, tarjeta"))
# ['bizum', 'efectivo', 'tarjeta']

Grupos

Los paréntesis crean grupos, que sirven para dos cosas: aplicar un cuantificador a varios caracteres a la vez y, sobre todo, extraer partes de la coincidencia.

m = re.search(r"(\d{2})/(\d{2})/(\d{4})", "Fecha de entrega: 15/11/2026")
print(m.group())     # 15/11/2026  (la coincidencia completa)
print(m.group(1))    # 15
print(m.groups())    # ('15', '11', '2026')

dia, mes, anio = m.groups()
print(f"{anio}-{mes}-{dia}")   # 2026-11-15

Cuando el patrón tiene grupos, findall devuelve los grupos en lugar de la coincidencia completa: una tupla por coincidencia.

agenda = "Reunión 03/10/2026, examen 20/10/2026 y entrega 05/11/2026"
print(re.findall(r"(\d{2})/(\d{2})/\d{4}", agenda))
# [('03', '10'), ('20', '10'), ('05', '11')]

Si necesitas agrupar sin capturar (solo para aplicar un cuantificador), usa (?:...): r"(?:ja)+" encaja con “ja”, “jaja”, “jajaja”.

Grupos con nombre

Con muchos grupos, los números se vuelven confusos. Puedes darles nombre con (?P<nombre>...) y recuperarlos con group("nombre") o con groupdict():

patron = r"(?P<dia>\d{2})/(?P<mes>\d{2})/(?P<anio>\d{4})"
m = re.search(patron, "Fecha de entrega: 15/11/2026")
print(m.group("mes"))    # 11
print(m.groupdict())     # {'dia': '15', 'mes': '11', 'anio': '2026'}

Usar los grupos al reemplazar

En re.sub, el texto de reemplazo puede referirse a los grupos con \1, \2… o con \g<nombre>. Así se cambia el formato de todas las fechas de un texto de una vez:

print(re.sub(patron, r"\g<anio>-\g<mes>-\g<dia>", agenda))
# Reunión 2026-10-03, examen 2026-10-20 y entrega 2026-11-05

Flags: modificar el comportamiento

Las flags (banderas) se pasan como último argumento y cambian cómo se aplica el patrón:

print(re.findall(r"python", "Python, PYTHON y python"))
# ['python']
print(re.findall(r"python", "Python, PYTHON y python", re.IGNORECASE))
# ['Python', 'PYTHON', 'python']

lista = """Tareas:
- comprar pintura
- llamar al fontanero
nota: urgente"""
print(re.findall(r"^- (.+)$", lista, re.MULTILINE))
# ['comprar pintura', 'llamar al fontanero']
  • re.IGNORECASE (o re.I): no distingue mayúsculas de minúsculas.
  • re.MULTILINE (o re.M): ^ y $ encajan al principio y al final de cada línea, no solo del texto entero.
  • re.VERBOSE (o re.X): permite escribir el patrón en varias líneas, con espacios y comentarios, que se ignoran.

re.VERBOSE es muy útil para que los patrones largos se entiendan:

telefono = re.compile(r"""
    (?:\+34\s?)?     # prefijo internacional opcional
    [6789]\d{2}      # empieza por 6, 7, 8 o 9
    \s?\d{3}         # tres dígitos
    \s?\d{3}         # otros tres
""", re.VERBOSE)

for t in ["+34 612 345 678", "942123456", "512 345 678"]:
    print(t, bool(telefono.fullmatch(t)))
# +34 612 345 678 True
# 942123456 True
# 512 345 678 False

Puedes combinar flags con |: re.IGNORECASE | re.MULTILINE.

re.compile: patrones reutilizables

En el ejemplo anterior el patrón se compila con re.compile y se guarda en una variable. El objeto resultante tiene los mismos métodos (search, fullmatch, findall, sub…). Merece la pena cuando usas el mismo patrón muchas veces o quieres darle un nombre que explique qué valida.

Ejemplos prácticos

Código postal español

Los códigos postales tienen 5 dígitos y los dos primeros indican la provincia, del 01 al 52:

CP = re.compile(r"(0[1-9]|[1-4]\d|5[0-2])\d{3}")

for cp in ["39011", "08001", "53000", "00123", "3901"]:
    print(cp, "válido" if CP.fullmatch(cp) else "no válido")
# 39011 válido
# 08001 válido
# 53000 no válido
# 00123 no válido
# 3901 no válido

DNI con letra de control

Un DNI son 8 dígitos y una letra. La expresión regular comprueba el formato; la letra correcta se calcula con el resto de dividir el número entre 23:

LETRAS = "TRWAGMYFPDXBNJZSQVHLCKE"

def dni_valido(dni):
    m = re.fullmatch(r"(\d{8})-?([A-Z])", dni.strip().upper())
    if not m:
        return False
    numero, letra = m.groups()
    return LETRAS[int(numero) % 23] == letra

for dni in ["12345678Z", "12345678-z", "12345678A", "1234567Z"]:
    print(dni, dni_valido(dni))
# 12345678Z True
# 12345678-z True
# 12345678A False
# 1234567Z False

Es un buen ejemplo de reparto de tareas: la regex comprueba la forma y separa las partes con grupos, y el código normal hace el cálculo.

Email básico

Validar un email al cien por cien con una expresión regular es casi imposible (las normas son muy complejas). Para un formulario basta con comprobar la forma general: algo, una arroba, un dominio con al menos un punto.

EMAIL = re.compile(r"[\w.+-]+@[\w-]+(\.[\w-]+)*\.[a-zA-Z]{2,}")

for correo in ["lucia.gomez@gmail.com", "info@treparriscos.es", "ana@@mail.com", "pepe@correo"]:
    print(correo, bool(EMAIL.fullmatch(correo)))
# lucia.gomez@gmail.com True
# info@treparriscos.es True
# ana@@mail.com False
# pepe@correo False

Nota: la única forma segura de saber si un email existe es enviarle un mensaje de confirmación. La regex solo evita errores de escritura evidentes.

Extraer fechas de un texto

correo = """Hola, te confirmo las clases:
iniciación el 7/10/2026, perfeccionamiento el 14/10/2026
y la salida a la roca el 2/11/2026."""

for m in re.finditer(r"(\d{1,2})/(\d{1,2})/(\d{4})", correo):
    dia, mes, anio = (int(x) for x in m.groups())
    print(f"{dia:02d}-{mes:02d}-{anio}")
# 07-10-2026
# 14-10-2026
# 02-11-2026

Si luego necesitas operar con esas fechas, conviértelas en objetos date como se explica en Fechas.

Limpiar texto

sucio = "  Hola,    esto   tiene\tespacios   de   más  "
print(re.sub(r"\s+", " ", sucio).strip())
# Hola, esto tiene espacios de más

telefono = "(+34) 942-12.34.56"
print(re.sub(r"\D", "", telefono))   # 34942123456

titulo = "¿Qué es Python? Guía 2026"
slug = re.sub(r"[^\w]+", "-", titulo.lower()).strip("-")
print(slug)   # qué-es-python-guía-2026

El último ejemplo crea un slug, el texto que se usa en las direcciones web. [^\w]+ agrupa cualquier secuencia de caracteres que no sean de palabra y la cambia por un guion.

Errores frecuentes

  • Olvidar la r del patrón: "\bpan\b" no encuentra nada, porque \b se ha convertido en otro carácter antes de llegar a re. Escribe r"\bpan\b".
  • Validar con search en lugar de fullmatch: re.search(r"\d{5}", "abc39011xyz") encuentra coincidencia, así que aceptarías datos incorrectos.
  • Llamar a .group() sin comprobar: si no hay coincidencia, el resultado es None y None.group() da AttributeError: 'NoneType' object has no attribute 'group'. Comprueba antes con if m:.
  • Olvidar escapar el punto: r"3.5" también encaja con “3,5” y “305”. Para un punto literal escribe \..
  • Usar .* codicioso: abarca más de la cuenta. Prueba .*? o una clase más concreta como [^<]*.
  • Sorprenderse con findall y los grupos: si el patrón tiene paréntesis, findall devuelve los grupos, no la coincidencia completa. Usa (?:...) si solo querías agrupar.
  • Querer resolverlo todo con regex: para HTML, JSON o CSV usa las herramientas adecuadas (json, csv, un parser de HTML). Las regex son para patrones de texto.

Resumen

ElementoSignificadoEjemplo
r"..."Cadena raw para el patrónr"\d+"
search / fullmatchBuscar / validar el texto enterore.fullmatch(r"\d{5}", cp)
findall / finditerTodas las coincidenciasre.findall(r"\d+", t)
sub / splitReemplazar / cortarre.sub(r"\s+", " ", t)
. \d \w \sCualquiera, dígito, letra, espacio\d{2}/\d{2}
[...] / [^...]Uno de estos / ninguno de estos[aeiou]
* + ? {n,m}Cuantificadores\d{2,3}
*? +?Versión perezosa<b>.*?</b>
^ $ \bInicio, final, borde de palabra\bsol\b
( ) / (?P<n>...)Grupo / grupo con nombrem.group("mes")
re.I, re.M, re.XFlagsre.findall(p, t, re.I)
re.compilePatrón reutilizableCP.fullmatch("39011")

En la siguiente lección aprenderás a leer y escribir ficheros y JSON.

Pon a prueba lo que has aprendido

[Python] ¿Qué imprimen search, fullmatch y match con el patrón de tres dígitos sobre "ab123cd"?
import re
t = "ab123cd"
print(bool(re.search(r"\d{3}", t)),
      bool(re.fullmatch(r"\d{3}", t)),
      bool(re.match(r"\d{3}", t)))

[Python] ¿Qué devuelve re.findall(r"\[.*\]", "[a] y [b]") y cómo se consigue obtener ['[a]', '[b]']?

[Python] ¿Qué devuelve este código?
import re
print(re.findall(r"(\d+)-(\d+)", "1-2 y 3-4"))

[Python] ¿Qué devuelve re.sub(r"(\w+) (\w+)", r"\2 \1", "Ana López")?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.