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ónUsoEquivalente 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 directoriomv
copytree(src, dst)Copia un árbol de directorios completocp -r
rmtree(path)Elimina un directorio y su contenidorm -rf
make_archive(name, fmt, root)Crea un archivo comprimidozip -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.