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