ZetCode

Python shutil

last modified May 28, 2026

In this article, we show how to use the shutil module in Python. The module provides a collection of high-level file operations that go beyond what the built-in os module offers. With functions for copying, moving, archiving, and deleting files and directories, shutil is well suited for automating routine file-management tasks in scripts and applications.

It abstracts away many low-level details, allowing developers to work with entire directory trees, preserve metadata, create compressed archives, or remove complex directory structures with just a few function calls. This makes it a practical tool for backup utilities, deployment scripts, data-processing pipelines, and any workflow that manipulates the filesystem extensively.

Copying Files

The shutil.copy function copies the content and permissions of a source file to a destination. If the destination is a directory, the file is copied into that directory with the same name. If it is a file path, the content is written to that path. Unlike shutil.copyfile, it also copies the file's permission bits.

main.py
import shutil
import os

# Create a source file
with open('source.txt', 'w') as f:
    f.write('Hello from source file.\n')

# Copy to a new file
shutil.copy('source.txt', 'destination.txt')
print("File copied successfully.")

# Copy into a directory
os.makedirs('backup', exist_ok=True)
shutil.copy('source.txt', 'backup/')
print(f"File also copied to: backup/source.txt")

The shutil.copy function copies both the file data and permission bits. The last-modified and last-accessed timestamps are not preserved; use shutil.copy2 when timestamps matter. The return value is the path of the newly created file.

$ python main.py
File copied successfully.
File also copied to: backup/source.txt

Copying Files with Metadata

The shutil.copy2 function is identical to shutil.copy except that it also preserves the file's metadata — including the last-accessed and last-modified timestamps. This makes it the preferred choice when an exact replica of a file is needed.

main.py
import shutil
import os

with open('report.txt', 'w') as f:
    f.write('Annual report data.\n')

# Copy with full metadata preservation
shutil.copy2('report.txt', 'report_backup.txt')

src_stat = os.stat('report.txt')
dst_stat = os.stat('report_backup.txt')

print(f"Source mtime     : {src_stat.st_mtime}")
print(f"Destination mtime: {dst_stat.st_mtime}")
print("Timestamps match:", src_stat.st_mtime == dst_stat.st_mtime)

Both the source and destination show identical modification times because shutil.copy2 calls os.utime internally to restore the original timestamps after writing. This behaviour is especially useful in backup scripts where file provenance must be maintained.

$ python main.py
Source mtime     : 1748390400.0
Destination mtime: 1748390400.0
Timestamps match: True

Copying Directories

The shutil.copytree function recursively copies an entire directory tree from a source to a destination. The destination directory must not already exist unless the dirs_exist_ok parameter is set to True. The function returns the path of the newly created directory.

main.py
import shutil
import os

# Build a sample source tree
os.makedirs('project/src', exist_ok=True)
os.makedirs('project/tests', exist_ok=True)
with open('project/src/app.py', 'w') as f:
    f.write('print("app")\n')
with open('project/tests/test_app.py', 'w') as f:
    f.write('# tests\n')

# Copy the entire directory tree
dest = shutil.copytree('project', 'project_backup')
print(f"Directory tree copied to: {dest}")

# Merge into an existing directory (Python 3.8+)
os.makedirs('project_merged', exist_ok=True)
shutil.copytree('project', 'project_merged', dirs_exist_ok=True)
print("Merged into existing directory successfully.")

When dirs_exist_ok=True is passed, files are merged into the destination tree instead of raising an error. The ignore parameter accepts a callable (such as shutil.ignore_patterns) to exclude specific files or patterns from the copy.

$ python main.py
Directory tree copied to: project_backup
Merged into existing directory successfully.

Moving Files and Directories

The shutil.move function moves a file or directory to a new location. When moving within the same filesystem, it first attempts an efficient rename operation. Across filesystems it falls back to copying followed by deletion of the original. If the destination is an existing directory, the source is moved inside it.

main.py
import shutil
import os

# Prepare files and directories
with open('notes.txt', 'w') as f:
    f.write('Important notes.\n')
os.makedirs('archive', exist_ok=True)
os.makedirs('old_data', exist_ok=True)

# Move a file into a directory
shutil.move('notes.txt', 'archive/')
print("File moved to archive/notes.txt")

