Al cerrar el Módulo 5 quedó anotada la primera carencia del toolkit: cada vez que hay que hacer cuentas por columnas sobre envios.csv, informe-diario.sh encadena cut, sort y uniq, y relee el fichero una vez por cada dato que quiere. Sumar importes por ciudad, contar por estado y calcular la media por repartidor son hoy tres tuberías distintas y tres lecturas del disco. awk resuelve las tres en una sola pasada, porque no es un comando más: es un pequeño lenguaje diseñado para texto organizado en registros y campos. Esta lección lo presenta como tal.
Contenido
- Por qué awk no es un comando más
- El modelo de ejecución:
patrón { acción },BEGINyEND - Registros, campos y variables integradas
- Separadores de entrada y de salida
- Patrones
- Acciones:
print,printf, condicionales y bucles - Variables de usuario y su inicialización automática
- Arrays asociativos: agregar en una sola pasada
- Recorrer y ordenar el resultado
- Funciones integradas
- Coma flotante gratis y variables de Bash con
-v - Programas largos, ficheros
.awky sabores - Aplicación: el nuevo núcleo de
informe-diario.sh
- Por qué awk no es un comando más
grep decide si una línea le interesa, cut extrae una columna, sort y uniq -c cuentan. Cada uno hace una cosa y por eso se encadenan. awk hace las cuatro a la vez porque tiene lo que a ellos les falta: memoria entre líneas (variables y arrays que sobreviven de un registro al siguiente) y aritmética.
Esa es la diferencia de fondo: una tubería es un flujo sin estado; un programa awk es un bucle con estado. En cuanto la pregunta deja de ser "¿qué líneas?" y pasa a ser "¿cuánto suma esto agrupado por aquello?", la tubería crece y awk se queda igual de corto.
- El modelo de ejecución:
patrón { acción }, BEGIN y END
patrón { acción }, BEGIN y ENDUn programa awk es una lista de reglas. Para cada línea de la entrada, awk recorre todas las reglas en orden: si el patrón se cumple, ejecuta la acción. En awk '/ERROR/ { print $0 }' /var/log/veloz/app.log, el patrón es /ERROR/ y la acción { print $0 }: awk lee una línea, comprueba, imprime si procede y pasa a la siguiente. No hay bucle escrito, el bucle es implícito, y es lo primero que hay que interiorizar. Las dos partes son opcionales:
| Escribes | Significa |
|---|---|
awk '/ERROR/' |
Patrón sin acción → la acción por defecto es { print $0 } |
awk '{ print $3 }' |
Acción sin patrón → patrón vacío: se cumple en todas las líneas |
awk '/ERROR/ { print $3 }' |
Ambos: el campo 3 solo de las líneas con ERROR |
Dos patrones especiales no dependen de ninguna línea: BEGIN { } se ejecuta una vez antes de leer nada (configurar separadores, imprimir cabeceras) y END { } una vez después de la última línea, que es donde se vuelcan los totales.
Ahí está ya el patrón que domina la lección: acumular durante el recorrido, imprimir en END. El programa va siempre entre comillas simples: dentro hay $1, $NF… y con comillas dobles Bash los expandiría a los argumentos del script (03-06) antes de que awk los viera.
- Registros, campos y variables integradas
awk parte la entrada en registros (por defecto, líneas) y cada registro en campos (por defecto, espacios o tabuladores). $0 es el registro completo, $1, $2… los campos contando desde 1, NF el número de campos y por tanto $NF el último y $(NF-1) el penúltimo. Los paréntesis son obligatorios: $NF-1 significa "el último campo, menos uno", una resta.
| Variable | Significado | Por defecto |
|---|---|---|
NR |
Número de registro global (contando todos los ficheros) | — |
FNR |
Número de registro dentro del fichero actual | — |
NF |
Número de campos del registro actual | — |
FS / OFS |
Separador de campos de entrada / de salida | Espacios / un espacio |
RS / ORS |
Separador de registros de entrada / de salida | Salto de línea |
FILENAME |
Nombre del fichero que se está leyendo | — |
$NF es oro cuando la línea tiene un número variable de campos: en acceso.log los bytes son siempre el último, aunque la petición tenga más o menos palabras, así que awk '{ s += $NF } END { printf "%.2f MB\n", s/1048576 }' /var/log/veloz/acceso.log funciona sin contar columnas.
NR y FNR se diferencian solo con varios ficheros: al empezar el segundo, FNR vuelve a 1 y NR sigue subiendo. De ahí el idioma para saltar cabeceras CSV: awk -F, 'FNR>1' envios.csv salta todas las cabeceras, mientras que NR>1 solo saltaría la del primer fichero. Con historico/2026/*/envios.csv, esa diferencia es un error de datos silencioso.
- Separadores de entrada y de salida
Para el CSV hay que decirle a awk que el separador es la coma, y hay dos formas equivalentes: la opción awk -F, '{ print $3 }' envios.csv y la asignación awk 'BEGIN { FS="," } { print $3 }' envios.csv. -F, es lo habitual; FS en BEGIN se usa cuando el separador es complejo, porque admite una expresión regular: FS="[,;]" acepta coma o punto y coma, y FS="[[:space:]]+" colapsa espacios múltiples.
La coma de print $3, $6 significa "sepáralos con OFS". Sin coma, print $3 $6 concatena: Valencia34.50. Es la confusión número uno con awk. Además, OFS solo se aplica cuando awk reconstruye el registro; si imprimes $0 sin tocarlo sale tal cual, y el truco para forzarlo es asignarse un campo a sí mismo ({ $1=$1; print }).
- Patrones
El patrón puede ser cualquier expresión que awk evalúe como verdadera:
| Patrón | Selecciona |
|---|---|
/ERROR/ |
Líneas que contienen ERROR (regex ERE, la de 05-04) |
$5 == "incidencia" |
Registros cuyo campo 5 es exactamente ese texto |
$5 ~ /^inc/ |
Campo 5 que empareja la regex (!~ para lo contrario) |
$6 > 50 |
Comparación numérica sobre el campo 6 |
NR == 1 |
Solo la primera línea |
/inicio/,/fin/ |
Rango: desde la línea que empareja la primera hasta la que empareja la segunda |
$3=="Madrid" && $5=="incidencia" |
Combinación lógica con &&, || y ! |
!/ERROR/ |
Negación: líneas que no contienen ERROR |
| (vacío) | Todas las líneas |
awk -F, '$3 == "Madrid" && $5 == "incidencia" && $6 > 50 { print $1, $4, $6 }' \
/srv/veloz/datos/envios.csvEsa línea sustituye a un grep | grep | cut o a un while read de siete líneas. awk decide solo si compara como número o como texto: $6 > 50 es numérico porque 50 lo es; $3 == "Madrid" es textual.
- Acciones:
print, printf, condicionales y bucles
print, printf, condicionales y buclesDentro de { } cabe un lenguaje completo con sintaxis parecida a C: asignaciones, if/else, for y while.
awk -F, 'FNR > 1 {
if ($5 == "incidencia") printf "%-10s %-8s %8.2f <-- REVISAR\n", $3, $4, $6
else if ($6 > 100) printf "%-10s %-8s %8.2f (alto)\n", $3, $4, $6
}' /srv/veloz/datos/envios.csvprintf funciona como el de Bash que viste en 04-04 (%-10s alinea texto a la izquierda en 10 columnas, %8.2f un número con 2 decimales), con una diferencia importante: el \n hay que ponerlo siempre, porque no lo añade; print sí lo añade. El for con sintaxis de C sirve para recorrer los campos de un registro: for (i=1; i<=NF; i++) if ($i == "") print FILENAME": linea "FNR" campo "i detecta campos vacíos en el CSV.
- Variables de usuario y su inicialización automática
En awk no se declaran variables: se usan. Y lo que más código ahorra es que una variable nueva vale 0 si la tratas como número y cadena vacía si la tratas como texto.
En awk -F, 'FNR>1 { total += $6; n++ } END { print n, total, total/n }' envios.csv, total y n no existen antes de la primera línea y aun así total += $6 funciona: awk las crea valiendo 0, mientras que en Bash tendrías que escribir total=0; n=0 antes del bucle. El reverso: un nombre mal escrito no da error, vale 0. Si acumulas en totl e imprimes total, el resultado será 0 sin ninguna queja.
- Arrays asociativos: agregar en una sola pasada
Este es el motivo de la lección. Los arrays de awk son siempre asociativos —el índice es una cadena, como los de 04-03— y también se crean solos:
awk -F, 'FNR>1 { importe[$3] += $6 } END { for (c in importe) print c, importe[c] }' \
/srv/veloz/datos/envios.csv # -> Sevilla 4820.30 / Valencia 6944.10 / Madrid 8210.55Léelo despacio: para cada registro, coge el campo 3 (la ciudad), úsalo como índice y súmale el campo 6. No hace falta saber de antemano qué ciudades hay, ni ordenar, ni recorrer dos veces. La estructura es siempre { acumulador[clave] += valor } durante el recorrido y END { for (k in acumulador) ... } al final. Y como las reglas son independientes, las tres preguntas del principio caben en un programa y una sola lectura:
awk -F, 'FNR > 1 {
importe[$3] += $6 # suma de importes por ciudad
estado[$5]++ # recuento por estado
suma_rep[$4] += $6; n_rep[$4]++ # para la media por repartidor
}
END {
for (c in importe) printf "ciudad %-10s %10.2f\n", c, importe[c]
for (e in estado) printf "estado %-12s %6d\n", e, estado[e]
for (r in suma_rep) printf "media %-8s %8.2f\n", r, suma_rep[r]/n_rep[r]
}' /srv/veloz/datos/envios.csvUn array puede indexarse además por la combinación de dos campos, y eso da tablas cruzadas gratis: celda[$3, $5] += $6 suma importes por ciudad y estado a la vez.
- Recorrer y ordenar el resultado
for (k in array) no garantiza ningún orden: awk recorre su tabla hash como le conviene. Si necesitas un orden, lo portable es imprimir y ordenar por tubería con el sort de 02-02:
awk -F, 'FNR>1 { i[$3] += $6 } END { for (c in i) printf "%.2f\t%s\n", i[c], c }' envios.csv | sort -rnFíjate en que se imprime primero el número, para que sort -rn ordene por él sin opciones raras. GNU awk tiene PROCINFO["sorted_in"] y asorti(), pero no son portables; la tubería sí.
- Funciones integradas
| Función | Qué hace | Ejemplo |
|---|---|---|
length(s) |
Longitud de la cadena (o de $0 si se omite) |
length($4) |
substr(s, i, n) |
Subcadena desde la posición i (empieza en 1) |
substr($2,1,7) → 2026-08 |
split(s, arr, sep) |
Parte s en el array arr; devuelve cuántos trozos |
split($2,f,"-") |
sub(re, rep) |
Sustituye la primera coincidencia en $0 (o en el 3.er argumento) |
sub(/^ +/, "") |
gsub(re, rep) |
Sustituye todas; devuelve cuántas | gsub(/,/, ".", $6) |
index(s, t) |
Posición de t dentro de s, o 0 |
index($0,"ERROR") |
toupper(s) / tolower(s) |
Cambia de caja | toupper($3) |
int(x) |
Trunca a entero (no redondea) | int(4.9) → 4 |
sprintf(fmt, ...) |
Como printf, pero devuelve la cadena |
k = sprintf("%s/%s",$3,$5) |
Combinadas con los arrays dan agregaciones que en Bash costarían un bucle entero: awk -F, 'FNR>1 { mes[substr($2,1,7)] += $6 } END { for (m in mes) print m, mes[m] }' envios.csv | sort factura por mes extrayendo el AAAA-MM de la fecha.
- Coma flotante gratis y variables de Bash con
-v
-vEn 04-06 quedó claro que $(( )) solo hace enteros y que para decimales había que salir a bc -l. En awk toda la aritmética es en coma flotante, sin instalar nada:
awk -F, 'FNR>1 { t++; if ($5=="entregado") ok++ }
END { printf "Tasa de entrega: %.1f%%\n", 100*ok/t }' envios.csv # -> 87.3%Esto convierte a awk en la calculadora natural de un script: pct=$(awk -v a="$ok" -v b="$tot" 'BEGIN { printf "%.1f", 100*a/b }') sustituye a bc sin tubería. Ojo con %%: en printf, un porcentaje literal se escribe duplicado.
Ese -v es la forma correcta de meter un valor de Bash en el programa, ya que las comillas simples impiden cualquier expansión. La tentación es escribir awk -F, "\$3 == \"$ciudad\" { n++ }", interpolando la variable. No lo hagas, por dos motivos. Primero, se rompe con nada: un valor con espacios, comillas o barras destroza la sintaxis. Segundo, es inyección de código: lo que hay en la variable pasa a ser programa, así que si viene de un argumento o de un fichero, quien controle ese valor controla lo que awk ejecuta —el mismo riesgo de eval (05-06), tratado a fondo en 08-03—. Con -v c="$ciudad" el valor entra como dato. Dos matices: -v procesa las secuencias de escape, y sus variables ya están disponibles en BEGIN, cosa que no ocurre con la sintaxis awk '...' var=valor fichero.
- Programas largos, ficheros
.awk y sabores
.awk y saboresUn programa puede ocupar varias líneas dentro de las mismas comillas simples, con sangrado normal. Cuando pasa de unas quince líneas o se reutiliza, se saca a un fichero y se invoca con awk -f ~/veloz-ops/lib/resumen.awk envios.csv:
# ~/veloz-ops/lib/resumen.awk — Resumen diario de envios.
BEGIN { FS="," }
FNR > 1 { importe[$3] += $6; estado[$5]++ }
END { for (c in importe) printf "ciudad\t%s\t%.2f\n", c, importe[c] }Un .awk admite comentarios con #, se versiona en Git y es mucho más legible que un mazacote entre comillas: es a awk lo que lib/comun.sh es a Bash.
awk es un estándar POSIX con varias implementaciones: en Ubuntu 24.04 /usr/bin/awk suele ser mawk (rápido, POSIX puro), en Fedora gawk (con extensiones como gensub(), asorti(), RS como regex o --csv) y en macOS/BSD el awk original. Todo lo de esta lección es POSIX y funciona en los tres; si usas una extensión de gawk, invoca gawk explícitamente para que el fallo en otra máquina sea "gawk no está instalado" y no una salida silenciosamente distinta. Un último aviso: awk parte por comas a secas, así que un campo entrecomillado que contenga una coma ("Madrid, centro") rompe el conteo. envios.csv no tiene ese caso; si lo tuviera, la respuesta es gawk --csv, no una regex más complicada.
- Aplicación: el nuevo núcleo de
informe-diario.sh
informe-diario.shAl cerrar el Módulo 5, el cálculo eran tres tuberías, tres lecturas del fichero y ningún decimal: tail -n +2 "$CSV" | wc -l para el total, cut -d, -f5 "$CSV" | grep -c '^entregado$' para los entregados y cut -d, -f3 "$CSV" | tail -n +2 | sort | uniq -c | sort -rn para el reparto por ciudad. Así queda ahora como función de lib/informe.sh, con una sola pasada y un cuadro cruzado de ciudad por estado que antes no era viable:
# veloz_resumen_csv — Vuelca el resumen del CSV como lineas clave<TAB>valor.
veloz_resumen_csv() {
local csv="${1:?falta el CSV}"
awk -F, '
FNR == 1 { next } # cabecera
{ total++; importe[$3] += $6; estado[$5]++; celda[$3 SUBSEP $5]++ }
END {
if (total == 0) { print "csv sin datos" > "/dev/stderr"; exit 65 }
print "total\t" total
printf "tasa_entrega\t%.1f\n", 100 * estado["entregado"] / total
for (c in importe)
printf "ciudad\t%s\t%.2f\t%d\n", c, importe[c], celda[c SUBSEP "incidencia"] + 0
}
' "$csv"
}Tres detalles merecen explicación. SUBSEP es el separador que awk usa internamente para índices compuestos —celda[$3, $5] y celda[$3 SUBSEP $5] son lo mismo— y es un carácter que no aparecerá nunca en los datos, más seguro que inventarse un "|". El + 0 fuerza a que una celda inexistente se imprima como 0 y no como cadena vacía. Y exit 65 dentro de END termina awk con ese código, que set -euo pipefail (05-03) convierte en un fallo del script; 65 es EX_DATAERR, el convencional para datos de entrada incorrectos.
El consumidor en Bash lee esa salida con el bucle de 04-01, sin volver a tocar el CSV:
while IFS=$'\t' read -r clave a b c; do
case "$clave" in
total) veloz_log_info "Envios procesados: $a" ;;
tasa_entrega) veloz_log_info "Tasa de entrega: ${a}%" ;;
ciudad) printf ' %-10s %10s EUR (%s incidencias)\n' "$a" "$b" "$c" ;;
esac
done < <(veloz_resumen_csv "$CSV")La sustitución de procesos < <( ) es la de 05-05: mantiene el bucle en el shell actual para que las variables que asigne sobrevivan. El reparto de responsabilidades queda claro: awk calcula, Bash orquesta y presenta.
Errores Comunes y Consejos
- Comillas dobles alrededor del programa.
awk "{ print $1 }"imprime líneas vacías porque Bash expandió$1antes. Programa entre comillas simples; los valores entran con-v. - Olvidar la coma en
print.print $3, $6separa conOFS;print $3 $6concatena. - Confundir
$NFconNF.NFes el número de campos;$NFes el contenido del último. El penúltimo necesita paréntesis:$(NF-1). - Usar
NR>1con varios ficheros. Salta solo la primera cabecera de todas; con comodines ohistorico/, usaFNR>1. - Esperar orden en
for (k in array). No lo hay: si importa, ordena con una tubería asort. printfsin\n. A diferencia deprint, no lo añade y toda la salida sale pegada.- Comparar texto que parece número. Un campo
007se compara como 7 frente a un número y como texto frente a una cadena; fuerza el tipo con$1+0o$1 ""cuando dependa de ello. - Consejo: awk no siempre es la respuesta. Para filtrar líneas sin más,
grepes más rápido y más claro; awk gana cuando hay campos, cuentas o memoria entre líneas. YLC_ALL=C awk ...acelera el procesado de ficheros grandes en ASCII (lo veremos en 06-03).
Ejercicios
Ejercicio 1. Con un solo comando awk sobre /srv/veloz/datos/envios.csv, imprime para cada repartidor su nombre, número de envíos, importe total e importe medio, ordenado de mayor a menor importe total y en columnas alineadas.
Ejercicio 2. Sobre /var/log/veloz/acceso.log (el código HTTP es el penúltimo campo y los bytes el último), obtén con un solo awk el número de peticiones y los megabytes servidos por código de respuesta, marcando los 5xx con <-- ATENCION.
Ejercicio 3. Escribe una función veloz_media_importe para lib/comun.sh que reciba el CSV y una ciudad, devuelva el importe medio de esa ciudad con dos decimales pasando la ciudad de forma segura, y salga con código 1 si esa ciudad no tiene envíos.
Soluciones
Solución 1.
awk -F, 'FNR>1 { n[$4]++; s[$4] += $6 }
END { for (r in n) printf "%-8s %5d %10.2f %8.2f\n", r, n[r], s[r], s[r]/n[r] }' \
/srv/veloz/datos/envios.csv | sort -k3 -rnDos arrays con el mismo índice (n cuenta, s suma) son el patrón para calcular medias, y la división se hace en END, nunca durante el recorrido. El orden se resuelve fuera con sort -k3 -rn sobre la columna del importe total, porque for (r in n) no garantiza ninguno.
Solución 2.
awk '{ n[$(NF-1)]++; b[$(NF-1)] += $NF }
END { for (c in n) {
marca = (c >= 500 && c < 600) ? " <-- ATENCION" : ""
printf "%-5s %7d peticiones %9.2f MB%s\n", c, n[c], b[c]/1048576, marca
} }' /var/log/veloz/acceso.log | sort # -> 503 47 peticiones 0.12 MB <-- ATENCION$(NF-1) y $NF evitan contar campos a mano en un formato donde la petición tiene longitud variable. El operador ternario condición ? a : b existe en awk igual que en C y ahorra un if/else de cuatro líneas; c >= 500 funciona porque awk trata c como número al compararlo con uno.
Solución 3.
# veloz_media_importe — Importe medio de una ciudad. Uso: veloz_media_importe <csv> <ciudad>
veloz_media_importe() {
local csv="${1:?falta el CSV}" ciudad="${2:?falta la ciudad}"
awk -F, -v c="$ciudad" '
FNR>1 && $3 == c { s += $6; n++ }
END { if (n == 0) exit 1; printf "%.2f\n", s/n }
' "$csv"
}La ciudad entra con -v c="$ciudad", así que un valor con comillas o espacios es un dato inofensivo y no parte del programa. El exit 1 dentro de END propaga el fallo al código de salida de la función —awk es un comando más y $? funciona como con cualquier otro (03-01)—, de forma que el llamante puede escribir media=$(veloz_media_importe "$CSV" Madrid) || veloz_log_error "sin datos". Sin el if (n == 0), la división daría un error o un nan según la implementación.
Conclusión
awk es un lenguaje con bucle implícito: para cada registro recorre sus reglas patrón { acción }, con BEGIN y END como los dos momentos que no dependen de ninguna línea. Trocea cada registro en campos accesibles como $1, $NF o $(NF-1) y describe su posición con NR, FNR, NF y FILENAME; -F, o FS fijan cómo parte la entrada y OFS cómo la junta al salir. Los patrones van de una regex a comparaciones sobre campos, rangos y combinaciones lógicas, y las acciones incluyen print, printf, condicionales y bucles. Pero lo que justifica la lección son dos rasgos que ninguna tubería tiene: variables que se inicializan solas a 0 y arrays asociativos, que convierten acumulador[clave] += valor seguido de for (k in acumulador) en END en el patrón universal de agregación —suma por ciudad, recuento por estado, media por repartidor y hasta tablas cruzadas con SUBSEP, todo en una lectura y con coma flotante gratuita—. Los valores de Bash entran con -v, nunca interpolados, y los programas largos se sacan a un fichero .awk con -f.
informe-diario.sh ya no relee el CSV tres veces: awk calcula y Bash orquesta. Pero awk lee y resume; no es la herramienta para reescribir texto conservando su forma. Cuando haya que anonimizar las IP de acceso.log antes de compartirlo, normalizar los separadores de un fichero mal exportado o cambiar una clave de veloz-ops.conf sin abrir un editor, hace falta un editor de flujo. Eso es sed, y es la próxima lección (06-02): las mismas expresiones regulares que ya dominas, pero aplicadas a transformar en lugar de a filtrar.
Curso de Programación en Bash
Módulo 1: Introducción a Bash
- ¿Qué es Bash?
- Configurando tu Entorno
- Navegación Básica en la Línea de Comandos
- Entendiendo el Shell
- Encontrar Ayuda: man, help y --help
Módulo 2: Comandos Básicos de Bash
- Operaciones con Archivos y Directorios
- Comandos de Procesamiento de Texto
- Permisos y Propiedad de Archivos
- Redirección y Tuberías
- Comodines y Expansión de Rutas
- Historial y Atajos de Teclado
Módulo 3: Fundamentos de Scripting
- Creando y Ejecutando un Script
- Variables y Constantes
- Operadores Básicos
- Sentencias Condicionales
- Argumentos y Entrada del Usuario
- Comillas, Expansión y Sustitución
Módulo 4: Scripting Intermedio
- Bucles en Bash
- Funciones en Bash
- Arrays y Arrays Asociativos
- Manipulación de Cadenas
- La Sentencia case y los Menús Interactivos
- Aritmética y Cálculos Numéricos
Módulo 5: Técnicas Avanzadas de Scripting
- Operaciones Avanzadas con Archivos
- Gestión de Procesos
- Manejo de Errores y Depuración
- Expresiones Regulares
- Entrada/Salida Avanzada: Descriptores y Here-Documents
- Scripts Modulares y Librerías Reutilizables
Módulo 6: Trabajando con Herramientas Externas
Módulo 7: Automatización y Programación
- Trabajos Cron
- Automatizando Tareas
- Scripts de Respaldo y Restauración
- Monitoreo y Registro
- Servicios y Temporizadores con systemd
- Automatización Remota con SSH
Módulo 8: Mejores Prácticas y Optimización
- Escribiendo Código Legible
- Optimizando Scripts en Bash
- Consideraciones de Seguridad
- Control de Versiones con Git
- Análisis Estático con ShellCheck y shfmt
- Pruebas Automatizadas con Bats
- Portabilidad: POSIX sh frente a Bashismos
