DevKitLab Logo DevKitLab
expresiones regulares / depuración / JavaScript

¿Por qué mi regex no coincide? Empieza por entender el motor

Una regex que «no coincide» casi siempre ha coincidido, solo que no con el tramo que esperabas. En vez de memorizar trucos, entiende cómo el motor recorre el texto y retrocede: voraz, perezoso, anclas, anticipación y retroceso catastrófico son caras de un mismo mecanismo.

Escribes una regex, la miras un buen rato y, cuanto más la miras, más te convences de que debería coincidir. Pero no coincide con nada, o coincide con algo muy alejado de lo que querías. Así que empiezas a añadir cosas: otro .*, otro \, un paréntesis más alrededor de ese grupo. Cada añadido la aleja un poco más de lo correcto.

Esto es lo que la mayoría de tutoriales de regex no dice primero: el motor nunca se equivoca. Hizo exactamente lo que escribiste, letra por letra; lo que pasa es que lo que escribiste no es lo que querías decir. Y eso convierte «¿por qué no coincide?» en la pregunta equivocada. La útil es: ¿dónde está el motor ahora mismo, qué probó y por qué se detuvo ahí? En cuanto aprendes a pensar como el motor, cada resultado que parecía cosa de magia se descompone en algo predecible.

Este artículo no es una lista de trucos. Dedica unos minutos a cómo funciona el motor y luego hace evolucionar un mismo ejemplo hasta el final: de un patrón disparatadamente incorrecto a uno correcto, robusto y que no hunde tu rendimiento. Voraz frente a perezoso, la bandera que olvidaste activar, la trampa de lastIndex que hace que los resultados alternen, las anclas, los dos mundos del escape, la anticipación de ancho cero, las tres capas de Unicode y, por último, el retroceso catastrófico capaz de clavar tu CPU: verás que todo son caras de la misma máquina.

Qué está haciendo el motor en realidad

JavaScript —igual que PCRE, el re de Python, Java y la mayoría de motores que tocas a diario— es un motor de retroceso (backtracking). Su forma de trabajar es, la verdad, bastante sencilla: el motor mantiene una «posición actual», arranca en el extremo izquierdo de la cadena y avanza carácter a carácter hacia la derecha, probando en cada posición si tu patrón puede coincidir a partir de ahí.

Todo el juego está en esa palabra: probar. Cada vez que el motor topa con un cuantificador como *, + o ?, se enfrenta a una elección: ¿comer un carácter más o pararse aquí? Un cuantificador voraz (los * y + por defecto) siempre elige «comer más», tragando todo lo que puede, mientras anota cada posición «podría haberme parado aquí» en una pila de retroceso. Cuando el patrón más adelante se atasca en algún punto, el motor no se rinde de inmediato: rebobina hasta la última posición anotada, devuelve un carácter, y prueba otro camino hacia delante. Solo cuando ha agotado todas las posibilidades en la posición actual se da por vencido, avanza una casilla a la derecha y empieza de cero.

Ese único movimiento —comer con voracidad y, al atascarte, retroceder devolviendo lo comido— es la llave maestra para leer el comportamiento de una regex. Lo verás una y otra vez más abajo: lo voraz se pasa porque traga primero y devuelve después; lo perezoso hace lo contrario porque se aguanta hasta que lo obligan; la anticipación no consume caracteres porque solo se asoma y no avanza de verdad; el retroceso catastrófico clava la CPU porque el número de «probar otro camino» explota de forma exponencial. Una máquina, un conjunto de reglas.

Vale la pena saberlo de pasada: existe otra clase de motor que no retrocede, como el regexp de Go y el regex de Rust (el que hay detrás de ripgrep). Están construidos sobre autómatas finitos que convierten la coincidencia en un único barrido lineal, lo que los hace inmunes por construcción al retroceso catastrófico, a costa de renunciar a funciones como las retrorreferencias, que necesitan retroceso por naturaleza. Eso explica un enigma habitual: el mismo patrón peligroso corre a toda velocidad bajo ripgrep y sin embargo puede colgar un proceso de Node.js. La clase a la que pertenece tu motor determina qué trampas pueden afectarte siquiera. Este artículo trata del motor de retroceso de JavaScript.

