Expresiones regulares desde cero: guía práctica con grep, Python y JavaScript

Una expresión regular (en inglés, regular expression, y en corto regex) es un patrón que describe un conjunto de textos: «cuatro dígitos, un guion y dos dígitos», «una palabra que empieza con mayúscula», «cualquier línea que tenga wp-login o phpmyadmin». Con las expresiones regulares se busca, se valida, se extrae y se reemplaza texto en casi cualquier lenguaje y herramienta. Esta guía empieza desde cero y llega hasta los errores que tumban servidores. Todos los ejemplos se ejecutaron de verdad con GNU grep 3.11 (Debian 13), Python 3.14 y Node 24, y debajo de cada uno va su salida real.

Si quieres probar los patrones mientras lees, usa el probador de expresiones regulares del sitio, que funciona en el navegador.

  1. Qué es y para qué sirve
  2. Literales y metacaracteres
  3. Clases y rangos
  4. Cuantificadores: codiciosos y perezosos
  5. Anclas y límites de palabra
  6. Grupos y referencias
  7. Alternancia
  8. Lookahead y lookbehind
  9. Banderas
  10. grep, Python y JavaScript no son iguales
  11. Recetas: correos, fechas, IP, espacios y logs
  12. Errores típicos
  13. Backtracking catastrófico (ReDoS)
  14. Cuándo no usar una expresión regular
  15. Chuleta

Qué es y para qué sirve

Una expresión regular no es un programa: es una descripción que un motor de expresiones regulares recorre letra por letra sobre un texto para ver si encaja. Sirve para cuatro cosas: buscar (¿qué líneas del log mencionan wp-login?), validar (¿esto tiene forma de fecha?), extraer (dame todas las IP) y reemplazar (cambia 2026-11-07 por 07/11/2026). Las vas a encontrar en grep, sed y awk, en los editores de código, en las bases de datos y en todos los lenguajes de programación.

En los ejemplos de grep usamos un registro de servidor web, app.log, con ocho peticiones inventadas (las IP son de los rangos reservados para documentación):

192.168.1.20 - - [07/Nov/2026:09:14:02 -0600] "GET /tienda/producto/15 HTTP/1.1" 200 5123
10.0.0.7 - - [07/Nov/2026:09:14:05 -0600] "POST /wp-login.php HTTP/1.1" 302 0
203.0.113.45 - - [07/Nov/2026:09:14:09 -0600] "GET /.env HTTP/1.1" 404 153
192.168.1.20 - - [07/Nov/2026:09:15:11 -0600] "GET /tienda/carrito HTTP/1.1" 200 2087
203.0.113.45 - - [07/Nov/2026:09:15:12 -0600] "GET /wp-admin/ HTTP/1.1" 404 153
198.51.100.9 - - [07/Nov/2026:09:16:40 -0600] "GET /tienda/producto/42 HTTP/1.1" 500 612
203.0.113.45 - - [07/Nov/2026:09:16:41 -0600] "GET /phpmyadmin/ HTTP/1.1" 404 153
10.0.0.7 - - [07/Nov/2026:09:17:02 -0600] "POST /wp-login.php HTTP/1.1" 302 0

El primer patrón busca las líneas que contienen wp-login o phpmyadmin. La barra vertical significa «o», y la opción -E le dice a grep que use la sintaxis extendida, la que se parece a la de los lenguajes de programación:

grep -E 'wp-login|phpmyadmin' app.log
Salida real (GNU grep 3.11, Debian 13)10.0.0.7 - - [07/Nov/2026:09:14:05 -0600] "POST /wp-login.php HTTP/1.1" 302 0
203.0.113.45 - - [07/Nov/2026:09:16:41 -0600] "GET /phpmyadmin/ HTTP/1.1" 404 153
10.0.0.7 - - [07/Nov/2026:09:17:02 -0600] "POST /wp-login.php HTTP/1.1" 302 0

Literales y metacaracteres

Casi todos los caracteres se buscan tal cual: el patrón gato busca la palabra gato. Las excepciones son los metacaracteres, que tienen un significado especial:

  • . cualquier carácter (menos el salto de línea, salvo que se pida).
  • ^ y $ el inicio y el final del texto o de la línea.
  • *, +, ? y {n,m} cuántas veces se repite lo anterior.
  • [ ] una clase: uno de los caracteres de dentro.
  • ( ) un grupo; | una alternativa.
  • \ la barra invertida, que quita el significado especial al siguiente carácter o le da uno nuevo (\d, \b).

El error más común de quien empieza es olvidar que el punto significa «cualquier carácter». Buscar la versión v1.2 con un punto sin escapar también encuentra v152 y v1-2:

printf 'v1.2\nv152\nv1-2\n' > versiones.txt
echo '--- grep "v1.2"'
grep 'v1.2' versiones.txt
echo '--- grep "v1\.2"'
grep 'v1\.2' versiones.txt
echo '--- grep -F "v1.2" (texto fijo, sin regex)'
grep -F 'v1.2' versiones.txt
Salida real (GNU grep 3.11, Debian 13)--- grep "v1.2"
v1.2
v152
v1-2
--- grep "v1\.2"
v1.2
--- grep -F "v1.2" (texto fijo, sin regex)
v1.2

Con \. el punto vuelve a ser un punto. Si lo que buscas es un texto fijo, sin patrones, grep -F es más claro y más rápido. En Python, re.escape() escapa por ti todos los metacaracteres de un texto (lo verás en los errores típicos).

Clases y rangos

Una clase entre corchetes coincide con un solo carácter de los que contiene. Dentro se pueden poner rangos ([0-9], [a-z], [A-Z]) y, si empieza con ^, se niega: [^0-9] es «cualquier cosa que no sea un dígito». Hay atajos para las clases más usadas: \d (dígito), \w (letra, dígito o guion bajo), \s (espacio, tabulador o salto de línea) y sus contrarias en mayúscula, \D, \W y \S.

import re
texto = "En el año 2026 el café subió a $58.50 (pedido A-17)."
print(re.findall(r"[aeiou]", "murciélago"))
print(re.findall(r"[0-9]+", texto))
print(re.findall(r"[A-Z]-[0-9]+", texto))
print(re.findall(r"[^a-z ]+", "hola, mundo 2026!"))
print(re.findall(r"\d+", texto))
print(re.findall(r"\w+", "año café"))
print(re.findall(r"\s", "a b\tc\nd"))
Salida real (Python 3.14)['u', 'i', 'a', 'o']
['2026', '58', '50', '17']
['A-17']
[',', '2026!']
['2026', '58', '50', '17']
['año', 'café']
[' ', '\t', '\n']

Fíjate en la última parte: en Python, \w entiende las letras con acento y la eñe, porque trabaja con Unicode por omisión. En JavaScript no: \w solo cubre las letras del inglés, incluso con la bandera u. Para palabras en español hay que usar la propiedad Unicode \p{L} («cualquier letra»):

console.log("año café".match(/\w+/g));
console.log("año café".match(/\w+/gu));
console.log("año café".match(/[\p{L}]+/gu));
Salida real (Node 24)[ 'a', 'o', 'caf' ]
[ 'a', 'o', 'caf' ]
[ 'año', 'café' ]

Cuantificadores: codiciosos y perezosos

Los cuantificadores dicen cuántas veces se repite lo que tienen a la izquierda: ? cero o una vez (lo hace opcional), * cero o más, + una o más, {3} exactamente tres, {2,4} de dos a cuatro y {2,} dos o más.

import re
for palabra in ["color", "colour", "colouur"]:
    print(palabra, bool(re.fullmatch(r"colou?r", palabra)))
print(re.findall(r"\d{3}", "1 12 123 1234"))
print(re.findall(r"\b\d{2,4}\b", "1 12 123 1234 12345"))
print(re.findall(r"ba*", "b ba baaa"))
print(re.findall(r"ba+", "b ba baaa"))
Salida real (Python 3.14)color True
colour True
colouur False
['123', '123']
['12', '123', '1234']
['b', 'ba', 'baaa']
['ba', 'baaa']

Dos detalles de esa salida. \d{3} encontró dos veces 123: la segunda sale de los tres primeros dígitos de 1234, porque el patrón no exige que el número termine ahí. Con \b en los extremos (lo vemos en las anclas) solo se aceptan números completos de dos a cuatro cifras.

Por omisión, los cuantificadores son codiciosos: se estiran todo lo que pueden y solo ceden si el resto del patrón no encaja. Con un ? detrás se vuelven perezosos: toman lo mínimo. La diferencia se ve clarísima con etiquetas:

import re
html = "<b>hola</b> y <i>adiós</i>"
print(re.findall(r"<.+>", html))
print(re.findall(r"<.+?>", html))
print(re.findall(r"<[^>]+>", html))
Salida real (Python 3.14)['<b>hola</b> y <i>adiós</i>']
['<b>', '</b>', '<i>', '</i>']
['<b>', '</b>', '<i>', '</i>']

