← Automatización con Scripts en Bash Bloque 7 · Texto y expresiones regulares · Tema 32 de 42

Tema 32

Procesar lotes de archivos

Comprimir los registros de más de una semana, convertir doscientas imágenes, renombrar las fotos de un viaje, borrar los temporales de un árbol de directorios: muchas tareas de automatización consisten en aplicar la misma operación a un lote de archivos. Hacerlo bien exige dos cosas: elegir exactamente los archivos correctos, y que ningún nombre, por raro que sea, rompa el proceso. Este tema reúne las herramientas para las dos, y termina con un renombrado masivo que no pierde ningún archivo.

1. Cuándo alcanzan los comodines

Para un lote dentro de un solo directorio, seleccionado por el nombre, un bucle sobre un comodín es lo más simple, como vimos en el tema 14:

un lote simple
shopt -s nullglob
for registro in /var/log/app/*.log; do
  gzip -- "$registro"
done

Los comodines dejan de alcanzar cuando la selección depende de algo más que el nombre, como la antigüedad o el tamaño; cuando hay que recorrer subdirectorios a cualquier profundidad (globstar lo permite, pero arma toda la lista en memoria); o cuando los archivos son tantos que la lista supera el límite de longitud de una línea de comandos, con el error Argument list too long. Para todo eso está find.

2. Elegir archivos con find

find recorre un árbol de directorios y selecciona los archivos que cumplen una serie de criterios, que se combinan como condiciones. El curso de Terminal lo presentó; estos son los criterios que más aparecen en la automatización:

CriterioSelecciona
-type f, -type dArchivos comunes; directorios.
-name '*.log', -iname '*.jpg'Por nombre, con comodines; -iname sin distinguir mayúsculas.
-mtime +7, -mtime -1Modificados hace más de 7 días; en el último día.
-mmin -30Modificados en los últimos 30 minutos.
-newer archivoMás nuevos que un archivo de referencia (tema 12).
-size +100M, -size -1kMás de 100 MiB; menos de 1 KiB.
-emptyArchivos o directorios vacíos.
-maxdepth 1Sin entrar en subdirectorios. Va antes que los demás criterios.
-path '*/.git' -prune -o …Saltear un directorio entero.
\( … -o … \)Agrupar alternativas: «esto o aquello».
! -name '*.gz'Negar un criterio.
combinar criterios
# Registros de más de 7 días que todavía no están comprimidos, sin entrar en .git
find /var/log/app -path '*/.git' -prune -o -type f -name '*.log' ! -name '*.gz' -mtime +7 -print

Los criterios se evalúan en orden y se combinan con «y» implícito; -o significa «o». La forma con -prune se lee: «si la ruta es un .git, no entres; si no, y es un archivo .log…, muéstralo». El -print final es necesario cuando hay un -o, para que solo se muestren los de la segunda rama. Antes de actuar sobre los archivos, conviene siempre ejecutar el find solo, con -print, y mirar la lista.

3. Actuar con find -exec

find puede ejecutar un comando sobre cada archivo que encuentra, con -exec. Las llaves {} se reemplazan por la ruta del archivo, y la acción termina con \; o con +, que se comportan de forma muy distinta:

FormaEjecutaCuándo
-exec comando {} \;Una vez por cada archivo.Cuando el comando acepta un solo archivo, o hay que procesar uno por vez.
-exec comando {} +Una vez con muchos archivos como argumentos.Cuando el comando acepta varios archivos: mucho más rápido.
-execdir comando {} \;Como -exec, pero dentro del directorio de cada archivo.Comandos que trabajan con el nombre relativo.
-deleteBorra cada archivo encontrado.Borrar, sin crear un proceso por archivo.
terminal
$ find . -name '*.log' -exec echo "uno:" {} \;
uno: ./-raro.log
uno: ./a b.log
uno: ./normal.log
$ find . -name '*.log' -exec echo "todos:" {} +
todos: ./-raro.log ./a b.log ./normal.log

Con +, find agrupa tantas rutas como admite una línea de comandos, y ejecuta el comando las veces necesarias. Para comprimir diez mil registros, -exec gzip {} + crea unos pocos procesos en lugar de diez mil. Y como find pasa las rutas directamente como argumentos, sin pasar por la shell, los espacios y caracteres raros no son un problema.

4. Los nombres problemáticos

En Linux, un nombre de archivo puede contener cualquier carácter, salvo la barra y el carácter nulo. Tres casos rompen los scripts descuidados:

NombreProblemaProtección
a b.logUn espacio parte el nombre en dos al pasar por la división en palabras (tema 3).Comillas en cada uso, y nunca recorrer la salida de ls.
-raro.logUn comando lo toma como un conjunto de opciones.-- antes de los archivos, o rutas que empiecen con ./.
Un nombre con un salto de líneaCualquier herramienta que trabaje por líneas lo ve como dos archivos.Separar con nulos: -print0, xargs -0, read -d ''.
terminal
$ touch "-raro.log"
touch: invalid option -- 'r'
$ touch -- "-raro.log"                      # ahora sí
$ for f in *.log; do ls -d "$f"; done
ls: invalid option -- '.'
a b.log
normal.log
$ for f in ./*.log; do ls -d "$f"; done
./-raro.log
./a b.log
./normal.log

Comprobado así, el nombre con guion inicial confundió a touch y a ls, aunque estaba entre comillas: las comillas protegen de la división en palabras, no de que el comando interprete un argumento como opción. Escribir el patrón como ./*.log hace que todas las rutas empiecen con ./, y el problema desaparece; find ya las devuelve así. Los saltos de línea son raros, pero un script que procesa archivos ajenos, como los de un directorio compartido, debe estar preparado para ellos.

5. xargs

xargs lee una lista de su entrada y la convierte en argumentos de un comando. Combinado con find, es una alternativa a -exec con más opciones de control:

terminal · sin y con separador nulo
$ find . -name '*.log' | xargs ls -d
ls: cannot access './a': No such file or directory
ls: cannot access 'b.log': No such file or directory
./-raro.log
./normal.log
$ find . -name '*.log' -print0 | xargs -0 ls -d --
./-raro.log
./a b.log
./normal.log

Sin opciones, xargs separa la entrada por espacios y saltos de línea, y además interpreta comillas, de modo que parte los nombres con espacios. Por eso se usa siempre con find -print0 y xargs -0, que separan por el carácter nulo. Estas son sus opciones más útiles:

OpciónEfecto
-0La entrada está separada por nulos.
-rSi la entrada está vacía, no ejecutar el comando. Sin ella, se ejecuta una vez sin argumentos, lo que con rm o ls puede ser sorprendente.
-n NPasar como máximo N argumentos por ejecución.
-I {}Ejecutar una vez por elemento, reemplazando {} en cualquier lugar del comando.
-P NEjecutar hasta N comandos a la vez (sección 7).
usos de xargs
find . -name '*.tmp' -print0 | xargs -0 -r rm -f --                     # borrar, si hay algo
find fotos -iname '*.png' -print0 | xargs -0 -I {} cp -- {} {}.original  # una copia de cada una
printf '%s\0' "${servidores[@]}" | xargs -0 -n 1 ping -c1 -W2            # una lista propia

6. Un bucle sobre la salida de find

Cuando la acción sobre cada archivo tiene varios pasos, o necesita variables del script, o hay que contar resultados, la forma más flexible es el bucle del tema 16, con la salida de find separada por nulos:

un bucle robusto sobre los archivos
comprimidos=0
while IFS= read -r -d '' registro; do
  if gzip -- "$registro"; then
    comprimidos=$(( comprimidos + 1 ))
  else
    echo "No se pudo comprimir $registro" >&2
  fi
done < <(find /var/log/app -type f -name '*.log' -mtime +7 -print0)
echo "Comprimidos: $comprimidos"

Esta forma combina lo mejor de las dos herramientas: find elige con sus criterios, y el cuerpo del bucle tiene toda la expresividad de Bash. Su costo es que cada vuelta ejecuta los comandos uno por vez, de modo que es más lenta que -exec … + para operaciones simples sobre muchísimos archivos.

7. Procesar en paralelo

Cuando cada archivo requiere un trabajo pesado, como comprimir, convertir una imagen o calcular una suma de verificación, procesarlos de a uno desaprovecha los procesadores del equipo. La opción -P de xargs ejecuta varios comandos a la vez:

terminal · cuatro tareas de un segundo
$ printf '%s\0' 1 2 3 4 | xargs -0 -n 1 -P 4 sh -c 'sleep 1; echo "terminó $0"'
terminó 1
terminó 2
terminó 3
terminó 4
# en total, un segundo y no cuatro

El número de procesos simultáneos conviene ajustarlo a los procesadores disponibles, que da el comando nproc. Para usar una función del script como tarea, se exporta con export -f, como vimos en el tema 18, y se llama a través de bash -c:

una función del script, en paralelo
reducir() {
  convert "$1" -resize 1600x1600\> "${1%.*}-web.jpg"
}
export -f reducir

find fotos -iname '*.jpg' ! -name '*-web.jpg' -print0 \
  | xargs -0 -n 1 -P "$(nproc)" bash -c 'reducir "$1"' _

El _ final ocupa el lugar de $0 dentro de bash -c, de modo que la ruta llega como $1. Dos advertencias: las salidas de las tareas simultáneas pueden mezclarse en la pantalla, y las tareas no deben escribir en los mismos archivos. Para necesidades más complejas existe GNU parallel, una herramienta dedicada con muchas más opciones.

8. Cuál elegir

SituaciónConviene
Un directorio, selección por nombrefor f in ./*.ext, con nullglob
Un comando simple sobre muchos archivosfind … -exec comando {} +
Borrar lo seleccionadofind … -delete
Varios pasos por archivo, contadores, mensajeswhile IFS= read -r -d '' f; do …; done < <(find … -print0)
Trabajo pesado por archivo, en paralelofind … -print0 | xargs -0 -P N …
Una lista que no viene de findprintf '%s\0' … | xargs -0 …

9. Renombrar en masa

Renombrar muchos archivos es una de las tareas por lotes más frecuentes, y una de las más peligrosas: un error puede hacer que dos archivos reciban el mismo nombre, y que uno pise al otro. Con las expansiones del tema 6, un bucle resuelve casi cualquier renombrado:

renombrados típicos
for f in ./*.jpeg; do mv -n -- "$f" "${f%.jpeg}.jpg"; done           # cambiar la extensión
for f in ./*\ *; do mv -n -- "$f" "${f// /_}"; done                  # espacios por guiones bajos
for f in ./IMG_*; do mv -n -- "$f" "./vacaciones_${f#./IMG_}"; done   # cambiar un prefijo
n=1; for f in ./*.png; do printf -v nuevo './captura-%03d.png' "$n"; mv -n -- "$f" "$nuevo"; n=$(( n + 1 )); done

La opción -n de mv (no clobber) es la protección esencial: si el destino ya existe, mv no lo sobrescribe. Sin ella, un renombrado que produce dos veces el mismo nombre borra un archivo en silencio. Pero -n solo evita el daño; el archivo que no se movió sigue con su nombre viejo, y el script debería detectarlo o, mejor, elegir otro nombre, como hace el script de la sección 11.

Muchas distribuciones incluyen además un comando rename para renombrar con expresiones, aunque hay dos versiones incompatibles con el mismo nombre: la de Perl, que usa rename 's/\.jpeg$/.jpg/' *.jpeg, y la de util-linux, que usa rename .jpeg .jpg *.jpeg. Por esa ambigüedad, en un script es más predecible el bucle de Bash.

10. Antes de tocar mil archivos

Una operación por lotes multiplica las consecuencias de un error, como advirtió el tema 1. Antes de ejecutarla sobre los datos reales:

  1. Mirar la selección. Ejecutar el find solo, con -print, o contar los resultados con | wc -l (o -printf '.' | wc -c, que no se confunde con saltos de línea). ¿Son los archivos esperados? ¿Son tantos como se esperaba?
  2. Simular. Reemplazar la acción por un echo, o usar una opción -n como la del script de este tema, y revisar la lista de cambios.
  3. Probar en una copia. Un cp -a del directorio a un lugar de pruebas, y ejecutar allí.
  4. Preferir lo reversible. Mover a un directorio de descarte antes que borrar; renombrar con mv -n; comprimir conservando el original con gzip -k hasta verificar.
  5. Hacer el proceso repetible. Que una segunda ejecución reconozca lo ya hecho y no lo rehaga, como en el tema 27. Así, si se interrumpe a la mitad, se vuelve a lanzar sin miedo.

11. Un script completo

Las cámaras y los teléfonos nombran las fotos con números, como IMG_4031.JPG, que no dicen nada y se repiten entre dispositivos. Este script las renombra con su fecha y hora de modificación, como 2026-10-14_10-32-07.jpg, de modo que quedan ordenadas cronológicamente. Resuelve los choques de nombres, admite simular y recorrer subdirectorios, y una segunda ejecución no cambia nada.

renombrar-por-fecha
#!/usr/bin/env bash
#
# renombrar-por-fecha — renombra fotos y videos con su fecha y hora de modificación:
# IMG_4031.JPG → 2026-10-14_10-32-07.jpg
#
# Uso: renombrar-por-fecha [-n] [-r] DIRECTORIO
#   -n  muestra los cambios sin hacerlos
#   -r  incluye los subdirectorios
#
set -euo pipefail

simular=0
profundidad=(-maxdepth 1)
while getopts ":nr" opcion; do
  case "$opcion" in
    n) simular=1 ;;
    r) profundidad=() ;;
    *) echo "Uso: ${0##*/} [-n] [-r] DIRECTORIO" >&2; exit 2 ;;
  esac
done
shift $(( OPTIND - 1 ))
(( $# == 1 )) || { echo "Uso: ${0##*/} [-n] [-r] DIRECTORIO" >&2; exit 2; }
dir="${1%/}"
[[ -d "$dir" ]] || { echo "${0##*/}: no existe el directorio $dir" >&2; exit 1; }

declare -A reservado=()                  # nombres ya asignados en esta ejecución
renombrados=0
sin_cambio=0

while IFS= read -r -d '' ruta; do
  carpeta="${ruta%/*}"
  nombre="${ruta##*/}"
  ext="${nombre##*.}"
  ext="${ext,,}"
  [[ "$ext" == jpeg ]] && ext=jpg

  base=$(date -r "$ruta" +%Y-%m-%d_%H-%M-%S)
  if [[ "$nombre" =~ ^${base}(_[0-9]+)?\.${ext}$ ]]; then
    sin_cambio=$(( sin_cambio + 1 ))   # ya tiene el nombre que le corresponde
    continue
  fi

  nuevo="$base.$ext"
  n=1
  while [[ -e "$carpeta/$nuevo" || -n "${reservado[$carpeta/$nuevo]:-}" ]]; do
    n=$(( n + 1 ))
    nuevo="${base}_$n.$ext"              # dos fotos del mismo segundo
  done
  reservado[$carpeta/$nuevo]=1

  if (( simular )); then
    printf '%s → %s\n' "${ruta#"$dir"/}" "$nuevo"
  else
    mv -n -- "$ruta" "$carpeta/$nuevo"
  fi
  renombrados=$(( renombrados + 1 ))
done < <(find "$dir" "${profundidad[@]}" -type f \
           \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.heic' -o -iname '*.mp4' \) \
           -print0 | sort -z)

verbo=$( (( simular )) && echo "se renombrarían" || echo "renombrados" )
echo "Archivos $verbo: $renombrados; ya tenían el nombre correcto: $sin_cambio" >&2
terminal
$ renombrar-por-fecha -n ~/fotos
IMG_4031.JPG → 2026-10-14_10-32-07.jpg
IMG_4032.JPG → 2026-10-14_10-32-07_2.jpg
foto de mamá.jpeg → 2026-10-14_10-35-00.jpg
Archivos se renombrarían: 3; ya tenían el nombre correcto: 0
$ renombrar-por-fecha -r ~/fotos
Archivos renombrados: 4; ya tenían el nombre correcto: 0
$ find ~/fotos -type f | sort
/home/ana/fotos/2026-10-14_10-32-07.jpg
/home/ana/fotos/2026-10-14_10-32-07_2.jpg
/home/ana/fotos/2026-10-14_10-35-00.jpg
/home/ana/fotos/viaje 2026/2026-09-01_08-00-00.png
$ renombrar-por-fecha -r ~/fotos
Archivos renombrados: 0; ya tenían el nombre correcto: 4

La fecha de modificación es la que tienen todos los archivos, pero no siempre es el momento en que se tomó la foto: copiarla o editarla la cambia. Las fotos guardan además la fecha original en sus metadatos EXIF, que se leen con herramientas como exiftool; el mismo script, con exiftool en lugar de date -r, daría fechas más fieles.

12. Errores frecuentes

EscribisteQué pasaCorrección
for f in $(find …)Los nombres con espacios se parten.while IFS= read -r -d '' con -print0.
find … | xargs comandoEspacios y comillas en los nombres los rompen.find … -print0 | xargs -0 comando
xargs rm sin -rSin entrada, se ejecuta igual.xargs -0 -r rm -f --
-exec gzip {} \; sobre miles de archivosUn proceso por archivo: lento.-exec gzip {} +
for f in *.log; do cmd "$f"Un archivo que empieza con guion se toma como opción../*.log, o cmd -- "$f".
mv "$f" "$nuevo" en un renombrado masivoDos archivos con el mismo destino: uno se pierde.mv -n, y resolver los choques.
-o en find sin paréntesis ni -printLa acción se aplica solo a una de las ramas, o a ninguna.\( … -o … \), y -print explícito.
Ejecutar directamente sobre los datos realesUn error afecta a todos los archivos.Mirar la selección, simular, probar en una copia.

13. Resumen

14. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Cuándo dejan de alcanzar los comodines para elegir un lote de archivos?

Cuando la selección depende de algo más que el nombre, como la fecha o el tamaño; cuando hay que recorrer subdirectorios a cualquier profundidad; o cuando la lista es tan larga que supera el límite de la línea de comandos.

2. ¿Qué selecciona find . -type f -name '*.log' -mtime +30?

Los archivos comunes con nombre terminado en .log, en el directorio actual y sus subdirectorios, modificados hace más de 30 días.

3. ¿Qué diferencia hay entre -exec gzip {} \; y -exec gzip {} +?

La primera ejecuta gzip una vez por archivo. La segunda lo ejecuta con muchos archivos como argumentos a la vez, creando muchos menos procesos.

4. ¿Por qué un archivo llamado -raro.log causa problemas aunque se escriba entre comillas?

Porque las comillas evitan la división en palabras, pero el comando recibe igual un argumento que empieza con guion, y lo interpreta como opciones. Se evita con -- o escribiendo la ruta como ./-raro.log.

5. ¿Por qué find … | xargs rm es peligroso?

Porque xargs sin -0 separa por espacios y saltos de línea e interpreta comillas, de modo que un nombre con espacios se convierte en varios argumentos, que pueden coincidir con otros archivos. Se usa find … -print0 | xargs -0 rm --.

6. ¿Qué hace xargs -r?

Si la entrada está vacía, no ejecuta el comando. Sin -r, el comando se ejecuta una vez sin argumentos.

7. ¿Cuándo conviene un bucle while read -d '' en lugar de -exec … +?

Cuando cada archivo requiere varios pasos, cuando hay que usar variables del script, contar resultados o informar errores por archivo. Para un comando simple sobre muchos archivos, -exec … + es más rápido.

8. ¿Cómo se usa una función del script con xargs -P?

Se exporta con export -f función y se llama a través de Bash: xargs -0 -n 1 -P 4 bash -c 'función "$1"' _.

9. ¿Qué protege mv -n en un renombrado masivo, y qué no?

Evita sobrescribir un archivo existente si dos archivos reciben el mismo destino. No resuelve el choque: el archivo que no se movió queda con su nombre viejo, y el script debe detectarlo o elegir otro nombre.

10. ¿Qué hace que renombrar-por-fecha sea idempotente?

Que antes de renombrar comprueba si el nombre actual ya corresponde a la fecha del archivo, con o sin sufijo. Si es así, lo deja como está, y una segunda ejecución no cambia nada.

Para practicar

  1. En un directorio de prueba, crea archivos con espacios, con un guion inicial y, si tu sistema lo permite, con un salto de línea (touch $'a\nb.txt'). Procésalos con for, con find | xargs y con find -print0 | xargs -0, y compara.
  2. Escribe un find que liste los archivos de más de 100 MiB de tu directorio personal, sin entrar en .cache.
  3. Comprime en paralelo, con xargs -P, los .log de más de una semana de un directorio de prueba, conservando los originales con gzip -k. Mide el tiempo con y sin -P.
  4. Escribe un bucle que reemplace los espacios por guiones en todos los nombres de un directorio, con mv -n, y prueba qué pasa si dos nombres terminan iguales.
  5. Copia algunas fotos a un directorio de prueba y usa renombrar-por-fecha -n y luego sin -n. Comprueba que una segunda ejecución no cambia nada.
  6. Si tienes exiftool, modifica el script para que use la fecha EXIF cuando existe, y la de modificación cuando no.