When you need to copy a directory, move files, or create an archive in Python, the temptation is to reach for subprocess.run(["cp", "-r", ...]) or os.system("mv ..."). That approach is fragile, non-portable, and unnecessary: shutil (shell utilities) has been in the Python standard library since version 2.3 and handles all of this cleanly. The Python shutil library is the go-to tool for high-level filesystem operations.

Why shutil instead of os or subprocess

os exposes low-level system calls: rename, link, create directories. It does not copy file contents. os.rename() fails when the source and destination are on different filesystems (separate partitions, mounted Docker volumes, etc.). subprocess with cp or mv does not work on Windows.

shutil operates at a higher level: it handles reading and writing file contents, permissions, metadata, and edge cases like cross-filesystem moves. The code stays portable across Linux, macOS, and Windows without any changes.

copy() : copy a file

import shutil

shutil.copy("report.pdf", "backup/report.pdf")

copy() copies the file contents and Unix permissions. It does not copy metadata (creation/modification timestamps). To also copy metadata, use copy2():

shutil.copy2("report.pdf", "backup/report.pdf")

The destination can be an existing directory: the file will be created there with the same name.

shutil.copy2("report.pdf", "backup/")  # → backup/report.pdf

move() : move a file or directory

shutil.move("old_logs/", "archive/logs_2025/")

move() uses os.rename() when the source and destination are on the same filesystem (atomic and instant). Otherwise, it copies then deletes the source. This is what makes it reliable where os.rename() raises an OSError.

# Moving a file across volumes: no problem
shutil.move("/tmp/export.csv", "/mnt/nas/exports/export.csv")

Be aware of the behavior when the destination is an existing directory: move() places the source inside that directory, it does not replace it.

# If archive/logs_2025/ already exists:
shutil.move("old_logs/", "archive/logs_2025/")
# Result: archive/logs_2025/old_logs/  ← placed inside

# If archive/logs_2025/ does not exist:
shutil.move("old_logs/", "archive/logs_2025/")
# Result: archive/logs_2025/  ← renamed

copytree() : copy an entire directory tree

shutil.copytree("src/", "dist/")

copytree() recursively recreates the entire directory structure. Since Python 3.8, the destination directory can already exist by passing dirs_exist_ok=True:

shutil.copytree("config/", "deploy/config/", dirs_exist_ok=True)

You can filter which files to copy using the ignore parameter:

shutil.copytree(
    "project/",
    "deploy/project/",
    ignore=shutil.ignore_patterns("*.pyc", "__pycache__", ".git"),
)

shutil.ignore_patterns() returns a filtering function that copytree() will call at each level of the directory tree.

rmtree() : remove a directory and its contents

shutil.rmtree("dist/")

os.rmdir() only removes empty directories. shutil.rmtree() recursively removes all contents. It is the equivalent of rm -rf, so use it with the same level of caution.

To continue on permission errors rather than raising an exception:

import os
import stat

def force_remove(func, path, exc_info):
    os.chmod(path, stat.S_IWRITE)
    func(path)

shutil.rmtree("dist/", onerror=force_remove)

Since Python 3.12, onerror is deprecated in favor of onexc, whose callback receives the exception directly rather than exc_info:

def force_remove_312(func, path, exc):
    os.chmod(path, stat.S_IWRITE)
    func(path)

shutil.rmtree("dist/", onexc=force_remove_312)

This pattern is useful on Windows, where read-only files block deletion.

make_archive() : create an archive

shutil.make_archive("backup_2025", "zip", root_dir="exports/")

Arguments: archive name (without extension), format, source directory. Natively supported formats: zip, tar, gztar (.tar.gz), bztar (.tar.bz2), xztar (.tar.xz).

# Creates backup_2025.tar.gz containing everything in exports/
shutil.make_archive("backup_2025", "gztar", root_dir="exports/")

To list the formats available on the current system:

print(shutil.get_archive_formats())
# [('bztar', ...), ('gztar', ...), ('tar', ...), ('xztar', ...), ('zip', ...)]

Concrete example: deployment script

A script that builds a deployment artifact from a Python project:

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"Archive created: {archive}")


if __name__ == "__main__":
    build()

This script is readable, portable, and has no external dependencies.

Summary

FunctionUseShell equivalent
copy(src, dst)Copy a file (contents + permissions)cp
copy2(src, dst)Copy a file (contents + metadata)cp -p
move(src, dst)Move a file or directorymv
copytree(src, dst)Copy an entire directory treecp -r
rmtree(path)Remove a directory and its contentsrm -rf
make_archive(name, fmt, root)Create an archivezip -r / tar czf

shutil covers the vast majority of file manipulation needs in a Python script. Before reaching for subprocess to run a shell command, check whether shutil already has the function you need.