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

  1. Por qué awk no es un comando más
  2. El modelo de ejecución: patrón { acción }, BEGIN y END
  3. Registros, campos y variables integradas
  4. Separadores de entrada y de salida
  5. Patrones
  6. Acciones: print, printf, condicionales y bucles
  7. Variables de usuario y su inicialización automática
  8. Arrays asociativos: agregar en una sola pasada
  9. Recorrer y ordenar el resultado
  10. Funciones integradas
  11. Coma flotante gratis y variables de Bash con -v
  12. Programas largos, ficheros .awk y sabores
  13. Aplicación: el nuevo núcleo de informe-diario.sh

  1. 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.

  1. El modelo de ejecución: patrón { acción }, BEGIN y END

Un 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.

awk '/ERROR/ { n++ } END { print "Errores:", n }' /var/log/veloz/app.log   # -> Errores: 47

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.

  1. 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.

  1. 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.

awk -F, 'BEGIN { OFS="\t" } FNR>1 { print $3, $6 }' /srv/veloz/datos/envios.csv | head -2
Valencia	34.50
Sevilla	18.90

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 }).

  1. 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.csv

Esa 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.

  1. Acciones: print, printf, condicionales y bucles

Dentro 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.csv

printf 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.

  1. 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.

  1. 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.55

Lé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.csv

Un 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.

  1. 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 -rn

Fí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í.

  1. 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.

  1. Coma flotante gratis y variables de Bash con -v

En 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.

  1. Programas largos, ficheros .awk y sabores

Un 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.

  1. Aplicación: el nuevo núcleo de informe-diario.sh

Al 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ó $1 antes. Programa entre comillas simples; los valores entran con -v.
  • Olvidar la coma en print. print $3, $6 separa con OFS; print $3 $6 concatena.
  • Confundir $NF con NF. NF es el número de campos; $NF es el contenido del último. El penúltimo necesita paréntesis: $(NF-1).
  • Usar NR>1 con varios ficheros. Salta solo la primera cabecera de todas; con comodines o historico/, usa FNR>1.
  • Esperar orden en for (k in array). No lo hay: si importa, ordena con una tubería a sort.
  • printf sin \n. A diferencia de print, no lo añade y toda la salida sale pegada.
  • Comparar texto que parece número. Un campo 007 se compara como 7 frente a un número y como texto frente a una cadena; fuerza el tipo con $1+0 o $1 "" cuando dependa de ello.
  • Consejo: awk no siempre es la respuesta. Para filtrar líneas sin más, grep es más rápido y más claro; awk gana cuando hay campos, cuentas o memoria entre líneas. Y LC_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 -rn

Dos 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

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