Esta lección salda una deuda que arrastramos desde el Módulo 2. Cuando presentamos grep en 02-02 dijimos explícitamente que sus patrones son mucho más potentes que los comodines y que lo dejábamos para más adelante; cuando en 02-05 comparamos el globbing con otras formas de emparejar texto, volvimos a aplazarlo. Y al cerrar 05-03 quedó claro dónde duele: el toolkit sabe validar rutas, procesos y códigos de salida, pero cuando tiene que validar texto —que --fecha 2026-08-03 sea una fecha de verdad, que una IP sea una IP, que una línea de app.log tenga la estructura esperada— recurre a comparaciones frágiles con globs. Las expresiones regulares son el lenguaje que resuelve eso, y con el operador =~ de Bash validan y extraen en un solo paso.

Contenido

  1. Regex frente a globbing
  2. Los tres sabores: BRE, ERE y PCRE
  3. Literales, el punto y las clases
  4. Clases POSIX
  5. Anclas
  6. Cuantificadores y avaricia
  7. Alternancia, agrupación y retrorreferencias
  8. Escapes y barras invertidas dentro de comillas
  9. El operador =~ de Bash
  10. BASH_REMATCH: validar y extraer a la vez
  11. Validaciones reales
  12. Extracción con grep -o
  13. Cuándo NO usar regex

  1. Regex frente a globbing

Las dos sintaxis se parecen lo justo para confundir, y significan cosas distintas:

Globbing (02-05) Regex
Quién lo interpreta El shell, sobre nombres de fichero Herramientas, sobre texto
* Cualquier secuencia de caracteres "Cero o más del elemento anterior"
? Exactamente un carácter "Cero o una vez del anterior"
Un carácter cualquiera ? .
Cualquier secuencia * .*
Emparejamiento La cadena entera Una subcadena, salvo anclas
[abc] Igual en ambos Igual en ambos

Las dos trampas de la tabla son las que causan casi todos los errores de principiante. Primera: en regex, * no significa nada por sí solo; modifica al elemento que tiene delante, así que *.log es una regex incorrecta y lo que quieres escribir es .*\.log. Segunda: grep ERROR encuentra la palabra en cualquier posición de la línea, mientras que [[ $x == ERROR ]] exige igualdad completa. Para exigir la línea entera en regex hay que anclar: ^ERROR$.

  1. Los tres sabores: BRE, ERE y PCRE

Existen tres dialectos, y la diferencia práctica está en qué caracteres necesitan barra invertida:

  • BRE (Basic): +, ?, {, |, ( y ) son literales; para usarlos como metacaracteres hay que escaparlos (\+, \|).
  • ERE (Extended): esos caracteres son metacaracteres directamente. Es el dialecto cómodo.
  • PCRE (estilo Perl): ERE más atajos (\d, \w, \s, \b), cuantificadores perezosos (*?) y mucho más.
Herramienta Sabor por defecto Cómo cambiarlo
grep BRE grep -E → ERE; grep -P → PCRE
egrep ERE Obsoleto: usa grep -E
sed BRE sed -E (o sed -r) → ERE
awk ERE No aplica
[[ =~ ]] de Bash ERE No se puede cambiar
expr, vi BRE

La recomendación práctica: usa ERE siempre que puedas (grep -E, sed -E, awk, [[ =~ ]]). Toda esta lección está escrita en ERE salvo donde se indique. grep -P no está disponible en todos los sistemas —no lo lleva macOS ni algunos contenedores mínimos—, así que evítalo en scripts portables.

  1. Literales, el punto y las clases

La mayoría de los caracteres se representan a sí mismos. Los especiales son . [ ] ^ $ * + ? { } ( ) | \.

grep -E 'entregado' /srv/veloz/datos/envios.csv   # literal
grep -E 'a.opez'  envios.csv                      # . = un carácter CUALQUIERA
grep -E '[aeiou]' fichero                         # uno de esos caracteres
grep -E '[^0-9]'  fichero                         # ^ dentro de [ ]: NEGACIÓN
grep -E '[A-Za-z0-9_]' fichero                    # rangos combinados

Dentro de los corchetes casi todo pierde su significado especial: [.*+] empareja un punto, un asterisco o un signo más literales. Las tres excepciones son ^ (si va primero, niega), - (si va en medio, forma rango: ponlo al principio o al final para que sea literal) y ] (debe ir el primero).

El . es el error más común al buscar extensiones o IPs: grep -E '192.168.1.1' también encuentra 192x168y1z1. Para un punto literal, escápalo: 192\.168\.1\.1.

  1. Clases POSIX

