Cuando se necesita copiar un directorio, mover ficheros o crear un archivo comprimido en Python, la tentación es recurrir a subprocess.run(["cp", "-r", ...]) o os.system("mv ..."). Este enfoque es frágil, no portable e innecesario: shutil (shell utilities) está en la biblioteca estándar de Python desde la versión 2.3 y gestiona todo esto de forma limpia. La librería Python shutil es la herramienta de referencia para operaciones de alto nivel sobre el sistema de archivos.
Por qué usar shutil en lugar de os o subprocess
os expone llamadas al sistema de bajo nivel: renombrar, enlazar, crear directorios. No copia el contenido de ficheros. os.rename() falla cuando el origen y el destino están en sistemas de archivos diferentes (particiones separadas, volúmenes Docker montados, etc.). subprocess con cp o mv no funciona en Windows.
shutil opera a un nivel superior: gestiona la lectura y escritura del contenido de ficheros, los permisos, los metadatos y los casos extremos como los movimientos entre sistemas de archivos. El código permanece portable entre Linux, macOS y Windows sin ningún cambio.
copy() : copiar un fichero
import shutil
shutil.copy("informe.pdf", "backup/informe.pdf")
copy() copia el contenido del fichero y los permisos Unix. No copia los metadatos (fechas de creación/modificación). Para copiar también los metadatos, usar copy2():
shutil.copy2("informe.pdf", "backup/informe.pdf")
El destino puede ser un directorio existente: el fichero se creará allí con el mismo nombre.
shutil.copy2("informe.pdf", "backup/") # → backup/informe.pdf
move() : mover un fichero o directorio
shutil.move("logs_antiguos/", "archivo/logs_2025/")
move() usa os.rename() cuando el origen y el destino están en el mismo sistema de archivos (operación atómica e instantánea). Si no es así, copia y luego elimina el origen. Este comportamiento es lo que lo hace fiable donde os.rename() lanza un OSError.
# Mover un fichero entre volúmenes: sin problema
shutil.move("/tmp/export.csv", "/mnt/nas/exports/export.csv")
Hay que tener en cuenta el comportamiento cuando el destino es un directorio existente: move() coloca el origen dentro de ese directorio, no lo reemplaza.
# Si archivo/logs_2025/ ya existe:
shutil.move("logs_antiguos/", "archivo/logs_2025/")
# Resultado: archivo/logs_2025/logs_antiguos/ ← colocado dentro
# Si archivo/logs_2025/ no existe:
shutil.move("logs_antiguos/", "archivo/logs_2025/")
# Resultado: archivo/logs_2025/ ← renombrado
copytree() : copiar un árbol de directorios completo
shutil.copytree("src/", "dist/")
copytree() recrea recursivamente toda la estructura de directorios. Desde Python 3.8, el directorio de destino puede existir pasando dirs_exist_ok=True:
shutil.copytree("config/", "deploy/config/", dirs_exist_ok=True)
Se puede filtrar qué ficheros copiar con el parámetro ignore:
shutil.copytree(
"proyecto/",
"deploy/proyecto/",
ignore=shutil.ignore_patterns("*.pyc", "__pycache__", ".git"),
)
shutil.ignore_patterns() devuelve una función de filtrado que copytree() llamará en cada nivel del árbol de directorios.
rmtree() : eliminar un directorio y su contenido
shutil.rmtree("dist/")
os.rmdir() solo elimina directorios vacíos. shutil.rmtree() elimina recursivamente todo el contenido. Es el equivalente de rm -rf, por lo que hay que usarlo con el mismo nivel de precaución.
Para continuar en caso de error de permisos en lugar de lanzar una excepción:
import os
import stat
def forzar_eliminacion(func, path, exc_info):
os.chmod(path, stat.S_IWRITE)
func(path)
shutil.rmtree("dist/", onerror=forzar_eliminacion)
Desde Python 3.12, onerror está deprecado en favor de onexc, cuyo callback recibe la excepción directamente en lugar de exc_info:
def forzar_eliminacion_312(func, path, exc):
os.chmod(path, stat.S_IWRITE)
func(path)
shutil.rmtree("dist/", onexc=forzar_eliminacion_312)
Este patrón es útil en Windows, donde los ficheros de solo lectura bloquean la eliminación.
make_archive() : crear un archivo comprimido
shutil.make_archive("backup_2025", "zip", root_dir="exports/")
Argumentos: nombre del archivo (sin extensión), formato, directorio fuente. Formatos soportados nativamente: zip, tar, gztar (.tar.gz), bztar (.tar.bz2), xztar (.tar.xz).
# Crea backup_2025.tar.gz con todo el contenido de exports/
shutil.make_archive("backup_2025", "gztar", root_dir="exports/")
Para conocer los formatos disponibles en el sistema:
print(shutil.get_archive_formats())
# [('bztar', ...), ('gztar', ...), ('tar', ...), ('xztar', ...), ('zip', ...)]
Ejemplo concreto: script de despliegue
Un script que construye un artefacto de despliegue a partir de un proyecto Python:
import shutil
from pathlib import Path
BUILD_DIR = Path("dist")
SOURCE_DIR = Path("src")
CONFIG_DIR = Path("config/production")
def build():
if BUILD_DIR.exists():
shutil.rmtree(BUILD_DIR)
shutil.copytree(
SOURCE_DIR,
BUILD_DIR / "app",
ignore=shutil.ignore_patterns("*.pyc", "__pycache__", "tests"),
)
shutil.copytree(CONFIG_DIR, BUILD_DIR / "config", dirs_exist_ok=True)
archive = shutil.make_archive(
base_name=str(BUILD_DIR / "release"),
format="gztar",
root_dir=BUILD_DIR,
)
print(f"Archivo creado: {archive}")
if __name__ == "__main__":
build()
Este script es legible, portable y no depende de ninguna dependencia externa.
Resumen
| Función | Uso | Equivalente shell |
|---|---|---|
copy(src, dst) | Copia un fichero (contenido + permisos) | cp |
copy2(src, dst) | Copia un fichero (contenido + metadatos) | cp -p |
move(src, dst) | Mueve un fichero o directorio | mv |
copytree(src, dst) | Copia un árbol de directorios completo | cp -r |
rmtree(path) | Elimina un directorio y su contenido | rm -rf |
make_archive(name, fmt, root) | Crea un archivo comprimido | zip -r / tar czf |
shutil cubre la gran mayoría de las necesidades de manipulación de ficheros en un script Python. Antes de llamar a subprocess para ejecutar un comando shell, comprobar si shutil ya tiene la función necesaria.
