Cerrábamos el Módulo 3 con una promesa: informe-diario.sh dejaría de ser un guion lineal para convertirse en un programa con estructura, y los bucles serían la primera herramienta en llegar. Aquí está. Hasta ahora, cuando querías el informe de Valencia y el de Sevilla, ejecutabas el script dos veces; cuando querías mirar los cuatro ficheros rotados de app.log, copiabas y pegabas la misma tubería cuatro veces cambiando el nombre. Un bucle es exactamente lo contrario: escribes la operación una vez y le das la lista de cosas sobre las que aplicarla. En esta lección verás las cuatro formas de iterar que ofrece Bash, cuándo usar cada una, y el patrón canónico —con sus trampas— para leer un fichero línea a línea.

Contenido

  1. for sobre listas literales
  2. for sobre globs y sobre "$@"
  3. for estilo C
  4. while y until
  5. Cuál elegir: tabla comparativa
  6. Leer un fichero línea a línea: el patrón canónico
  7. La trampa de la tubería y el subshell
  8. Leer un CSV separando campos
  9. break y continue, y sus niveles
  10. Bucles infinitos controlados
  11. Anidamiento, contadores y coste

  1. for sobre listas literales

La forma más simple de for recorre una lista de palabras separadas por espacios y va asignando cada una a una variable:

for ciudad in Valencia Sevilla Bilbao Madrid; do
    echo "Generando informe de $ciudad..."
done

Imprime las cuatro líneas, una por ciudad. Desglose de cada pieza:

  • for ciudad in ...: ciudad es la variable de control. No se declara antes ni se borra después: al terminar conserva el último valor (Madrid).
  • La lista tras in es una lista de palabras, no una cadena. Bash la genera aplicando las expansiones de 03-06: for n in {1..5} o for f in $(date +%F) son igual de válidos.
  • do ... done delimitan el cuerpo. El ; antes de do solo hace falta si están en la misma línea.

Cuidado: entrecomillar una cadena con espacios (for c in "$CIUDADES") produce un único elemento, no varios. Para listas de verdad se usan arrays (04-03).

  1. for sobre globs y sobre "$@"

Como el globbing (02-05) ocurre antes de ejecutar el comando, un patrón se convierte en la lista de ficheros que existen. Este es el idioma correcto para recorrer ficheros, y la razón por la que en 03-06 insistimos en que nunca se hace for f in $(ls):

for fichero in /var/log/veloz/app.log.*; do
    [[ -f "$fichero" ]] || continue          # protege del caso "sin coincidencias"
    echo "== $fichero: $(wc -l < "$fichero") líneas"
done

La línea de guarda es importante: si no existe ningún app.log.*, Bash deja el patrón sin expandir y la variable valdrá literalmente /var/log/veloz/app.log.*. Las dos soluciones son la guarda [[ -f ... ]] || continue o activar shopt -s nullglob, que hace que un patrón sin coincidencias produzca una lista vacía y el bucle no se ejecute ninguna vez.

Sin lista in, for recorre implícitamente los argumentos del script, es decir "$@":

for ciudad; do                 # equivale a: for ciudad in "$@"; do
    echo "Procesando $ciudad"
done

Escribir for ciudad in "$@" es más explícito y siempre correcto. Escribir for ciudad in $@ (sin comillas) rompe con cualquier argumento que contenga espacios, por lo que ya sabes de 03-05.

  1. for estilo C

Cuando lo que necesitas es un contador numérico y no una lista, Bash ofrece la sintaxis de tres expresiones aritméticas heredada de C:

for (( i = 1; i <= 3; i++ )); do
    printf 'Intento %d de 3\n' "$i"
done

Dentro de los dobles paréntesis estás en contexto aritmético: las variables no llevan $, se comparan con < y > de forma numérica, y i++ funciona. Es el mismo contexto que verás a fondo en 04-06. Las tres expresiones son inicialización, condición de continuación e incremento, y cualquiera puede omitirse (for (( ;; )) es un bucle infinito).

Úsalo cuando el índice importa; para recorrer valores, el for ... in es más legible.

  1. while y until

while repite el cuerpo mientras un comando devuelva código de salida 0, y until hasta que lo devuelva. Nota clave que enlaza con 03-04: la condición no es una expresión booleana, es un comando cuyo $? se evalúa.

intentos=0
while ! curl -sf http://localhost:8080/salud > /dev/null; do
    intentos=$(( intentos + 1 ))
    (( intentos >= 5 )) && { echo "veloz-api no responde" >&2; exit 4; }
    echo "Esperando a veloz-api (intento $intentos)..."
    sleep 2
done
echo "veloz-api operativa"

El mismo bucle con until elimina la negación y se lee mejor:

until curl -sf http://localhost:8080/salud > /dev/null; do
    ...
done

Regla práctica: si tu while empieza por !, probablemente querías un until.

  1. Cuál elegir: tabla comparativa

Forma Se usa cuando Ejemplo típico en Veloz Envíos
for x in lista Conoces de antemano los elementos Las cuatro ciudades, los repartidores
for (( i=0; i<n; i++ )) Necesitas un índice numérico Recorrer un array por posición, reintentos numerados
while cond Repites mientras algo sea cierto y no sabes cuántas veces Leer líneas de un log hasta el final
until cond Igual que while, pero la condición natural es la negativa Esperar a que la API arranque

for responde a "para cada uno de estos"; while/until responden a "mientras/hasta que ocurra esto".

  1. Leer un fichero línea a línea: el patrón canónico

Este es probablemente el fragmento de Bash que más veces escribirás en tu vida. Memorízalo entero:

while IFS= read -r linea; do
    echo "[$linea]"
done < /var/log/veloz/app.log

Cada pieza está ahí por una razón concreta:

  • IFS= vacía el separador de campos solo para este comando (recuerda de 03-06 que VAR=valor comando afecta únicamente a ese comando). Sin ello, read recortaría los espacios y tabuladores del principio y del final de cada línea. Con IFS= la línea llega literal, con su indentación intacta.
  • -r desactiva la interpretación de la barra invertida. Sin -r, una ruta como C:\veloz\datos perdería las barras y un \ final uniría dos líneas. Salvo que estés implementando un intérprete de escapes, siempre -r.
  • linea es la variable destino. Si no pones ninguna, read deposita el contenido en REPLY.
  • < fichero al final: la redirección se aplica a todo el bucle, que actúa como un único comando compuesto. read va consumiendo esa entrada línea a línea hasta agotarla, momento en que devuelve un código distinto de 0 y el while termina.

Una advertencia poco conocida: si el fichero no acaba en salto de línea, read guarda la última línea pero devuelve código de fallo y el bucle la descarta. El seguro es while IFS= read -r linea || [[ -n "$linea" ]]; do ... done < fichero.

  1. La trampa de la tubería y el subshell

Es tentador escribir el bucle anterior alimentándolo con una tubería. Funciona… hasta que intentas usar una variable después:

errores=0
grep '\[ERROR\]' /var/log/veloz/app.log | while IFS= read -r linea; do
    errores=$(( errores + 1 ))
done
echo "Errores: $errores"      # imprime 0, no el total real

La explicación viene directa de 01-04: cada tramo de una tubería se ejecuta en un subshell, un proceso hijo con su propia copia de las variables. El bucle incrementa errores de verdad, pero en la copia del hijo; cuando el hijo muere, el valor muere con él y el padre sigue viendo su 0 original.

flowchart LR
    A["Shell padre<br/>errores=0"] -->|"| tubería"| B["Subshell<br/>errores=0→1→2"]
    B -.->|"el hijo muere<br/>y se lleva el valor"| C["Shell padre<br/>errores=0"]
    A ==>|"< redirección"| D["Mismo shell<br/>errores=2 ✓"]

Las dos soluciones correctas:

# A) Redirección al final: el bucle corre en el shell actual
errores=0
while IFS= read -r linea; do errores=$(( errores + 1 )); done \
    < <(grep '\[ERROR\]' /var/log/veloz/app.log)

# B) Sustitución de procesos con un fichero real
while IFS= read -r linea; do ...; done < /var/log/veloz/app.log