La cadena que vamos a arreglar

Todo el artículo apunta a un objetivo, y está cerca de algo real: dos enlaces uno al lado del otro.

<a href="/products/12">Boots</a> <a href="/products/34">Hat</a>

El objetivo es modesto: extraer los dos valores de href, es decir, /products/12 y /products/34. Es tan simple que esperarías clavarlo al primer intento, y aun así consigue sacar a la luz cada manía del motor que acabamos de describir. Antes de tocar nada, recuerda una cosa: el primer paso para depurar una regex nunca es cambiar el patrón, sino ver dónde cae ahora mismo. Junta el patrón, las banderas y el texto de prueba, y deja que resalte cada tramo coincidente sobre el original: para eso sirve exactamente el probador de regex. Convierte un patrón abstracto en una franja de texto coloreado que tus ojos pueden leer. En cada sección de abajo puedes pegar el patrón de esa sección y ver con tus propios ojos con qué tramo coincidió de verdad.

Voraz: no es que no coincida, es que retrocedió demasiado tarde

Esta es una primera versión que casi todo el mundo ha escrito:

<a href="(.*)">

Esperas que (.*) capture /products/12. Lo que captura en realidad es:

/products/12">Boots</a> <a href="/products/34

Desde justo después del primer href=", come hasta justo antes del último ">. Con el modelo del motor no tiene ningún misterio: .* se tragó con voracidad, de un bocado, todo lo que hay de aquí al final de la línea, y luego el motor notó que al patrón aún le faltaba un ">, así que empezó a retroceder, devolviendo caracteres de uno en uno, del borde derecho hacia la izquierda. Cuando había retrocedido hasta el último ">, el patrón quedó satisfecho y dio la coincidencia por válida, así que se detuvo ahí y no devolvió nada más. No es que «quisiera comer tanto»: es que retrocedió demasiado tarde para llegar al primer sitio donde podía cerrar.

El arreglo es añadir un ? al cuantificador, volviéndolo de voraz a perezoso:

<a href="(.*?)">

.*? invierte la elección por defecto: en cada paso elige primero «no comer», y solo traga un carácter a regañadientes cuando el paso siguiente no funcionaría de otro modo. Así arranca desde vacío, se expande poco a poco y se detiene en el primer ">, dejando que (.*?) capture exactamente /products/12. Pega ambos patrones por turnos en el probador de regex y verás cómo el resaltado se encoge de golpe: la forma más directa de ver lo voraz frente a lo perezoso.

Dicho esto, la versión más limpia no es ni voraz ni perezosa: es la que no le da al motor margen para retroceder desde el principio:

<a href="([^"]*)">

