← Automatización con Scripts en Bash Bloque 7 · Texto y expresiones regulares · Tema 30 de 42

Tema 30

Procesar CSV y archivos de configuración

Dos clases de archivos aparecen una y otra vez en la automatización. Los datos tabulares, como un CSV exportado de una planilla o de una base de datos, que hay que leer, filtrar y resumir. Y los archivos de configuración, que permiten cambiar el comportamiento de un script sin tocar su código. Los dos parecen simples, y los dos tienen una trampa: el CSV real es más complicado de lo que aparenta, y cargar una configuración de la forma más cómoda puede ejecutar código ajeno.

1. El formato CSV

Un archivo CSV (Comma-Separated Values, valores separados por comas) guarda una tabla como texto: una fila por línea y los campos de cada fila separados por comas. Casi siempre la primera línea es un encabezado con los nombres de las columnas.

gastos.csv
fecha,categoria,descripcion,importe
2026-10-01,comida,Supermercado,45.30
2026-10-02,transporte,Tarjeta,20
2026-10-03,comida,Panadería,8.70
2026-10-05,servicios,Electricidad,61.25

Es el formato de intercambio más común entre planillas, bases de datos y sistemas, precisamente porque es texto simple. Mientras los campos no contengan comas, comillas ni saltos de línea, se procesa con las herramientas que ya conoces. Cuando sí los contienen, las cosas cambian, como veremos en la sección 5.

2. Leer un CSV con read

Un CSV simple se recorre con el bucle del tema 16, con IFS fijado a la coma para separar los campos, como en el tema 9:

recorrer un CSV fila por fila
while IFS=, read -r fecha categoria descripcion importe; do
  echo "$fecha: $descripcion ($categoria) — \$$importe"
done < gastos.csv

Cada variable recibe un campo, y la última, como siempre, recibe todo lo que sobra. Si el CSV tiene más columnas de las que interesan, una variable final como _ las absorbe; si tiene menos, las variables sobrantes quedan vacías. Esta forma conviene cuando hay que hacer algo con cada fila: crear un usuario, copiar un archivo, enviar un mensaje. Para calcular sobre todas las filas, awk es mucho más rápido.

3. Encabezado, campos vacíos y Windows

Tres detalles aparecen en casi todo CSV real:

DetalleProblemaSolución
El encabezadoSe procesa como si fuera una fila de datos.Leerlo antes del bucle con un read aparte, o saltar la primera línea con tail -n +2.
Campos vacíosa,,c: falta un valor.Con IFS=, se respetan: la variable queda vacía, como vimos en el tema 9. Hay que decidir qué hacer con ella.
Finales de línea de WindowsCada línea termina en \r\n, y el último campo arrastra un \r invisible.Quitarlo con ${campo%$'\r'}, o convertir el archivo antes con sed 's/\r$//'.
un recorrido que contempla los tres detalles
{
  IFS= read -r encabezado                         # consumir la primera línea
  while IFS=, read -r fecha categoria descripcion importe || [[ -n "$fecha" ]]; do
    importe="${importe%$'\r'}"                    # por si viene de Windows
    [[ -n "$importe" ]] || { echo "Fila sin importe: $fecha" >&2; continue; }
    …
  done
} < gastos.csv

Las llaves agrupan el read del encabezado y el bucle, y la redirección después de la llave de cierre les da a todos la misma entrada: el primer read consume una línea, y el bucle sigue desde la segunda. El \r de Windows es especialmente traicionero en CSV, porque los exportan planillas que suelen correr en Windows, y el síntoma es desconcertante: un importe que se ve como 20 no pasa la validación de número, porque en realidad es 20\r.

4. Calcular con awk

Para sumar, contar o agrupar, awk con -F, procesa el CSV en una sola pasada, con los arrays del tema 29, y además calcula con decimales, que Bash no tiene:

total por categoría
awk -F, '
  { sub(/\r$/, "") }                    # quitar el \r de Windows, si lo hay
  NR == 1 { next }                      # saltar el encabezado
  { total[$2] += $4 }
  END { for (c in total) printf "%-12s %8.2f\n", c, total[c] }
' gastos.csv | sort -k2 -rn
salida
servicios       61.25
comida          54.00
transporte      20.00

NR == 1 { next } descarta la primera línea: next pasa a la línea siguiente sin ejecutar el resto del programa. sub(/\r$/, "") quita un retorno de carro final de la línea completa antes de que awk la divida en campos. Y como awk convierte los textos en números al operar, $4 se suma directamente.