En grep -E '[[:digit:]]{4}-[[:digit:]]{2}' app.log, los nombres van entre [: :] dentro de unos corchetes de clase, de ahí el doble par:

Clase POSIX Equivale a Uso
[[:digit:]] [0-9] Dígitos
[[:alpha:]] / [[:alnum:]] [A-Za-z] / [A-Za-z0-9] Letras / letras y dígitos
[[:space:]] espacio, tabulador, salto Espacio en blanco
[[:upper:]] / [[:lower:]] [A-Z] / [a-z] Mayúsculas / minúsculas
[[:punct:]] / [[:xdigit:]] .,;:!?... / [0-9A-Fa-f] Puntuación / hexadecimal

¿Por qué preferirlas a [0-9]? Por las locales. Un rango como [a-z] se interpreta según el orden de colación del idioma configurado: en algunas locales incluye caracteres acentuados y en otras no, y [A-z] (error tipográfico frecuente) abarca además [, \, ], ^, _ y la comilla invertida. [[:alpha:]] significa "letra" en cualquier locale, sin sorpresas, y en un servidor donde LANG puede cambiar entre despliegues eso es exactamente lo que quieres. Para dígitos, [0-9] y [[:digit:]] son equivalentes en la práctica; para letras, la diferencia es real.

  1. Anclas

grep -E '^2026-08-03'  app.log        # líneas que EMPIEZAN por esa fecha
grep -E 'ERROR$'       app.log        # líneas que TERMINAN en ERROR
grep -E '^$'           fichero        # líneas vacías
grep -E '^[[:space:]]*$' fichero      # líneas vacías o solo con espacios
grep -E '^alopez,'     envios.csv     # primer campo exacto

^ y $ no consumen ningún carácter: marcan posiciones. Y \b marca un límite de palabra, la frontera entre un carácter de palabra y uno que no lo es: grep -E 'jruiz' también encuentra jruizperez, mientras que grep -E '\bjruiz\b' acota al repartidor exacto.

Anclar es también una cuestión de rendimiento y de seguridad: una validación sin ^ y $ acepta basura alrededor de lo válido, que es como se cuelan datos malformados en un informe.

  1. Cuantificadores y avaricia

Cuantificador Significado Ejemplo
* Cero o más veces [0-9]*
+ Una o más veces [0-9]+
? Cero o una vez (opcional) https?
{n} / {n,} / {n,m} Exactamente n / n o más / entre n y m [0-9]{4}, [0-9]{1,3}

Los cuantificadores son avariciosos (greedy): consumen todo lo que pueden y luego retroceden lo justo para que el resto encaje. El efecto práctico se ve al extraer la ruta de una línea de acceso.log:

linea='10.0.0.5 - [03/Aug/2026] "GET /envios/1234 HTTP/1.1" 200 512'
grep -oE '".*"'   <<< "$linea"      # "GET /envios/1234 HTTP/1.1"   ← aquí acierta
grep -oE '".*" 2' <<< "$linea"      # con dos comillas separadas, se pasaría de largo
grep -oE '"[^"]*"' <<< "$linea"     # SIEMPRE correcto: hasta la siguiente comilla