[^"]* significa «cualquier carácter salvo una comilla doble». No puede alcanzar siquiera esa comilla de cierre, así que se detiene de forma natural justo antes: nada de comer de más y devolverlo, nada de retroceso. Eso no solo es más rápido, es más seguro. Y aquí va un límite duro de JavaScript que conviene conocer pronto: algunos motores ofrecen grupos atómicos (?>...) o cuantificadores posesivos a++ que le dicen al motor de forma explícita «una vez comido esto, no lo devuelvas jamás», cerrando el retroceso de raíz. JavaScript, a día de hoy, no tiene ninguno de los dos. Así que en JS la única palanca que tienes sobre el retroceso es justo esta: reescribir el patrón en una forma sin ambigüedad, usando una clase de caracteres negada para clavar el límite en vez de apoyarte en .*?. Guárdate esto; es la cura cuando lleguemos al retroceso catastrófico.

Banderas y estado: el patrón está bien, son los interruptores y la memoria del motor

Digamos que ya pasaste a <a href="([^"]*)">, pero solo coincide con el primer enlace; el segundo, /products/34, se niega a aparecer. El patrón no tiene nada malo: el problema es una bandera. Esos interruptores vienen apagados por defecto, no se quejan cuando los olvidas y en silencio te entregan medio resultado.

  • g (global) — hace que replace procese cada coincidencia y es obligatoria para matchAll. También cambia lo que devuelve String.prototype.match(): sin g obtienes la primera coincidencia y sus grupos de captura; con g obtienes todas las coincidencias completas. Para iterar cada coincidencia y sus grupos con fiabilidad, matchAll es la opción más clara. Arriba solo coincidió la primera porque g estaba apagada.
  • i (ignore case)/hat/ no coincide con Hat; activa i y HAT, Hat, hat son todos iguales.
  • m (multiline) — solo cambia lo que significan ^ y $; en la sección siguiente.
  • s (dotall) — por defecto . no coincide con un salto de línea. Si tu patrón tiene un .* y el contenido abarca varias líneas, . se detiene en el salto; activa s y . pasa a coincidir también con los saltos de línea.

Hasta aquí, todo esto es de sobra conocido. Pero g esconde una trampa más honda: una regex con g (o y) tiene estado. El mismo objeto RegExp recuerda un lastIndex —dónde terminó la coincidencia anterior— y la próxima vez continúa desde ahí. Pásale el mismo objeto a .test() o .exec() repetidamente y obtienes este resultado en apariencia imposible:

const re = /href/g;
re.test('href');  // true —— lastIndex avanza a 4
re.test('href');  // false —— busca desde la posición 4, ya no hay nada
re.test('href');  // true —— llega al final, lastIndex vuelve a 0 y empieza de nuevo

La misma cadena, la misma regex, y aun así test alterna entre true y false. El culpable es ese lastIndex reescrito en silencio. Reutilizar una regex con g no es un error en sí —así es exactamente como recorrerías las coincidencias a propósito con exec()—, pero antes de una comprobación con .test() que no necesita continuar desde una posición, restablece lastIndex = 0 o crea una regex nueva. Sobre todo, no la asciendas a constante a nivel de módulo para luego hacerle test por todas partes. Para leer cada coincidencia con sus grupos, prefiere String.prototype.matchAll(); deriva un iterador de la regex original y nunca toca el lastIndex de ese objeto. Un apunte: el probador de regex de este sitio enumera cada coincidencia en su lista de resultados para que se lean bien, mientras que la vista previa de reemplazo sí se ejecuta según hayas activado o no g.

Anclas y límites: coinciden con una posición, no con un carácter

Las anclas son un caso especial dentro del modelo de retroceso: son de ancho cero, coinciden con la posición entre caracteres en lugar de con un carácter en sí. Ahí es donde más contradicen la intuición.

^ y $ se anclan al inicio y al final de toda la cadena por defecto, no de cada línea. Mira este texto de varias líneas:

error: disk full
error: timeout

Escribes ^error: con la esperanza de coincidir con el inicio de cada línea, pero por defecto ^ solo reconoce esa única posición al principio de toda la cadena, así que solo coincide la primera línea. Para que ^ y $ actúen al inicio y al final de cada línea, activa m (multiline). Estas dos casi siempre van en pareja; olvidar m es el motivo más habitual del «¿por qué solo coincide con la primera línea?».

\b (límite de palabra) es otra aserción de ancho cero que se malinterpreta a menudo. Coincide con la frontera «entre un carácter de palabra y uno que no lo es». \bcat\b captura cat en a cat sat, pero no coincide con el cat de category, porque a cat le sigue de inmediato una e y ahí no hay límite. Mucha gente toma \b por «un espacio», pero la puntuación y el inicio/fin de línea también cuentan como límites. Y hay una trampa más honda: \b decide «qué cuenta como carácter de palabra» con la definición ASCII ([A-Za-z0-9_]), incluso con u activada. Así que para el chino, o para letras latinas acentuadas, \b discrepará de tu intuición: un carácter han no es un «carácter de palabra» a sus ojos, así que el límite cae en el lugar equivocado.

