<.+> contra <b>bold</b> and <i>italic</i> no encuentra <b>. Encuentra toda la cadena, desde el primer < hasta el último >. No hay ningún error. Eso es justo lo que debe hacer un cuantificador greedy, y también es el bug más común de “el regex está matcheando de más”. Una vez que sabes qué hace el motor mientras lee tu patrón, este bug y la mayoría de sus parientes dejan de ser un misterio.

Cómo Lee un Motor Regex tu Patrón

Un motor regex no compara tu patrón con la cadena como comparas dos cadenas por igualdad. Los motores que usas a diario (re de Python, RegExp de JavaScript, PCRE, .NET, Java) son regex-directed: recorren el patrón de izquierda a derecha, token a token, y para cada token intentan consumir caracteres de la cadena en la posición actual. Cuando un token encuentra coincidencia, el motor avanza tanto el puntero del patrón como el de la cadena. Cuando un token falla, el motor hace backtracking: vuelve al último punto donde tenía una opción, como un cuantificador que podía haber consumido menos caracteres o una rama de alternancia que no había probado, y toma la otra opción.

Por eso <.+> se traga toda la línea. .+ es greedy, así que primero agarra todos los caracteres restantes de la cadena, < y > incluidos. El motor todavía necesita un > literal para terminar, así que va devolviendo caracteres de uno en uno desde el final hasta que el último > de la cadena lo satisface. El motor devuelve la primera coincidencia que encuentra en ese orden greedy-first. Para este patrón, es la más larga posible, no la que te parece correcta a simple vista.

Probar primero la opción greedy y retroceder un paso al fallar: esa única regla explica tanto el matching normal del día a día como las ralentizaciones patológicas que vienen más adelante. Es la misma dinámica de explorar y deshacer que una búsqueda recursiva sin memoización, el tipo de explosión que la recursividad explicada cubre desde el lado de las llamadas a función.

Sintaxis Básica del Regex: Anclas, Cuantificadores, Grupos, Lookarounds

Anclas y Clases de Caracteres

^ y $ anclan una coincidencia al inicio y al final de la cadena (o de la línea, con el flag multiline). \d, \w y \s son abreviaturas de clases de caracteres para dígito, carácter de palabra y espacio, y [...] te permite escribir una clase de forma explícita.

1
2
3
4
import re

re.search(r"^\d{3}-\d{4}$", "555-0199")
# <re.Match object; span=(0, 8), match='555-0199'>

Sin las anclas, \d{3}-\d{4} también encontraría coincidencia dentro de "call 555-0199 now". Las anclas convierten “contiene” en “es exactamente”.

Las clases de caracteres operan sobre lo que el motor considera un carácter, y fuera del ASCII puro eso no siempre es lo que esperas. \w encuentra una letra, un dígito o un guion bajo según la noción de carácter del motor, que es la misma distinción entre code point y byte que trata qué son Unicode y la codificación UTF-8. Ejecuta un patrón sobre bytes UTF-8 crudos en lugar de texto decodificado y un carácter multibyte queda dividido en lo que el motor cree que son caracteres separados.

Cuantificadores Greedy y Lazy

+, * y {m,n} son greedy por defecto: toman todo lo que pueden y hacen backtracking si eso rompe el resto del patrón. Pon un ? después de cualquiera de ellos y el cuantificador se vuelve lazy. Toma lo mínimo posible y se expande solo cuando no le queda otra.

1
2
3
s = "<b>bold</b> and <i>italic</i>"
re.findall(r"<.+>", s)    # ['<b>bold</b> and <i>italic</i>']
re.findall(r"<.+?>", s)   # ['<b>', '</b>', '<i>', '</i>']

Un carácter de diferencia, un resultado completamente distinto. Cuando quieras lo más corto que satisfaga el resto del patrón, usa la forma lazy. Si lo que estás matcheando es markup, lee antes la última sección.

Grupos de Captura, Sin Captura y con Nombre

(...) captura lo que encuentra para que puedas extraerlo después. (?:...) agrupa por estructura, por ejemplo para aplicar un cuantificador a todo un subpatrón, sin crear una captura. (?P<name>...) en Python ((?<name>...) en JavaScript, Java y .NET) nombra un grupo para que no tengas que contar paréntesis para encontrarlo más tarde.

1
2
3
m = re.match(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})", "2026-10-28")
m.groupdict()
# {'year': '2026', 'month': '10', 'day': '28'}
1
2
3
const m = "2026-10-28".match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
m.groups;
// [Object: null prototype] { year: '2026', month: '10', day: '28' }

Lees el resultado por nombre, con groupdict() en Python o .groups en JavaScript (los grupos con nombre llegaron ahí en ES2018), en vez de match[1], match[2]. En cuanto un patrón tiene más de dos grupos, los caracteres de más se pagan solos.

Lookahead y Lookbehind

Un lookaround verifica una posición, no caracteres. Comprueba que algo está (o no está) ahí sin consumirlo. (?=...) es un lookahead positivo, (?!...) negativo; (?<=...) y (?<!...) son los equivalentes de lookbehind.

1
2
"$40 bill".match(/(?<=\$)\d+/);
// [ '40', index: 1, ... ]   -- encuentra "40", no "$40"

