Procesar texto en Bash: grep, sed, awk, sort y find

Lección 6 de 8: procesar texto. Buena parte del trabajo diario en una terminal consiste en leer registros, CSV y salidas de otros programas para sacar algo en limpio: cuántos errores hubo, quién hizo más peticiones, cuánto se vendió. Para eso están grep, sed, awk, sort, uniq, cut y find, y la gracia está en encadenarlos. Todo lo de esta lección se ejecuta sobre un registro de un servidor web de ejemplo.

Primero, mirar: cat, head, tail y wc

Todo lo de esta lección trabaja con dos archivos de ejemplo: access.log, el registro de un servidor web con 16 peticiones inventadas, y ventas.csv, unas ventas de una tienda. Las direcciones IP son de los rangos que se reservan para documentación, así que no pertenecen a nadie. Antes de procesar un archivo conviene echarle un vistazo:

terminal$ wc -l access.log ventas.csv
  16 access.log
   8 ventas.csv
  24 total
$ head -n 3 access.log
192.0.2.10 - - [30/Oct/2026:10:00:01 +0000] "GET /index.html HTTP/1.1" 200 5120 "-" "Mozilla/5.0"
198.51.100.7 - - [30/Oct/2026:10:00:04 +0000] "GET /blog/bash HTTP/1.1" 200 8340 "-" "Mozilla/5.0"
203.0.113.42 - - [30/Oct/2026:10:00:09 +0000] "GET /wp-login.php HTTP/1.1" 404 512 "-" "curl/8.5.0"
$ tail -n 2 ventas.csv
2026-10-03,ratón,1,12.00
2026-10-04,monitor,2,189.90

wc -l cuenta líneas, head enseña las primeras y tail las últimas (10 si no dices otra cosa). Con un registro de verdad, de millones de líneas, cat llenaría la pantalla: por eso se usan casi siempre head, tail o less, que permite moverse por el archivo y buscar con /. Y tail -f se queda esperando y enseña las líneas nuevas según llegan, que es como se vigila un registro en directo.

Cada línea del registro sigue el formato combinado de Apache y nginx: la IP del cliente, la fecha entre corchetes, la petición entre comillas, el código de estado, los bytes enviados y el navegador. Si separas por espacios, la IP es el campo 1, el código el 9 y los bytes el 10. Esos números de campo los vas a usar toda la lección.

grep: buscar líneas

grep escribe las líneas que contienen un patrón. Es la herramienta que más vas a usar de esta lección, y sus opciones más útiles caben en una sesión:

terminal$ grep 'wp-login' access.log
203.0.113.42 - - [30/Oct/2026:10:00:09 +0000] "GET /wp-login.php HTTP/1.1" 404 512 "-" "curl/8.5.0"
$ grep -c ' 404 ' access.log
3
$ grep -i 'MOZILLA' access.log | wc -l
10
$ grep -v 'Mozilla' access.log | cut -c1-60
203.0.113.42 - - [30/Oct/2026:10:00:09 +0000] "GET /wp-login
203.0.113.42 - - [30/Oct/2026:10:01:16 +0000] "GET /.env HTT
198.51.100.23 - - [30/Oct/2026:10:02:30 +0000] "POST /api/pe
203.0.113.42 - - [30/Oct/2026:10:03:05 +0000] "GET /admin HT
198.51.100.23 - - [30/Oct/2026:10:06:00 +0000] "POST /api/pe
203.0.113.42 - - [30/Oct/2026:11:00:33 +0000] "GET /backup.z
$ grep -n 'POST' access.log | cut -c1-50
6:198.51.100.23 - - [30/Oct/2026:10:02:30 +0000] "
11:198.51.100.23 - - [30/Oct/2026:10:06:00 +0000] 
$ grep -o '/blog/[a-z]*' access.log
/blog/bash
/blog/sed
/blog/awk
/blog/bash
  • OpciónQué hace
  • -iNo distingue mayúsculas de minúsculas.
  • -vInvierte: las líneas que NO contienen el patrón.
  • -cSolo cuenta las líneas que coinciden.
  • -nAñade el número de línea delante.
  • -oEscribe solo el trozo que coincide, no la línea entera.
  • -wSolo palabras enteras: -w 'red' no encuentra «redes».
  • -rBusca en una carpeta y todas sus subcarpetas.
  • -lSolo el nombre de los archivos que tienen alguna coincidencia.
  • -EExpresiones regulares extendidas (las de la sección siguiente).
  • -FEl patrón es texto literal: los puntos y los asteriscos no son especiales.

