Leer un archivo línea a línea en Bash sin perder espacios ni líneas

Leer un archivo línea a línea en Bash parece trivial, pero las formas más intuitivas pierden espacios, barras invertidas, líneas vacías o la última línea. Aquí tienes la forma correcta, while IFS= read -r, y por qué lleva cada pieza, cómo separar campos, cuándo usar mapfile y los tres errores que más se repiten.

Partimos de este archivo, notas.txt. Tiene a propósito una línea que empieza con espacios, otra con barras invertidas y una línea vacía, que son justo los casos que rompen las formas ingenuas de leerlo:

terminal$ cat notas.txt
Comprar café
   Llamar al dentista (con sangría)
Ruta: C:\temp\notas

Pagar la luz

La forma correcta: while IFS= read -r

#!/usr/bin/env bash
while IFS= read -r linea; do
    printf '[%s]\n' "$linea"
done < notas.txt
salida[Comprar café]
[   Llamar al dentista (con sangría)]
[Ruta: C:\temp\notas]
[]
[Pagar la luz]

Cada pieza tiene su motivo:

  • read -r lee una línea y la guarda en linea; la -r hace que las barras invertidas se lean tal cual.
  • IFS= (vacío), delante de read, evita que se recorten los espacios del principio y del final de cada línea. Solo afecta a ese read, no al resto del script.
  • done < notas.txt conecta el archivo a la entrada del bucle entero: se abre una vez y cada vuelta lee la siguiente línea.
  • printf '[%s]\n' imprime la línea entre corchetes para que se vean los espacios. Con printf no hay sorpresas si la línea empieza por -n o -e, que echo tomaría como opciones.

Lo que pasa si quitas IFS= o -r

#!/usr/bin/env bash
while read linea; do
    printf '[%s]\n' "$linea"
done < notas.txt
salida[Comprar café]
[Llamar al dentista (con sangría)]
[Ruta: C:tempnotas]
[]
[Pagar la luz]

Los espacios del principio han desaparecido y la ruta ha perdido sus barras: \t y \n se han convertido en una simple t y una simple n. ShellCheck lo avisa:

shellcheckIn sin_opciones.sh line 2:
while read linea; do
      ^--^ SC2162 (info): read without -r will mangle backslashes.

For more information:
  https://www.shellcheck.net/wiki/SC2162 -- read without -r will mangle backs...

El error más común: for linea in $(cat archivo)

#!/usr/bin/env bash
for linea in $(cat notas.txt); do
    printf '[%s]\n' "$linea"
done
salida[Comprar]
[café]
[Llamar]
[al]
[dentista]
[(con]
[sangría)]
[Ruta:]
[C:\temp\notas]
[Pagar]
[la]
[luz]

Eso no recorre líneas, sino palabras: $(cat notas.txt) sin comillas se parte por espacios, tabuladores y saltos de línea, se pierde la línea vacía y, además, cada trozo pasa por la expansión de comodines, así que un * en el archivo se convertiría en la lista de archivos de la carpeta. ShellCheck lo detecta:

shellcheckIn mal.sh line 2:
for linea in $(cat notas.txt); do
             ^--------------^ SC2013 (info): To read lines rather than words, pipe/redirect to a 'while read' loop.

For more information:
  https://www.shellcheck.net/wiki/SC2013 -- To read lines rather than words, ...

La última línea sin salto de línea

Muchos editores y programas guardan el archivo sin el salto de línea final. read devuelve error al encontrar el final del archivo sin un salto, aunque sí haya llenado la variable, así que el bucle se salta la última línea. wc -l, que cuenta saltos de línea, también se equivoca:

terminal$ wc -l lista.txt
2 lista.txt
#!/usr/bin/env bash
echo "Sin el arreglo:"
while IFS= read -r linea; do
    echo "  $linea"
done < lista.txt

echo "Con el arreglo:"
while IFS= read -r linea || [[ -n $linea ]]; do
    echo "  $linea"
done < lista.txt
salidaSin el arreglo:
  uno
  dos
Con el arreglo:
  uno
  dos
  tres

El || [[ -n $linea ]] hace una vuelta más si read falló pero dejó algo en la variable. Es un añadido barato que conviene poner siempre que el archivo venga de fuera.

Línea a línea y por campos

Si cada línea tiene campos separados por un carácter, IFS le dice a read por dónde cortar y cada campo va a su variable. Con el formato de /etc/passwd (aquí, una copia de ejemplo), separado por dos puntos:

#!/usr/bin/env bash
while IFS=: read -r usuario _ uid _ nombre casa interprete; do
    if (( uid >= 1000 )); then
        printf '%-6s %5s  %-12ls %-11s %s\n' "$usuario" "$uid" "$nombre" "$casa" "$interprete"
    fi
done < usuarios.txt
salidaana     1000  Ana López    /home/ana   /bin/bash
luis    1001  Luis Pérez   /home/luis  /bin/zsh

El guion bajo, _, es el nombre habitual para los campos que no interesan. El %-12ls del printf, con una l, hace que el ancho se cuente en caracteres: con %-12s, Bash lo cuenta en bytes, y «López» y «Pérez», con una letra de dos bytes, quedarían descolocados. Si una línea tiene más campos que variables, la última variable se queda con todo el resto de la línea, separadores incluidos.

Todo el archivo en un array con mapfile

#!/usr/bin/env bash
mapfile -t lineas < notas.txt

echo "Hay ${#lineas[@]} líneas"
echo "La segunda es: ${lineas[1]}"
for i in "${!lineas[@]}"; do
    printf '%2d: %s\n' "$((i + 1))" "${lineas[i]}"
done
salidaHay 5 líneas
La segunda es:    Llamar al dentista (con sangría)
 1: Comprar café
 2:    Llamar al dentista (con sangría)
 3: Ruta: C:\temp\notas
 4: 
 5: Pagar la luz

mapfile (también llamado readarray) carga cada línea en un elemento del array, y -t quita el salto de línea del final. Es cómodo cuando necesitas volver atrás o saber cuántas líneas hay antes de empezar, pero carga el archivo entero en memoria. Llegó en Bash 4, así que no existe en el Bash 3.2 que trae macOS.

La variable que se pierde en la tubería

#!/usr/bin/env bash
contador=0
cat notas.txt | while IFS= read -r _; do
    contador=$((contador + 1))
done
echo "Con tubería: $contador"

contador=0
while IFS= read -r _; do
    contador=$((contador + 1))
done < notas.txt
echo "Con redirección: $contador"
salidaCon tubería: 0
Con redirección: 5

Cada parte de una tubería se ejecuta en una subshell, un proceso hijo con su propia copia de las variables. El bucle cuenta bien, pero cuenta en su copia, y al terminar la tubería esa copia desaparece. Con done < notas.txt el bucle se ejecuta en el propio script y el contador sobrevive. ShellCheck lo ve venir:

shellcheckIn tuberia.sh line 4:
    contador=$((contador + 1))
    ^------^ SC2030 (info): Modification of contador is local (to subshell caused by pipeline).

In tuberia.sh line 6:
echo "Con tubería: $contador"
                   ^-------^ SC2031 (info): contador was modified in a subshell. That change might be lost.

For more information:
  https://www.shellcheck.net/wiki/SC2030 -- Modification of contador is local...
  https://www.shellcheck.net/wiki/SC2031 -- contador was modified in a subshe...

Cuando una orden de dentro se come las líneas

Dentro del bucle, cualquier orden que lea de la entrada estándar lee del mismo archivo que read. Pasa con ssh, ffmpeg o, aquí, con head -n 1, que en cada vuelta se lleva una línea, así que el bucle solo ve una de cada dos (otras órdenes, como ssh, se llevan todo lo que queda de una vez):

#!/usr/bin/env bash
echo "Mal:"
while IFS= read -r linea; do
    echo "  $linea"
    head -n 1 > /dev/null
done < notas.txt

echo "Bien:"
while IFS= read -r -u 3 linea; do
    echo "  $linea"
    head -n 1 > /dev/null
done 3< notas.txt
salidaMal:
  Comprar café
  Ruta: C:\temp\notas
  Pagar la luz
Bien:
  Comprar café
     Llamar al dentista (con sangría)
  Ruta: C:\temp\notas
  
  Pagar la luz

La solución es leer el archivo por otro descriptor: 3< notas.txt lo abre como descriptor 3 y read -u 3 lee de ahí, así que la entrada estándar queda libre. Otra opción es darle a la orden de dentro su propia entrada vacía: ssh servidor orden < /dev/null.

Y si el archivo es enorme

Un bucle while read lee carácter a carácter y ejecuta varias órdenes por línea: para unos miles de líneas va bien, pero con millones se nota. Si lo que quieres es filtrar, contar o sumar, deja el trabajo a herramientas que recorren el archivo en un solo proceso: grep -c para contar coincidencias, awk para sumar columnas o wc -l para contar líneas. Están en la lección de procesar texto.

  • ParaUsaOjo
  • Línea a línea, tal cualwhile IFS= read -r linea; do … done < archivoañade || [[ -n $linea ]] si puede faltar el último salto
  • Separar camposwhile IFS=: read -r a b cla última variable recibe el resto de la línea
  • Todo en memoriamapfile -t lineas < archivono existe en el Bash 3.2 de macOS
  • Contar o filtrar muchogrep, awk, wcmucho más rápidos que un bucle
  • Nuncafor l in $(cat archivo)recorre palabras, no líneas

Todos los scripts de esta página se ejecutaron con Bash 5.3 en Ubuntu 26.04, en un contenedor limpio sin acceso a la red, y pasaron ShellCheck 0.11 sin avisos antes de publicarla; las salidas están copiadas de esa ejecución.