Escape: un punto, dos mundos

En una regex, . + * ? ( ) [ ] { } ^ $ | \ tienen todos un significado especial; para coincidir con ellos en sí hay que escaparlos con \. La víctima más habitual es el punto: quieres coincidir con el . de un número de versión, así que escribes \d+\.\d+; te da pereza y escribes \d+.\d+, y ese . desnudo pasa a ser «cualquier carácter», con lo que 1X2, 1 2, 1a2 coinciden todos: mucho más laxo de lo que querías, sin error, coincidiendo en silencio con cosas que no debería el día que llegue algún dato sucio.

Pero lo que de verdad saca de quicio son los dos mundos del escape. La casilla de entrada de un probador recibe la regex en crudo, donde una barra invertida es un \; pero una regex en código suele vivir dentro de una cadena, y la propia cadena también usa \ para escapar, así que el mismo patrón se escribe distinto en los dos mundos:

// En un literal de regex, basta una barra invertida:
/\d+\.\d+/

// Pero construido desde una cadena, las barras se duplican: la cadena se come una capa primero:
new RegExp('\\d+\\.\\d+')

«En el probador funciona perfecto y falla en cuanto lo meto en código» es, nueve de cada diez veces, esta capa de barras que no cuadra: el \\d que copiaste de algún JSON o de un campo de log debería ser en realidad \d al pegarlo en el probador, y volver a duplicarse al devolverlo a una cadena de código. La función de exportación del probador de regex se ocupa de esa diferencia por ti y te da la forma lista para pegar en el lenguaje de destino, ahorrándote contar barras a mano.