Codicioso contra perezosoQué atrapa cada patrón en el mismo texto<.+> codicioso: se estira todo lo que puede<b>hola</b> y <i>adiós</i>1 coincidencia, de la primera < a la última ><.+?> perezoso: se detiene en la primera ><b>hola</b> y <i>adiós</i>4 coincidencias: <b>, </b>, <i> y </i><[^>]+> la clase negada hace lo mismo<b>hola</b> y <i>adiós</i>y no depende de que el motor sea perezoso

El mismo texto con tres patrones: el codicioso se lleva todo de un jalón; el perezoso y la clase negada se detienen en cada >. Esquema: decodigo.com.

La tercera forma, <[^>]+> («un menor que, luego todo lo que no sea mayor que, luego un mayor que»), suele ser la mejor: dice exactamente lo que quieres y el motor no tiene que probar y retroceder.

Anclas y límites de palabra

Las anclas no coinciden con caracteres, sino con posiciones. ^ es el principio y $ el final; \b es un límite de palabra, el punto entre una letra y algo que no lo es. Con \b se evita que gato encuentre también «garabato» o «gatos»:

import re
frase = "El gato y los gatos persiguen al garabato"
print(re.findall(r"gato", frase))
print(re.findall(r"\bgato\b", frase))
print(re.findall(r"\bgat\w*", frase))
lineas = "uno\ndos\ntres"
print(re.findall(r"^\w+", lineas))
print(re.findall(r"^\w+", lineas, re.M))
print(bool(re.search(r"^\d+$", "12345")), bool(re.search(r"^\d+$", "123a5")))
Salida real (Python 3.14)['gato', 'gato']
['gato']
['gato', 'gatos']
['uno']
['uno', 'dos', 'tres']
True False

Sin opciones, ^ y $ se refieren al principio y al final de todo el texto; con la bandera re.M (multilínea) se refieren a cada línea. Y ^\d+$ es la forma de exigir que el texto entero sean dígitos: sin las anclas, 123a5 también tendría «dígitos en alguna parte». En grep, cada línea se revisa por separado, y -w equivale a poner \b en los dos extremos:

printf 'gato\ngatos\nel gato duerme\ngarabato\n' > gatos.txt
echo '--- grep "gato"'
grep 'gato' gatos.txt
echo '--- grep -w "gato"'
grep -w 'gato' gatos.txt
echo '--- grep "^203" app.log | wc -l'
grep '^203' app.log | wc -l
Salida real (GNU grep 3.11, Debian 13)--- grep "gato"
gato
gatos
el gato duerme
--- grep -w "gato"
gato
el gato duerme
--- grep "^203" app.log | wc -l
3

Grupos y referencias

Los paréntesis forman un grupo. Sirven para dos cosas: aplicar un cuantificador a varias letras a la vez ((ha)+) y capturar lo que coincidió, para usarlo después.

Cómo se lee una expresión regularUna fecha con forma 2026-11-07^(\d{4})-(\d{2})-(\d{2})$^inicio del texto(\d{4})grupo 1: cuatro dígitos–un guion, tal cual(\d{2})grupo 2: dos dígitos–otro guion(\d{2})grupo 3: dos dígitos$fin del textoCon el texto 2026-11-07:grupo 12026grupo 211grupo 307Con 2026-11-7 o 26-11-07 no hay coincidencia.