5. Cuando el CSV tiene comillas

Las técnicas anteriores funcionan con un CSV simple. Pero el formato permite que un campo contenga comas, comillas o incluso saltos de línea, encerrándolo entre comillas dobles:

un CSV real
fecha,categoria,descripcion,importe
2026-10-09,hogar,"Lámpara, mesa y silla",120.50
2026-10-10,regalos,"Libro ""Cien años de soledad""",18.90

En la primera fila, la descripción contiene dos comas, y IFS=, o awk -F, la partirían en tres campos, desplazando el importe. En la segunda, las comillas dentro del campo se escriben duplicadas. Ninguna de las herramientas de este tema entiende estas reglas, y una expresión regular que las cubra todas es frágil. Las opciones razonables son:

OpciónCuándo
Pedir el archivo en otro formato, como TSV (sección 6)Si se controla cómo se exporta.
Miller (mlr), una herramienta para CSV en la línea de comandosPara filtrar, ordenar y resumir CSV reales desde un script: mlr --icsv --opprint stats1 -a sum -f importe -g categoria gastos.csv.
csvkit (csvcut, csvgrep, csvstat)Similar a Miller, escrito en Python.
gawk con FPATPara campos con comas entre comillas, sin saltos de línea. Es una extensión de GNU awk.
Un pequeño programa en Python, con su módulo csvCuando el procesamiento es complejo: es el límite del que hablamos en el tema 1.

La regla práctica: si no sabes con certeza que el CSV es simple, compruébalo antes de procesarlo. Un grep -c '"' archivo.csv que devuelve algo distinto de cero es una buena señal de que IFS=, no alcanza.

6. TSV y otros separadores

Cuando se puede elegir el formato, el TSV (valores separados por tabuladores) evita casi todos los problemas del CSV: un tabulador rara vez aparece dentro de un dato, y muchos programas lo exportan. Se procesa igual, cambiando el separador:

TSV con read y con awk
while IFS=$'\t' read -r nombre correo puesto; do … done < personal.tsv
awk -F'\t' '{ print $2 }' personal.tsv

Lo mismo vale para otros separadores que aparecen en el sistema, como los dos puntos de /etc/passwd o el punto y coma que usan las planillas configuradas en español, donde la coma es el separador decimal. En ese último caso, además del separador, los importes vienen como 45,30, y hay que convertir la coma en punto antes de calcular: sub(",", ".", $4) en awk.

7. Archivos de configuración

Un script con muchas opciones de configuración, o que se instala en varias máquinas con valores distintos, se beneficia de leerlas de un archivo. El formato más común en el mundo Unix es el de pares clave=valor, con comentarios:

~/.config/gastos.conf
# Configuración de resumen-gastos
moneda = "€"
limite = 500

# separador de campos del CSV
separador = ,

Como vimos en el tema 25, la convención es buscar la configuración del usuario en ~/.config/, y la del sistema en /etc/. El orden de prioridad habitual, de menor a mayor, es: valores por defecto en el script, archivo de configuración, variables de entorno y opciones en la línea de comandos. Así, cada nivel puede sobrescribir al anterior.

8. Por qué no cargarlos con source

La forma más cómoda de leer un archivo con líneas clave=valor es cargarlo con source, como vimos en el tema 2: cada línea se convierte en una asignación de variable. El problema es que source no lee asignaciones: ejecuta el archivo como código. Cualquier cosa que contenga, se ejecuta con los permisos del script:

terminal · un archivo de configuración con algo más que valores
$ cat malicioso.conf
moneda=$(echo "¡esto se ejecutó!" >&2)
$ source malicioso.conf
¡esto se ejecutó!

En lugar de un echo inofensivo podría haber un rm, o un comando que envíe datos a otro lugar. El riesgo es real cuando el script se ejecuta con más privilegios que quien puede escribir el archivo: un script que corre como root y carga una configuración que un usuario común puede modificar le da a ese usuario los privilegios de root. Y aun sin malicia, un valor con un espacio sin comillas, como nombre=Ana Pérez, produce un error al cargarlo, porque Bash intenta ejecutar Pérez.

9. Cuándo source es aceptable

Cargar con source no es siempre un error. Es razonable cuando el archivo es tan confiable como el propio script:

Si se usa source para una configuración, conviene al menos comprobar que el archivo pertenece al usuario que ejecuta el script y que nadie más puede escribirlo:

