Lesson 8 · Python Beginner Course
Automate Files & Folders with Python: pathlib, shutil, zipfile (Beginner Python Course, Part 8)
In this lesson you'll be able to: Find and describe files with pathlib: glob, rglob, stem, suffix and stat Read and write text, CSV and JSON files in a line…
- CoursePython Beginner Course
- Lesson8 of 10
- Video58 min
- FormatJupyter notebook · 88 code cells
What you'll learn
Data
No separate download needed — the notebook creates or downloads everything it uses.
📓 Full notebook
Download .ipynbAutomate Your Files and Folders#
In this lesson you'll be able to:
- Find and describe files with pathlib: glob, rglob, stem, suffix and stat
- Read and write text, CSV and JSON files in a line or two
- Copy, move, rename and delete files safely with shutil inside a sandbox
- Walk a whole folder tree with os.walk and see what takes up space
- Zip folders into timestamped backups and rehearse changes with a dry run
- Capstone: a Downloads folder organiser that sorts by type, renames duplicates and keeps a JSON log
Warm-Up#
In lesson 3 we used open(). In lesson 4 we met pathlib. Today we put both to work on real folders.
Same job, two ways: Path can write and read a whole file in one line.
from pathlib import Path
note = Path("warmup.txt")
note.write_text("Water the plants\nBook the gym\n")
print(note.read_text())
with open("warmup.txt") as f:
tasks = f.read().splitlines()
print(tasks)
note.unlink()
print("Still there after unlink()?", note.exists())
Paths: Every File Has an Address#
A path is like a postal address: the folders are the town and street, the file name is the front door.
A Path is not the file itself, just its address. It can describe a file that doesn't exist yet.
report = Path("lesson_files") / "work" / "sales report.final.csv"
print(report)
print("name: ", report.name)
print("stem: ", report.stem)
print("suffix:", report.suffix)
print("parent: ", report.parent)
print("parts: ", report.parts)
print("suffixes:", report.suffixes)
photo = Path("holiday/IMG_0042.JPG")
print(photo.with_suffix(".png"))
print(photo.with_name("beach.jpg"))
print(photo.with_stem("rome_001"))
print("Is it a jpg?", photo.suffix.lower() == ".jpg")
folder = "lesson_files"
as_text = folder + "/" + "notes.txt"
as_path = Path(folder) / "notes.txt"
print(type(as_text).__name__, as_text)
print(type(as_path).__name__, as_path)
print("Exists yet?", as_path.exists())
Common mistake: gluing a Path and a string with +#
print(Path("lesson_files") + "/notes.txt")
print(Path("lesson_files") / "notes.txt")
print(Path("lesson_files", "notes.txt"))
Build a Messy Downloads Folder#
Practise on a sandbox: a folder full of fake files, so nothing real can get hurt.
Everything in this lesson lives inside lesson_files/. Delete that one folder and you're back to a clean start.
sandbox = Path("lesson_files")
sandbox.mkdir(exist_ok=True)
downloads = sandbox / "Downloads"
downloads.mkdir(exist_ok=True)
print(downloads, "| exists:", downloads.exists(), "| folder:", downloads.is_dir())
Common mistake: making a folder that already exists#
downloads.mkdir()
downloads.mkdir(exist_ok=True)
print("No error: exist_ok=True means 'fine if it's already there'")
deep = sandbox / "projects" / "2026" / "march"
deep.mkdir(parents=True, exist_ok=True)
print("Created:", deep)
print("parents=True also made:", deep.parent, "and", deep.parent.parent)
FAKE_FILES = {
"IMG_001.jpg": 48, "IMG_002.jpg": 52, "beach day.PNG": 31,
"cat_meme.gif": 12, "screenshot 2026-03-01.png": 20,
"invoice_march.pdf": 9, "invoice_april.pdf": 8, "CV final.docx": 15,
"CV final (2).docx": 16, "notes.txt": 2, "recipe-lasagne.txt": 3,
"budget.xlsx": 7, "budget_2026.csv": 4, "podcast_ep12.mp3": 60,
"birthday_song.mp3": 35, "holiday_clip.mp4": 80, "photos_backup.zip": 40,
"fonts.zip": 25, "script.py": 1, "index.html": 2, "data.json": 1,
"setup_app.exe": 70, "game_installer.dmg": 65, "mystery_file": 1,
"playlist.m3u": 1,
}
print(len(FAKE_FILES), "fake files, about", sum(FAKE_FILES.values()), "KB in total")
Now 25 fake downloads: photos, invoices, music, installers and a couple of mystery files.
We use a fixed "today" so your dates match mine. Real scripts use datetime.now().
os.utime sets a file's modified date, so each file looks like it arrived on a different day.
import os
import shutil
from datetime import datetime, timedelta
FIXED_TODAY = datetime(2026, 3, 15, 9, 0)
def make_downloads(folder):
if folder.exists():
shutil.rmtree(folder) # start fresh (shutil comes later)
folder.mkdir(parents=True)
for number, (name, kb) in enumerate(FAKE_FILES.items()):
path = folder / name
path.write_text("x" * kb * 1024)
when = (FIXED_TODAY - timedelta(days=number * 3)).timestamp()
os.utime(path, (when, when)) # pretend it arrived on that day
return folder
make_downloads(downloads)
print("Files created:", len(list(downloads.iterdir())))
Look Around: iterdir, glob and rglob#
globis asking a librarian: "bring me every book whose title matches this pattern".
for path in sorted(downloads.iterdir())[:6]:
print(path.name)
print("...")
jpgs = sorted(downloads.glob("*.jpg"))
print([p.name for p in jpgs])
invoices = sorted(downloads.glob("invoice*"))
print([p.name for p in invoices])
Patterns to know: * means any characters, ? means exactly one character, [0-9] means one digit.
print("*.mp? ->", [p.name for p in sorted(downloads.glob("*.mp?"))])
print("*[0-9]* ->", [p.name for p in sorted(downloads.glob("*[0-9]*"))][:4])
print("glob('*.png'):", [p.name for p in sorted(downloads.glob("*.png"))])
image_types = {".jpg", ".png", ".gif"}
images = [p.name for p in sorted(downloads.iterdir()) if p.suffix.lower() in image_types]
print("by suffix.lower():", images)
Pro tip: patterns can be case sensitive, so
*.pngmay missbeach day.PNG. Comparesuffix.lower()to be safe.
(downloads / "old" / "2025").mkdir(parents=True, exist_ok=True)
(downloads / "old" / "2025" / "tax_return.pdf").write_text("tax")
print("glob *.pdf:", [p.name for p in sorted(downloads.glob("*.pdf"))])
print("rglob *.pdf:", [p.name for p in sorted(downloads.rglob("*.pdf"))])
for p in sorted(downloads.rglob("*.pdf")):
print(p.relative_to(downloads))
counts = {}
for p in sorted(downloads.iterdir()):
if p.is_file():
ext = p.suffix.lower() or "(none)"
counts[ext] = counts.get(ext, 0) + 1
for ext in sorted(counts, key=counts.get, reverse=True)[:5]:
print(f"{ext:<8}{counts[ext]}")
Quick check#
What's the difference between downloads.glob("*.pdf") and downloads.rglob("*.pdf")?
print("glob looks in one folder only.")
print("rglob also searches every subfolder (r = recursive).")
Mini-Project: A Pocket File Finder#
Type part of a name, get every matching file, however deep it's hidden. It ignores upper and lower case, and a blank answer ends the search.
def find_files(folder, text):
text = text.lower()
return [p for p in sorted(folder.rglob("*"))
if p.is_file() and text in p.name.lower()]
print([p.name for p in find_files(downloads, "cv")])
while True:
query = input("Search Downloads for (blank to stop): ").strip()
if not query:
break
found = find_files(downloads, query)
names = ", ".join(p.name for p in found) or "nothing"
print(f" {len(found)} match(es): {names}")
print("Search finished.")
File Facts: Sizes and Dates#
Every file carries a little luggage label: how big it is and when it last changed.
stat()reads it.
st_size is in bytes. st_mtime is the modified time as a timestamp: seconds since 1 January 1970.
clip = downloads / "holiday_clip.mp4"
info = clip.stat()
print("Size in bytes:", info.st_size)
print("Size in KB: ", info.st_size / 1024)
def human_size(num_bytes):
for unit in ["B", "KB", "MB", "GB"]:
if num_bytes < 1024:
return f"{num_bytes:.1f} {unit}"
num_bytes /= 1024
return f"{num_bytes:.1f} TB"
for n in [512, 2048, 81920, 5_000_000]:
print(f"{n:>9,} -> {human_size(n)}")
modified = datetime.fromtimestamp(clip.stat().st_mtime)
print("Last modified:", modified.strftime("%d %b %Y"))
print("Days before our fixed 'today':", (FIXED_TODAY - modified).days)
def size_of(path):
return path.stat().st_size
files = [p for p in sorted(downloads.iterdir()) if p.is_file()]
for p in sorted(files, key=size_of, reverse=True)[:5]:
print(f"{p.name:<22}{human_size(size_of(p)):>10}")
total = sum(size_of(p) for p in files)
print(f"{len(files)} files using {human_size(total)}")
print("Average file:", human_size(total / len(files)))
cutoff = FIXED_TODAY - timedelta(days=60)
old = [p for p in files if datetime.fromtimestamp(p.stat().st_mtime) < cutoff]
print(f"{len(old)} files older than {cutoff:%d %b %Y}:")
for p in old:
print(" ", p.name)
Quick check#
A file is 3,500,000 bytes. What does human_size print for it?
print(human_size(3_500_000))
Your turn#
- Print the five smallest files in Downloads.
- List every file modified in February 2026.
Reading and Writing Text, CSV and JSON#
Text is a sticky note, CSV is a spreadsheet, JSON is a labelled lunchbox with compartments.
Path has read_text and write_text built in. For appending, path.open("a") works just like open().
shopping = sandbox / "shopping.txt"
shopping.write_text("oat milk\nbananas\ncoffee beans\n")
items = shopping.read_text().splitlines()
print(items)
with shopping.open("a") as f:
f.write("dark chocolate\n")
for number, line in enumerate(shopping.read_text().splitlines(), start=1):
print(f"{number}. {line}")
visits = sandbox / "visits.txt"
visits.write_text(str(41))
count = int(visits.read_text()) + 1
visits.write_text(str(count))
print("Visits now:", visits.read_text())
import csv
gym_log = [
{"day": "Mon", "exercise": "squats", "reps": 30},
{"day": "Wed", "exercise": "push-ups", "reps": 25},
{"day": "Fri", "exercise": "lunges", "reps": 40},
]
csv_path = sandbox / "gym_log.csv"
with csv_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["day", "exercise", "reps"])
writer.writeheader()
writer.writerows(gym_log)
print(csv_path.read_text())
with csv_path.open(newline="") as f:
rows = list(csv.DictReader(f))
print(rows[0])
print("Total reps:", sum(int(row["reps"]) for row in rows))
import json
settings = {"owner": "Sam", "dark_mode": True,
"folders": ["Images", "Documents"], "max_mb": 50}
settings_path = sandbox / "settings.json"
settings_path.write_text(json.dumps(settings, indent=2))
print(settings_path.read_text())
loaded = json.loads(settings_path.read_text())
print(loaded["folders"], loaded["dark_mode"])
print("Same as before?", loaded == settings)
Common mistake: JSON with single quotes#
bad_json = "{'owner': 'Sam'}"
json.loads(bad_json)
good_json = '{"owner": "Sam"}'
print(json.loads(good_json))
print(json.dumps({'owner': 'Sam'}), "<- json.dumps writes the double quotes for you")
Walk the Whole Tree with os.walk#
os.walkis a guided tour: it visits every room (folder) and tells you what's inside.
Each step of the loop hands you three things: the folder, its subfolders and its files.
photos = sandbox / "Photos"
for trip, pics in {"rome": 3, "paris": 2, "rome/day2": 2, "lake": 1}.items():
(photos / trip).mkdir(parents=True, exist_ok=True)
for i in range(1, pics + 1):
(photos / trip / f"pic_{i:02d}.jpg").write_text("p" * 1024 * i)
print("Photo tree ready:", len(list(photos.rglob("*.jpg"))), "photos")
for folder, subfolders, filenames in os.walk(photos):
subfolders.sort() # visit folders in A to Z order
print(folder, sorted(filenames))
def show_tree(top):
top = Path(top)
for folder, subfolders, filenames in os.walk(top):
subfolders.sort()
depth = len(Path(folder).relative_to(top).parts)
print(" " * depth + Path(folder).name + "/")
for name in sorted(filenames):
print(" " * (depth + 1) + name)
show_tree(photos)
for folder, subfolders, filenames in os.walk(photos):
subfolders.sort()
size = sum((Path(folder) / name).stat().st_size for name in filenames)
print(f"{folder:<30}{len(filenames)} files {human_size(size):>9}")
all_pics = sorted(photos.rglob("*.jpg"))
print(len(all_pics), "photos found by rglob too")
print("First:", all_pics[0].relative_to(photos))
print("Last: ", all_pics[-1].relative_to(photos))
Pro tip: use
rglobwhen you want a list of files,os.walkwhen you care about each folder as you pass through it.
Copy, Move and Delete with shutil#
shutilis the removal company: it lifts whole files and folders. Always point it at a sandbox first.
copy copies the contents, copy2 also keeps the original date, move moves or renames, rmtree deletes a whole folder.
practice = sandbox / "shutil_practice"
practice.mkdir(exist_ok=True)
original = practice / "recipe.txt"
original.write_text("Pancakes: flour, eggs, milk")
copied = shutil.copy(original, practice / "recipe_copy.txt")
print("Copied to:", copied)
print(sorted(p.name for p in practice.iterdir()))
xmas = datetime(2025, 12, 25, 8, 0)
os.utime(original, (xmas.timestamp(), xmas.timestamp()))
plain = Path(shutil.copy(original, practice / "plain.txt"))
keeps = Path(shutil.copy2(original, practice / "keeps_date.txt"))
for p in [original, plain, keeps]:
same = datetime.fromtimestamp(p.stat().st_mtime) == xmas
print(f"{p.name:<15} dated Christmas Day? {same}")
archive = practice / "archive"
archive.mkdir(exist_ok=True)
shutil.move(plain, archive / plain.name)
print("practice:", sorted(p.name for p in practice.iterdir()))
print("archive: ", sorted(p.name for p in archive.iterdir()))
renamed = (practice / "recipe_copy.txt").rename(practice / "pancakes.txt")
print("Renamed to:", renamed.name)
print(renamed.read_text())
photo_copy = shutil.copytree(photos, sandbox / "Photos_copy", dirs_exist_ok=True)
print("Copied a whole tree:", len(list(Path(photo_copy).rglob("*.jpg"))), "photos")
(practice / "keeps_date.txt").unlink()
(practice / "ghost.txt").unlink(missing_ok=True)
print(sorted(p.name for p in practice.iterdir()))
Common mistake: rmdir on a folder that isn't empty#
(sandbox / "Photos_copy").rmdir()
shutil.rmtree(sandbox / "Photos_copy")
print("Photos_copy still there?", (sandbox / "Photos_copy").exists())
Pro tip:
rmtreeskips the recycle bin. Deleted means gone, so add a guard like the one below.
def safe_rmtree(folder):
target = Path(folder)
if sandbox.resolve() not in target.resolve().parents:
print("Refusing to delete outside the sandbox:", target)
return
shutil.rmtree(target)
print("Deleted", target)
safe_rmtree(practice / "archive")
safe_rmtree(Path(".."))
Your turn#
- Copy every
.txtfile from Downloads into a newlesson_files/Textfolder. - Use
safe_rmtreeto delete that folder again.
Bulk Renaming#
Renaming 200 photos by hand takes an afternoon. A loop takes a second.
Golden rule: preview the new names first, then rename. A typo in a rename loop hits every file.
def tidy_name(path):
stem = path.stem.strip().lower()
for messy, clean in [(" ", "_"), ("-", "_"), ("(", ""), (")", "")]:
stem = stem.replace(messy, clean)
return stem + path.suffix.lower()
for name in ["beach day.PNG", "CV final (2).docx", "recipe-lasagne.txt"]:
print(f"{name:<20} -> {tidy_name(Path(name))}")
for p in sorted(downloads.iterdir()):
if p.is_file() and p.name != tidy_name(p):
print(f"{p.name:<26} -> {tidy_name(p)}")
renamed_count = 0
for p in sorted(downloads.iterdir()):
if p.is_file() and p.name != tidy_name(p):
p.rename(p.with_name(tidy_name(p)))
renamed_count += 1
print("Renamed", renamed_count, "files")
print(sorted(p.name for p in downloads.glob("*.png")))
Common mistake: using the old path after a rename#
beach = downloads / "beach_day.png"
new_beach = beach.rename(downloads / "beach_2026.png")
print(beach.read_text()[:5])
print(new_beach.name, "exists:", new_beach.exists())
print(beach.name, "exists:", beach.exists())
print("rename() hands back the new path, so use that one:", new_beach.read_text()[:5])
rome = photos / "rome"
prefix = input("Prefix for the Rome photos: ").strip().lower().replace(" ", "_")
for number, pic in enumerate(sorted(rome.glob("*.jpg")), start=1):
new = pic.with_name(f"{prefix}_{number:03d}{pic.suffix}")
pic.rename(new)
print(f"{pic.name} -> {new.name}")
def unique_path(path):
if not path.exists():
return path
number = 1
while True:
candidate = path.with_name(f"{path.stem} ({number}){path.suffix}")
if not candidate.exists():
return candidate
number += 1
print(unique_path(downloads / "brand_new.txt").name)
print(unique_path(downloads / "notes.txt").name)
(downloads / "notes (1).txt").write_text("a second copy")
print(unique_path(downloads / "notes.txt").name)
(downloads / "notes (1).txt").unlink()
Quick check#
Why does unique_path keep looping instead of always adding (1)?
print("Because 'notes (1).txt' might already exist too.")
print("The loop tries (1), (2), (3)... until it finds a free name.")
Dry-Run Mode: Look Before You Leap#
A dry run is a rehearsal: the script says what it would do and changes nothing.
One function, two modes: with dry_run=True it only prints, with dry_run=False it really moves files.
def archive_old_files(folder, days, dry_run=True):
cutoff = FIXED_TODAY - timedelta(days=days)
target = folder / "Old"
moved = []
for p in sorted(folder.iterdir()):
if p.is_file() and datetime.fromtimestamp(p.stat().st_mtime) < cutoff:
moved.append(p.name)
if dry_run:
print(f"[DRY RUN] would move {p.name}")
else:
target.mkdir(exist_ok=True)
shutil.move(p, target / p.name)
return moved
print("Ready. Safe by default: dry_run is True unless you say otherwise.")
plan = archive_old_files(downloads, days=60)
print(len(plan), "files would move.")
print("Old folder created?", (downloads / "Old").exists())
moved = archive_old_files(downloads, days=60, dry_run=False)
print("Moved:", moved)
print("Old now holds:", sorted(p.name for p in (downloads / "Old").iterdir()))
Pro tip: make
dry_run=Truethe default, so forgetting the argument can never hurt you.
def plan_renames(folder):
return [(p.name, tidy_name(p)) for p in sorted(folder.iterdir())
if p.is_file() and p.name != tidy_name(p)]
print(plan_renames(downloads) or "Nothing to rename: Downloads is already tidy!")
print(plan_renames(photos / "paris") or "Paris photos are tidy too.")
Zip It Up: Archives and Timestamped Backups#
A zip file is a vacuum-packed suitcase: everything squashed into one bag you can carry.
arcname sets the name inside the zip, so the archive doesn't carry the whole lesson_files/... path.
import zipfile
zip_path = sandbox / "photos.zip"
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for pic in sorted(photos.rglob("*.jpg")):
zf.write(pic, arcname=pic.relative_to(photos))
print("Zipped into", zip_path.name)
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
print(len(names), "files inside:")
for name in names:
print(" ", name)
before = sum(size_of(p) for p in photos.rglob("*.jpg"))
after = size_of(zip_path)
print("Photos:", human_size(before), "| zip:", human_size(after))
print(f"Saved {1 - after / before:.0%} of the space")
restore = sandbox / "restored"
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(restore)
show_tree(restore / "rome")
stamp = FIXED_TODAY.strftime("%Y-%m-%d_%H%M")
print("Backup name:", f"photos_{stamp}.zip")
print("Date-first names sort in time order:", sorted(["2026-03-15", "2025-12-01", "2026-01-09"]))
def backup_folder(folder, backup_dir, when=None):
when = when or datetime.now()
backup_dir.mkdir(exist_ok=True)
target = backup_dir / f"{folder.name}_{when:%Y-%m-%d_%H%M}"
return Path(shutil.make_archive(target, "zip", root_dir=folder))
backups = sandbox / "Backups"
for day in [1, 8, 15]:
made = backup_folder(photos, backups, when=datetime(2026, 3, day, 18, 30))
print("Made", made.name)
def prune_backups(backup_dir, keep=2):
all_backups = sorted(backup_dir.glob("*.zip"))
for old_zip in all_backups[:-keep]:
old_zip.unlink()
print("Removed old backup", old_zip.name)
print("Kept:", [p.name for p in sorted(backup_dir.glob("*.zip"))])
prune_backups(backups)
Your turn#
- Back up the Downloads folder into
Backupswith a fixed date of your choice. - Change
prune_backupsso it keeps only the newest backup.
Capstone: The Downloads Folder Organiser#
Fresh messy folder in, tidy folders out. Rules: sort by file type, never overwrite (rename duplicates), dry run first, ask before doing it for real, save a JSON log, print a summary.
Steps:
- Build a fresh messy folder (with two old category folders, so duplicates happen)
- Decide a category for every file from its suffix
- Plan every move, then rehearse it with a dry run
- Ask for a yes, move the files, save the log and check the result
- Prove it's safe: run it twice, then undo it from the log
messy = make_downloads(sandbox / "Capstone" / "Downloads")
(messy / "Images").mkdir()
(messy / "Images" / "IMG_001.jpg").write_text("sorted last month")
(messy / "Documents").mkdir()
(messy / "Documents" / "notes.txt").write_text("old notes")
loose = [p for p in messy.iterdir() if p.is_file()]
print(len(loose), "loose files to organise")
CATEGORIES = {
"Images": {".jpg", ".jpeg", ".png", ".gif"},
"Documents": {".pdf", ".docx", ".txt", ".md"},
"Spreadsheets": {".xlsx", ".csv"},
"Audio": {".mp3", ".wav"},
"Video": {".mp4", ".mov"},
"Archives": {".zip"},
"Code": {".py", ".html", ".json"},
"Installers": {".exe", ".dmg"},
}
print(len(CATEGORIES), "categories, plus 'Other' for everything else")
def category_for(path):
ext = path.suffix.lower()
for category, extensions in CATEGORIES.items():
if ext in extensions:
return category
return "Other"
for name in ["beach day.PNG", "fonts.zip", "mystery_file", "playlist.m3u"]:
print(f"{name:<15} -> {category_for(Path(name))}")
def plan_moves(folder):
plan = []
for path in sorted(folder.iterdir()):
if path.is_file() and not path.name.startswith("organiser_log"):
plan.append((path, folder / category_for(path) / path.name))
return plan
moves = plan_moves(messy)
print(len(moves), "moves planned. First three:")
for source, target in moves[:3]:
print(" ", source.name, "->", target.relative_to(messy))
def organise(folder, dry_run=True):
log = []
for source, target in plan_moves(folder):
final = unique_path(target)
log.append({"file": source.name,
"to": final.relative_to(folder).as_posix(),
"renamed": final.name != source.name})
if not dry_run:
final.parent.mkdir(exist_ok=True)
shutil.move(source, final)
return log
print("organise() is ready. Default mode:", "dry run")
def print_summary(log, dry_run):
title = "DRY RUN: nothing moved yet" if dry_run else "DONE: files organised"
print(title)
print("-" * len(title))
per_folder = {}
for entry in log:
folder = entry["to"].split("/")[0]
per_folder[folder] = per_folder.get(folder, 0) + 1
for folder, count in sorted(per_folder.items()):
print(f"{folder:<14}{'#' * count:<8}{count}")
renamed = [e for e in log if e["renamed"]]
for e in renamed:
print(f"Duplicate! {e['file']} saved as {e['to']}")
print(f"Total: {len(log)} files, {len(renamed)} renamed")
print_summary([{"file": "a.jpg", "to": "Images/a.jpg", "renamed": False}], dry_run=True)
preview = organise(messy, dry_run=True)
print_summary(preview, dry_run=True)
print("Loose files still there:", len([p for p in messy.iterdir() if p.is_file()]))
answer = input("Happy with the plan? Organise for real? (y/n): ").strip().lower()
if answer == "y":
log = organise(messy, dry_run=False)
print_summary(log, dry_run=False)
else:
log = []
print("Cancelled. Nothing was touched.")
log_file = messy / f"organiser_log_{FIXED_TODAY:%Y-%m-%d}.json"
report = {"run_at": FIXED_TODAY.isoformat(), "moves": log}
log_file.write_text(json.dumps(report, indent=2))
print("Log saved:", log_file.name)
print(json.dumps(log[:2], indent=2))
for folder in sorted(p for p in messy.iterdir() if p.is_dir()):
names = sorted(p.name for p in folder.iterdir())
size = sum(size_of(p) for p in folder.iterdir())
print(f"{folder.name + '/':<14}{human_size(size):>9} {', '.join(names[:3])}")
saved = json.loads(log_file.read_text())
print("Run at:", saved["run_at"])
print("Moves in the log:", len(saved["moves"]))
print("Renamed:", [m["to"] for m in saved["moves"] if m["renamed"]])
second = organise(messy, dry_run=True)
print("Second dry run finds", len(second), "files to move.")
print("Running the organiser twice is safe.")
def undo(folder, log):
for entry in reversed(log):
shutil.move(folder / entry["to"], folder / entry["file"])
print(f"Undo: put {len(log)} files back where they were")
undo(messy, saved["moves"])
print("Loose files again:", len(plan_moves(messy)))
log = organise(messy, dry_run=False)
left = [p.name for p in messy.iterdir() if p.is_file()]
print("Re-organised", len(log), "files. Left loose:", left)
show_tree(messy / "Images")
Recap#
Pathobjects:/,name,stem,suffix,parent,with_suffix,exists,mkdir(parents=True, exist_ok=True)- Finding files:
iterdir,glob(one folder),rglob(every subfolder),suffix.lower() stat()for sizes and modified dates, plus ahuman_sizehelperread_text/write_text,csv.DictReader/DictWriter,json.dumps/json.loadsos.walkto tour a folder tree one folder at a timeshutil:copy,copy2,move,copytree,rmtree(with a sandbox guard)- Bulk renaming,
unique_pathfor duplicates, anddry_run=Trueby default zipfileandmake_archivefor timestamped backups, pruning the old ones
Try it yourself#
- Add a
--by-datestyle option: sort files into2026-03,2026-02folders using their modified date. - Make the organiser skip files bigger than a size limit and list them as "too big to move" in the log.
- Before organising, call
backup_folderon Downloads so you can always restore it from the zip.
What's next#
Lesson 9: Databases for Beginners: SQLite with Python. Instead of loose files, you'll store your data in a real database and ask it questions with SQL. You'll build a personal library tracker that remembers every book and every loan.
Found this useful?
All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.