El código de salida de grep también sirve: es 0 si encontró algo, 1 si no encontró nada y 2 si hubo un error, como un archivo que no existe. Con -q no escribe nada, y así se usa dentro de un if:

#!/usr/bin/env bash
if grep -q ' 500 ' access.log; then
    echo "Hay errores del servidor: revisa la aplicación"
fi
if ! grep -q 'DELETE' access.log; then
    echo "Nadie ha intentado borrar nada"
fi
salidaHay errores del servidor: revisa la aplicación
Nadie ha intentado borrar nada

Expresiones regulares

El patrón de grep no es solo texto: es una expresión regular, un pequeño lenguaje para describir trozos de texto. Hay dos dialectos: las básicas, que usan grep y sed por omisión, y las extendidas, que se activan con grep -E y sed -E y son las que conviene usar siempre, porque en las básicas hay que escribir \(, \{ y \+ con barra.

  • SímboloSignificaEjemplo
  • .Cualquier carácterc.sa: casa, cosa, c9sa
  • ^ y $Principio y final de la línea^192: líneas que empiezan por 192
  • [abc]Uno de esos caracteres[0-9]: una cifra
  • [^abc]Cualquiera menos esos[^ ]+: todo hasta el siguiente espacio
  • *, +, ?Cero o más, uno o más, cero o uno[0-9]+: un número
  • {n,m}Entre n y m veces[0-9]{3}: tres cifras
  • ( | )Grupo y alternativa(GET|POST)
  • \.Un punto de verdad\.php$: acaba en .php
terminal$ grep -E '" (4|5)[0-9]{2} ' access.log | cut -d' ' -f1,7,9
203.0.113.42 /wp-login.php 404
203.0.113.42 /.env 404
203.0.113.42 /admin 403
192.0.2.10 /api/precios 500
198.51.100.23 /api/pedidos 500
203.0.113.42 /backup.zip 404
$ grep -oE '^[0-9.]+' access.log | sort -u
192.0.2.10
192.0.2.55
198.51.100.23
198.51.100.7
203.0.113.42
$ grep -cE '\.(php|env|zip)' access.log
3

La primera orden busca los códigos de error: unas comillas y un espacio (el final de la petición), un 4 o un 5 y dos cifras más. La segunda extrae las IP sin repetir. La tercera cuenta las peticiones a archivos que delatan a un escáner automático: .php en un sitio que no es PHP, .env y .zip. Las tres vienen de la misma IP, 203.0.113.42, que además usa curl: en un registro real, ese es el aspecto de un robot buscando puertas abiertas.

Bash también entiende expresiones regulares extendidas, sin llamar a ningún programa, con el operador =~ dentro de [[ ]]. Los grupos entre paréntesis quedan en el array BASH_REMATCH:

#!/usr/bin/env bash
patron='^([0-9.]+) .*"(GET|POST) ([^ ]+) [^"]*" ([0-9]{3})'
while IFS= read -r linea; do
    if [[ $linea =~ $patron ]]; then
        ip=${BASH_REMATCH[1]}
        metodo=${BASH_REMATCH[2]}
        ruta=${BASH_REMATCH[3]}
        estado=${BASH_REMATCH[4]}
        if (( estado >= 500 )); then
            printf '%-14s %-4s %-14s %s\n' "$ip" "$metodo" "$ruta" "$estado"
        fi
    fi
done < access.log
salida192.0.2.10     GET  /api/precios   500
198.51.100.23  POST /api/pedidos   500
El patrón va en una variable y sin comillas a la derecha de =~. Si lo escribes entre comillas, Bash lo toma como texto literal y deja de ser una expresión regular: [[ $x =~ "^[0-9]+$" ]] solo es cierto si $x contiene literalmente esos caracteres.

cut, sort, uniq, tr y wc: la tubería clásica

La fuerza de estas herramientas está en combinarlas. Cada una hace una sola cosa y lee lo que le escribe la anterior. Para saber qué IP hacen más peticiones:

terminal$ cut -d' ' -f1 access.log | sort | uniq -c | sort -rn | head -3
      5 192.0.2.10
      4 203.0.113.42
      3 198.51.100.7

Una tubería de cinco órdenes: cut se queda con la IP, sort las ordena para que las iguales queden juntas, uniq -c cuenta las repetidas seguidas, sort -rn pone primero las que más aparecen y head -3 deja las tres primerasUna tubería: cada orden recibe lo que escribe la anteriorcut -d' ' -f1solo la IPsortlas juntauniq -ccuenta seguidassort -rnmayor primerohead -3las 3 primeras192.0.2.10198.51.100.7203.0.113.42192.0.2.10192.0.2.10192.0.2.10198.51.100.7203.0.113.422 192.0.2.101 198.51.100.71 203.0.113.425 192.0.2.104 203.0.113.423 198.51.100.72 192.0.2.555 192.0.2.104 203.0.113.423 198.51.100.7Columnas 1 a 3: un trozo del registro. Columnas 4 y 5: el principio del resultado con todo el archivo.

Cada orden de la tubería lee lo que escribe la anterior. uniq solo junta líneas iguales seguidas: por eso va siempre después de un sort. Esquema: decodigo.com.

cut -d' ' -f1 corta cada línea por los espacios y se queda con el campo 1. sort ordena, para que las IP iguales queden juntas, y uniq -c cuenta cuántas veces se repite cada línea seguida. El segundo sort, con -r (al revés) y -n (como números, no como texto), pone arriba las que más aparecen, y head -3 se queda con las tres primeras. El mismo esquema sirve para casi todo: las páginas más visitadas, los códigos de estado más frecuentes, los usuarios que más escriben.

terminal$ cut -d, -f2 ventas.csv | tail -n +2 | sort | uniq -c
      1 cable USB
      2 monitor
      2 ratón
      2 teclado
$ sort -t, -k3,3n ventas.csv | tail -n 3
2026-10-01,teclado,3,24.50
2026-10-01,ratón,5,12.00
2026-10-03,cable USB,10,3.25
$ cut -d, -f2 ventas.csv | tail -n +2 | tr 'a-z' 'A-Z' | sort -u
CABLE USB
MONITOR
RATóN
TECLADO
$ cut -d' ' -f9 access.log | sort | uniq -c
      8 200
      1 201
      1 304
      1 403
      3 404
      2 500

Con -t, le dices a sort que separe por comas, y con -k3,3n, que ordene por el tercer campo, y solo por él, como número. tail -n +2 empieza en la línea 2, para saltarse la cabecera del CSV. tr cambia caracteres uno por uno: aquí, minúsculas por mayúsculas. Fíjate en que «ratón» se queda como «RATóN»: tr trabaja byte a byte y no sabe que la «ó» de UTF-8 son dos bytes, así que con acentos solo cambia lo que entra en a-z. Para pasar a mayúsculas un texto en español es mejor ${var^^}, de la lección 4, o awk con toupper.

sed: cambiar texto sobre la marcha

sed («stream editor») lee línea a línea y aplica órdenes de edición. La que más se usa, con diferencia, es la sustitución, s/buscar/cambiar/:

terminal$ sed 's/teclado/teclado mecánico/' ventas.csv | head -n 3
fecha,producto,unidades,precio
2026-10-01,teclado mecánico,3,24.50
2026-10-01,ratón,5,12.00
$ sed -n '2,4p' ventas.csv
2026-10-01,teclado,3,24.50
2026-10-01,ratón,5,12.00
2026-10-02,monitor,1,189.90
$ sed '1d' ventas.csv | head -n 2
2026-10-01,teclado,3,24.50
2026-10-01,ratón,5,12.00
$ sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})/\3\/\2\/\1/' ventas.csv | sed -n '2,3p'
01/10/2026,teclado,3,24.50
01/10/2026,ratón,5,12.00
$ sed 's/,/;/g' ventas.csv | head -n 2
fecha;producto;unidades;precio
2026-10-01;teclado;3;24.50
  • OrdenQué hace
  • s/a/b/Cambia la primera «a» de cada línea por «b».
  • s/a/b/gLas cambia todas (global).
  • -n '2,4p'No escribe nada salvo lo que mandes imprimir: aquí, las líneas 2 a 4.
  • '1d'Borra la línea 1. '/^#/d' borra las que empiezan por #.
  • -E y \1Expresiones extendidas; \1, \2… son los grupos capturados.
  • s|a|b|El separador puede ser otro carácter, útil si el texto tiene barras.

