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.
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:
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:
| Detalle | Problema | Solución |
|---|---|---|
| El encabezado | Se 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íos | a,,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 Windows | Cada 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$//'. |
{
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:
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
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:
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ón | Cuá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 comandos | Para 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 FPAT | Para 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 csv | Cuando 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:
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:
# 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:
$ 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:
- Lo escribió el mismo usuario que ejecuta el script, y nadie más puede modificarlo.
- Es parte del sistema y solo root puede escribirlo, como
/etc/os-release, que usamos en el tema 13. - Es una biblioteca de funciones del propio proyecto, como en el tema 22.
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:
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:
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"
}
RE_VACIAreconoce las líneas vacías, las que solo tienen espacios y los comentarios, incluso con sangría.RE_PARexige una clave con forma de nombre de variable, un=con espacios opcionales alrededor, y captura el valor sin los espacios del final: el grupo(.*[^[:space:]])?termina en un carácter que no es un espacio, o está vacío.- Las comillas dobles o simples que rodean un valor se quitan, de modo que
moneda = "€"ymoneda=€dan lo mismo. El valor nunca se expande: un$(…)en el archivo queda como texto. - Las líneas que no tienen la forma esperada se informan con el archivo y el número de línea, y se ignoran, en lugar de detener el script.
- La función recibe el nombre del array por referencia, con
local -n, como en el tema 19, y el llamador decide dónde quiere los valores.
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:
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:
[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:
awk '
/^[[:space:]]*\[.*\][[:space:]]*$/ { gsub(/[][[:space:]]/, ""); seccion = $0 "_"; next }
/^[[:space:]]*[^#=]+=/ { print seccion $0; next }
' respaldo.ini
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.
#!/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"
$ 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 %
- La configuración se carga sin ejecutar nada: con el archivo malicioso de la sección 8, el
$(…)aparece como texto en la salida, en lugar de ejecutarse. Y una línea mal escrita, comonombre del usuario = ana, con espacios en la clave, se informa y se ignora. - Los valores se pasan a
awkpor los dos caminos del tema 29: el límite, ya validado como número, con-v; la moneda, que podría contener cualquier carácter, conENVIRON. El separador va en-F, después de comprobar que es un solo carácter. awkvalida cada fila: si no tiene cuatro campos, o el importe no es un número, la informa por la salida de errores con su número de línea y la salta. En el ejemplo, la fila 6 no tenía importe.- Para ordenar por importe sin depender de la posición de las columnas, cada línea se escribe con el importe delante y un tabulador;
sort -rnordena por esa clave ycut -f2-la quita. fflush()hace que el total se escriba antes que el aviso, que va por la salida de errores y de otro modo podría aparecer primero.
14. Errores frecuentes
| Escribiste | Qué pasa | Corrección |
|---|---|---|
while IFS=, read … sin tratar el encabezado | Los nombres de las columnas se procesan como datos. | Un read previo, o NR == 1 { next } en awk. |
Un CSV de Windows sin quitar \r | El último campo no pasa validaciones ni comparaciones. | ${campo%$'\r'}, o sub(/\r$/, ""). |
IFS=, con campos entre comillas | Las 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 ajeno | Ejecuta lo que contenga. | Un lector seguro. |
| Un lector que no valida los valores | Un límite como quinientos rompe el cálculo. | Validar después de cargar. |
| Sumar importes con decimales en Bash | La 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
- Un CSV simple se recorre con
while IFS=, read -r …para actuar sobre cada fila, y se resume conawk -F,para calcular. - El encabezado se consume aparte; los campos vacíos se respetan; el
\rde los archivos de Windows se quita. - Un CSV con campos entre comillas no puede procesarse con
IFSni conawk -F,; para eso existen Miller, csvkit o el módulocsvde Python. - El TSV evita la mayoría de esos problemas; las planillas en español pueden usar punto y coma y coma decimal.
- Los archivos de configuración
clave=valorse buscan en~/.configo/etc; la prioridad va de los valores por defecto a las opciones de la línea de comandos. sourceejecuta el archivo como código: es aceptable solo si el archivo es tan confiable como el script.- Un lector seguro reconoce los pares con una expresión regular y los guarda en un array asociativo sin evaluar nada.
- El patrón completo: valores por defecto, carga del archivo encima, validación del resultado.
- Las secciones de un INI se pueden aplanar con
awken claves comoseccion_clave.
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
- Exporta una planilla propia a CSV y recórrela con
while IFS=, read, saltando el encabezado y quitando el\rsi lo tiene. - Con
awk, calcula el total, el promedio y el máximo de una columna numérica del CSV. - 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 conmlr --icsv --ojson cat. - Escribe un archivo de configuración con una línea que contenga
$(date)y cárgalo consourcey conleer_config. Compara. - Agrega a
leer_configuna lista de claves permitidas, y haz que avise de cualquier clave desconocida. - Agrega a
resumen-gastosuna opción-m AAAA-MMque filtre los gastos de un mes, comparando el comienzo de la fecha enawk.