Home Tutorials Standard Library

Standard Library

Python's shutil Module: Copying, Moving, and Removing Files and Trees

Pyford Notes July 6, 2026 7 min read
Key points
  • pathlib and open() handle single files well but have no built-in way to copy or delete an entire directory tree -- that's shutil's job.
  • copy2() preserves metadata like modification time alongside the file's contents; plain copy() only preserves permission bits.
  • shutil.move() tries a fast rename first and transparently falls back to copy-then-delete when source and destination are on different filesystems.
  • os.remove() only deletes a single file and rmdir() only deletes an empty directory; rmtree() is the one that deletes a populated directory tree.

Why shutil exists alongside pathlib

pathlib.Path is excellent for representing paths and reading or writing individual files, but it deliberately stays low-level about whole-tree operations. Copying a directory with everything inside it, deleting a non-empty directory, or moving a file across filesystem boundaries all involve enough platform-specific edge cases that Python centralises them in shutil instead of scattering the logic across pathlib's API.

copy(), copy2(), and copytree()

shutil.copy(src, dst) copies a file's content and permission bits. shutil.copy2(src, dst) does the same but also preserves metadata — modification and access times — which matters for build tools and backups that rely on timestamps to decide what's changed:

import shutil

shutil.copy("report.csv", "backup/report.csv")            # content + permissions
shutil.copy2("report.csv", "backup/report_dated.csv")      # + preserves mtime/atime

# Copying an entire directory tree, recursively
shutil.copytree("project/", "project_backup/")

# Skip certain files while copying a tree
shutil.copytree(
    "project/", "project_clean_backup/",
    ignore=shutil.ignore_patterns("*.pyc", "__pycache__", "*.log"),
)

copytree() requires the destination not to already exist by default (it will raise FileExistsError otherwise), which is a safety rail against silently merging into or overwriting an existing directory; pass dirs_exist_ok=True explicitly when merging into an existing tree is actually what you want.

move(): renames when possible, copies when necessary

shutil.move(src, dst) tries the fast path first — a plain filesystem rename, which is close to instantaneous regardless of file size — and only falls back to a full copy-then-delete when that's not possible, which happens whenever the source and destination live on different filesystems or drives:

import shutil

shutil.move("draft.txt", "archive/draft.txt")     # same filesystem: instant rename
shutil.move("/tmp/upload.zip", "/mnt/nfs/data/")    # different filesystem: copies, then deletes source

This fallback is exactly why os.rename() alone isn't a safe general-purpose "move a file" function — it raises OSError outright when source and destination are on different filesystems, whereas shutil.move() handles that case transparently for you.

rmtree(): deleting non-empty directories

The standard library deliberately makes deleting a populated directory require an explicit, separate function rather than a flag on a more general delete call — a small speed bump against accidental data loss. os.remove() deletes a single file, os.rmdir() only deletes a directory that's already empty, and shutil.rmtree() is the one that recursively deletes a directory along with everything inside it:

import shutil, os

os.remove("old_file.txt")          # single file only
# os.rmdir("some_dir")             # raises OSError if the directory isn't empty
shutil.rmtree("build/")              # recursively deletes build/ and everything in it

# Guard against deleting something unexpected
target = "build/"
if os.path.isdir(target) and target not in ("/", ""):
    shutil.rmtree(target, ignore_errors=False)

Because rmtree() is irreversible and recursive, validating the target path before calling it — especially if any part of the path comes from user input or configuration — is worth the extra line every time.

Disk usage and archive creation

shutil.disk_usage(path) reports total, used, and free bytes for the filesystem containing path, useful for a pre-flight check before writing a large file. shutil.make_archive() and shutil.unpack_archive() wrap zip and tar creation without needing the lower-level zipfile or tarfile APIs directly, for the common case of "just zip this whole directory":

import shutil

total, used, free = shutil.disk_usage("/")
print(f"{free / 1e9:.1f} GB free")

shutil.make_archive("project_backup", "zip", "project/")   # creates project_backup.zip
shutil.unpack_archive("project_backup.zip", "restored/")

For anything needing finer control over archive contents — adding files individually, controlling compression per entry — the dedicated zipfile module is the better tool; shutil's archive helpers are meant for the "whole directory in, whole directory out" case.

One more corner of the module worth knowing: shutil.which(cmd) searches the directories in PATH the same way a shell does, and returns the full path to the first matching executable, or None if nothing matches. It's the right tool for checking that a required external command is actually installed before a script tries to shell out to it with subprocess:

import shutil

git_path = shutil.which("git")
if git_path is None:
    raise RuntimeError("git is not installed or not on PATH")
print(git_path)   # e.g. "/usr/bin/git"

Checking with which() up front produces a clear, specific error message instead of letting a missing dependency surface later as a cryptic FileNotFoundError from deep inside subprocess.run(). Full details on every function here are in the shutil documentation.