Las piezas de un patrón de fecha y lo que captura cada grupo. Esquema: decodigo.com.
import re
fecha = re.search(r"(\d{4})-(\d{2})-(\d{2})", "Corte del 2026-11-07 a las 9:00")
print(fecha.group(0), fecha.group(1), fecha.group(2), fecha.group(3))
print(re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", "Del 2026-11-07 al 2026-12-24"))
m = re.search(r"(?P<anio>\d{4})-(?P<mes>\d{2})-(?P<dia>\d{2})", "2026-11-07")
print(m.groupdict())
print(re.findall(r"(?:ha)+", "jajaja hahaha"))
print(re.findall(r"(ha)+", "jajaja hahaha"))
print(re.findall(r"\b(\w+) \1\b", "es es un error de de dedo"))
Salida real (Python 3.14)2026-11-07 2026 11 07
Del 07/11/2026 al 24/12/2026
{'anio': '2026', 'mes': '11', 'dia': '07'}
['hahaha']
['ha']
['es', 'de']

En esa salida hay cinco ideas. group(1), group(2) y group(3) son lo que capturó cada par de paréntesis, contando desde la izquierda. En un reemplazo, \3/\2/\1 los reordena. Los grupos con nombre, (?P<anio>…) en Python, son más fáciles de leer. (?:…) agrupa sin capturar: por eso findall devuelve la coincidencia completa, hahaha, y no solo el último ha. Y \1 dentro del patrón es una referencia a lo ya capturado: \b(\w+) \1\b encuentra palabras repetidas como «de de».

En JavaScript los grupos con nombre se escriben sin la P, (?<anio>…), y en el reemplazo se usan $1 o $<nombre>:

const m = "2026-11-07".match(/(?<anio>\d{4})-(?<mes>\d{2})-(?<dia>\d{2})/);
console.log(m.groups.anio, m.groups.mes, m.groups.dia);
console.log("Del 2026-11-07 al 2026-12-24".replace(/(\d{4})-(\d{2})-(\d{2})/g, "$3/$2/$1"));
console.log("2026-11-07".replace(/(?<anio>\d{4})-(?<mes>\d{2})-(?<dia>\d{2})/, "$<dia>.$<mes>.$<anio>"));
Salida real (Node 24)2026 11 07
Del 07/11/2026 al 24/12/2026
07.11.2026

Alternancia

La barra vertical separa alternativas, y tiene la prioridad más baja de todas: ^gato|perro$ no significa «el texto es gato o perro», sino «empieza con gato» o «termina con perro». Para que las anclas abracen a las dos opciones hay que agruparlas:

import re
for t in ["gato", "perro", "gatopardo", "superperro"]:
    sin = bool(re.search(r"^gato|perro$", t))
    con = bool(re.search(r"^(gato|perro)$", t))
    print(f"{t:11} ^gato|perro$ -> {sin!s:5}  ^(gato|perro)$ -> {con}")
Salida real (Python 3.14)gato        ^gato|perro$ -> True   ^(gato|perro)$ -> True
perro       ^gato|perro$ -> True   ^(gato|perro)$ -> True
gatopardo   ^gato|perro$ -> True   ^(gato|perro)$ -> False
superperro  ^gato|perro$ -> True   ^(gato|perro)$ -> False

Lookahead y lookbehind

Los lookaround son anclas a la medida: revisan lo que hay antes o después de una posición sin incluirlo en la coincidencia. Hay cuatro:

  • (?=…) lookahead: lo que sigue tiene que encajar.
  • (?!…) lookahead negativo: lo que sigue no puede encajar.
  • (?<=…) lookbehind: lo de antes tiene que encajar.
  • (?<!…) lookbehind negativo: lo de antes no puede encajar.
import re
texto = "Envío $99, producto $1299.50 y garantía 12 meses"
print(re.findall(r"(?<=\$)\d+(?:\.\d{2})?", texto))
print(re.findall(r"\d+(?= meses)", texto))
print(re.findall(r"\w+\.(?!exe\b)\w+", "foto.jpg virus.exe notas.txt"))
for clave in ["secreto", "Secreto123", "secreto123", "SECRETO1"]:
    ok = bool(re.fullmatch(r"(?=.*\d)(?=.*[A-Z]).{8,}", clave))
    print(f"{clave:11} {ok}")
try:
    re.compile(r"(?<=\$|USD )\d+")
except re.error as e:
    print("re.error:", e)
Salida real (Python 3.14)['99', '1299.50']
['12']
['foto.jpg', 'notas.txt']
secreto     False
Secreto123  True
secreto123  False
SECRETO1    True
re.error: look-behind requires fixed-width pattern

El primer patrón saca los importes que van después de un $ sin incluir el signo. El segundo, los números seguidos de « meses». El tercero, los archivos cuya extensión no es exe. El de las contraseñas usa dos lookahead al principio para exigir «al menos un dígito» y «al menos una mayúscula» sin importar dónde estén, y después pide ocho caracteres o más. La última línea es una limitación de Python: su lookbehind tiene que tener ancho fijo, y \$|USD mide 1 o 4 caracteres. JavaScript sí lo acepta:

console.log("Pago $40 y USD 75".match(/(?<=\$|USD )\d+/g));
Salida real (Node 24)[ '40', '75' ]

Banderas

Las banderas cambian cómo se interpreta el patrón entero. En Python se pasan como argumento (re.I, re.M, re.S, re.X); en JavaScript van después de la barra de cierre (/…/gi); en grep son opciones como -i.

  • i (re.I, /i, grep -i): sin distinguir mayúsculas de minúsculas.
  • m (re.M, /m): ^ y $ valen en cada línea.
  • s (re.S, /s): el punto también coincide con el salto de línea.
  • x (re.X, solo Python): permite espacios y comentarios dentro del patrón, para escribirlo en varias líneas.
  • g (solo JavaScript): buscar todas las coincidencias, no solo la primera.
import re
print(re.findall(r"error", "Error, ERROR y error"))
print(re.findall(r"error", "Error, ERROR y error", re.I))
print(re.findall(r"<p>.*</p>", "<p>uno\ndos</p>"))
print(re.findall(r"<p>.*</p>", "<p>uno\ndos</p>", re.S))
fecha = re.compile(r"""
    (?P<anio>\d{4}) -   # año
    (?P<mes>\d{2})  -   # mes
    (?P<dia>\d{2})      # día
""", re.X)
print(fecha.fullmatch("2026-11-07").groupdict())
Salida real (Python 3.14)['error']
['Error', 'ERROR', 'error']
[]
['<p>uno\ndos</p>']
{'anio': '2026', 'mes': '11', 'dia': '07'}

La bandera g de JavaScript tiene una trampa famosa. Una expresión con g recuerda dónde se quedó en su propiedad lastIndex, y test() sigue buscando desde ahí aunque le pases otro texto. Con el mismo patrón y cuatro textos idénticos, la respuesta alterna entre true y false:

console.log("Error, ERROR y error".match(/error/gi));
const re = /gato/g;
for (const t of ["gato", "gato", "gato", "gato"]) console.log(t, re.test(t), "lastIndex =", re.lastIndex);
Salida real (Node 24)[ 'Error', 'ERROR', 'error' ]
gato true lastIndex = 4
gato false lastIndex = 0
gato true lastIndex = 4
gato false lastIndex = 0

La solución es no usar g con test(), o crear la expresión dentro de la función que la usa.

grep, Python y JavaScript no son iguales

No hay una sola sintaxis de expresiones regulares, sino varias familias. Las tres que más vas a encontrar:

  • grep sin opciones usa la sintaxis básica (BRE): +, ?, |, ( ) y { } son caracteres normales, y para usarlos como operadores hay que escaparlos (\+, \|).
  • grep -E (y egrep) usa la extendida (ERE), de POSIX: ya entiende +, ?, | y los grupos, pero no tiene \d, grupos sin captura ni lookaround. En su lugar usa clases POSIX como [[:digit:]], [[:alpha:]] o [[:space:]].
  • grep -P, Python, JavaScript, PHP, Java y C# usan variantes de la familia Perl/PCRE: \d, perezosos, grupos con nombre y lookaround, con diferencias de detalle como las que ya vimos.

La diferencia más traicionera es que grep -E no da error con \d: lo interpreta como una letra d. Por eso, en este archivo, \d+ no encuentra a1 ni b22, pero sí dd:

printf 'a1\nb22\nccc\ndd\n' > mezcla.txt
echo '--- grep -E "\d+"'
grep -E '\d+' mezcla.txt; echo "código de salida: $?"
echo '--- grep -E "[0-9]+"'
grep -E '[0-9]+' mezcla.txt
echo '--- grep -E "[[:digit:]]+"'
grep -E '[[:digit:]]+' mezcla.txt
echo '--- grep -P "\d+"'
grep -P '\d+' mezcla.txt
echo '--- grep "c+" (BRE: el + es literal)'
grep 'c+' mezcla.txt; echo "código de salida: $?"
echo '--- grep -E "c+"'
grep -E 'c+' mezcla.txt
Salida real (GNU grep 3.11, Debian 13)--- grep -E "\d+"
dd
código de salida: 0
--- grep -E "[0-9]+"
a1
b22
--- grep -E "[[:digit:]]+"
a1
b22
--- grep -P "\d+"
a1
b22
--- grep "c+" (BRE: el + es literal)
código de salida: 1
--- grep -E "c+"
ccc

Con un lookbehind pasa algo parecido: grep -E no lo entiende y solo avisa con un mensaje confuso; grep -P sí lo ejecuta:

echo 'Envío $99, producto $1299.50' > precios.txt
echo '--- grep -oE "(?<=\$)[0-9]+"'
grep -oE '(?<=\$)[0-9]+' precios.txt; echo "código de salida: $?"
echo '--- grep -oP "(?<=\$)[0-9]+"'
grep -oP '(?<=\$)[0-9]+' precios.txt
Salida real (GNU grep 3.11, Debian 13)--- grep -oE "(?<=\$)[0-9]+"
grep: warning: ? at start of expression
código de salida: 1
--- grep -oP "(?<=\$)[0-9]+"
99
1299

Para trabajar con texto desde la terminal tienes las guías de comandos de Linux y de procesar texto en Bash. Y cada lenguaje del sitio tiene su página de expresiones regulares con lo propio de su biblioteca: JavaScript, PHP, Java, C#, Go y Rust.

Recetas: correos, fechas, IP, espacios y logs

Cuatro recetas que se usan todos los días, con sus límites:

import re

correo = re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+")
for c in ["ana@tutienda.mx", "ana.perez+avisos@correo.com.mx", "ana@tutienda", "ana @tutienda.mx"]:
    print(f"{c:32} {bool(correo.fullmatch(c))}")

fecha = re.compile(r"\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])")
for f in ["2026-11-07", "2026-13-01", "2026-02-31"]:
    print(f, bool(fecha.fullmatch(f)))

octeto = r"(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)"
ipv4 = re.compile(rf"{octeto}(\.{octeto}){{3}}")
for ip in ["192.168.1.20", "10.0.0.255", "256.1.1.1", "1.2.3"]:
    print(f"{ip:13} {bool(ipv4.fullmatch(ip))}")

sucio = "  Hola,\t\tmundo   cruel  \n"
print(repr(re.sub(r"\s+", " ", sucio).strip()))
Salida real (Python 3.14)ana@tutienda.mx                  True
ana.perez+avisos@correo.com.mx   True
ana@tutienda                     False
ana @tutienda.mx                 False
2026-11-07 True
2026-13-01 False
2026-02-31 True
192.168.1.20  True
10.0.0.255    True
256.1.1.1     False
1.2.3         False
'Hola, mundo cruel'
  • Correo razonable. [^\s@]+@[^\s@]+\.[^\s@]+ pide «algo, una arroba, algo, un punto y algo», sin espacios. Atrapa los errores de dedo, que es lo que importa en un formulario. Un patrón que cumpla al pie de la letra la norma de los correos es enorme y aun así no te dice si el buzón existe: para eso se manda un correo de confirmación.
  • Fechas. El patrón comprueba la forma (mes del 01 al 12, día del 01 al 31), pero acepta el 31 de febrero. Para saber si una fecha existe, conviértela con la biblioteca de fechas de tu lenguaje.
  • IPv4. \d{1,3} por octeto aceptaría 256 o 999; la alternancia 25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d limita cada número a 0-255.
  • Espacios. \s+ reemplazado por un espacio junta tabuladores, saltos de línea y espacios repetidos; strip() quita los de los extremos.

Y la receta que más tiempo ahorra: sacar datos de un log. Con grep, -o imprime solo la parte que coincidió, y combinado con sort y uniq -c cuenta. Aquí, qué IP recibió errores 404 (alguien buscando .env, wp-admin y phpmyadmin) y cuántas veces salió cada código de respuesta:

echo '--- IP que recibieron un 404, de más a menos'
grep -E '" 404 ' app.log | grep -oE '^[0-9.]+' | sort | uniq -c | sort -rn
echo '--- códigos de respuesta'
grep -oE '" [0-9]{3} ' app.log | tr -d '" ' | sort | uniq -c
Salida real (GNU grep 3.11, Debian 13)--- IP que recibieron un 404, de más a menos
      3 203.0.113.45
--- códigos de respuesta
      2 200
      2 302
      3 404
      1 500

En Python, un patrón con grupos con nombre convierte cada línea en un diccionario, y a partir de ahí puedes contar y filtrar lo que quieras. Para saber qué buscar en los registros de un servidor de verdad, sigue con la guía Cómo saber si atacaron tu WordPress.

import re
from collections import Counter

LINEA = re.compile(
    r'(?P<ip>\S+) \S+ \S+ \[(?P<fecha>[^\]]+)\] '
    r'"(?P<metodo>[A-Z]+) (?P<ruta>\S+) [^"]*" (?P<codigo>\d{3}) (?P<bytes>\d+)'
)
registros = [LINEA.match(l).groupdict() for l in open("app.log")]
print(registros[2])
print(Counter(r["codigo"] for r in registros))
print(sorted({r["ruta"] for r in registros if r["codigo"] == "404"}))
Salida real (Python 3.14){'ip': '203.0.113.45', 'fecha': '07/Nov/2026:09:14:09 -0600', 'metodo': 'GET', 'ruta': '/.env', 'codigo': '404', 'bytes': '153'}
Counter({'404': 3, '200': 2, '302': 2, '500': 1})
['/.env', '/phpmyadmin/', '/wp-admin/']

Errores típicos

Los tropiezos que más se repiten, todos en un solo programa:

import re
print(re.match(r"\d+", "pedido 1042"))
print(re.search(r"\d+", "pedido 1042"))
print(re.findall("\bgato\b", "el gato duerme"))
print(re.findall(r"\bgato\b", "el gato duerme"))
print(re.findall(r"[^0-9]", "a1"), re.findall(r"^[0-9]", "1a"))
print(re.escape("¿Cuánto cuesta? $1.50 (IVA)"))
Salida real (Python 3.14)None
<re.Match object; span=(7, 11), match='1042'>
[]
['gato']
['a'] ['1']
¿Cuánto\ cuesta\?\ \$1\.50\ \(IVA\)
  • re.match en lugar de re.search. En Python, match solo busca al principio del texto; por eso devuelve None con «pedido 1042». search busca en cualquier parte, y fullmatch exige que coincida el texto entero (ideal para validar).
  • Olvidar la r delante del patrón. En una cadena normal de Python, "\b" es el carácter de retroceso, no un límite de palabra, y el patrón no encuentra nada sin dar error. Escribe siempre los patrones como cadenas crudas, r"…".
  • Confundir los dos ^. Dentro de corchetes, al principio, niega la clase ([^0-9]); fuera, es el inicio del texto (^[0-9]).
  • Meter texto del usuario sin escapar. Si armas un patrón con algo que escribió el usuario, sus ?, $ o paréntesis se vuelven operadores. re.escape() los neutraliza.
  • Usar .* para todo. Es codicioso y casi siempre atrapa de más; una clase negada como [^"]* dice mejor lo que quieres.

Backtracking catastrófico (ReDoS)

La mayoría de los motores (los de Python, JavaScript, PHP, Java, C# y Perl) funcionan con backtracking: cuando un camino falla, regresan y prueban otra forma de repartir las letras. Casi siempre eso es instantáneo, pero hay patrones en los que el número de repartos posibles crece de forma exponencial. El ejemplo clásico es (a+)+$, un cuantificador dentro de otro, contra una fila de letras a que termina en algo que no encaja:

import re, time

def medir(patron, n):
    texto = "a" * n + "!"
    t0 = time.perf_counter()
    re.match(patron, texto)
    return time.perf_counter() - t0

for n in (16, 18, 20, 22, 24, 26):
    print(f"(a+)+$   n={n}: {medir(r'(a+)+$', n):8.3f} s")
for n in (24, 10_000):
    print(f"a+$      n={n}: {medir(r'a+$', n):8.3f} s")
    print(f"(?>a+)+$ n={n}: {medir(r'(?>a+)+$', n):8.3f} s")
Salida real (Python 3.14)(a+)+$   n=16:    0.003 s
(a+)+$   n=18:    0.011 s
(a+)+$   n=20:    0.050 s
(a+)+$   n=22:    0.194 s
(a+)+$   n=24:    0.746 s
(a+)+$   n=26:    2.893 s
a+$      n=24:    0.000 s
(?>a+)+$ n=24:    0.000 s
a+$      n=10000:    0.000 s
(?>a+)+$ n=10000:    0.000 s

Dos letras más, cuatro veces más lento(a+)+$ contra n letras a y un signo !n=160.003 sn=180.011 sn=200.050 sn=220.194 sn=240.746 sn=262.893 sCon (?>a+)+$ o con a+$, n=24: 0.000 sMismo resultado, sin probar combinaciones.Medido en Python 3.14, un solo intento por fila.

El tiempo se multiplica por cuatro cada dos letras: con 26 ya son casi tres segundos, y con 40 serían horas. Esquema: decodigo.com.

Node aguanta un poco más, pero la curva es la misma:

for (const n of [20, 22, 24, 26, 28, 30]) {
  const texto = "a".repeat(n) + "!";
  const t0 = performance.now();
  /^(a+)+$/.test(texto);
  console.log(`(a+)+$ n=${n}: ${((performance.now() - t0) / 1000).toFixed(3)} s`);
}
Salida real (Node 24)(a+)+$ n=20: 0.042 s
(a+)+$ n=22: 0.018 s
(a+)+$ n=24: 0.076 s
(a+)+$ n=26: 0.302 s
(a+)+$ n=28: 1.210 s
(a+)+$ n=30: 4.830 s

A esto se le llama ReDoS (denegación de servicio por expresión regular): basta con que un atacante mande un texto preparado a un formulario o a una API que valida con un patrón así para dejar un procesador ocupado durante minutos. No es teoría. El 2 de julio de 2019, una regla del firewall de Cloudflare con una expresión de este tipo llevó al 100 % el uso de procesador de sus servidores en todo el mundo durante 27 minutos. En 2016, Stack Overflow estuvo 34 minutos caído porque una publicación con unos 20,000 espacios seguidos puso a trabajar de más a la expresión que recortaba los espacios del final de cada línea; ahí el trabajo crecía con el cuadrado del número de espacios, no de forma exponencial, y aun así bastó.

Cómo evitarlo:

  • No anides cuantificadores sobre lo mismo ((a+)+, (\w*)*, (a|aa)+). Casi siempre hay una forma equivalente sin anidar: aquí, a+$.
  • Usa grupos atómicos o cuantificadores posesivos, que prohíben retroceder: (?>a+) o a++. Python los acepta desde la versión 3.11, igual que Java, PHP y Perl; JavaScript no.
  • Limita el tamaño de la entrada antes de aplicar el patrón, y usa límites de tiempo donde existan (en .NET, el parámetro matchTimeout).
  • Elige un motor sin backtracking si procesas texto de desconocidos: los de Go y Rust y la biblioteca RE2 garantizan un tiempo proporcional al tamaño del texto, a cambio de no tener referencias ni lookaround.

Cuándo no usar una expresión regular

Las expresiones regulares son para texto plano y regular. Para formatos con estructura anidada, como HTML, XML o JSON, se usa un analizador (parser): el patrón funciona con los ejemplos que pruebas y se rompe con el primero que no previste. Aquí, un atributo con un > dentro y una etiqueta anidada confunden al patrón, mientras que el analizador de HTML de Python lo resuelve bien. Y en JSON, unas comillas escapadas bastan para que el patrón corte el texto a la mitad:

import re, json
from html.parser import HTMLParser

html = '<a href="/ofertas" title="2 > 1">Ofertas</a> <a href="/blog">Blog <b>nuevo</b></a>'
print(re.findall(r"<a [^>]*>(.*?)</a>", html))

class Enlaces(HTMLParser):
    def __init__(self):
        super().__init__(); self.dentro = False; self.textos = []
    def handle_starttag(self, tag, attrs):
        if tag == "a": self.dentro = True; self.textos.append("")
    def handle_endtag(self, tag):
        if tag == "a": self.dentro = False
    def handle_data(self, data):
        if self.dentro: self.textos[-1] += data

p = Enlaces(); p.feed(html); print(p.textos)

datos = '{"nombre": "Ana", "nota": "dijo \\"hola\\", luego \\"adiós\\""}'
print(re.findall(r'"nota": "([^"]*)"', datos))
print(json.loads(datos)["nota"])
Salida real (Python 3.14)[' 1">Ofertas', 'Blog <b>nuevo</b>']
['Ofertas', 'Blog nuevo']
['dijo \\']
dijo "hola", luego "adiós"

Tampoco son la herramienta adecuada para validar fechas de calendario, números de tarjeta (para eso está el algoritmo de Luhn) o cualquier regla que dependa de cálculos. Un buen criterio: si el patrón ya no cabe en una línea y nadie de tu equipo lo entiende al leerlo, probablemente lo que necesitas es código normal.

Chuleta

  • . cualquier carácter · \. un punto
  • [abc] uno de esos · [^abc] cualquiera menos esos · [a-z0-9] rangos
  • \d dígito · \w letra, dígito o _ · \s espacio · \D \W \S lo contrario
  • ? 0 o 1 · * 0 o más · + 1 o más · {n} n · {n,m} de n a m
  • *? +? ?? versiones perezosas · *+ ++ posesivas · (?>…) grupo atómico
  • ^ inicio · $ final · \b límite de palabra
  • (…) grupo que captura · (?:…) sin capturar · (?P<n>…) o (?<n>…) con nombre · \1 referencia
  • a|b una u otra (prioridad más baja: agrupa)
  • (?=…) (?!…) (?<=…) (?<!…) lo que sigue o lo que precede
  • Banderas: i mayúsculas · m multilínea · s el punto incluye el salto · x comentarios · g todas (JavaScript)

Todos los ejemplos se ejecutaron el 7 de noviembre de 2026 en contenedores sin red: GNU grep 3.11 en Debian 13, Python 3.14 y Node 24. Referencias: módulo re de Python, expresiones regulares en MDN, manual de GNU grep, ReDoS en OWASP, caída de Cloudflare de 2019 y caída de Stack Overflow de 2016.