Hay también un caso avanzado: cuando construyes una regex a partir de una variable —por ejemplo, tratando la entrada del usuario como una cadena literal a buscar—, cualquier ., ( o ? de esa entrada se lee como metacarácter, lo que en el mejor caso coincide mal y en el peor deja que la entrada externa reescriba tu lógica de coincidencia. RegExp.escape() se estandarizó en ES2025 y convierte con seguridad una cadena en un fragmento de coincidencia literal; los navegadores modernos lo admiten, pero si necesitas dar soporte a navegadores o entornos más antiguos, comprueba primero tu entorno de destino y echa mano de un polyfill mantenido si hace falta. Al construir una regex de forma dinámica, la parte variable debe escaparse primero: nunca insertes una cadena externa directamente en el patrón.

Anticipación y retrospección: comprueban una condición, no consumen caracteres

A estas alturas quizá quieras algo más fino: extraer el valor de href sin las comillas que lo rodean. La anticipación (lookahead) y la retrospección (lookbehind) existen justo para esto, y arrastran un malentendido en el que cae casi todo el mundo.

Volvamos al modelo del motor: una anticipación (?=...) hace que el motor «se asome hacia delante» para comprobar si lo que sigue coincide con algún patrón, pero tras ese vistazo la posición actual no avanza. Es de ancho cero: que la condición se cumpla afecta al éxito o al fracaso, pero los caracteres que comprueba no cuentan en el resultado de la coincidencia. Así que:

\d+(?=px)

para 12px coincide con 12, sin incluir px. Si imprimes match[0] y no encuentras px dentro, no es un fallo: es la definición misma de la anticipación. Del mismo modo, la anticipación negativa (?!...), la retrospección (?<=...) y la retrospección negativa (?<!...) son todas de ancho cero: enmarcan el contexto sin consumirlo.

Esa propiedad de «no ocupar caracteres» es justo lo que las hace valiosas. Para sacar el valor de entre las comillas sin las comillas, puedes escribir:

(?<=href=")[^"]*(?=")

La retrospección afirma «me precede href="», la anticipación afirma «me sigue "», y el [^"]* del medio es la única parte que de verdad se consume y se devuelve: las comillas son solo condiciones, no resultado, lo que te ahorra un slice posterior en ambos extremos. Pégalo en el probador de regex y lo verás con claridad: las regiones de condición no se resaltan, solo se colorea el tramo realmente consumido; la palabra abstracta «ancho cero» hecha concreta de un vistazo. (Un extra de JavaScript: su retrospección admite patrones de longitud variable, a diferencia de algunos motores que solo permiten retrospección de longitud fija, así que (?<=\w+=") es válido en JS.)

Las tres capas de Unicode: ASCII, puntos de código, clústeres de grafemas

En cuanto tu texto contiene chino, emoji o cualquier carácter no ASCII, hay toda una capa de trampas esperando, y es más honda de lo que la mayoría supone. El «soporte Unicode» viene en realidad en tres capas, y cada una puede morderte.

Capa uno: deja de suponer que \d y \w conocen lo no ASCII. En JavaScript, \d coincide por defecto solo con 0-9 ASCII y \w reconoce solo [A-Za-z0-9_], así que \w+ no captura ni un solo carácter han. Para coincidir por categoría Unicode, activa la bandera u y cambia a los escapes de propiedad \p{...}: \p{L} es una letra en cualquier idioma, \p{N} es un número, \p{Han} es caracteres han en concreto, y /\p{L}+/u por fin puede coincidir con una palabra china. Aquí va el detalle profundamente contraintuitivo: activar u no hará que \d, \w o \b entiendan Unicode; siguen con semántica ASCII. Para coincidir con «cualquier dígito», incluidos los de ancho completo y los arábigo-índicos, tienes que escribir \p{Nd}, no confiar en que u ascienda \d.

Capa dos: u hace que el motor recorra por punto de código en vez de por unidad de código UTF-16. Sin u, un carácter que ocupa dos unidades de código (como muchos emoji) queda partido por la mitad por ., y lo que recortas es basura. Con u, . maneja puntos de código completos, y una notación como \u{1F600} por fin se reconoce.

Capa tres, la que más se pasa por alto: un punto de código todavía no es «un carácter». Lo que el ojo ve como un solo carácter es, técnicamente, un clúster de grafemas, que puede ensamblarse a partir de varios puntos de código: un emoji con tono de piel, una bandera, un emoji de familia como 👨‍👩‍👧 pegado con uniones de ancho cero, son todos combinaciones de varios puntos de código. Aun con u activada, . coincide solo con uno de esos puntos de código, así que todavía puedes despedazar un emoji. Para tratar las cosas como «caracteres tal y como los ve la gente» hay dos caminos: la bandera v de ES2024 (unicodeSets) trae operaciones de conjuntos y propiedades de cadena como \p{RGI_Emoji}, capaces de coincidir con un emoji compuesto como un todo; y para la segmentación de grafemas en general, usa Intl.Segmenter, que no es una regex, pero encaja mejor para partir el texto según la percepción humana. El probador de este sitio ofrece hoy u y no un interruptor de v; si necesitas v, verifícalo en tu entorno de destino. Cuando trabajes con emoji o texto multilingüe, decide de antemano qué capa quieres: te ahorra toda una clase de problemas del tipo «coincidí con medio carácter».

Coincide bien, pero congela la página: retroceso catastrófico

El último tipo de «mal» no es una coincidencia errónea, sino la coincidencia que se cuelga en cuanto se ejecuta: la pestaña deja de responder, la CPU se clava. Esto es el retroceso catastrófico (catastrophic backtracking), y es el mecanismo detrás de los ataques de denegación de servicio ReDoS. Con el modelo del motor de antes, ahora puedes entenderlo desde sus fundamentos.

La forma clásica son los cuantificadores anidados:

(a+)+$

Ejecútalo contra una cadena de aaaaaaaaaaX (cuyo final, a propósito, no es lo que quiere). El a+ interno y el + externo pueden coincidir con el mismo lote de caracteres, así que esa tira de aes tiene un número exponencial de formas de repartirse en grupos: (a)(a)(a)…, (aa)(a)…, (a)(aa)…, etcétera; y ese $ final nunca puede cumplirse ante la X, lo que obliga al motor a retroceder por cada reparto antes de atreverse a declarar el fracaso. Alarga un poco la entrada y el tiempo hasta fallar una coincidencia sube de forma abrupta; cuánto exactamente depende del navegador, del hardware y de la implementación del motor, así que no se puede afirmar con un número fijo de caracteres. El perfil del culpable es fijo: un cuantificador envuelto en otro, donde ambos pueden coincidir con el mismo lote de caracteres; (a+)+, (.*)*, (\d+)* son todos de esta familia; (.*?)*, que anida lo perezoso dentro de lo voraz, es igual de peligroso.

Aquí es donde cobra sentido lo que preparé antes: por qué, allá en la sección voraz, te insistí en usar [^"]* en vez de .*?. Una clase de caracteres negada traza el límite por adelantado, sin necesidad de tantear de acá para allá entre todas las posiciones. JavaScript no tiene ahora mismo grupos atómicos ni cuantificadores posesivos —las dos sintaxis que acotan el retroceso de forma directa—, así que la jugada de referencia es reescribir el patrón para eliminar la ambigüedad: que los rangos de coincidencia de cada rama no se solapen, añadir anclas en los puntos adecuados, cambiar el .* sin límites por una clase de caracteres explícita. El probador de regex de este sitio comprueba el riesgo potencial de ReDoS de forma asíncrona; cuando detecta riesgo muestra un ejemplo de ataque, y cuando no puede completar el diagnóstico lo indica con claridad. Es bueno para pillar problemas pronto, pero no sustituye a una prueba de rendimiento a escala de entrada real.

Una lista que puedes seguir de verdad

La próxima vez que una regex se porte mal, no corras a apilar caracteres. Vuelve al punto de vista del motor y sigue este orden; localizarás el problema casi siempre.

  1. Primero mira dónde coincide ahora. Pega el patrón y el texto en el probador de regex y observa el resaltado; no adivines. Ocho de cada diez veces el problema se muestra justo aquí.
  2. ¿Se pasa de largo? Un .* voraz retrocede demasiado tarde: cámbialo a .*?, o mejor, a la clase de caracteres negada [^…]*.
  3. ¿Solo coincide con el primero / la primera línea? Seguramente olvidaste activar g o m.
  4. ¿El mismo código funciona y luego no? Comprueba si estás reutilizando una regex con g y lastIndex anda haciendo de las suyas.
  5. ¿. ( [ no coinciden de forma literal? Escapa lo que haga falta con \, y asegúrate de que las barras no se comieron ni se duplicaron en la capa de la cadena.
  6. ¿El resultado tiene más o menos de lo que esperabas? Piensa si has tratado una anticipación/retrospección de ancho cero como un grupo normal.
  7. ¿El chino o los emoji no coinciden, o salen despedazados? Separa las tres capas —ASCII, punto de código, clúster de grafemas—: activa u, usa \p{...} y echa mano de la bandera v o de Intl.Segmenter cuando haga falta.
  8. ¿Se cuelga en cuanto se ejecuta? Busca cuantificadores anidados: eso es retroceso catastrófico, que en JS se resuelve reescribiendo el patrón.

Acertar con la regex suele ser donde empieza el trabajo. Sustituir en bloque lo que has capturado por otra cosa es lo que hace el reemplazador de texto con los grupos de captura de la regex; confirmar qué cambió exactamente entre dos versiones de un texto es tarea de la herramienta de comparación de texto. Pero cada paso arranca del mismo sitio: una regex cuyas coincidencias, y el motivo de cada paso que da, puedes ver con claridad, no una que solo puedes mirar fijamente y adivinar.