Home› Tutorials› Standard Library
Standard LibraryPython's shutil Module: Copying, Moving, and Removing Files and Trees
- 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.