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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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
List all Python tutorials.