# Rename a file by specifying a full destination path
with open('draft.txt', 'w') as f:
    f.write('Draft content.\n')
shutil.move('draft.txt', 'final.txt')
print("File renamed to final.txt")

# Move an entire directory
shutil.move('old_data', 'archive/old_data')
print("Directory moved to archive/old_data")

shutil.move accepts both string paths and pathlib.Path objects. When the destination already exists as a file, it is overwritten on most platforms. The function returns the path of the moved file or directory.

$ python main.py
File moved to archive/notes.txt
File renamed to final.txt
Directory moved to archive/old_data

Deleting Directories

The shutil.rmtree function deletes an entire directory tree, including all files and subdirectories. This is more convenient than calling os.rmdir repeatedly, which only removes empty directories. Use it with care because the deletion is immediate and not reversible.

main.py
import shutil
import os

# Create a directory tree to remove
os.makedirs('temp/cache/images', exist_ok=True)
with open('temp/cache/data.bin', 'wb') as f:
    f.write(b'\x00' * 1024)

print("Before removal:", os.path.exists('temp'))

shutil.rmtree('temp')

print("After removal :", os.path.exists('temp'))

# Safe removal: only delete if the path exists
target = 'temp'
if os.path.exists(target):
    shutil.rmtree(target)
    print(f"Removed {target}")
else:
    print(f"{target} does not exist, nothing to remove.")

For situations where read-only files inside the tree would cause an error, the optional onerror callback (or onexc in Python 3.12+) can be used to handle individual failures — for example, to clear the read-only flag and retry.

$ python main.py
Before removal: True
After removal : False
temp does not exist, nothing to remove.

Archiving Files and Directories

The shutil.make_archive function creates a compressed archive from a directory. It supports several formats: zip, tar, gztar (tar.gz), bztar (tar.bz2), and xztar (tar.xz). The first argument is the base name of the archive file (without extension), and the second is the format.

main.py
import shutil
import os

# Prepare a directory to archive
os.makedirs('release/docs', exist_ok=True)
with open('release/readme.txt', 'w') as f:
    f.write('Release notes.\n')
with open('release/docs/api.md', 'w') as f:
    f.write('# API docs\n')

# Create a ZIP archive
zip_path = shutil.make_archive('release_v1', 'zip', root_dir='.', base_dir='release')
print(f"ZIP archive created: {zip_path}")

# Create a compressed tar.gz archive
tar_path = shutil.make_archive('release_v1', 'gztar', root_dir='.', base_dir='release')
print(f"TAR.GZ archive created: {tar_path}")

# List supported formats
formats = [fmt for fmt, _ in shutil.get_archive_formats()]
print(f"Supported formats: {formats}")

The root_dir parameter sets the working directory for the archive operation, while base_dir is the directory that will be archived relative to root_dir. Using both together prevents absolute paths from being embedded in the archive. shutil.get_archive_formats returns all formats available on the current system.

$ python main.py
ZIP archive created: /home/user/project/release_v1.zip
TAR.GZ archive created: /home/user/project/release_v1.tar.gz
Supported formats: ['bztar', 'gztar', 'tar', 'xztar', 'zip']

Unpacking Archives

The shutil.unpack_archive function extracts an archive to a specified directory. It automatically detects the archive format from the file extension, so no format argument is needed in most cases. Supported formats mirror those of shutil.make_archive.

main.py
import shutil
import os

# Create a sample archive first
os.makedirs('payload/data', exist_ok=True)
with open('payload/data/config.json', 'w') as f:
    f.write('{"version": "1.0"}\n')
shutil.make_archive('payload_bundle', 'zip', root_dir='.', base_dir='payload')

# Unpack the archive into a new directory
shutil.unpack_archive('payload_bundle.zip', 'extracted')
print("Archive unpacked to: extracted/")

# Verify extracted contents
for root, dirs, files in os.walk('extracted'):
    for name in files:
        rel = os.path.relpath(os.path.join(root, name), 'extracted')
        print(f"  {rel}")

When the destination directory does not exist, shutil.unpack_archive creates it automatically. An explicit format argument can be supplied when the filename extension is absent or misleading. Use shutil.get_unpack_formats to list all formats that can be unpacked on the current system.