La construcción < <(comando) se llama sustitución de procesos: convierte la salida de un comando en algo que se puede redirigir como si fuera un fichero, sin crear ningún subshell para el bucle. Fíjate en el espacio obligatorio entre los dos <.

  1. Leer un CSV separando campos

read acepta varias variables y reparte los campos según IFS. Fijando IFS=, obtienes un lector de CSV en una línea:

{
    read -r _cabecera                                   # descarta la primera línea
    while IFS=, read -r id fecha ciudad repartidor estado importe; do
        [[ "$estado" == "incidencia" ]] || continue
        printf '%-8s %-10s %-10s %s\n' "$id" "$ciudad" "$repartidor" "$importe"
    done
} < /srv/veloz/datos/envios.csv

Salida: E1043 Sevilla mgarcia 31.20, una línea por incidencia. Tres detalles importantes:

  • El read -r _cabecera inicial consume la línea id_envio,fecha,.... Como está dentro del mismo bloque { ... } < fichero, comparte la entrada con el bucle.
  • Si hay más campos que variables, la última variable recibe todo el resto. Por eso importe, siendo la última, se queda con lo que sobre. Y si hay menos, las variables finales quedan vacías.
  • Este lector no entiende comillas ni comas dentro de campos. Para CSV complejos se usa awk (06-01); para el nuestro, es perfecto.

  1. break y continue, y sus niveles

break sale del bucle; continue salta a la siguiente iteración. Ambos aceptan un número que indica cuántos niveles de anidamiento afectan:

for ciudad in Valencia Sevilla Bilbao Madrid; do
    for repartidor in alopez mgarcia jruiz; do
        if [[ ! -r "$RUTA_CSV" ]]; then
            echo "CSV ilegible, abortando todo" >&2
            break 2                    # sale de LOS DOS bucles
        fi
        [[ "$repartidor" == "jruiz" && "$ciudad" == "Madrid" ]] && continue
        echo "$ciudad / $repartidor"
    done
done

break sin número equivale a break 1 y solo saldría del bucle interno. Los niveles se cuentan de dentro hacia fuera. Úsalos con moderación: un break 3 es señal de que ese bloque pide ser una función con un return (04-02).

  1. Bucles infinitos controlados

Un bucle sin condición de salida es una herramienta legítima cuando el script es un vigilante:

while true; do
    ahora=$(date '+%F %T')
    errores=$(grep -c '\[ERROR\]' /var/log/veloz/app.log)
    printf '%s  errores acumulados: %s\n' "$ahora" "$errores"
    sleep 60
done

true es un comando que no hace nada y siempre devuelve 0, así que la condición nunca falla. Reglas de seguridad:

  • Siempre un sleep dentro. Sin él consumirás una CPU al 100% sin ganar nada.
  • Siempre una vía de salida: un break bajo alguna condición, o al menos que Ctrl+C lo mate (por defecto lo hace).
  • Si el bucle va a vivir para siempre, lo correcto no es un while true lanzado a mano, sino un temporizador del sistema. Lo verás en 07-04 y 07-05.

  1. Anidamiento, contadores y coste

Anidar bucles multiplica el trabajo: 4 ciudades × 3 repartidores son 12 iteraciones, y si dentro haces grep sobre un CSV de 50.000 líneas, son 12 lecturas completas. El coste real en Bash casi nunca está en el bucle, sino en los procesos externos que se lanzan dentro de él: un for de 4 ciudades con dos grep cada una son 8 procesos y 8 recorridos del fichero.

Una sola pasada acumulando contadores es mucho más rápida, pero para eso necesitas un contador por ciudad: eso son arrays (04-03), y el porcentaje con decimales es aritmética (04-06). Por ahora, el contador simple, que sí puedes escribir ya:

total=0; incidencias=0
while IFS=, read -r _ _ ciudad _ estado _; do
    (( total++ ))
    [[ "$estado" == "incidencia" ]] && (( incidencias++ ))
done < <(tail -n +2 "$RUTA_CSV")
echo "$incidencias incidencias de $total envíos"