comprobar antes de cargar
if [[ -O "$config" ]] && ! find "$config" -perm /022 | grep -q .; then
  source "$config"
else
  echo "No cargo $config: debe ser tuyo y nadie más debe poder escribirlo" >&2
  exit 1
fi

-O es cierto si el archivo pertenece al usuario actual, y -perm /022 encuentra el archivo si el grupo u otros tienen permiso de escritura. Para todo lo demás, especialmente archivos que pueden venir de otras personas, la alternativa es un lector que solo entienda pares clave=valor y no ejecute nada.

10. Un lector seguro

Este lector recorre el archivo línea por línea, reconoce los pares con una expresión regular y los guarda en un array asociativo, sin evaluar nada. Usa las técnicas de los temas 8, 16, 19 y 28:

leer_config
readonly RE_VACIA='^[[:space:]]*(#|$)'
readonly RE_PAR='^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=[[:space:]]*(.*[^[:space:]])?[[:space:]]*$'
readonly RE_DOBLES='^"(.*)"$'
readonly RE_SIMPLES="^'(.*)'$"

# leer_config ARCHIVO ARRAY — carga los pares clave=valor en un array asociativo, sin ejecutar nada.
leer_config() {
  local archivo="$1"
  local -n _conf="$2"
  local linea valor numero=0
  while IFS= read -r linea || [[ -n "$linea" ]]; do
    numero=$(( numero + 1 ))
    linea="${linea%$'\r'}"
    [[ "$linea" =~ $RE_VACIA ]] && continue
    if [[ ! "$linea" =~ $RE_PAR ]]; then
      echo "${archivo}:${numero}: línea no válida, se ignora" >&2
      continue
    fi
    local clave="${BASH_REMATCH[1]}"
    valor="${BASH_REMATCH[2]:-}"
    if [[ "$valor" =~ $RE_DOBLES || "$valor" =~ $RE_SIMPLES ]]; then
      valor="${BASH_REMATCH[1]}"
    fi
    _conf[$clave]="$valor"
  done < "$archivo"
}

11. Valores por defecto y validación

Un lector seguro garantiza que no se ejecute nada, pero no que los valores tengan sentido. El patrón completo tiene tres pasos: llenar el array con los valores por defecto, cargar el archivo encima (si existe) y validar el resultado:

por defecto, archivo, validación
declare -A conf=([moneda]='$' [limite]=0 [separador]=',')    # 1. valores por defecto

[[ -r "$archivo_conf" ]] && leer_config "$archivo_conf" conf  # 2. el archivo, si existe

[[ "${conf[limite]}" =~ ^[0-9]+(\.[0-9]+)?$ ]] \             # 3. validar
  || { echo "limite no es un número: ${conf[limite]}" >&2; exit 1; }

Como el archivo sobrescribe solo las claves que define, un archivo con una sola línea alcanza para cambiar un valor, y uno inexistente deja todo en los valores por defecto. Las claves desconocidas, como un error de tipeo, quedan en el array sin usarse; un lector más estricto podría avisar de ellas.

12. Secciones al estilo INI

Algunos programas agrupan su configuración en secciones, con el formato llamado INI:

respaldo.ini
[origen]
directorio = /var/www
excluir = *.tmp

[destino]
servidor = respaldos.example.com
directorio = /copias/web

La misma clave puede aparecer en dos secciones con valores distintos. Una forma simple de leerlo es combinar sección y clave en una sola clave del array, como destino.directorio. Con awk, se convierte el INI en pares planos que el lector de la sección 10 puede procesar:

