Grupos de captura en expresiones regulares: numerados, con nombre, no capturantes y reemplazos

Los paréntesis en una expresión regular hacen dos cosas a la vez: agrupan parte del patrón para aplicarle un cuantificador o una alternativa, y capturan el texto que coincidió para usarlo después. Esa doble función es la fuente de casi todos los errores con grupos: números que se desplazan al añadir un paréntesis, grupos repetidos que «pierden» valores y reemplazos que funcionan en un lenguaje y no en otro.

Grupos numerados

Cada paréntesis de apertura crea un grupo, numerado de izquierda a derecha según la posición del (. El grupo 0 es siempre la coincidencia completa.

const m = /(\d{4})-(\d{2})-(\d{2})/.exec('Entrega: 2026-09-28');
m[0];   // '2026-09-28'   coincidencia completa
m[1];   // '2026'
m[2];   // '09'
m[3];   // '28'
m.index // 9: posición donde empieza
import re
m = re.search(r'(\d{4})-(\d{2})-(\d{2})', 'Entrega: 2026-09-28')
m.group(1), m.groups()     # '2026', ('2026', '09', '28')

Los grupos anidados también cuentan por su paréntesis de apertura: en ((\d{4})-(\d{2})), el grupo 1 es 2026-09, el 2 es 2026 y el 3 es 09. El problema de los números es la fragilidad: añadir un paréntesis al principio del patrón cambia todos los números siguientes y rompe el código que los usa.

Grupos con nombre

Los grupos con nombre resuelven esa fragilidad y hacen el patrón autodocumentado. La sintaxis varía según el motor.

JavaScript, PCRE (PHP), Java, .NET   (?<anio>\d{4})
Python, Go                          (?P<anio>\d{4})      Go 1.22+ acepta también (?<anio>...)
const { groups } = /(?<anio>\d{4})-(?<mes>\d{2})/.exec('2026-09');
groups.anio;   // '2026'

# Python
re.match(r'(?P<anio>\d{4})-(?P<mes>\d{2})', '2026-09').group('anio')   # '2026'
re.match(r'(?P<anio>\d{4})-(?P<mes>\d{2})', '2026-09').groupdict()    # {'anio': '2026', 'mes': '09'}

Un grupo con nombre sigue teniendo número, así que $1 y groups.anio se refieren al mismo texto. En Python la sintaxis (?<nombre>…) no es válida y produce un error al compilar; hay que usar (?P<nombre>…).

Grupos no capturantes: (?:…)

Muchas veces los paréntesis solo sirven para agrupar, por ejemplo para aplicar + a una secuencia o limitar una alternativa, y no necesitas el texto. (?:…) agrupa sin capturar.

/^(?:https?|ftp):\/\/([^/]+)/        // el grupo 1 es el host; el protocolo no captura
/(?:\d{3}-){2}\d{4}/                 // repite '123-' dos veces sin crear grupos

Usarlos por defecto cuando no necesitas el valor mantiene estables los números de los grupos que sí importan, y ahorra algo de trabajo al motor. Los lookarounds (?=…), (?!…), (?<=…) y (?<!…) tampoco capturan.

Un grupo repetido solo guarda la última repetición

Este es el error más sorprendente. Si un grupo tiene un cuantificador, captura en cada vuelta, pero solo se conserva la última.

/(\d)+/.exec('123')[1];          // '3', no '123' ni ['1','2','3']
/(\w+,)+/.exec('a,b,c,')[1];     // 'c,'

Si quieres el bloque completo, pon el cuantificador dentro del grupo: (\d+). Si quieres cada elemento por separado, no uses un grupo repetido: busca todas las coincidencias de un patrón más simple con matchAll en JavaScript o findall/finditer en Python, o divide el texto con split. Solo .NET conserva todas las capturas de un grupo repetido, en la colección Group.Captures.

[...'a@x.com b@y.com'.matchAll(/(\w+)@(\w+)\.com/g)].map(m => [m[1], m[2]]);
// [['a','x'], ['b','y']]

re.findall(r'(\w+)@(\w+)\.com', 'a@x.com b@y.com')
# [('a', 'x'), ('b', 'y')]   -> con grupos, findall devuelve tuplas de grupos

Grupos que no participan

En (a)|(b) aplicado a b, el grupo 1 no participa en la coincidencia. Cada lenguaje lo representa de forma distinta: undefined en JavaScript, None en Python, cadena vacía '' en PHP y null en Java. Un código que concatena el grupo sin comprobarlo puede acabar escribiendo «undefined» o «None» en los datos.

La cadena vacía de PHP es especialmente engañosa, porque no distingue entre «el grupo no participó» y «el grupo capturó texto vacío». Si necesitas diferenciarlo, usa la opción PREG_UNMATCHED_AS_NULL de preg_match.

Referencias y reemplazos

Dentro del propio patrón, \1 hace referencia a lo que capturó el grupo 1. Sirve para detectar repeticiones, como palabras duplicadas o comillas que deben cerrar con el mismo carácter que abrieron.

/\b(\w+)\s+\1\b/i        // palabras repetidas: 'el el', 'The the'
/(['"])(.*?)\1/          // texto entre comillas simples o dobles, cerradas igual

En los reemplazos, la sintaxis cambia según el lenguaje, y es donde más se equivoca uno al copiar un ejemplo:

JavaScript  '2026-09'.replace(/(\d{4})-(\d{2})/, '$2/$1')            // '09/2026'
            '2026-09'.replace(/(?<a>\d{4})-(?<m>\d{2})/, '$<m>/$<a>')
Python      re.sub(r'(\d{4})-(\d{2})', r'\2/\1', '2026-09')
            re.sub(r'(?P<a>\d{4})-(?P<m>\d{2})', r'\g<m>/\g<a>', '2026-09')
PHP         preg_replace('/(\d{4})-(\d{2})/', '$2/$1', '2026-09')     // solo números
Java/.NET   "$2/$1"  y  "${m}/${a}" para grupos con nombre

Dos trampas: en Python, \10 puede interpretarse como grupo 10 o como grupo 1 seguido de un 0; \g<1>0 elimina la ambigüedad. En JavaScript, para escribir un $ literal en el reemplazo hay que usar $$.

Buenas prácticas con grupos

Usa grupos con nombre en cuanto un patrón tenga más de dos grupos o vaya a mantenerlo otra persona: el código que lee groups.anio sigue funcionando aunque alguien añada un paréntesis delante. Usa (?:…) para todo paréntesis cuyo texto no necesites, y así los números que sí usas no se desplazan.

No intentes extraer una lista con un solo patrón lleno de grupos repetidos. Un patrón sencillo aplicado con matchAll o finditer es más legible, más fácil de probar y más rápido. Y prueba siempre los grupos opcionales con una entrada donde no participen, porque ahí es donde aparecen los undefined y los None en producción.

Por último, recuerda que las referencias \1 dentro del patrón obligan al motor a retroceder, y algunos motores, como RE2 en Go, no las admiten. Si el patrón tiene que funcionar en varios lenguajes, evítalas o compruébalas en cada motor.

Fuentes y lecturas recomendadas

La guía de expresiones regulares de MDN documenta grupos, grupos con nombre y los patrones de reemplazo de JavaScript. La documentación del módulo re de Python describe (?P<nombre>…), groupdict() y \g<nombre>. El manual de PHP documenta preg_match, preg_replace y PREG_UNMATCHED_AS_NULL, y la documentación de .NET describe Group.Captures.

Frequently asked questions

¿Qué es un grupo de captura en regex?

Una parte del patrón entre paréntesis que guarda el texto que coincidió, para usarlo después en el código, en un reemplazo (`$1`, `\1`) o dentro del propio patrón como referencia.

¿Cómo se crea un grupo con nombre?

Con `(?<nombre>…)` en JavaScript, PHP, Java y .NET, y con `(?P<nombre>…)` en Python. Después se accede con `groups.nombre`, `group('nombre')` o `$<nombre>` en los reemplazos de JavaScript.

¿Qué es un grupo no capturante?

`(?:…)` agrupa parte del patrón, por ejemplo para repetirla o limitar una alternativa, sin guardar el texto ni crear un número de grupo.

¿Por qué mi grupo repetido solo devuelve el último valor?

Un grupo con cuantificador, como `(\d)+`, se sobrescribe en cada repetición y conserva solo la última. Pon el cuantificador dentro del grupo o usa `matchAll`/`findall` para obtener todas las coincidencias.

¿Cómo uso un grupo en un reemplazo?

`$1` en JavaScript, PHP, Java y .NET; `\1` o `\g<1>` en Python. Para grupos con nombre: `$<nombre>` en JavaScript, `\g<nombre>` en Python y `${nombre}` en Java y .NET.

¿Qué devuelve un grupo que no participó en la coincidencia?

`undefined` en JavaScript, `None` en Python, `null` en Java y una cadena vacía en PHP, salvo que uses `PREG_UNMATCHED_AS_NULL`. Compruébalo antes de usar el valor.