.* entre comillas dobles empareja hasta la última comilla de la línea, no hasta la primera. El idioma robusto es [^X]* —"todo lo que no sea el delimitador"—, que no depende de la avaricia. ERE no tiene cuantificadores perezosos (.*?); eso es PCRE. En ERE, [^"]* es la respuesta.

  1. Alternancia, agrupación y retrorreferencias

grep -E 'ERROR|WARN'         app.log      # alternancia: una u otra
grep -E '^(ERROR|WARN):'     app.log      # agrupada y anclada
grep -E '(19|20)[0-9]{2}'    app.log      # año plausible
grep -E '^(.*),\1$'          fichero      # retrorreferencia: primer campo = último

La alternancia tiene la prioridad más baja de todo el lenguaje, así que ^ERROR|WARN$ significa "empieza por ERROR, o termina en WARN", que casi nunca es lo que se quiere. Los paréntesis lo arreglan y, además, capturan el fragmento emparejado para reutilizarlo: \1 es el contenido del primer grupo, \2 el del segundo. Los grupos se numeran por el orden de sus paréntesis de apertura.

grep -E '\b([a-z]+) \1\b' documento     # palabras repetidas: "el el"

Las retrorreferencias son caras y no todas las herramientas las soportan en todos los sabores (awk no las tiene), pero su verdadero valor aparece en la sección 10, donde Bash las expone como un array, y en sed (06-02), donde permiten reescribir texto conservando trozos del original.

  1. Escapes y barras invertidas dentro de comillas

Aquí es donde se pierde más tiempo del que nadie confesaría. Hay dos niveles de interpretación: primero el shell procesa las comillas, y lo que sobreviva llega a la regex.

grep -E "\." fichero       # el shell convierte \. en .  → la regex es "." ¡cualquier carácter!
grep -E '\.' fichero       # comillas SIMPLES: la regex recibe \. → punto literal

Regla de oro: los patrones regex van siempre entre comillas simples. Dentro de ellas nada se interpreta, y lo que escribes es exactamente lo que recibe la herramienta. La única excepción es cuando necesitas interpolar una variable —grep -E "^[0-9]+,[^,]+,$ciudad," envios.csv—, y entonces conviene construir el patrón en una variable aparte.

Los caracteres que necesitan escape para ser literales son . [ ] ^ $ * + ? { } ( ) | \. Y la barra invertida misma se escribe \\ en la regex, lo que dentro de comillas dobles del shell se convierte en \\\\: otro motivo más para usar comillas simples.

  1. El operador =~ de Bash

Bash trae un motor ERE integrado, disponible solo dentro de [[ ]]: if [[ "$fecha" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then .... Dos reglas de sintaxis, y ambas son contraintuitivas:

  1. El patrón NO va entrecomillado. Si escribes [[ "$x" =~ "^[0-9]+$" ]], las comillas convierten el patrón en una cadena literal y solo emparejará si $x contiene exactamente esos caracteres. Es el error más frecuente con =~, y no da ningún aviso: simplemente nunca coincide.
  2. La cadena de la izquierda SÍ va entrecomillada, como siempre, para protegerla de la división de palabras (03-06).

Si el patrón es complejo o contiene espacios, guárdalo en una variable y usa la variable sin comillas —es la forma legible y portable de tenerlo todo bajo control—:

readonly RE_FECHA='^([0-9]{4})-([0-9]{2})-([0-9]{2})$'
[[ "$1" =~ $RE_FECHA ]] || morir 64 "Fecha inválida: $1 (se espera AAAA-MM-DD)"

Además, =~ no está anclado por defecto: [[ abc123 =~ [0-9]+ ]] es cierto. Para validar, ancla siempre con ^ y $.

  1. BASH_REMATCH: validar y extraer a la vez

Esta es la razón por la que =~ merece una sección propia. Tras un emparejamiento con éxito, Bash rellena el array BASH_REMATCH: la posición 0 es el texto completo emparejado y las siguientes son los grupos capturados, en orden.

linea='2026-08-03 10:15:22 [ERROR] timeout al consultar veloz-api'
if [[ "$linea" =~ ^([0-9-]{10})\ ([0-9:]{8})\ \[([A-Z]+)\]\ (.*)$ ]]; then
    fecha="${BASH_REMATCH[1]}"    # 2026-08-03
    hora="${BASH_REMATCH[2]}"     # 10:15:22
    nivel="${BASH_REMATCH[3]}"    # ERROR
    mensaje="${BASH_REMATCH[4]}"  # timeout al consultar veloz-api
    printf '%s a las %s: %s\n' "$nivel" "$hora" "$mensaje"
fi

Compáralo con la alternativa: un cut para la fecha, otro para la hora, un tr -d '[]' para el nivel y un ${linea#* } repetido cuatro veces para el mensaje. Aquí, una sola comprobación valida el formato y extrae los cuatro campos, sin lanzar un solo proceso externo. En un bucle sobre un app.log de cien mil líneas, la diferencia se cuenta en minutos.

Los espacios del patrón van escapados (\ ) porque, dentro de [[ ]], el patrón sin comillas está sujeto a la división de palabras. Guardarlo en una variable evita ese ruido:

readonly RE_APPLOG='^([0-9-]{10}) ([0-9:]{8}) \[([A-Z]+)\] (.*)$'
[[ "$linea" =~ $RE_APPLOG ]] && nivel="${BASH_REMATCH[3]}"

BASH_REMATCH se sobrescribe con cada =~ con éxito y no se limpia cuando uno falla, así que copia lo que necesites inmediatamente después de la comprobación, dentro del if.

  1. Validaciones reales

readonly RE_FECHA='^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$'
readonly RE_OCTETO='(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])'
readonly RE_IPV4="^$RE_OCTETO\.$RE_OCTETO\.$RE_OCTETO\.$RE_OCTETO$"
readonly RE_HTTP='^[1-5][0-9]{2}$'
readonly RE_CIUDAD='^(Valencia|Sevilla|Bilbao|Madrid)$'

validar_fecha() { [[ "$1" =~ $RE_FECHA ]]; }
validar_ip()    { [[ "$1" =~ $RE_IPV4  ]]; }

Fíjate en el diseño de RE_FECHA: no se conforma con [0-9]{2} para el mes, sino que exige 01-12 con la alternancia (0[1-9]|1[0-2]). Aun así no valida la fecha, solo su forma: 2026-02-31 la supera. Para saber si la fecha existe hace falta date -d "$f" &>/dev/null, que ya conoces de 04-06. La regla general: la regex valida el formato; la semántica se comprueba aparte.

RE_OCTETO muestra el otro principio: construir por partes. Escribir la regex de IPv4 de una tacada es ilegible; componerla desde un octeto es evidente. Y así se depura, incrementalmente: prueba primero ^[0-9]{4}, luego añade el mes, luego el día. Herramientas como regex101.com explican cada elemento y muestran las capturas en vivo, y en la terminal basta con grep -oE sobre un fichero de ejemplo para ver qué empareja de verdad.

  1. Extracción con grep -o

Cuando lo que quieres no es la línea sino el fragmento:

grep -oE '^[0-9.]+' /var/log/veloz/acceso.log | sort -u        # IPs únicas
grep -oE '"GET [^ ]+' acceso.log | cut -d' ' -f2 | sort | uniq -c | sort -rn | head
grep -coE 'ERROR' app.log            # -c cuenta LÍNEAS, no coincidencias
grep -oE 'ERROR' app.log | wc -l     # esto sí cuenta coincidencias

Esa última pareja esconde un matiz que estropea informes: grep -c cuenta líneas que contienen al menos una coincidencia; si una línea tiene tres, sigue contando 1. Para contar coincidencias hay que usar -o y contar líneas de la salida.

grep -o es la herramienta de la tubería; BASH_REMATCH es la del bucle. Si ya estás recorriendo el fichero línea a línea con while IFS= read -r (04-01), =~ evita lanzar un proceso por línea.

  1. Cuándo NO usar regex

Las regex son un lenguaje regular, y hay estructuras que no son regulares. Insistir en ellas produce código que funciona con los ejemplos y falla en producción:

  • CSV con comas dentro de comillas. "Sevilla, Este" rompe cualquier cut -d, y cualquier regex ingenua. Los CSV de Veloz Envíos son simples y por eso IFS=, basta; el día que dejen de serlo, la herramienta es awk con un separador bien definido (06-01) o un parser de verdad.
  • HTML y XML. Anidamiento arbitrario: no es un lenguaje regular. Existe una respuesta legendaria en Stack Overflow sobre esto, y tiene razón.
  • JSON. Misma razón. La herramienta es jq, y llega en 06-05.
  • Rutas y nombres de fichero. Ya tienes find (05-01) y las expansiones ${s##*/} (04-04).

Y un aviso de rendimiento: patrones con cuantificadores anidados como (a+)+ pueden provocar retroceso catastrófico y colgar el proceso con una entrada corta. Si tu regex necesita anidar cuantificadores, casi siempre hay una formulación más simple.

Errores Comunes y Consejos

  • Entrecomillar el patrón de =~. Se convierte en literal y no empareja nunca, sin aviso alguno.
  • Usar comillas dobles en un patrón de grep. El shell se come las barras invertidas. Comillas simples.
  • Olvidar escapar el punto. 192.168.1.1 empareja 19216811 y muchas cosas más.
  • *.log como regex. * modifica al anterior; se escribe .*\.log.
  • No anclar una validación. [[ $x =~ [0-9]+ ]] acepta abc123def como número.
  • ^ERROR|WARN$ sin paréntesis. La alternancia tiene la prioridad más baja y parte la expresión entera.
  • Confiar en grep -c para contar coincidencias. Cuenta líneas. Usa grep -o | wc -l.
  • Consejo: guarda cada regex en una variable readonly RE_ALGO con nombre descriptivo, cerca del principio del script. Se documenta sola, se reutiliza, se prueba por separado y desaparece el ruido de comillas.

Ejercicios

Ejercicio 1. Escribe validar_fecha() que acepte solo AAAA-MM-DD con mes 01-12 y día 01-31, y además verifique que la fecha existe de verdad (rechazando 2026-02-31). Debe devolver 0 o 1 sin imprimir nada.

Ejercicio 2. Recorre /var/log/veloz/app.log con un solo bucle y produce un recuento por nivel (INFO, WARN, ERROR), mostrando además la hora y el mensaje del primer ERROR del día, sin lanzar procesos externos dentro del bucle.

Ejercicio 3. Extrae de acceso.log las IPs que hayan provocado al menos un código 5xx, junto con cuántas veces, ordenadas de mayor a menor.

Soluciones

Solución 1.

readonly RE_FECHA='^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$'
validar_fecha() {                     # Uso: validar_fecha AAAA-MM-DD
    [[ "${1:-}" =~ $RE_FECHA ]] || return 1
    date -d "$1" > /dev/null 2>&1     # la semántica, aparte del formato
}
validar_fecha 2026-08-03 && echo ok      # ok
validar_fecha 2026-02-31 || echo malo    # malo (formato válido, fecha inexistente)
validar_fecha 2026-8-3   || echo malo    # malo (formato incorrecto)

Dos capas: la regex descarta lo que ni siquiera tiene forma de fecha —barato, sin procesos— y date -d resuelve lo que la regex no puede saber, como los años bisiestos. El orden importa: si date fuera primero, aceptaría entradas como next friday.

Solución 2.

readonly RE_APPLOG='^([0-9]{4}-[0-9]{2}-[0-9]{2}) ([0-9:]{8}) \[([A-Z]+)\] (.*)$'
declare -A NIVELES=()
primer_error=''
while IFS= read -r linea; do
    [[ "$linea" =~ $RE_APPLOG ]] || continue        # descarta líneas malformadas
    (( NIVELES["${BASH_REMATCH[3]}"]++ ))
    if [[ "${BASH_REMATCH[3]}" == ERROR && -z "$primer_error" ]]; then
        primer_error="${BASH_REMATCH[2]} — ${BASH_REMATCH[4]}"
    fi
done < /var/log/veloz/app.log

for nivel in "${!NIVELES[@]}"; do
    printf '%-6s %5d\n' "$nivel" "${NIVELES[$nivel]}"
done
[[ -n "$primer_error" ]] && printf 'Primer ERROR: %s\n' "$primer_error"

Es el patrón contador de 04-03 alimentado por BASH_REMATCH. El || continue convierte la validación en un filtro que protege el recuento de líneas basura, y todo ocurre dentro de Bash: ni un cut, ni un grep, ni un proceso por línea.

Solución 3.

grep -E '" [5][0-9]{2} ' /var/log/veloz/acceso.log \
  | grep -oE '^[0-9]{1,3}(\.[0-9]{1,3}){3}' \
  | sort | uniq -c | sort -rn
#      41 10.0.0.87
#       9 10.0.0.5

El primer grep selecciona las líneas cuyo código va tras la petición entrecomillada —el espacio y la comilla evitan confundirlo con un número de bytes que empiece por 5—, y el segundo extrae solo la IP inicial. El (\.[0-9]{1,3}){3} muestra que un grupo también se puede cuantificar. El remate sort | uniq -c | sort -rn es el idioma del Módulo 2, que ahora encaja con la extracción precisa que solo las regex dan.

Conclusión

Una regex describe texto, no nombres de fichero, y sus metacaracteres se parecen a los del globbing solo lo suficiente para engañar: * modifica al elemento anterior, . es el comodín de un carácter y .* es el equivalente del * del shell. De los tres sabores, ERE es el punto dulce y lo hablan grep -E, sed -E, awk y el =~ de Bash. La sintaxis se construye con literales, ., clases [...] con su negación [^...], clases POSIX [[:digit:]] inmunes a la locale, anclas ^, $ y \b, cuantificadores *, +, ? y {n,m} —avariciosos, de ahí el idioma [^"]*—, alternancia | de prioridad mínima y grupos ( ) que capturan. Los patrones van siempre entre comillas simples, salvo en =~, donde el patrón va sin comillas y lo mejor es guardarlo en una variable readonly. Y ahí está la joya: BASH_REMATCH convierte una comprobación en una extracción de todos los campos a la vez, sin lanzar procesos, ideal para bucles largos. Lo que no debes hacer con regex es parsear CSV con comillas, HTML o JSON: para eso hay herramientas específicas que llegan en el Módulo 6.

informe-diario.sh ya valida su --fecha y descompone app.log en un solo paso. Lo que queda por resolver es la salida: hoy imprime a pantalla con printf sueltos, y si quieres el informe en un fichero tienes que redirigir todo el script desde fuera. No sabe escribir a dos sitios a la vez, ni mantener abierto un fichero de registro mientras trabaja, ni componer una plantilla de informe de varias líneas sin veinte printf. En 05-05 entran los descriptores de fichero, los here-documents, los here-strings y la sustitución de procesos: la maquinaria de entrada/salida que convierte la salida del toolkit en algo que se puede dirigir con precisión.

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados