Quick answer: Use the os module for paths and directories and shutil for copying, moving, and archiving files. os.path joins and inspects paths portably, os.makedirs(..., exist_ok=True) creates folder trees safely, and shutil.copy2() preserves timestamps when copying. Reserve shutil.rmtree() for cases where you genuinely mean to delete a directory tree.
Part 6 of our Python series — Module 2, the standard library. Start from Module 1 if you are new here.
Why replace shell commands with Python
Shell scripts break the moment you change operating system, and they are nearly untestable. Python’s standard library gives you the same work — create folders, copy files, zip archives — with real error handling and no shell quoting hazards. This is the module where Python stops being a language exercise and starts saving you hours of manual file shuffling.
os and os.path: the portable foundation
Never build paths by gluing strings with / or \. Use os.path.join:
import os
base = os.path.join("reports", "2026")
csv_path = os.path.join(base, "sales.csv")
os.path.exists(csv_path) # does it exist?
os.path.basename(csv_path) # 'sales.csv'
os.path.dirname(csv_path) # 'reports/2026'
os.path.splitext(csv_path) # ('reports/2026/sales', '.csv')
Modern Python also ships pathlib, whose Path objects are cleaner still — but os.path is what you will meet in existing codebases everywhere.
Creating directories safely is a one-liner when you use exist_ok:
os.makedirs(base, exist_ok=True) # no crash if it already exists
os.getcwd() # current working directory
os.listdir(base) # names inside a folder
Without exist_ok=True, calling makedirs on an existing folder raises FileExistsError — the single most common first-week bug in automation scripts.
shutil: copy, move, and archive
import shutil
shutil.copy("sales.csv", "backup/") # copy file into a folder
shutil.copy2("sales.csv", "sales.bak") # copy AND keep mtime/metadata
shutil.move("old.csv", "archive/old.csv") # move or rename (works across disks)
shutil.copytree("reports", "reports_old") # whole directory tree
copy vs copy2: copy2 preserves modification times, which matters when a downstream tool sorts by date. Use copy2 by default in backup scripts.
Archiving without leaving Python:
shutil.make_archive("backup_2026", "zip", root_dir="reports")
# -> backup_2026.zip in the current folder
That single call replaces zip -r, tar czf, and their Windows equivalents — the format argument (zip, tar, gztar, bztar) decides.
Complete executable example
# organize.py — sort loose files into folders by extension
import os
import shutil
SOURCE = "inbox"
DEST = "sorted"
os.makedirs(SOURCE, exist_ok=True)
os.makedirs(DEST, exist_ok=True)
# seed some files so the script does something on a fresh machine
for name in ["report.csv", "photo.jpg", "notes.txt", "data.json", "IMG_2.jpg"]:
path = os.path.join(SOURCE, name)
if not os.path.exists(path):
with open(path, "w", encoding="utf-8") as fh:
fh.write("sample")
MOVED = 0
for name in os.listdir(SOURCE):
src = os.path.join(SOURCE, name)
if not os.path.isfile(src):
continue
ext = os.path.splitext(name)[1].lstrip(".").lower() or "misc"
target_dir = os.path.join(DEST, ext)
os.makedirs(target_dir, exist_ok=True)
shutil.move(src, os.path.join(target_dir, name))
MOVED += 1
print(f"{name:>14} -> {ext}/")
print(f"\nDone. {MOVED} files sorted into {DEST}/")
print("Folders created:", sorted(os.listdir(DEST)))
# archive the whole result in one call
archive = shutil.make_archive("sorted_archive", "zip", root_dir=DEST)
print("Archive:", archive, os.path.getsize(archive), "bytes")
Line by line: makedirs(..., exist_ok=True) makes the script safe to re-run; the seeding loop guarantees output even on an empty folder; isfile skips subdirectories; lstrip(".") turns .jpg into the folder name jpg; or "misc" handles files with no extension; shutil.move handles both same-disk rename and cross-disk copy; make_archive zips the result and returns the path it created.
Common mistakes and edge cases
FileNotFoundErroron move — the destination folder must exist;shutil.movewill not create it.makedirsfirst, always.- Overwriting silently —
shutil.copyreplaces an existing destination without asking. Checkos.path.existswhen that matters. rmtreeon the wrong path —shutil.rmtree("./build")deletes the whole tree with no undo. Never build that path from user input, and never combine it with an empty variable.- Hardcoded separators —
"reports/" + namebreaks on Windows. Useos.path.joinin every code path. - Assuming the working directory — a script run from Task Scheduler or cron starts wherever the scheduler decides. Anchor paths with
os.path.dirname(os.path.abspath(__file__))when you need the script’s own folder.
Key takeaways and challenge
os.path.joinfor paths,os.makedirs(..., exist_ok=True)for folders.shutil.copy2preserves metadata;shutil.movehandles cross-disk moves.shutil.make_archivereplaces your zip/tar command line.
Challenge: extend organize.py to skip files older than 30 days (compare os.path.getmtime against time.time() - 30 * 86400) and log every skip. Then wrap the whole loop in try/except OSError so one locked file does not abort the run.
Want one-to-one help getting ramped in Python? Ampersand Academy offers hands-on training.
What is the difference between shutil.copy and shutil.copy2?
shutil.copy copies file contents and permissions; shutil.copy2 also preserves metadata such as modification time. Use copy2 for backups because tools that sort by date rely on the preserved mtime.
Why does os.makedirs fail when the folder already exists?
makedirs raises FileExistsError by default. Pass exist_ok=True to make the call idempotent, which is essential in scripts that may run repeatedly.
How do I build file paths that work on Windows and Linux?
Use os.path.join instead of concatenating strings with slashes. It inserts the correct separator for the operating system and avoids the class of bugs where a Windows path breaks a Linux run.
Is shutil.rmtree safe to use?
It permanently deletes an entire directory tree with no undo. Only use it on paths you fully control, never on a path built from user input, and double-check that the variable is not empty before running it.
How do I create a zip archive in Python without calling the zip command?
Call shutil.make_archive(base_name, format, root_dir). The format argument accepts zip, tar, gztar or bztar, so one function replaces your platform-specific archiving command.