El _ es un nombre de variable convencional para "este campo no me interesa"; no tiene ningún significado especial en Bash, es solo una costumbre legible.

Errores Comunes y Consejos

  • for f in $(ls *.csv). Ya avisado en 03-06, pero se repite: rompe con espacios en los nombres. Usa for f in *.csv.
  • Olvidar IFS= o -r en read. Funcionará durante meses y fallará el día que aparezca una ruta con \ o una línea indentada.
  • Contar dentro de una tubería. El contador siempre saldrá a 0. Redirección o < <(...), nunca cmd | while.
  • Bucle sobre un glob que no coincide. Añade la guarda [[ -e "$f" ]] || continue o shopt -s nullglob.
  • Modificar la lista mientras se recorre. La lista de for se calcula una sola vez, al principio; borrar ficheros dentro del bucle no cambia lo que queda por recorrer.
  • Consejo: cuando un bucle supere unas 15 líneas, extrae el cuerpo a una función. Es exactamente lo que harás en la siguiente lección.

Ejercicios

Ejercicio 1. Escribe un bucle que recorra las cuatro ciudades y, para cada una, imprima cuántas líneas del CSV le corresponden, en formato Valencia: 128 envíos.

Ejercicio 2. Recorre los ficheros rotados /var/log/veloz/app.log.* y muestra por cada uno su nombre y el número de líneas [ERROR] que contiene, saltando los que no existan o estén vacíos.

Ejercicio 3. Lee envios.csv línea a línea (sin cabecera) y cuenta cuántos envíos ha hecho alopez y cuántos de ellos acabaron en incidencia. Imprime ambos números después del bucle: deben salir correctos.

Soluciones

Solución 1.

RUTA_CSV="/srv/veloz/datos/envios.csv"
for ciudad in Valencia Sevilla Bilbao Madrid; do
    n=$(grep -c ",${ciudad}," "$RUTA_CSV")
    echo "${ciudad}: ${n} envíos"
done

El patrón ,${ciudad}, con comas a ambos lados evita falsos positivos si una ciudad fuera subcadena de otra.

Solución 2.

for f in /var/log/veloz/app.log.*; do
    [[ -f "$f" && -s "$f" ]] || continue      # -s: existe y no está vacío
    printf '%-32s %4d errores\n' "$f" "$(grep -c '\[ERROR\]' "$f")"
done

-s (visto en 03-03) descarta de un golpe los ficheros vacíos y el patrón sin expandir.

Solución 3.

envios=0; incidencias=0
while IFS=, read -r _ _ _ repartidor estado _; do
    [[ "$repartidor" == "alopez" ]] || continue
    (( envios++ ))
    [[ "$estado" == "incidencia" ]] && (( incidencias++ ))
done < <(tail -n +2 /srv/veloz/datos/envios.csv)
echo "alopez: ${envios} envíos, ${incidencias} incidencias"

La clave está en < <(tail ...): con tail ... | while los dos contadores saldrían a 0. Nota además el || continue en lugar de un if envolvente: reduce un nivel de indentación y se lee igual de bien.

Conclusión

Ya sabes repetir. for recorre listas conocidas —literales, globs o "$@"—, su variante estilo C recorre índices, y while/until repiten mientras un comando siga devolviendo el código que esperas. El patrón while IFS= read -r linea; do ... done < fichero es el lector de ficheros oficial de Bash, y la regla que lo acompaña —redirección al final, nunca una tubería— te ahorrará el bug más frustrante del lenguaje, ese en el que un contador siempre vale 0 porque vivía en un subshell.

Pero fíjate en lo que ha pasado en los últimos ejemplos: dentro de cada bucle repetíamos las mismas cinco líneas de validación y el mismo formato de salida, solo que con otra ciudad. Hemos eliminado la repetición entre invocaciones del script, pero no dentro de él. La herramienta que resuelve eso son las funciones (04-02): bloques de código con nombre, con sus propios argumentos y sus propias variables, que convertirán validar_entorno, contar_errores y resumen_ciudad en piezas que se escriben una vez y se llaman muchas.

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