El re de Python exige que el lookbehind sea de anchura fija: (?<=\$) funciona, (?<=\$+) lanza un error, porque el motor necesita saber exactamente cuánto retroceder antes de comprobar. JavaScript, que añadió el lookbehind en ES2018 junto con los grupos con nombre, no tiene esa limitación, así que (?<=\$+) funciona ahí. En Python, el paquete de terceros regex en PyPI elimina la restricción si necesitas un lookbehind de anchura variable.

Por Qué Algunos Regex se Quedan Colgados: Catastrophic Backtracking

Toma la regla de backtracking del principio y dale un caso del que no pueda recuperarse. Anida dos cuantificadores para que el motor tenga muchas formas de dividir la misma secuencia de caracteres entre ellos, y luego haz que la coincidencia falle justo al final. El motor no falla rápido. Prueba cada división posible antes de rendirse.

1
2
3
4
5
6
7
8
import re, time

pattern = re.compile(r"(a+)+b")
for n in (20, 24, 26, 28):
    s = "a" * n  # sin "b" final, así que nunca coincide
    start = time.perf_counter()
    pattern.match(s)
    print(n, f"{(time.perf_counter() - start) * 1000:.1f} ms")
1
2
3
4
20 41.6 ms
24 525.8 ms
26 2083.6 ms
28 8369.3 ms

Cada a extra duplica aproximadamente el tiempo: dos caracteres más, cuatro veces más lento. Eso es crecimiento exponencial, la curva que la notación Big O coloca al otro lado de O(n^2). (a+)+ puede dividir una secuencia de 28 a en grupos de 2^27 formas distintas, y sin ninguna b en la cadena el motor las prueba una tras otra antes de poder reportar “sin coincidencia”. Sigue la misma curva hasta 40 caracteres y la espera son unas nueve horas.

Cuando la entrada viene de un usuario en lugar de tus fixtures de prueba, esto es una vulnerabilidad explotable: ReDoS, regular expression denial of service. El disparador habitual es exactamente esta forma, dos cuantificadores apilados sin nada entre ellos que fuerce una división única.

Tres formas de evitarlo, de más barata a más cara:

  1. Elimina la ambigüedad. (a+)+b y a+b encuentran coincidencia en las mismas cadenas válidas aquí; el + exterior no aporta nada. La mayoría de los patrones catastróficos tienen una capa redundante como esta en cuanto la buscas.

  2. Haz posesivo el cuantificador interno, o envuélvelo en un grupo atómico. Ambos le dicen al motor “una vez que esto coincide, no hagas backtracking dentro”. Eso elimina la explosión exponencial, al coste de rechazar ocasionalmente una cadena que la versión con backtracking sí habría aceptado. El re de Python soporta ambos desde la 3.11:

    1
    2
    3
    4
    5
    6
    7
    
    pattern = re.compile(r"(?>a+)+b")   # grupo atómico
    # o bien: re.compile(r"a++b")       # cuantificador posesivo, Python 3.11+
    for n in (28, 1_000, 100_000):
        s = "a" * n
        start = time.perf_counter()
        pattern.match(s)
        print(n, f"{(time.perf_counter() - start) * 1000:.3f} ms")
    
    1
    2
    3
    
    28 0.002 ms
    1000 0.001 ms
    100000 0.026 ms
    

    La misma coincidencia fallida sobre 100.000 caracteres tarda 26 microsegundos. El RegExp nativo de JavaScript no tiene ninguna de las dos características. El paquete npm regex añade grupos atómicos y cuantificadores posesivos como sintaxis extendida si los necesitas sin cambiar de motor.

  3. Acota los cuantificadores sin límite. {1,50} en vez de + limita cuánto puede empeorar el peor caso, incluso sin reescribir la lógica.

Si la cadena puede venir de fuera de tu código, como el cuerpo de una petición o un valor de configuración escrito por otra persona, trata los cuantificadores anidados como tratarías un bucle sin límite sobre entrada externa. Prueba el patrón contra una secuencia larga del carácter que dispara la ambigüedad, no solo contra tus fixtures del camino feliz.

Cuándo No Usar Regex

Un regex no tiene memoria de cuán profundo está. Eso está bien para texto plano y de forma fija, como una fecha o una línea de log con campos conocidos. También por eso <.+> no puede saltar de forma fiable un solo tag HTML: no tiene el concepto de “este < abre un tag que el siguiente > correspondiente cierra”, solo “toma hasta el último > que encuentres”. Las estructuras anidadas como HTML, JSON, código fuente y paréntesis balanceados necesitan una gramática, no un patrón. El fallo típico es un regex que pasa todos los casos de prueba que escribiste, y luego se encuentra con un archivo real con un comentario que contiene un > y encuentra silenciosamente el tramo equivocado en lugar de lanzar un error.

Usa el parser que ya existe: un parser HTML para HTML, json.loads para JSON, el módulo AST de tu lenguaje para código. Reserva el regex para validar o extraer texto de forma plana, y cambia a un parser en el momento en que “balanceado” o “anidado” aparezca en la descripción de lo que estás matcheando.

Artículos relacionados