Expresiones regulares en Python
Las expresiones regulares son el bisturí del procesamiento de texto: precisas, potentes y un poco intimidantes la primera vez que las ves. Pero una vez que entiendes la lógica detrás de r'\b[\w.+-]+@[\w-]+\.[a-z]{2,6}\b', no puedes imaginar cómo vivías sin ellas. Extraer emails de un log, validar teléfonos, limpiar texto con espacios irregulares, encontrar fechas en cualquier formato: todo eso son tres líneas con el módulo re.
La búsqueda con superpoderes
Cuando Python tiene los métodos str.find(), str.replace() y str.split(), ¿para qué necesitas expresiones regulares? La respuesta es que esos métodos trabajan con texto literal: saben buscar exactamente la cadena "2026", pero no saben buscar "cualquier secuencia de cuatro dígitos". Las regex sí.
Una expresión regular es un patrón que describe un conjunto de strings. En lugar de decir "busca exactamente esto", dices "busca cualquier cosa que tenga esta estructura". El módulo re de Python es la librería estándar que implementa el motor de regex; no necesitas instalar nada.
Casos de uso cotidianos donde las regex son la herramienta correcta:
- Validación de formatos: ¿Es esto un email válido? ¿Un teléfono español? ¿Un código postal?
- Extracción de datos: Dame todas las fechas que aparecen en este documento. Dame todos los importes en euros de este PDF convertido a texto.
- Limpieza de texto: Elimina todos los espacios dobles. Quita las etiquetas HTML. Normaliza los saltos de línea.
- Parseo de logs: Extrae el timestamp, el nivel de log y el mensaje de cada línea de un fichero de logs con formato fijo.
re.search() hace exactamente eso: recorre el texto completo y te señala exactamente dónde está lo que buscas, junto con la posición y el texto capturado. Sin lupa puedes leer el texto; con lupa encuentras el detalle que necesitas. Fuente: Pexels (licencia libre).El primer contacto con el módulo re es siempre el mismo: importar y probar una búsqueda básica.
import re
texto = "El pedido 42837 llegará el 2026-03-15 a las 10:30"
# Buscar el número de pedido (secuencia de dígitos)
m = re.search(r'\d+', texto)
if m:
print(m.group()) # → 42837
print(m.start()) # → 10 (posición donde empieza)
print(m.end()) # → 15 (posición donde termina)
El r'' antes de la cadena indica un raw string: le dice a Python que no interprete los backslashes, dejando que el motor de regex los procese. Acostúmbrate a escribir siempre r'' en los patrones. Más adelante verás por qué es importante.
Metacaracteres esenciales
Los metacaracteres son los caracteres que tienen significado especial dentro de un patrón. Hay que memorizar un puñado de ellos para poder leer y escribir cualquier regex. El resto se puede consultar.
Clases de caracteres y cuantificadores
import re
# . → cualquier carácter excepto salto de línea
re.search(r'h.la', 'hola') # coincide: . = 'o'
re.search(r'h.la', 'hXla') # coincide: . = 'X'
# \d → dígito [0-9] \D → no dígito
re.findall(r'\d+', 'abc 123 def 456') # → ['123', '456']
# \w → alfanumérico + guión bajo \W → lo contrario
re.findall(r'\w+', 'hola mundo!') # → ['hola', 'mundo']
# \s → espacio/tab/\n \S → no espacio
re.sub(r'\s+', ' ', ' demasiado espacio ') # → ' demasiado espacio '
# \b → límite de palabra (posición, no carácter)
re.findall(r'\bpython\b', 'python pythonista') # → ['python'] (no 'pythonista')
Anclas: ^ y $
# ^ → inicio del string $ → fin del string
re.match(r'^\d{4}', '2026-03-07') # → coincide (empieza con 4 dígitos)
re.search(r'\d{2}$', 'Módulo 06') # → coincide (termina en 2 dígitos)
re.fullmatch(r'\d{5}', '28080') # → coincide (exactamente 5 dígitos)
re.fullmatch(r'\d{5}', '28080-Madrid') # → None (hay más texto)
Clases personalizadas con []
# [abc] → a, b o c (exactamente uno de los listados)
# [a-z] → rango: cualquier letra minúscula
# [^abc] → cualquier cosa EXCEPTO a, b o c (negación)
# [a-zA-Z0-9_] → equivale a \w
re.findall(r'[aeiou]', 'python') # → ['o'] (solo vocales)
re.findall(r'[^aeiou\s]+', 'hola py') # → ['hl', 'py'] (consonantes juntas)
re.findall(r'[A-Z][a-z]+', 'Ana y Luis van a Madrid') # → ['Ana', 'Luis', 'Madrid']
Cuantificadores: *, +, ?, {n,m}
# * → 0 o más + → 1 o más ? → 0 o 1
# {n} → exactamente n {n,m} → entre n y m
re.findall(r'\d{2,4}', '12 345 6789 1') # → ['12', '345', '6789']
re.search(r'colou?r', 'color') # → coincide: u es opcional
re.search(r'colou?r', 'colour') # → también coincide
# Lazy vs greedy: añade ? para capturar lo mínimo posible
texto_html = 'negrita y cursiva'
re.findall(r'<.+>', texto_html) # greedy: ['<b>negrita</b> y <i>cursiva</i>']
re.findall(r'<.+?>', texto_html) # lazy: ['<b>', '</b>', '<i>', '</i>']
r'.*' no cruza líneas. Si tu texto tiene múltiples líneas y necesitas que . coincida también con \n, añade el flag re.DOTALL.
search, match, findall: elegir la correcta
El módulo re tiene cuatro funciones de búsqueda. Confundirlas es el error más frecuente entre los que aprenden regex en Python.
re.search() — la más usada
import re
texto = "Factura núm. 2026-0042 emitida el 07/03/2026"
m = re.search(r'\d{4}-\d{4}', texto)
if m:
print(m.group()) # → '2026-0042'
print(m.span()) # → (13, 22) — posición inicio, fin
else:
print("No encontrado")
re.search() recorre todo el string y devuelve un Match object con la primera coincidencia, o None si no hay ninguna. Comprueba siempre el resultado antes de llamar a .group(): si no hay match y llamas a m.group(), obtienes un AttributeError porque None no tiene ese método.
re.match() — solo al inicio
# re.match() solo coincide si el patrón está AL INICIO del string
re.match(r'\d+', '123abc') # → Match: '123'
re.match(r'\d+', 'abc123') # → None (123 no está al principio)
# Equivalente a buscar con ^ al inicio:
re.search(r'^\d+', 'abc123') # → None (igual resultado)
re.fullmatch() — el string completo
# re.fullmatch() exige que el patrón coincida con TODO el string
# Perfecto para validaciones donde no puede haber nada extra
PATRON_CP = re.compile(r'[0-5]\d{4}')
re.fullmatch(r'[0-5]\d{4}', '28080') # → Match ✓
re.fullmatch(r'[0-5]\d{4}', '28080-Madrid') # → None ✗ (sobra texto)
re.fullmatch(r'[0-5]\d{4}', '9876') # → None ✗ (faltan dígitos)
re.findall() — todos los matches a la vez
log = """
2026-03-07 10:14:22 ERROR conexión rechazada por 192.168.1.5
2026-03-07 10:15:01 INFO petición recibida desde 10.0.0.1
2026-03-07 10:15:43 ERROR timeout al conectar con 192.168.1.5
"""
# Extraer todas las IPs del log
ips = re.findall(r'\b(?:\d{1,3}\.){3}\d{1,3}\b', log)
print(ips) # → ['192.168.1.5', '10.0.0.1', '192.168.1.5']
# Extraer todas las horas
horas = re.findall(r'\d{2}:\d{2}:\d{2}', log)
print(horas) # → ['10:14:22', '10:15:01', '10:15:43']
Cuando el patrón no tiene grupos, findall devuelve una lista de strings. Cuando tiene un grupo, devuelve la lista del contenido de ese grupo. Cuando tiene dos o más grupos, devuelve una lista de tuplas.
# Sin grupos → lista de matches completos
re.findall(r'\d+', 'a1 b22 c3') # → ['1', '22', '3']
# Con un grupo → lista del contenido del grupo
re.findall(r'(\d+)', 'a1 b22 c3') # → ['1', '22', '3'] (idéntico)
# Con dos grupos → lista de tuplas
re.findall(r'(\w+)=(\d+)', 'a=1 b=22') # → [('a', '1'), ('b', '22')]
re.finditer() — cuando necesitas posición o muchos matches
# finditer devuelve un iterador de Match objects (más eficiente con textos grandes)
for m in re.finditer(r'\d+', 'página 12 sección 3 ítem 456'):
print(f"'{m.group()}' en posición {m.start()}-{m.end()}")
# → '12' en posición 7-9
# → '3' en posición 19-20
# → '456' en posición 26-29
Grupos de captura y grupos nombrados
Los grupos son la característica más poderosa de las regex: permiten extraer partes específicas del match en lugar de solo saber si hay coincidencia.
Grupos numerados con ()
import re
# Extraer partes de una fecha
m = re.search(r'(\d{4})-(\d{2})-(\d{2})', '2026-03-07')
if m:
print(m.group()) # → '2026-03-07' (match completo)
print(m.group(1)) # → '2026' (grupo 1)
print(m.group(2)) # → '03' (grupo 2)
print(m.group(3)) # → '07' (grupo 3)
print(m.groups()) # → ('2026', '03', '07') (todos los grupos)
Grupos nombrados con (?P<nombre>...)
Los grupos nombrados son mejores que los numerados para todo lo que no sea trivial. Si cambias el patrón y añades un grupo antes, los índices de todos los grupos posteriores cambian. Con nombres, el código sigue funcionando.
patron_fecha = re.compile(
r'(?P<anio>\d{4})-(?P<mes>\d{2})-(?P<dia>\d{2})'
)
m = patron_fecha.search('Entrega: 2026-03-15 antes del mediodía')
if m:
print(m.group('anio')) # → '2026'
print(m.group('mes')) # → '03'
print(m.groupdict()) # → {'anio': '2026', 'mes': '03', 'dia': '15'}
# Con findall: devuelve lista de groupdict cuando hay grupos nombrados? No exactamente:
# findall devuelve tuplas aunque sean grupos nombrados; usa finditer para groupdict
for m in patron_fecha.finditer('2026-01-01 hasta 2026-12-31'):
print(m.groupdict())
# → {'anio': '2026', 'mes': '01', 'dia': '01'}
# → {'anio': '2026', 'mes': '12', 'dia': '31'}
Grupos no capturantes con (?:...)
A veces necesitas agrupar parte del patrón (para aplicar un cuantificador al grupo) sin que ese grupo aparezca en el resultado:
# Con (?:...) el grupo se usa para estructura, no para captura
re.findall(r'(?:\d{1,3}\.){3}\d{1,3}', '192.168.1.5 y 10.0.0.1')
# → ['192.168.1.5', '10.0.0.1'] ← el grupo (?:...) no aparece en el resultado
re.fullmatch() funciona exactamente igual — el texto completo debe encajar en el patrón, carácter por carácter, sin sobrar ni faltar nada. Por eso se usa para validar emails, teléfonos o códigos postales: o coincide del todo, o la puerta no abre. Fuente: Pexels (licencia libre).Sustituir y limpiar texto con re.sub()
re.sub(patrón, sustitución, texto) es la versión regex de str.replace(). Encuentra todas las coincidencias del patrón y las reemplaza por el string de sustitución. Devuelve siempre un string nuevo (las strings son inmutables en Python).
Sustituciones básicas
import re
# Normalizar espacios
limpio = re.sub(r'\s+', ' ', ' demasiado espacio en blanco ').strip()
print(limpio) # → 'demasiado espacio en blanco'
# Eliminar caracteres no alfanuméricos (limpiar strings para slugs)
slug = re.sub(r'[^a-z0-9-]', '', 'Expresiones Regulares en Python!'.lower().replace(' ', '-'))
print(slug) # → 'expresiones-regulares-en-python'
# Eliminar etiquetas HTML simples
sin_html = re.sub(r'<[^>]+>', '', '<b>Texto <em>importante</em></b>')
print(sin_html) # → 'Texto importante'
# Limitar a N sustituciones con el argumento count
texto = 'a1 b2 c3 d4'
print(re.sub(r'\d', 'X', texto, count=2)) # → 'aX bX c3 d4'
Usar grupos en la sustitución con \1 y \g<nombre>
# Reformatear fechas de DD/MM/YYYY a YYYY-MM-DD
fechas = "Entregado: 07/03/2026. Vence: 15/04/2026."
resultado = re.sub(
r'(\d{2})/(\d{2})/(\d{4})',
r'\3-\2-\1', # \1=dia \2=mes \3=anio → reordenamos
fechas
)
print(resultado)
# → "Entregado: 2026-03-07. Vence: 2026-04-15."
# Con grupos nombrados: más legible
resultado2 = re.sub(
r'(?P<dia>\d{2})/(?P<mes>\d{2})/(?P<anio>\d{4})',
r'\g<anio>-\g<mes>-\g<dia>',
fechas
)
print(resultado2) # → mismo resultado, código más legible
Sustitución con función
Si la sustitución no es un string fijo sino que depende del match, pasa una función como segundo argumento:
# Convertir todos los importes en euros a dólares (tasa x1.08)
def euros_a_dolares(m):
importe = float(m.group(1).replace(',', '.'))
return f"${importe * 1.08:.2f}"
texto = "Precio: 29,99€. IVA: 6,30€. Total: 36,29€."
print(re.sub(r'([\d,]+)€', euros_a_dolares, texto))
# → "Precio: $32.39. IVA: $6.80. Total: $39.19."
re.subn() que devuelve una tupla (nuevo_texto, num_sustituciones). Útil para auditorías o para saber si el texto cambió.
Flags: IGNORECASE, MULTILINE, DOTALL
Los flags modifican el comportamiento del motor de regex. Se pasan como tercer argumento a las funciones, o en el segundo argumento de re.compile(). Se pueden combinar con |.
import re
# ── re.IGNORECASE (re.I) ───────────────────────────────────────
# No distingue mayúsculas de minúsculas
re.findall(r'python', 'Python y PYTHON y python', re.IGNORECASE)
# → ['Python', 'PYTHON', 'python']
# ── re.MULTILINE (re.M) ───────────────────────────────────────
# ^ y $ aplican a cada línea, no solo al principio/fin del string
texto = "primera línea\nsegunda línea\ntercera línea"
re.findall(r'^\w+', texto, re.MULTILINE)
# → ['primera', 'segunda', 'tercera'] ← sin re.M: solo ['primera']
# ── re.DOTALL (re.S) ──────────────────────────────────────────
# El punto . coincide también con \n
html = "<div>\n contenido\n multilinea\n</div>"
re.search(r'<div>.*</div>', html) # → None (. no cruza líneas)
re.search(r'<div>.*</div>', html, re.DOTALL) # → Match ✓
# ── re.VERBOSE (re.X) ─────────────────────────────────────────
# Permite espacios y comentarios en el patrón (para patrones complejos)
patron_email = re.compile(r"""
[\w.+-]+ # usuario (con puntos, +, -)
@ # arroba literal
[\w-]+ # dominio principal
\. # punto literal
[a-z]{2,6} # TLD: 2 a 6 letras
""", re.IGNORECASE | re.VERBOSE)
# Combinar flags con |
patron = re.compile(r'^\d+', re.MULTILINE | re.IGNORECASE)
r'(?i)python' equivale a usar re.IGNORECASE. Los flags disponibles son (?i), (?m), (?s), (?x). Útil cuando compilas el patrón como string en una config externa.
re.compile() y buenas prácticas
El módulo re tiene una caché interna que almacena los últimos patrones compilados. Si usas un patrón una sola vez, la diferencia es mínima. Pero si lo aplicas en un bucle de millones de iteraciones, o si quieres dar nombre a los patrones para que el código sea legible, re.compile() es la herramienta correcta.
import re
# Compilar al nivel de módulo (se compila una sola vez al importar)
PATRON_FECHA = re.compile(r'(?P<anio>\d{4})-(?P<mes>\d{2})-(?P<dia>\d{2})')
PATRON_EMAIL = re.compile(r'[\w.+-]+@[\w-]+\.[a-z]{2,6}', re.IGNORECASE)
PATRON_TELEFONO = re.compile(r'(\+34)?[6789]\d{8}')
# El objeto Pattern tiene los mismos métodos que las funciones del módulo
m = PATRON_FECHA.search('Fecha de entrega: 2026-03-15')
emails = PATRON_EMAIL.findall(texto_largo)
limpio = re.sub(r'\s+', ' ', texto) # para uso único, la función directa está bien
re.escape() — incluir texto literal en un patrón
Si necesitas buscar un string que puede contener metacaracteres (como un precio con punto, una URL con ?, una ruta de fichero), usa re.escape() para escaparlo automáticamente:
precio = "29.99€" # el punto es metacarácter en regex
patron_literal = re.escape(precio)
print(patron_literal) # → '29\\.99€' (punto escapado)
# Útil cuando el patrón viene de input del usuario:
def buscar_literal(texto, buscar):
return re.findall(re.escape(buscar), texto)
Las cinco reglas de oro
- Usa siempre raw strings
r''para los patrones. Sin excepción. - Comprueba siempre el resultado de search/match antes de llamar a
.group(). - Usa
re.compile()para patrones que se usan más de una vez o que merecen un nombre descriptivo. - Usa grupos nombrados
(?P<nombre>...)en patrones con más de un grupo. - No uses regex para todo. Para HTML usa BeautifulSoup. Para JSON usa el módulo json. Para splits simples usa
str.split().
re: metacaracteres con ejemplos, las funciones y sus diferencias, grupos y flags, y patrones del mundo real listos para copiar. Ficha de referencia: Ciberaula.Patrones del mundo real
Aquí la teoría aplicada a los casos que más vas a necesitar. Estos patrones están calibrados para ser útiles en Python sin caer en el error de intentar ser perfectos (un validador de email RFC 5322 completo ocupa páginas; estos son los que funcionan bien en el 99% de los casos reales).
import re
# ── Email (uso práctico, no RFC 5322 estricto) ────────────────
EMAIL = re.compile(r'[\w.+-]+@[\w-]+(?:\.[\w-]+)*\.[a-z]{2,10}', re.I)
emails = EMAIL.findall("Escribe a soporte@ciberaula.com o info@ciberaula.es")
# → ['soporte@ciberaula.com', 'info@ciberaula.es']
# Validar (string completo = email):
def es_email(s): return bool(EMAIL.fullmatch(s))
# ── Teléfono España (móvil + fijo, con o sin +34) ─────────────
TELEFONO_ES = re.compile(r'(\+34\s?|0034\s?)?[6789]\d{2}[\s.-]?\d{3}[\s.-]?\d{3}')
print(TELEFONO_ES.findall("Llama al 612 345 678 o al +34 91 234 5678"))
# ── Fecha DD/MM/YYYY → convertir a YYYY-MM-DD ─────────────────
FECHA_DMY = re.compile(r'(?P<d>\d{2})/(?P<m>\d{2})/(?P<y>\d{4})')
normalizada = FECHA_DMY.sub(r'\g<y>-\g<m>-\g<d>', "Entrega: 07/03/2026")
print(normalizada) # → "Entrega: 2026-03-07"
# ── Extraer importes en euros ─────────────────────────────────
IMPORTE = re.compile(r'(\d{1,3}(?:\.\d{3})*(?:,\d{2})?)[\s]*€')
print(IMPORTE.findall("Total: 1.234,56 € + IVA 258,86 €"))
# → ['1.234,56', '258,86']
# ── Parsear línea de log ──────────────────────────────────────
LOG_LINE = re.compile(
r'(?P<fecha>\d{4}-\d{2}-\d{2})\s'
r'(?P<hora>\d{2}:\d{2}:\d{2})\s'
r'(?P<nivel>DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+'
r'(?P<mensaje>.+)'
)
linea = "2026-03-07 10:14:22 ERROR conexión rechazada por 192.168.1.5"
m = LOG_LINE.match(linea)
if m:
print(m.groupdict())
# → {'fecha': '2026-03-07', 'hora': '10:14:22', 'nivel': 'ERROR', 'mensaje': 'conexión rechazada...'}
# ── Limpiar texto para slugs URL ──────────────────────────────
def to_slug(texto):
texto = texto.lower()
texto = re.sub(r'[áàäâ]', 'a', texto)
texto = re.sub(r'[éèëê]', 'e', texto)
texto = re.sub(r'[íìïî]', 'i', texto)
texto = re.sub(r'[óòöô]', 'o', texto)
texto = re.sub(r'[úùüû]', 'u', texto)
texto = re.sub(r'ñ', 'n', texto)
texto = re.sub(r'[^a-z0-9]+', '-', texto)
return texto.strip('-')
print(to_slug("Expresiones Regulares: el poder oculto"))
# → "expresiones-regulares-el-poder-oculto"
❓ Preguntas frecuentes
❓ Preguntas frecuentes sobre Expresiones regulares en Python
Las dudas más comunes respondidas de forma clara y directa.
💬 Foro de discusión
¿Tienes dudas sobre Expresiones regulares en Python? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!