StackPractices
beginner Por Mathias Paulenko

Cómo Iterar sobre Archivos en Bash

Cómo iterar de forma segura sobre archivos y directorios en Bash, manejando espacios, globs y listas grandes de archivos con patrones correctos.

Descripción General

Iterar sobre archivos es una de las operaciones más comunes en Bash, pero con frecuencia se hace mal. Los nombres de archivo con espacios, saltos de línea o caracteres glob (*, ?) rompen los loops ingenuos. Abajo están los patrones que sobreviven a esos nombres — globs simples, find, mapfile y xargs — y cuándo conviene cada uno.

Cuándo Usar

  • Ejecutar el mismo comando sobre muchos archivos (convertir, analizar, mover)
  • Encontrar archivos que coincidan con un patrón y procesarlos en orden
  • Renombrar, cambiar permisos o validar por lotes (ver Cómo Copiar y Mover Archivos)
  • Generar reportes desde un directorio de archivos de entrada
  • Reemplazar texto en un directorio de archivos (ver Procesamiento de Texto en Bash)

Cuándo NO Usar

  • Procesar millones de archivos — los límites de longitud de lista de argumentos (ARG_MAX) fallarán
  • Filtrado complejo que se expresa mejor con find usando -exec o xargs
  • Operaciones que requieren estado entre archivos — usa un lenguaje de scripting apropiado (Python, Perl)
  • Tareas que necesitan recuperación de errores por archivo — set -e con loops es complicado

Implementación Paso a Paso

Dos preguntas eligen la herramienta correcta: ¿necesitas recursión o filtros complejos, y puede estar vacío el directorio?

flowchart diagram: Archivos a iterar

Loop Seguro Básico con Glob

#!/bin/bash
set -euo pipefail

# CORRECTO: Siempre entrecomilla la variable
txt_count=0
for file in *.txt; do
    # Manejar el caso de no-coincidencia (glob deja literal '*.txt')
    [ -e "$file" ] || continue
    echo "Procesando: $file"
    ((txt_count++))
done
echo "Total archivos .txt: $txt_count"

Recursar con find

#!/bin/bash
set -euo pipefail

# Procesar todos los archivos .py bajo src/, manejando espacios de forma segura
while IFS= read -r -d '' file; do
    echo "Analizando: $file"
    pylint "$file"
done < <(find src/ -type f -name '*.py' -print0)

# Versión one-liner con xargs (no se necesita loop)
find src/ -type f -name '*.py' -print0 | xargs -0 pylint

# Procesar con un límite (más seguro para directorios enormes)
find src/ -maxdepth 2 -type f -name '*.py' -print0 | \
    xargs -0 -n 10 -P 4 pylint

Filtrar y Ordenar

#!/bin/bash
set -euo pipefail
shopt -s nullglob

# Orden numérico en nombres como report_001.txt, report_002.txt.
# Expandir el glob a un array y ordenarlo — nunca `for f in $(ls ...)`.
files=(report_*.txt)
mapfile -t sorted < <(printf '%s\n' "${files[@]}" | sort -t_ -k2 -n)

for file in "${sorted[@]}"; do
    echo "Ordenado: $file"
done

Procesar Archivos con Espacios y Caracteres Especiales

#!/bin/bash
set -euo pipefail

# Manejar nombres de archivo con espacios, saltos de línea y globs
srcdir="/data/uploads"

# Enfoque 1: read con find -print0
while IFS= read -r -d '' filepath; do
    filename=$(basename "$filepath")
    echo "Archivo: $filename"
done < <(find "$srcdir" -type f -print0)

# Enfoque 2: shopt nullglob + expansión entrecomillada
shopt -s nullglob
targets=("$srcdir"/*)
shopt -u nullglob

for filepath in "${targets[@]}"; do
    [ -f "$filepath" ] || continue
    echo "Encontrado: $(basename "$filepath")"
done

Operaciones por Lotes

#!/bin/bash
set -euo pipefail

# Renombrar .jpeg a .jpg
for file in *.jpeg; do
    [ -e "$file" ] || continue
    mv -- "$file" "${file%.jpeg}.jpg"
done

# Convertir todas las imágenes HEIC a JPEG
for file in *.heic; do
    [ -e "$file" ] || continue
    base="${file%.heic}"
    heif-convert "$file" "$base.jpg"
done

# Validar todos los archivos JSON
error_count=0
for file in *.json; do
    [ -e "$file" ] || continue
    if ! jq empty "$file" 2>/dev/null; then
        echo "ERROR: JSON inválido en $file" >&2
        ((error_count++))
    fi
done
[ "$error_count" -eq 0 ] || exit 1

Trabajar con Listas Grandes de Archivos

Para ejecución paralela más allá de un solo xargs -P, consulta Ejecución Paralela en Bash. Cuando la lista de archivos es grande o impredecible, tres formas idiomáticas superan a un loop for simple:

  1. Usa mapfile para leer listas de archivos en arrays. Es más rápido que un loop while read para listas grandes y preserva caracteres especiales:
#!/bin/bash
# Leer todos los archivos .py en un array de forma segura
mapfile -d '' -t pyfiles < <(find src/ -type f -name '*.py' -print0)

echo "Encontrados ${#pyfiles[@]} archivos Python"
for file in "${pyfiles[@]}"; do
    echo "  $file"
done
  1. Usa xargs para operaciones simples de un comando por archivo. Gestiona lotes y paralelismo automáticamente:
#!/bin/bash
# Comprimir todos los archivos .log en paralelo (4 a la vez)
find /var/log -type f -name '*.log' -print0 | xargs -0 -P 4 -I{} gzip "{}"

# Ejecutar eslint en todos los archivos .js, 20 a la vez
find src/ -type f -name '*.js' -print0 | xargs -0 -n 20 eslint
  1. Configura IFS correctamente al parsear salida de comandos. El IFS por defecto incluye espacios, lo que rompe nombres de archivo. Siempre usa IFS= con read:
#!/bin/bash
# Mal: IFS incluye espacio, rompe con "My File.txt"
# echo "My File.txt" | while read file; do echo "$file"; done
# Salida: "My" y "File.txt" en líneas separadas

# Bien: IFS= evita recortar espacios iniciales/finales
echo "My File.txt" | while IFS= read -r file; do echo "$file"; done
# Salida: "My File.txt"

Archivos del companion: todos los scripts de esta página son ejecutables desde el companion repo bash-loop-over-files, un .sh por patrón.

Lo que funciona

  • Siempre entrecomilla variables de archivo. "$file" previene la división de palabras en espacios y la interpretación de caracteres glob.
  • Usa find -print0 | while read -r -d '' para recursión o filtros complejos — es la única forma que sobrevive a un nombre de archivo que contiene un salto de línea.
  • Habilita nullglob al usar globs en loops. De lo contrario *.txt sin coincidencias itera una vez con el string literal *.txt.
  • Usa -- antes de nombres de archivo en comandos. Sin él, un archivo llamado -rf se lee como flags por mv o rm.
  • Verifica [ -e "$file" ] al inicio del loop. Es una guarda de una línea para cuando nullglob está desactivado y el glob no encontró nada.

Errores Comunes

  • for file in $(ls *.txt) — nunca hagas esto. La salida de ls no es parseable; los espacios y saltos de línea en nombres de archivo rompen el loop.
  • Variables sin entrecomillar: mv $file $dest divide My Document.txt en My y Document.txt, así que mv recibe rutas equivocadas.
  • Olvidar nullglob: un glob sin coincidencias se expande a sí mismo, así que el loop corre una vez sobre el literal *.txt.
  • Usar cat para alimentar un solo archivo a un programa: cat "$file" | grep pattern es un uso inútil de cat. Usa grep pattern "$file".
  • No manejar el caso de no-coincidencia: Un directorio vacío hace que un loop ingenuo corra una vez sobre el literal *.txt — el mismo fallo que nullglob previene.
  1. Usar for file in $(find ...) en vez de while read. La división de palabras destroza cualquier nombre que contenga espacios. Envía find por -print0 a while IFS= read -r -d '' en su lugar:
# Mal: rompe con espacios
# for file in $(find . -name "*.txt"); do echo "$file"; done

# Bien: maneja todos los nombres de archivo
while IFS= read -r -d '' file; do
    echo "$file"
done < <(find . -name "*.txt" -print0)
  1. No usar set -euo pipefail en scripts. Sin set -e, los errores en el cuerpo del loop se ignoran silenciosamente. Sin set -u, las variables indefinidas se expanden a strings vacíos. Sin pipefail, los comandos pipeados que fallan se enmascaran:
#!/bin/bash
# Mal: los errores se ignoran silenciosamente
# for file in *.txt; do
#     process "$file"
# done

# Bien: aborta en error, variable indefinida o fallo de pipe
set -euo pipefail
for file in *.txt; do
    [ -e "$file" ] || continue
    process "$file"
done
  1. Modificar archivos mientras se itera. Añadir, eliminar o renombrar archivos durante un loop glob puede causar procesamiento saltado o duplicado. Recolecta la lista de archivos primero, luego itera:
#!/bin/bash
shopt -s nullglob
files=(*.tmp)
shopt -u nullglob

for file in "${files[@]}"; do
    # Seguro: ya capturamos la lista
    rm -- "$file"
done

Más Patrones de Iteración

¿Cómo itero sobre archivos ordenados por fecha de modificación?

Usa find con -printf y sort, o ls -t con mapfile:

#!/bin/bash
# Ordenar por fecha de modificación (más reciente primero)
mapfile -d '' -t files < <(find . -type f -name '*.log' -printf '%T@ %p\0' | sort -rz -n | cut -z -d' ' -f2-)

for file in "${files[@]}"; do
    echo "$(date -r "$file" '+%Y-%m-%d %H:%M') $file"
done

¿Cómo salto archivos mayores a cierto tamaño?

Usa find con filtro -size:

#!/bin/bash
# Procesar solo archivos menores a 10MB
while IFS= read -r -d '' file; do
    size=$(stat -c%s "$file")
    echo "Procesando $file ($((size / 1024)) KB)"
done < <(find . -type f -name '*.log' -size -10M -print0)

¿Cómo itero sobre archivos con múltiples extensiones?

Usa brace expansion con nullglob, o find con múltiples patrones -name:

#!/bin/bash
shopt -s nullglob

# Brace expansion
for file in *.{jpg,jpeg,png,webp}; do
    [ -f "$file" ] || continue
    echo "Imagen: $file"
done

# find con múltiples -name
while IFS= read -r -d '' file; do
    echo "Imagen: $file"
done < <(find . -type f \( -name '*.jpg' -o -name '*.jpeg' -o -name '*.png' -o -name '*.webp' \) -print0)

¿Cómo manejo nombres de archivo con saltos de línea?

Solo find -print0 con read -d '' maneja nombres de archivo que contienen saltos de línea. Los patrones glob también funcionan ya que Bash los expande correctamente. Nunca uses salida de ls o sustitución de comandos sin entrecomillar:

#!/bin/bash
# Esto maneja nombres con saltos de línea, espacios y caracteres especiales
while IFS= read -r -d '' file; do
    echo "Procesando: $file"
done < <(find . -type f -print0)

Lectura Adicional

Preguntas frecuentes

¿Por qué debo evitar for f in $(ls)?

Falla con nombres de archivo con espacios o caracteres especiales. Usa un patrón glob como for f in *.txt o while IFS= read -r con find -print0.

¿Cuándo debo usar find en lugar de un loop con glob?

Usa find cuando necesites recursión, filtros por tamaño o fecha, o manejo garantizado de nombres arbitrarios con -print0. Un simple for file in *.txt basta para un solo directorio plano.

¿Cómo proceso archivos en subdirectorios?

Ejecuta find . -type f -name "*.txt" -print0 y aliméntalo a while IFS= read -r -d '' file. Los globs como **/*.txt también recorren en Bash 4+ con globstar activado, pero find te da filtros gratis.

¿Por qué mi loop corre una vez con el literal *.txt cuando no hay coincidencias?

Con nullglob desactivado, un glob sin coincidencias se expande a sí mismo. Activa shopt -s nullglob o añade [ -e "$file" ] || continue al inicio del loop.

¿Cómo detengo el loop en el primer error?

Un loop while alimentado por un pipeline corre en un subshell, así que set -e no aborta de forma fiable. Verifica el resultado dentro del cuerpo (cmd "$file" || break) o recolecta la lista primero con mapfile y usa un loop for.

¿Cómo itero solo sobre archivos más antiguos de 30 días?

Añade -mtime +30 a find — el + significa estrictamente más de N días: find . -type f -name '*.log' -mtime +30 -print0 | while IFS= read -r -d '' file; do ... done.