$ python main.py
Archive unpacked to: extracted/
  payload/data/config.json

Getting Disk Usage

The shutil.disk_usage function returns the total, used, and free disk space for the filesystem that contains the given path. The values are reported in bytes and can be formatted for human-readable output.

main.py
import shutil

def human_readable(size_bytes):
    """Convert bytes to a human-readable string."""
    for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
        if size_bytes < 1024:
            return f"{size_bytes:.2f} {unit}"
        size_bytes /= 1024
    return f"{size_bytes:.2f} PB"

usage = shutil.disk_usage('/')

print(f"Total : {human_readable(usage.total)}")
print(f"Used  : {human_readable(usage.used)}")
print(f"Free  : {human_readable(usage.free)}")
print(f"Usage : {usage.used / usage.total * 100:.1f}%")

shutil.disk_usage returns a named tuple with three fields: total, used, and free. All values are in bytes. This function is useful in backup or deployment scripts that need to verify sufficient space is available before writing large files.

$ python main.py
Total : 465.76 GB
Used  : 112.34 GB
Free  : 353.42 GB
Usage : 24.1%

Copying File Permissions

The shutil.copystat function copies permission bits, last-accessed time, last-modified time, and flags from one file to another without copying the file content. This is useful when you have already written new content to a file and simply want to apply the metadata of an existing file to it.

main.py
import shutil
import os
import stat
import time

# Create source file with specific permissions
with open('template.sh', 'w') as f:
    f.write('#!/bin/bash\necho "template"\n')
os.chmod('template.sh', stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP)

# Create a destination file with different content but same metadata
with open('deploy.sh', 'w') as f:
    f.write('#!/bin/bash\necho "deploy"\n')

print("Before copystat:")
print(f"  template.sh mode: {oct(os.stat('template.sh').st_mode)}")
print(f"  deploy.sh mode  : {oct(os.stat('deploy.sh').st_mode)}")

# Copy only the metadata (permissions + timestamps)
shutil.copystat('template.sh', 'deploy.sh')

print("After copystat:")
print(f"  template.sh mode: {oct(os.stat('template.sh').st_mode)}")
print(f"  deploy.sh mode  : {oct(os.stat('deploy.sh').st_mode)}")

shutil.copystat copies the permission bits and timestamps but does not change the file's ownership. It also works on symbolic links when the underlying platform supports os.lchmod. This function is called internally by shutil.copy2 and shutil.copytree.

$ python main.py
Before copystat:
  template.sh mode: 0o100750
  deploy.sh mode  : 0o100644
After copystat:
  template.sh mode: 0o100750
  deploy.sh mode  : 0o100750

Finding Executables

The shutil.which function searches the system's PATH for a named executable and returns its full path, or None if it cannot be found. It is the Python equivalent of the Unix which command and works on Windows as well by also checking the PATHEXT environment variable.

main.py
import shutil

tools = ['python3', 'git', 'curl', 'docker', 'nonexistent_tool']

for tool in tools:
    path = shutil.which(tool)
    if path:
        print(f"{tool:20s} -> {path}")
    else:
        print(f"{tool:20s} -> not found")

# Guard before invoking an external command
ffmpeg = shutil.which('ffmpeg')
if ffmpeg is None:
    print("\nffmpeg is not installed; skipping video processing.")
else:
    print(f"\nffmpeg found at {ffmpeg}, ready to use.")

shutil.which accepts optional mode and path arguments. The mode parameter (default os.F_OK | os.X_OK) controls what access check is performed. Passing a custom path string overrides the system's PATH variable for the search. This function is particularly valuable for writing portable scripts that depend on external tools.

$ python main.py
python3              -> /usr/bin/python3
git                  -> /usr/bin/git
curl                 -> /usr/bin/curl
docker               -> /usr/bin/docker
nonexistent_tool     -> not found

ffmpeg is not installed; skipping video processing.

Source

Python shutil - Documentation

In this article, we have shown how to use the shutil module in Python for high-level file operations. We covered copying files and directories, preserving metadata, moving and deleting content, creating and unpacking archives, checking disk usage, managing file permissions, and locating executables on the system path.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Python tutorials.