aplanar un INI
awk '
  /^[[:space:]]*\[.*\][[:space:]]*$/ { gsub(/[][[:space:]]/, ""); seccion = $0 "_"; next }
  /^[[:space:]]*[^#=]+=/             { print seccion $0; next }
' respaldo.ini
salida
origen_directorio = /var/www
origen_excluir = *.tmp
destino_servidor = respaldos.example.com
destino_directorio = /copias/web

Para configuraciones complejas, con listas o estructuras anidadas, los formatos JSON, YAML o TOML son más adecuados, pero ya no son terreno de Bash: se leen con herramientas como jq o yq, o con otro lenguaje.

13. Un script completo

Este script resume un CSV de gastos por categoría, con el porcentaje de cada una y un aviso si el total supera un límite. La moneda, el límite y el separador vienen de un archivo de configuración, que se lee con el lector seguro. El cálculo lo hace awk, que valida además cada fila.

resumen-gastos
#!/usr/bin/env bash
#
# resumen-gastos — totales por categoría de un archivo CSV de gastos.
#
# Uso:  resumen-gastos [-c CONFIG] ARCHIVO.csv
#       El CSV tiene encabezado y las columnas: fecha,categoria,descripcion,importe
# Configuración (por defecto ~/.config/gastos.conf), con líneas clave=valor:
#       moneda, limite (límite de gasto total) y separador (una coma por defecto)
#
set -euo pipefail

readonly RE_VACIA='^[[:space:]]*(#|$)'
readonly RE_PAR='^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=[[:space:]]*(.*[^[:space:]])?[[:space:]]*$'
readonly RE_DOBLES='^"(.*)"$'
readonly RE_SIMPLES="^'(.*)'$"

# leer_config ARCHIVO ARRAY — carga los pares clave=valor en un array asociativo, sin ejecutar nada.
leer_config() {
  local archivo="$1"
  local -n _conf="$2"
  local linea valor numero=0
  while IFS= read -r linea || [[ -n "$linea" ]]; do
    numero=$(( numero + 1 ))
    linea="${linea%$'\r'}"
    [[ "$linea" =~ $RE_VACIA ]] && continue
    if [[ ! "$linea" =~ $RE_PAR ]]; then
      echo "${archivo}:${numero}: línea no válida, se ignora" >&2
      continue
    fi
    local clave="${BASH_REMATCH[1]}"
    valor="${BASH_REMATCH[2]:-}"
    if [[ "$valor" =~ $RE_DOBLES || "$valor" =~ $RE_SIMPLES ]]; then
      valor="${BASH_REMATCH[1]}"
    fi
    _conf[$clave]="$valor"
  done < "$archivo"
}

declare -A conf=([moneda]='$' [limite]=0 [separador]=',')
archivo_conf="$HOME/.config/gastos.conf"

while getopts ":c:" opcion; do
  case "$opcion" in
    c) archivo_conf="$OPTARG" ;;
    *) echo "Uso: ${0##*/} [-c CONFIG] ARCHIVO.csv" >&2; exit 2 ;;
  esac
