shutil handles file operations that os doesn't—copying, moving, and archiving. Here's how to use it.

Copying Files

import shutil
 
# Copy file (preserves permissions)
shutil.copy("source.txt", "dest.txt")
shutil.copy("source.txt", "dest_dir/")  # Into directory
 
# Copy with metadata (timestamps, permissions)
shutil.copy2("source.txt", "dest.txt")
 
# Copy file object
with open("source.txt", "rb") as src:
    with open("dest.txt", "wb") as dst:
        shutil.copyfileobj(src, dst)
 
# Copy just the content (no metadata)
shutil.copyfile("source.txt", "dest.txt")

Copying Directories

import shutil
 
# Copy entire directory tree
shutil.copytree("src_dir", "dst_dir")
 
# With ignore patterns
shutil.copytree(
    "src_dir",
    "dst_dir",
    ignore=shutil.ignore_patterns("*.pyc", "__pycache__")
)
 
# Custom ignore function
def ignore_hidden(dir, files):
    return [f for f in files if f.startswith(".")]
 
shutil.copytree("src_dir", "dst_dir", ignore=ignore_hidden)
 
# Copy into existing directory (Python 3.8+)
shutil.copytree("src_dir", "dst_dir", dirs_exist_ok=True)

Moving Files and Directories

import shutil
 
# Move file or directory
shutil.move("old_path", "new_path")
 
# Move into directory
shutil.move("file.txt", "dest_dir/")
 
# Works across filesystems (copy then delete)
shutil.move("/mnt/usb/file.txt", "/home/user/file.txt")

Removing Directories

import shutil
 
# Remove directory tree (careful!)
shutil.rmtree("directory")
 
# With error handling
def on_error(func, path, exc_info):
    print(f"Error removing {path}: {exc_info[1]}")
 
shutil.rmtree("directory", onerror=on_error)
 
# Ignore errors
shutil.rmtree("directory", ignore_errors=True)
 
# Python 3.12+: onexc instead of onerror
# shutil.rmtree("directory", onexc=handler)

Disk Usage

import shutil
 
# Get disk usage statistics
usage = shutil.disk_usage("/")
print(f"Total: {usage.total / 1e9:.1f} GB")
print(f"Used: {usage.used / 1e9:.1f} GB")
print(f"Free: {usage.free / 1e9:.1f} GB")
 
# As percentage
percent_used = usage.used / usage.total * 100
print(f"Used: {percent_used:.1f}%")

Finding Executables

import shutil
 
# Find executable in PATH
python_path = shutil.which("python")
print(python_path)  # /usr/bin/python
 
# Returns None if not found
missing = shutil.which("nonexistent")
print(missing)  # None
 
# Custom PATH
shutil.which("python", path="/custom/bin:/usr/bin")

Archives

import shutil
 
# Create archive
shutil.make_archive(
    "backup",           # Archive name (without extension)
    "zip",              # Format: zip, tar, gztar, bztar, xztar
    "source_dir"        # Directory to archive
)
# Creates backup.zip
 
# With base directory
shutil.make_archive(
    "backup",
    "gztar",            # Creates .tar.gz
    root_dir="/home",
    base_dir="user/documents"
)
 
# Extract archive
shutil.unpack_archive("backup.zip", "extract_dir")
shutil.unpack_archive("backup.tar.gz", "extract_dir")
 
# List supported formats
print(shutil.get_archive_formats())
# [('bztar', "bzip2'ed tar-file"), ('gztar', "gzip'ed tar-file"), ...]
 
print(shutil.get_unpack_formats())

Terminal Size

import shutil
 
# Get terminal dimensions
size = shutil.get_terminal_size()
print(f"Terminal: {size.columns}x{size.lines}")
 
# With fallback
size = shutil.get_terminal_size(fallback=(80, 24))

Copying Metadata

import shutil
import os
 
# Copy permissions
shutil.copymode("source.txt", "dest.txt")
 
# Copy all metadata (permissions, timestamps, flags)
shutil.copystat("source.txt", "dest.txt")
 
# Copy owner and group (Unix, requires privileges)
# shutil.chown("file.txt", user="newowner", group="newgroup")

Safe File Operations

import shutil
import tempfile
from pathlib import Path
 
def safe_copy(src: Path, dst: Path) -> None:
    """Copy file safely using temp file."""
    dst = Path(dst)
    with tempfile.NamedTemporaryFile(
        dir=dst.parent,
        delete=False
    ) as tmp:
        tmp_path = Path(tmp.name)
    
    try:
        shutil.copy2(src, tmp_path)
        tmp_path.replace(dst)  # Atomic on POSIX
    except Exception:
        tmp_path.unlink(missing_ok=True)
        raise
 
def safe_move(src: Path, dst: Path) -> None:
    """Move file with verification."""
    src, dst = Path(src), Path(dst)
    src_size = src.stat().st_size
    
    shutil.copy2(src, dst)
    
    if dst.stat().st_size == src_size:
        src.unlink()
    else:
        dst.unlink()
        raise IOError("Copy verification failed")

Backup Patterns

import shutil
from datetime import datetime
from pathlib import Path
 
def backup_file(path: Path) -> Path:
    """Create timestamped backup."""
    path = Path(path)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup = path.with_name(f"{path.stem}_{timestamp}{path.suffix}")
    shutil.copy2(path, backup)
    return backup
 
def rotate_backups(pattern: str, keep: int = 5) -> None:
    """Keep only N most recent backups."""
    backups = sorted(Path(".").glob(pattern), reverse=True)
    for old_backup in backups[keep:]:
        old_backup.unlink()

Directory Sync

import shutil
from pathlib import Path
 
def sync_directories(src: Path, dst: Path) -> None:
    """Sync source to destination."""
    src, dst = Path(src), Path(dst)
    
    # Copy new and updated files
    for src_file in src.rglob("*"):
        if src_file.is_file():
            rel_path = src_file.relative_to(src)
            dst_file = dst / rel_path
            
            if not dst_file.exists():
                dst_file.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(src_file, dst_file)
            elif src_file.stat().st_mtime > dst_file.stat().st_mtime:
                shutil.copy2(src_file, dst_file)

Common Patterns

import shutil
from pathlib import Path
 
# Copy if newer
def copy_if_newer(src: Path, dst: Path) -> bool:
    src, dst = Path(src), Path(dst)
    if not dst.exists() or src.stat().st_mtime > dst.stat().st_mtime:
        shutil.copy2(src, dst)
        return True
    return False
 
# Ensure directory exists and copy into it
def copy_to_dir(src: Path, dst_dir: Path) -> Path:
    dst_dir = Path(dst_dir)
    dst_dir.mkdir(parents=True, exist_ok=True)
    dst = dst_dir / Path(src).name
    shutil.copy2(src, dst)
    return dst
 
# Check space before copy
def safe_copy_check_space(src: Path, dst: Path) -> None:
    src, dst = Path(src), Path(dst)
    needed = src.stat().st_size
    available = shutil.disk_usage(dst.parent).free
    
    if needed > available:
        raise IOError(f"Need {needed} bytes, only {available} available")
    
    shutil.copy2(src, dst)

shutil is your go-to for file operations beyond basic open/read/write. Use it for copying, moving, archiving, and cleaning up directory trees.

React to this post: