← Automatización con Scripts en Bash Bloque 4 · Bucles · Tema 14 de 42

Tema 14

El bucle for

Repetir es la razón de ser de la automatización: lo que a mano se hace con un archivo, un script lo hace con mil. El bucle for toma una lista, sea de archivos, servidores, argumentos o números, y ejecuta los mismos comandos para cada elemento. Ya lo vimos de pasada en varios ejemplos; este tema lo estudia a fondo, junto con las opciones de Bash que deciden qué archivos entran en la lista y qué pasa cuando no hay ninguno.

1. La forma de for

El bucle for asigna a una variable, uno por uno, cada elemento de una lista, y ejecuta el cuerpo del bucle una vez para cada uno:

la forma de for
for variable in elemento1 elemento2 elemento3; do
  comandos que usan "$variable"
done
terminal
$ for servidor in web01 web02 db01; do echo "Revisando $servidor"; done
Revisando web01
Revisando web02
Revisando db01

Como con if y then, el ; antes de do puede ser un salto de línea, y en este curso do va en la misma línea que for. La variable del bucle es una variable común: se escribe sin $ en el for y con $, entre comillas, al usarla. Conviene darle un nombre que diga qué contiene, en singular: for archivo in, for servidor in.

2. De dónde sale la lista

Lo que sigue a in es una lista de palabras, y pasa por todas las expansiones del tema 3. Eso significa que la lista puede escribirse de muchas formas, y que las reglas de las comillas deciden cuántos elementos tiene:

ListaElementosCuándo usarla
a b "c d"Las palabras escritas: a, b, c d.Listas fijas y breves.
"${lista[@]}"Los elementos de un array, intactos.Listas construidas por el script (tema 8).
"$@"Los argumentos del script, intactos.Procesar todo lo que se le pasó.
*.logLos archivos que coinciden con el patrón.Procesar archivos.
{1..10}Los números del 1 al 10.Repetir una cantidad fija de veces.
$(comando)La salida del comando, dividida en palabras.Solo si la salida son palabras sin espacios (sección 9).
$variableEl valor de la variable, dividido en palabras.Casi nunca: para eso están los arrays.

Las dos últimas filas son las delicadas, por la división en palabras y la expansión de comodines. Las demás son seguras siempre que se respeten las comillas: "${lista[@]}" y "$@" con comillas; los patrones de archivos, sin ellas.

3. Recorrer los argumentos

Un script que procesa varios archivos, servidores o nombres los recibe como argumentos, y los recorre con "$@". Es tan común que tiene una forma abreviada: un for sin in recorre los argumentos.

dos formas equivalentes
for archivo in "$@"; do
  gzip -- "$archivo"
done

for archivo; do               # sin «in»: recorre "$@"
  gzip -- "$archivo"
done

La primera forma es más explícita y es la que usará el curso. Lo que nunca debe hacerse es escribir $@ o $* sin comillas, que dividiría en pedazos los argumentos con espacios. Veremos los argumentos en detalle en el tema 20.

4. Recorrer archivos

Con un patrón de comodines en la lista, el bucle recorre los archivos que coinciden. Cada nombre es un elemento, con sus espacios intactos, porque la expansión de nombres de archivo ocurre después de la división en palabras:

terminal · en un directorio con a b.txt y c.txt
$ for f in *.txt; do echo "<$f>"; done
<a b.txt>
<c.txt>

El patrón puede incluir una ruta, y entonces cada elemento la incluye también. La parte variable, si la hay, va entre comillas, y el comodín fuera de ellas:

recorrer los registros de un directorio
for registro in "$dir"/*.log; do
  nombre="${registro##*/}"            # sin el directorio (tema 6)
  echo "Procesando $nombre"
done

Los archivos se recorren en orden alfabético, según la configuración regional. Y dentro del cuerpo, la variable se usa siempre entre comillas, como cualquier nombre de archivo: "$registro".

5. Cuando no hay archivos: nullglob

Como vimos en el tema 3, un patrón que no coincide con ningún archivo queda tal cual. En un bucle, eso significa una vuelta con un nombre que no existe:

terminal · en un directorio sin archivos PDF
$ for f in *.pdf; do echo "Procesando [$f]"; done
Procesando [*.pdf]

Hay tres formas de resolverlo:

SoluciónEfectoCuándo conviene
[[ -e "$f" ]] || continue al comienzo del cuerpoSalta la vuelta si el archivo no existe.En un bucle aislado, sin cambiar opciones de Bash. Es lo que usamos hasta ahora.
shopt -s nullglobUn patrón sin coincidencias se convierte en nada: el bucle no da ninguna vuelta.En scripts que recorren archivos a menudo. Se activa una vez al comienzo.
shopt -s failglobUn patrón sin coincidencias es un error: el comando no se ejecuta.Cuando la falta de archivos indica un problema.
terminal
$ shopt -s nullglob
$ for f in *.pdf; do echo "Procesando [$f]"; done      # ninguna vuelta
$ pdfs=(*.pdf); echo "Hay ${#pdfs[@]} PDF"
Hay 0 PDF

nullglob hace también que un array llenado con un patrón quede vacío, como en el tema 8, lo que permite contar los archivos de forma fiable. Tiene un efecto secundario que conviene conocer: afecta a todos los patrones del script, incluidos los argumentos de cualquier comando. ls *.pdf, sin coincidencias, se convierte en ls a secas, que lista todo el directorio. Por eso algunos prefieren activarlo solo alrededor del bucle, y desactivarlo después con shopt -u nullglob.

6. Ocultos y subdirectorios: dotglob y globstar

Otras dos opciones cambian qué archivos entran en un patrón:

OpciónEfecto
shopt -s dotglob* incluye también los archivos ocultos, los que empiezan con punto. Nunca incluye . ni ...
shopt -s globstar** coincide con cualquier cantidad de directorios: **/*.txt son los .txt de este directorio y de todos sus subdirectorios.
terminal · con a b.txt, c.txt, .oculto.txt y sub/d.txt
$ for f in *.txt; do echo -n "<$f> "; done; echo
<a b.txt> <c.txt>
$ shopt -s dotglob
$ for f in *.txt; do echo -n "<$f> "; done; echo
<.oculto.txt> <a b.txt> <c.txt>
$ shopt -u dotglob; shopt -s globstar
$ for f in **/*.txt; do echo -n "<$f> "; done; echo
<a b.txt> <c.txt> <sub/d.txt>

globstar permite recorrer un árbol de directorios sin find, y para árboles pequeños o medianos es cómodo. Para árboles enormes, o cuando hacen falta criterios como el tamaño o la fecha, find sigue siendo la herramienta, como veremos en el tema 32: ** arma la lista completa en memoria antes de dar la primera vuelta.

7. Rangos de números

Para repetir algo una cantidad de veces, o recorrer números, la expansión de llaves genera la lista:

terminal
$ echo {1..5}
1 2 3 4 5
$ echo {01..10}                           # con ceros a la izquierda
01 02 03 04 05 06 07 08 09 10
$ echo {0..20..5}                         # con paso
0 5 10 15 20
$ echo {a..e}
a b c d e
$ for dia in {01..31}; do mkdir -p "2026-10-$dia"; done

La limitación, como vimos en el tema 3, es que las llaves se expanden antes que las variables: {1..$n} no funciona. Cuando el límite está en una variable, hay dos opciones: el comando seq, que genera la secuencia como salida, o la forma al estilo C de la sección siguiente.

un rango variable con seq
for i in $(seq 1 "$n"); do        # la salida son números: dividirla es seguro
  echo "Intento $i de $n"
done

for i in $(seq -w 1 "$n"); do     # -w: mismo ancho, con ceros (01, 02…)

8. La forma al estilo C

Bash tiene una segunda forma de for, tomada del lenguaje C, que no recorre una lista sino que repite mientras se cumpla una condición aritmética. Entre los dobles paréntesis van tres expresiones, separadas por punto y coma: la inicial, la condición y el paso.

la forma al estilo C
for (( i = 1; i <= n; i++ )); do
  echo "Vuelta $i de $n"
done
  1. Antes de empezar, se evalúa la expresión inicial: i = 1. una vez
  2. Antes de cada vuelta, se evalúa la condición: i <= n. Si es falsa, el bucle termina. cada vuelta
  3. Se ejecuta el cuerpo. cada vuelta
  4. Después de cada vuelta, se evalúa el paso: i++, y se vuelve al punto 2. cada vuelta

Las tres expresiones son aritméticas, como las de (( )) del tema 7: las variables van sin $, y el límite puede estar en una variable. Aquí i++ es seguro aun con set -e, porque su resultado no se usa como código de salida. Esta forma conviene cuando hace falta contar hacia atrás, avanzar de a varios o calcular el índice:

terminal
$ for (( i = 10; i > 0; i -= 3 )); do echo -n "$i "; done; echo
10 7 4 1
$ n=${#lista[@]}; for (( i = n - 1; i >= 0; i-- )); do echo "${lista[i]}"; done

La segunda línea recorre un array del último al primero. Recuerda la advertencia del tema 8: si el array puede tener huecos, recorrer índices calculados se salta elementos; en ese caso, mejor "${!lista[@]}".

9. Por qué no recorrer líneas con for

Uno de los errores más extendidos en Bash es intentar recorrer las líneas de un archivo así:

terminal · un archivo con las líneas «línea uno» y «línea dos»
$ for linea in $(cat lineas.txt); do echo "<$linea>"; done
<línea>
<uno>
<línea>
<dos>

for no recorre líneas: recorre palabras. La salida de $(cat …) pasa por la división en palabras, que corta por espacios además de saltos de línea, y por la expansión de comodines, de modo que una línea con un * se convertiría en una lista de archivos. Además, el archivo entero se carga en memoria antes de empezar.

Para recorrer líneas existe while IFS= read -r línea, que es el tema 16. Y para recorrer la salida de un comando, la regla es la misma que para llenar un array en el tema 8: si la salida son palabras sin espacios ni comodines, como nombres de usuario o números, for x in $(comando) es aceptable; si no, mapfile y un bucle sobre el array, o while read.

cuándo es aceptable recorrer la salida de un comando
for usuario in $(cut -d: -f1 /etc/passwd); do   # nombres de usuario: sin espacios
  …
done

mapfile -t archivos < <(find . -name '*.log' -mtime +7)
for archivo in "${archivos[@]}"; do              # nombres de archivo: pueden tener espacios
  …
done

10. La salida de un bucle entero

Un bucle completo, de for a done, es un solo comando compuesto, y su salida puede redirigirse o conectarse a una tubería como la de cualquier comando. La redirección se escribe después de done, y afecta a todo lo que el bucle escribe:

redirigir y ordenar la salida de un bucle
for servidor in "${servidores[@]}"; do
  printf '%s\t%s\n' "$servidor" "$(ssh "$servidor" uptime)"
done > informe-carga.txt                 # un solo archivo, abierto una vez

for archivo in *.log; do
  printf '%d %s\n' "$(wc -l < "$archivo")" "$archivo"
done | sort -rn | head -5                 # los cinco registros más largos

Redirigir al final es mejor que escribir >> informe.txt en cada vuelta: el archivo se abre una sola vez, y no queda mezclado con el contenido de una ejecución anterior. La tubería después de done, como en el script por-extension del tema 8, permite ordenar o filtrar todo lo que el bucle produjo.

Hay un precio: cuando un bucle es parte de una tubería, se ejecuta en un proceso aparte, y las variables que modifique, como un contador, no conservan su valor después. Es el mismo problema que vimos con read y mapfile, y lo resolveremos en el tema 16.

11. Detalles del funcionamiento

Tres comportamientos de for que conviene conocer:

El último punto lleva a una pregunta de diseño: cuando un bucle procesa muchos elementos y uno falla, ¿debe detenerse todo, o seguir con los demás y avisar al final? Para un despliegue en varios servidores, quizá detenerse; para comprimir cien registros, seguramente seguir. La forma de seguir es manejar el fallo dentro del cuerpo, con un if, como hizo verificar-sitios en el tema 10, y contar los errores para informarlos y devolver el código adecuado al terminar. Los comandos continue y break, que saltan una vuelta o terminan el bucle, son el tema 17.

12. Un script completo

Una aplicación que escribe siempre en el mismo archivo de registro lo hace crecer sin límite. La solución clásica es rotarlo: cada día, el registro actual pasa a ser la copia .1, la anterior pasa a .2 comprimida, y así hasta un número de copias, más allá del cual se descartan. Linux trae una herramienta para esto, logrotate, pero escribir la versión en Bash muestra los dos tipos de for trabajando juntos.

rotar-registros
#!/usr/bin/env bash
#
# rotar-registros — rota los registros *.log de un directorio:
# app.log → app.log.1 → app.log.2.gz → … conservando un número de copias.
#
# Uso:     rotar-registros [DIRECTORIO]
# Entorno: COPIAS  copias a conservar, incluida la .1 (5; mínimo 2)
#
set -euo pipefail
shopt -s nullglob

dir="${1:-.}"
copias="${COPIAS:-5}"

[[ -d "$dir" ]] || { echo "No existe el directorio $dir" >&2; exit 1; }
if [[ ! "$copias" =~ ^[0-9]+$ ]] || (( copias < 2 )); then
  echo "COPIAS debe ser un número entero mayor o igual que 2" >&2
  exit 2
fi

rotados=0
for registro in "$dir"/*.log; do
  [[ -s "$registro" ]] || continue                # vacío: nada que rotar

  rm -f -- "$registro.$copias.gz"                 # la copia más vieja se descarta
  for (( i = copias - 1; i >= 2; i-- )); do      # .4.gz → .5.gz, …, .2.gz → .3.gz
    if [[ -e "$registro.$i.gz" ]]; then
      mv -- "$registro.$i.gz" "$registro.$(( i + 1 )).gz"
    fi
  done
  if [[ -e "$registro.1" ]]; then                 # la copia anterior se comprime
    gzip -f -- "$registro.1"
    mv -- "$registro.1.gz" "$registro.2.gz"
  fi

  cp -p -- "$registro" "$registro.1"              # la copia nueva, sin comprimir
  : > "$registro"                                 # vaciar el original sin borrarlo
  rotados=$(( rotados + 1 ))
done

echo "Registros rotados: $rotados"
terminal · después de cinco días, con COPIAS=3
$ ls logs
app.log  app.log.1  app.log.2.gz  app.log.3.gz  vacio.log  web.log  web.log.1  web.log.2.gz  web.log.3.gz
$ zcat logs/app.log.3.gz; zcat logs/app.log.2.gz; cat logs/app.log.1
registro del miércoles
registro del jueves
registro del viernes

13. Errores frecuentes

EscribisteQué pasaCorrección
for f in $(ls *.txt)Los nombres con espacios se parten.for f in *.txt
for f in "*.txt"Una sola vuelta, con el texto *.txt.El patrón sin comillas.
for f in *.txt sin archivosUna vuelta con el texto *.txt.shopt -s nullglob, o [[ -e $f ]] || continue.
for a in $@Los argumentos con espacios se parten.for a in "$@"
for linea in $(cat archivo)Recorre palabras, no líneas, y expande comodines.while IFS= read -r linea (tema 16).
for i in {1..$n}Una vuelta con el texto {1..5}.for (( i = 1; i <= n; i++ )) o seq.
cmd "$f" >> salida.txt en cada vueltaSe acumula sobre una ejecución anterior; el archivo se abre en cada vuelta.done > salida.txt
… done | sort y luego usar un contador del bucleEl contador vale lo que tenía antes del bucle.Ver el tema 16.
Desplazar copias de menor a mayorCada copia pisa la siguiente antes de moverla.Recorrer de mayor a menor.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Cuántas vueltas da for x in a "b c" d; do …; done?

Tres: a, b c y d. Las comillas mantienen b c como un solo elemento.

2. ¿Qué recorre for archivo; do …; done?

Los argumentos con que se llamó al script o a la función, igual que for archivo in "$@".

3. ¿Por qué for f in *.txt no parte los nombres con espacios, y for f in $(ls *.txt) sí?

Porque la expansión de nombres de archivo ocurre después de la división en palabras, y cada nombre generado es un elemento. La salida de ls, en cambio, es texto que pasa por la división en palabras y se corta por los espacios.

4. ¿Qué hace un bucle sobre *.pdf en un directorio sin PDF, con y sin nullglob?

Sin nullglob, da una vuelta con el texto literal *.pdf. Con nullglob, el patrón desaparece y el bucle no da ninguna vuelta.

5. ¿Qué efecto secundario tiene activar nullglob en todo el script?

Afecta a todos los patrones, no solo a los de los bucles. Un comando como ls *.pdf sin coincidencias se convierte en ls sin argumentos, y lista todo el directorio.

6. ¿Qué hacen dotglob y globstar?

dotglob hace que * incluya los archivos que empiezan con punto. globstar activa **, que coincide con cualquier cantidad de directorios, para recorrer un árbol.

7. ¿Por qué for i in {1..$n} no funciona, y qué alternativas hay?

Porque la expansión de llaves ocurre antes que la de variables, y en ese momento {1..$n} no es un rango válido. Se usa for (( i = 1; i <= n; i++ )) o for i in $(seq 1 "$n").

8. ¿En qué orden se evalúan las tres expresiones de for (( a; b; c ))?

a una vez al comienzo; b antes de cada vuelta, y si es falsa el bucle termina; c después de cada vuelta.

9. ¿Por qué for linea in $(cat archivo) no recorre las líneas?

Porque for recorre palabras. La salida de la sustitución se divide por espacios, tabuladores y saltos de línea, y además se expanden los comodines. Para leer líneas se usa while IFS= read -r linea.

10. ¿Qué ventaja tiene done > informe.txt sobre >> informe.txt dentro del bucle?

El archivo se abre una sola vez y se reemplaza su contenido, en lugar de abrirlo en cada vuelta y acumular sobre lo que hubiera de ejecuciones anteriores.

11. Si el cuerpo de un bucle sobre *.txt crea archivos .txt nuevos, ¿los procesa el bucle?

No. La lista se calcula una sola vez, antes de la primera vuelta.

12. En rotar-registros, ¿por qué las copias se desplazan de mayor a menor?

Porque si se moviera primero .2 a .3, se pisaría la copia .3 antes de haberla movido a .4. Empezando por la más vieja, cada destino ya quedó libre.

13. ¿Por qué rotar-registros copia y vacía el registro en lugar de moverlo?

Porque la aplicación mantiene el archivo abierto. Si se lo moviera, seguiría escribiendo en el archivo movido. Copiarlo y vaciar el original permite seguir sin reiniciar la aplicación.

Para practicar

  1. Escribe un bucle que cree los directorios enero a diciembre dentro de un directorio 2026, usando una lista escrita.
  2. Crea archivos con espacios en el nombre y recórrelos con for f in * y con for f in $(ls). Compara.
  3. Recorre *.pdf en un directorio sin PDF con y sin nullglob, y prueba también failglob.
  4. Usa globstar para contar cuántos archivos .sh hay en tu directorio personal y sus subdirectorios.
  5. Escribe un script que reciba un número n y muestre una cuenta regresiva de n a 1 con la forma al estilo C, esperando un segundo entre números.
  6. Escribe un bucle que muestre, para cada archivo .txt del directorio, su cantidad de líneas y su nombre, y conecta la salida a sort -n.
  7. Prueba rotar-registros en un directorio de prueba, agregando una línea al registro antes de cada ejecución, como en el ejemplo. Comprueba que nunca haya más copias que las indicadas.