done
shift $(( OPTIND - 1 ))
(( $# == 1 )) || { echo "Uso: ${0##*/} [-c CONFIG] ARCHIVO.csv" >&2; exit 2; }
csv="$1"
[[ -r "$csv" ]] || { echo "${0##*/}: no se puede leer $csv" >&2; exit 1; }

if [[ -r "$archivo_conf" ]]; then
  leer_config "$archivo_conf" conf
fi
[[ "${conf[limite]}" =~ ^[0-9]+(\.[0-9]+)?$ ]] || { echo "${0##*/}: limite no es un número: ${conf[limite]}" >&2; exit 1; }
(( ${#conf[separador]} == 1 )) || { echo "${0##*/}: el separador debe ser un solo carácter" >&2; exit 1; }

MONEDA="${conf[moneda]}" awk -F "${conf[separador]}" -v limite="${conf[limite]}" '
  BEGIN { moneda = ENVIRON["MONEDA"] }
  { sub(/\r$/, "") }                             # finales de línea de Windows
  NR == 1 { next }                               # el encabezado
  NF != 4 || $4 !~ /^-?[0-9]+(\.[0-9]+)?$/ {
    printf "%s:%d: línea no válida, se ignora\n", FILENAME, NR > "/dev/stderr"
    next
  }
  { por_categoria[$2] += $4; total += $4; n++ }
  END {
    orden = "sort -rn | cut -f2-"                # ordenar por importe y quitar la clave
    for (c in por_categoria)
      printf "%.2f\t  %-14s %s %10.2f  %5.1f %%\n", por_categoria[c], c, moneda, por_categoria[c], 100 * por_categoria[c] / total | orden
    close(orden)
    printf "  %-14s %s %10.2f  (%d gastos)\n", "TOTAL", moneda, total, n
    fflush()
    if (limite > 0 && total > limite)
      printf "\nATENCIÓN: el total supera el límite de %s %.2f\n", moneda, limite > "/dev/stderr"
  }
' "$csv"
terminal
$ resumen-gastos -c gastos.conf octubre.csv
gastos.conf:5: línea no válida, se ignora
octubre.csv:6: línea no válida, se ignora
  servicios      €      61.25   41.6 %
  comida         €      54.00   36.7 %
  transporte     €      20.00   13.6 %
  ocio           €      12.00    8.1 %
  TOTAL          €     147.25  (5 gastos)

ATENCIÓN: el total supera el límite de € 120.00
$ resumen-gastos -c malicioso.conf octubre.csv | head -1
  servicios      $(echo "¡esto se ejecutó!" >&2)      61.25   41.6 %

14. Errores frecuentes

EscribisteQué pasaCorrección
while IFS=, read … sin tratar el encabezadoLos nombres de las columnas se procesan como datos.Un read previo, o NR == 1 { next } en awk.
Un CSV de Windows sin quitar \rEl último campo no pasa validaciones ni comparaciones.${campo%$'\r'}, o sub(/\r$/, "").
IFS=, con campos entre comillasLas comas internas desplazan los campos.Miller, csvkit u otro lenguaje.
for linea in $(cat datos.csv)Recorre palabras, no filas.while IFS=, read -r …
source config.conf con un archivo ajenoEjecuta lo que contenga.Un lector seguro.
Un lector que no valida los valoresUn límite como quinientos rompe el cálculo.Validar después de cargar.
Sumar importes con decimales en BashLa aritmética de Bash es entera.awk.
Importes con coma decimal (45,30)Se toman como 45.Convertir la coma en punto antes de calcular.

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Cuándo conviene recorrer un CSV con while read y cuándo con awk?

Con while read, cuando hay que ejecutar acciones por cada fila. Con awk, cuando hay que calcular sobre todas las filas: sumar, contar, agrupar.

2. ¿Cómo se salta el encabezado de un CSV en awk?

Con NR == 1 { next }, que pasa a la línea siguiente cuando se está en la primera.

3. Un importe que se ve como 20 no pasa la validación ^[0-9]+$. ¿Qué puede estar pasando?

Que el archivo tiene finales de línea de Windows, y el último campo es en realidad 20\r. Se corrige quitando el \r con ${campo%$'\r'}.

4. ¿Por qué IFS=, no sirve para un CSV con campos como "Lámpara, mesa y silla"?

Porque corta por todas las comas, incluidas las que están dentro de las comillas, y el campo se parte en varios, desplazando los siguientes.

5. ¿Qué ventaja tiene el TSV sobre el CSV?

Que el tabulador rara vez aparece dentro de un dato, de modo que no hacen falta comillas y el archivo se procesa con IFS=$'\t' o awk -F'\t' sin sorpresas.

6. ¿Por qué es peligroso cargar una configuración con source?

Porque source ejecuta el archivo como código. Una línea como valor=$(comando) ejecuta el comando con los permisos del script.

7. ¿En qué casos es aceptable usar source para una configuración?

Cuando el archivo es tan confiable como el script: lo escribió el mismo usuario y nadie más puede modificarlo, o es un archivo del sistema que solo root puede escribir.

8. ¿Qué hace el lector seguro con nombre=$(rm -rf ~)?

Lo guarda como texto en el array: la clave nombre con el valor $(rm -rf ~). No se ejecuta nada.

9. ¿En qué orden se aplican valores por defecto, archivo de configuración, variables de entorno y opciones?

De menor a mayor prioridad: valores por defecto, archivo, variables de entorno y opciones en la línea de comandos. Cada uno sobrescribe al anterior.

10. ¿Por qué el script pasa la moneda a awk con ENVIRON y el límite con -v?

Porque la moneda viene de un archivo y podría contener cualquier carácter, incluidas barras invertidas, que -v interpretaría. El límite ya fue validado como número, de modo que -v es seguro.

Para practicar

  1. Exporta una planilla propia a CSV y recórrela con while IFS=, read, saltando el encabezado y quitando el \r si lo tiene.
  2. Con awk, calcula el total, el promedio y el máximo de una columna numérica del CSV.
  3. Crea un CSV con un campo entre comillas que contenga una coma y comprueba cómo lo separa awk -F,. Si tienes Miller, procésalo con mlr --icsv --ojson cat.
  4. Escribe un archivo de configuración con una línea que contenga $(date) y cárgalo con source y con leer_config. Compara.
  5. Agrega a leer_config una lista de claves permitidas, y haz que avise de cualquier clave desconocida.
  6. Agrega a resumen-gastos una opción -m AAAA-MM que filtre los gastos de un mes, comparando el comienzo de la fecha en awk.