Por omisión, sed no toca el archivo: escribe el resultado en la salida. Para cambiarlo de verdad está -i (in place), y lo prudente es pedirle que guarde antes una copia con una extensión:

#!/usr/bin/env bash
cp ventas.csv tienda.csv
sed -i.bak 's/cable USB/cable USB-C/' tienda.csv
diff tienda.csv.bak tienda.csv
ls tienda.csv*
salida6c6
< 2026-10-03,cable USB,10,3.25
---
> 2026-10-03,cable USB-C,10,3.25
tienda.csv
tienda.csv.bak

diff enseña lo que cambió: la línea 6 de antes (<) y la de después (>), y ls confirma que la copia tienda.csv.bak está ahí por si hay que deshacer el cambio. Ojo en macOS: su sed es el de BSD y exige la extensión pegada o un argumento vacío, sed -i '' 's/…/…/' archivo. Si tus scripts tienen que funcionar en los dos sistemas, -i.bak con la extensión pegada funciona en ambos.

Ojo con la seguridad: no metas datos ajenos dentro de una orden de sed

Si construyes la orden de sed con una variable que viene de fuera, por ejemplo sed "s/$buscar/$cambio/", quien controla esa variable controla el programa de sed, no solo el texto: una barra de más rompe la orden, y el sed de GNU tiene órdenes que escriben archivos (w) o ejecutan programas (e). Si tienes que aplicar una orden de sed que no has escrito tú, la opción --sandbox de GNU sed rechaza esas órdenes peligrosas:

#!/usr/bin/env bash
orden='1w copia-robada.txt'
sed --sandbox "$orden" ventas.csv > /dev/null
echo "código de sed: $?"
ls
salidased: -e expression #1, char 2: e/r/w commands disabled in sandbox mode
código de sed: 1
access.log
sandbox.sh
ventas.csv

Para buscar y reemplazar un texto que llega de fuera es más seguro awk con -v, que lo trata como dato (lo verás enseguida), o la sustitución de Bash ${linea//"$buscar"/"$cambio"}, que no interpreta nada.

Más en Los fallos de seguridad típicos de los scripts de Bash.

awk: un lenguaje para columnas

awk es casi un lenguaje de programación completo, pensado para texto en columnas. Lee cada línea, la parte en campos ($1, $2…; $0 es la línea entera y NF, cuántos campos tiene) y ejecuta las reglas condición { acción } que le des:

terminal$ awk '{ print $1, $9 }' access.log | head -n 3
192.0.2.10 200
198.51.100.7 200
203.0.113.42 404
$ awk '$9 >= 400 { print $9, $7 }' access.log
404 /wp-login.php
404 /.env
403 /admin
500 /api/precios
500 /api/pedidos
404 /backup.zip
$ awk -F, 'NR > 1 { print $2 " x" $3 }' ventas.csv | head -n 3
teclado x3
ratón x5
monitor x1

Donde awk brilla es en los cálculos. Las variables no se declaran, valen 0 al empezar y los arrays pueden tener texto como índice. La regla END se ejecuta cuando ya se leyó todo el archivo:

#!/usr/bin/env bash
awk -F, '
    NR == 1 { next }
    {
        importe = $3 * $4
        total += importe
        por_producto[$2] += importe
    }
    END {
        for (p in por_producto)
            printf "%-10s %8.2f\n", p, por_producto[p]
        printf "%-10s %8.2f\n", "TOTAL", total
    }
' ventas.csv | sort -k2,2rn
salidaTOTAL        796.70
monitor      569.70
teclado      122.50
ratón         72.00
cable USB     32.50

next se salta la cabecera, y el array por_producto acumula el importe de cada producto. El orden en que for (p in …) recorre un array no está garantizado, así que el resultado pasa por sort. El mismo esquema da los bytes servidos por cada código de estado del registro:

#!/usr/bin/env bash
echo "código  peticiones   bytes"
awk '{ bytes[$9] += $10; peticiones[$9]++ }
     END { for (c in bytes) printf "%s     %6d  %7d\n", c, peticiones[c], bytes[c] }' access.log | sort
salidacódigo  peticiones   bytes
200          8    46418
201          1      310
304          1        0
403          1      256
404          3     1536
500          2      256

Para pasar un valor de Bash a awk, usa -v, nunca lo pegues dentro del programa: -v lo convierte en una variable de awk, que se trata como dato aunque contenga comillas o llaves.

#!/usr/bin/env bash
minimo=100
producto="ratón"
awk -F, -v min="$minimo" 'NR > 1 && $4 >= min { print "Caro:", $1, $2, $4 }' ventas.csv
awk -F, -v p="$producto" '$2 == p { n += $3 } END { print p ": " n " unidades" }' ventas.csv
salidaCaro: 2026-10-02 monitor 189.90
Caro: 2026-10-04 monitor 189.90
ratón: 6 unidades

El error que te ahorra: un patrón sin comillas

Una búsqueda con un patrón que tiene espacios, guardado en una variable sin comillas:

#!/usr/bin/env bash
patron="POST /api"
grep $patron access.log
salida con errorgrep: /api: No such file or directory
access.log:198.51.100.23 - - [30/Oct/2026:10:02:30 +0000] "POST /api/pedidos HTTP/1.1" 201 310 "-" "Python-urllib/3.12"
access.log:198.51.100.23 - - [30/Oct/2026:10:06:00 +0000] "POST /api/pedidos HTTP/1.1" 500 128 "-" "Python-urllib/3.12"
código de salida: 2

Bash parte $patron por el espacio, así que grep recibe dos argumentos: busca «POST» en un archivo llamado /api, que no existe, y luego en access.log, donde sí encuentra líneas. Por eso el resultado es una mezcla de aciertos y un error. ShellCheck lo ve antes de ejecutar nada:

shellcheckIn ahorra.sh line 3:
grep $patron access.log
     ^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
grep "$patron" access.log

For more information:
  https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...

Con comillas, grep "$patron" access.log, el patrón llega entero. Es la misma regla de oro de la lección 2, y en esta lección se rompe más que en ninguna otra, porque los patrones casi siempre tienen espacios, asteriscos o corchetes.

find: encontrar archivos

grep busca dentro de los archivos; find busca los archivos, por nombre, tipo, tamaño, fecha o permisos, recorriendo las subcarpetas. El primer argumento es dónde buscar y después van las condiciones:

#!/usr/bin/env bash
mkdir -p web/css web/img registros
touch web/index.html web/css/estilo.css
head -c 48000 /dev/zero > "web/img/foto de portada.jpg"
head -c 3500 /dev/zero > web/img/logo.png
touch -d '2026-01-15' registros/enero.log
touch -d '2026-10-29' registros/octubre.log

echo "== Los .css y .html:"
find web -type f \( -name '*.css' -o -name '*.html' \) | sort
echo "== Registros modificados hace más de 30 días:"
find registros -name '*.log' -mtime +30
echo "== Tamaño de cada imagen:"
find web/img -type f -exec du -b {} + | sort
echo "== Bytes de los .jpg, aunque el nombre tenga espacios:"
find web -type f -name '*.jpg' -print0 | xargs -0 wc -c
salida== Los .css y .html:
web/css/estilo.css
web/index.html
== Registros modificados hace más de 30 días:
registros/enero.log
== Tamaño de cada imagen:
3500	web/img/logo.png
48000	web/img/foto de portada.jpg
== Bytes de los .jpg, aunque el nombre tenga espacios:
48000 web/img/foto de portada.jpg

Varias cosas que conviene memorizar. Los patrones de -name van entre comillas simples, para que Bash no los expanda antes de que los vea find. Los paréntesis para agrupar condiciones con -o (o) llevan barra, \(, porque son especiales para Bash. -mtime +30 significa «modificado hace más de 30 días». Con -exec … {} +, find ejecuta la orden pasándole muchos archivos de una vez. Y -print0 con xargs -0 separa los nombres con un carácter nulo en lugar de con saltos de línea, la única forma segura de pasar nombres que tienen espacios o saltos de línea, como «foto de portada.jpg».

Nunca recorras la salida de find o de ls con for f in $(find …): los nombres con espacios se parten en trozos. Usa -exec, -print0 | xargs -0 o while IFS= read -r -d '' f; do …; done < <(find … -print0).

Qué herramienta usar

  • Quieres…UsaEjemplo
  • Las líneas que contienen algogrepgrep -i error app.log
  • Una columna de un texto bien separadocutcut -d, -f2 datos.csv
  • Ordenar, quitar repetidas, contarsort, uniqsort | uniq -c | sort -rn
  • Cambiar un texto por otrosedsed 's/viejo/nuevo/g'
  • Calcular con columnas, filtrar por valorawkawk '$3 > 100'
  • Cambiar o borrar caracteres sueltostrtr -d '\r'
  • Encontrar archivosfindfind . -name '*.log' -mtime +7

Cuando la tarea crece, una docena de órdenes encadenadas se vuelve ilegible: a partir de ahí, un script de awk en su propio archivo, o directamente Python, suele ser mejor idea. Y para formatos con estructura, como JSON o CSV con comillas, usa herramientas que los entiendan: jq para JSON y un lector de verdad para CSV, como el de Procesar CSV en Bash.

Retos

Reto 1. Cuenta cuántas peticiones del registro terminaron en 404 y escribe, sin repetir, las rutas que se pidieron.

Ver la solución
#!/usr/bin/env bash
echo "404: $(awk '$9 == 404' access.log | wc -l)"
awk '$9 == 404 { print $7 }' access.log | sort -u
salida404: 3
/.env
/backup.zip
/wp-login.php

Comparar el campo 9 con awk es más preciso que grep 404, que también encontraría un 404 en una ruta o en el número de bytes.

Reto 2. ¿Cuántas unidades se vendieron en total, y cuál fue el día con más importe vendido?

Ver la solución
#!/usr/bin/env bash
awk -F, 'NR > 1 { u += $3 } END { print "Unidades:", u }' ventas.csv
awk -F, 'NR > 1 { dia[$1] += $3 * $4 }
         END { for (d in dia) print dia[d], d }' ventas.csv | sort -rn | head -n 1
salidaUnidades: 24
379.8 2026-10-04

El segundo awk escribe el importe primero, para que sort -rn ordene por él.

Reto 3. Con sed, deja la petición de cada línea del registro en el formato «MÉTODO ruta» (por ejemplo, «GET /index.html») y quédate solo con las tres primeras.

Ver la solución
#!/usr/bin/env bash
sed -E 's/^[^"]*"([A-Z]+) ([^ ]+) [^"]*".*$/\1 \2/' access.log | head -n 3
salidaGET /index.html
GET /blog/bash
GET /wp-login.php

[^"]* avanza hasta las primeras comillas, y los dos grupos capturan el método y la ruta. Todo lo demás de la línea se sustituye por esos dos grupos.

Todos los scripts de esta lección se ejecutaron con Bash 5.3 en Ubuntu 26.04, en un contenedor limpio sin acceso a la red, y se revisaron con ShellCheck 0.11 antes de publicarla; las salidas están copiadas de esa ejecución. ¿Algo se comporta distinto? Mira tu versión con bash --version: macOS trae de serie Bash 3.2, y en Windows hay que usar WSL o Git Bash.