Mathew K Analytics

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…

⬇ Download notebook

What you'll learn

Data

No separate download needed — the notebook creates or downloads everything it uses.

📓 Full notebook

Download .ipynb

Automate 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())
Water the plants
Book the gym

with open("warmup.txt") as f:
    tasks = f.read().splitlines()
print(tasks)
note.unlink()
print("Still there after unlink()?", note.exists())
['Water the plants', 'Book the gym']
Still there after unlink()? False

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)
lesson_files/work/sales report.final.csv
name:   sales report.final.csv
stem:   sales report.final
suffix: .csv
print("parent:  ", report.parent)
print("parts:   ", report.parts)
print("suffixes:", report.suffixes)
parent:   lesson_files/work
parts:    ('lesson_files', 'work', 'sales report.final.csv')
suffixes: ['.final', '.csv']
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")
holiday/IMG_0042.png
holiday/beach.jpg
holiday/rome_001.JPG
Is it a jpg? True
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())
str lesson_files/notes.txt
PosixPath lesson_files/notes.txt
Exists yet? False

Common mistake: gluing a Path and a string with +#

print(Path("lesson_files") + "/notes.txt")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[8], line 1
----> 1 print(Path("lesson_files") + "/notes.txt")

TypeError: unsupported operand type(s) for +: 'PosixPath' and 'str'
print(Path("lesson_files") / "notes.txt")
print(Path("lesson_files", "notes.txt"))
lesson_files/notes.txt
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())
lesson_files/Downloads | exists: True | folder: True

Common mistake: making a folder that already exists#

downloads.mkdir()
---------------------------------------------------------------------------
FileExistsError                           Traceback (most recent call last)
Cell In[11], line 1
----> 1 downloads.mkdir()

File /usr/lib/python3.13/pathlib/_local.py:722, in Path.mkdir(self, mode, parents, exist_ok)
    718 """
    719 Create a new directory at this given path.
    720 """
    721 try:
--> 722     os.mkdir(self, mode)
    723 except FileNotFoundError:
    724     if not parents or self.parent == self:

FileExistsError: [Errno 17] File exists: 'lesson_files/Downloads'
downloads.mkdir(exist_ok=True)
print("No error: exist_ok=True means 'fine if it's already there'")
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)
Created: lesson_files/projects/2026/march
parents=True also made: lesson_files/projects/2026 and lesson_files/projects
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")
25 fake files, about 608 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())))
Files created: 25

Look Around: iterdir, glob and rglob#

glob is asking a librarian: "bring me every book whose title matches this pattern".

for path in sorted(downloads.iterdir())[:6]:
    print(path.name)
print("...")
CV final (2).docx
CV final.docx
IMG_001.jpg
IMG_002.jpg
beach day.PNG
birthday_song.mp3
...
jpgs = sorted(downloads.glob("*.jpg"))
print([p.name for p in jpgs])
invoices = sorted(downloads.glob("invoice*"))
print([p.name for p in invoices])
['IMG_001.jpg', 'IMG_002.jpg']
['invoice_april.pdf', 'invoice_march.pdf']

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])
*.mp?   -> ['birthday_song.mp3', 'holiday_clip.mp4', 'podcast_ep12.mp3']
*[0-9]* -> ['CV final (2).docx', 'IMG_001.jpg', 'IMG_002.jpg', 'birthday_song.mp3']
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)
glob('*.png'): ['screenshot 2026-03-01.png']
by suffix.lower(): ['IMG_001.jpg', 'IMG_002.jpg', 'beach day.PNG', 'cat_meme.gif', 'screenshot 2026-03-01.png']

Pro tip: patterns can be case sensitive, so *.png may miss beach day.PNG. Compare suffix.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"))])
glob  *.pdf: ['invoice_april.pdf', 'invoice_march.pdf']
rglob *.pdf: ['invoice_april.pdf', 'invoice_march.pdf', 'tax_return.pdf']
for p in sorted(downloads.rglob("*.pdf")):
    print(p.relative_to(downloads))
invoice_april.pdf
invoice_march.pdf
old/2025/tax_return.pdf
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]}")
.docx   2
.jpg    2
.png    2
.mp3    2
.zip    2

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).")
glob looks in one folder only.
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")])
['CV final (2).docx', 'CV final.docx']
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.")
Search Downloads for (blank to stop): invoice
  2 match(es): invoice_april.pdf, invoice_march.pdf
Search Downloads for (blank to stop): .mp3
  2 match(es): birthday_song.mp3, podcast_ep12.mp3
Search Downloads for (blank to stop): zebra
  0 match(es): nothing
Search Downloads for (blank to stop): 
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)
Size in bytes: 81920
Size in KB:    80.0
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)}")
      512 -> 512.0 B
    2,048 -> 2.0 KB
   81,920 -> 80.0 KB
5,000,000 -> 4.8 MB
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)
Last modified: 29 Jan 2026
Days before our fixed 'today': 45
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}")
holiday_clip.mp4         80.0 KB
setup_app.exe            70.0 KB
game_installer.dmg       65.0 KB
podcast_ep12.mp3         60.0 KB
IMG_002.jpg              52.0 KB
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)))
25 files using 608.0 KB
Average file: 24.3 KB
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)
4 files older than 14 Jan 2026:
   game_installer.dmg
   mystery_file
   playlist.m3u
   setup_app.exe

Quick check#

A file is 3,500,000 bytes. What does human_size print for it?

print(human_size(3_500_000))
3.3 MB

Your turn#

  1. Print the five smallest files in Downloads.
  2. 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)
['oat milk', 'bananas', 'coffee beans']
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}")
1. oat milk
2. bananas
3. coffee beans
4. dark chocolate
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())
Visits now: 42
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())
day,exercise,reps
Mon,squats,30
Wed,push-ups,25
Fri,lunges,40

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))
{'day': 'Mon', 'exercise': 'squats', 'reps': '30'}
Total reps: 95
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())
{
  "owner": "Sam",
  "dark_mode": true,
  "folders": [
    "Images",
    "Documents"
  ],
  "max_mb": 50
}
loaded = json.loads(settings_path.read_text())
print(loaded["folders"], loaded["dark_mode"])
print("Same as before?", loaded == settings)
['Images', 'Documents'] True
Same as before? True

Common mistake: JSON with single quotes#

bad_json = "{'owner': 'Sam'}"
json.loads(bad_json)
---------------------------------------------------------------------------
JSONDecodeError                           Traceback (most recent call last)
Cell In[40], line 2
      1 bad_json = "{'owner': 'Sam'}"
----> 2 json.loads(bad_json)

File /usr/lib/python3.13/json/__init__.py:352, in loads(s, cls, object_hook, parse_float, parse_int, parse_constant, object_pairs_hook, **kw)
    347     s = s.decode(detect_encoding(s), 'surrogatepass')
    349 if (cls is None and object_hook is None and
    350         parse_int is None and parse_float is None and
    351         parse_constant is None and object_pairs_hook is None and not kw):
--> 352     return _default_decoder.decode(s)
    353 if cls is None:
    354     cls = JSONDecoder

File /usr/lib/python3.13/json/decoder.py:345, in JSONDecoder.decode(self, s, _w)
    340 def decode(self, s, _w=WHITESPACE.match):
    341     """Return the Python representation of ``s`` (a ``str`` instance
    342     containing a JSON document).
    343 
    344     """
--> 345     obj, end = self.raw_decode(s, idx=_w(s, 0).end())
    346     end = _w(s, end).end()
    347     if end != len(s):

File /usr/lib/python3.13/json/decoder.py:361, in JSONDecoder.raw_decode(self, s, idx)
    352 """Decode a JSON document from ``s`` (a ``str`` beginning with
    353 a JSON document) and return a 2-tuple of the Python
    354 representation and the index in ``s`` where the document ended.
   (...)    358 
    359 """
    360 try:
--> 361     obj, end = self.scan_once(s, idx)
    362 except StopIteration as err:
    363     raise JSONDecodeError("Expecting value", s, err.value) from None

JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
good_json = '{"owner": "Sam"}'
print(json.loads(good_json))
print(json.dumps({'owner': 'Sam'}), "<- json.dumps writes the double quotes for you")
{'owner': 'Sam'}
{"owner": "Sam"} <- json.dumps writes the double quotes for you

Walk the Whole Tree with os.walk#

os.walk is 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")
Photo tree ready: 8 photos
for folder, subfolders, filenames in os.walk(photos):
    subfolders.sort()                 # visit folders in A to Z order
    print(folder, sorted(filenames))
lesson_files/Photos []
lesson_files/Photos/lake ['pic_01.jpg']
lesson_files/Photos/paris ['pic_01.jpg', 'pic_02.jpg']
lesson_files/Photos/rome ['pic_01.jpg', 'pic_02.jpg', 'pic_03.jpg']
lesson_files/Photos/rome/day2 ['pic_01.jpg', 'pic_02.jpg']
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)
Photos/
    lake/
        pic_01.jpg
    paris/
        pic_01.jpg
        pic_02.jpg
    rome/
        pic_01.jpg
        pic_02.jpg
        pic_03.jpg
        day2/
            pic_01.jpg
            pic_02.jpg
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}")
lesson_files/Photos           0 files     0.0 B
lesson_files/Photos/lake      1 files    1.0 KB
lesson_files/Photos/paris     2 files    3.0 KB
lesson_files/Photos/rome      3 files    6.0 KB
lesson_files/Photos/rome/day2 2 files    3.0 KB
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))
8 photos found by rglob too
First: lake/pic_01.jpg
Last:  rome/pic_03.jpg

Pro tip: use rglob when you want a list of files, os.walk when you care about each folder as you pass through it.

Copy, Move and Delete with shutil#

shutil is 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()))
Copied to: lesson_files/shutil_practice/recipe_copy.txt
['recipe.txt', 'recipe_copy.txt']
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}")
recipe.txt      dated Christmas Day? True
plain.txt       dated Christmas Day? False
keeps_date.txt  dated Christmas Day? True
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()))
practice: ['archive', 'keeps_date.txt', 'recipe.txt', 'recipe_copy.txt']
archive:  ['plain.txt']
renamed = (practice / "recipe_copy.txt").rename(practice / "pancakes.txt")
print("Renamed to:", renamed.name)
print(renamed.read_text())
Renamed to: pancakes.txt
Pancakes: flour, eggs, milk
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")
Copied a whole tree: 8 photos
(practice / "keeps_date.txt").unlink()
(practice / "ghost.txt").unlink(missing_ok=True)
print(sorted(p.name for p in practice.iterdir()))
['archive', 'pancakes.txt', 'recipe.txt']

Common mistake: rmdir on a folder that isn't empty#

(sandbox / "Photos_copy").rmdir()
---------------------------------------------------------------------------
OSError                                   Traceback (most recent call last)
Cell In[53], line 1
----> 1 (sandbox / "Photos_copy").rmdir()

File /usr/lib/python3.13/pathlib/_local.py:755, in Path.rmdir(self)
    751 def rmdir(self):
    752     """
    753     Remove this directory.  The directory must be empty.
    754     """
--> 755     os.rmdir(self)

OSError: [Errno 39] Directory not empty: 'lesson_files/Photos_copy'
shutil.rmtree(sandbox / "Photos_copy")
print("Photos_copy still there?", (sandbox / "Photos_copy").exists())
Photos_copy still there? False

Pro tip: rmtree skips 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(".."))
Deleted lesson_files/shutil_practice/archive
Refusing to delete outside the sandbox: ..

Your turn#

  1. Copy every .txt file from Downloads into a new lesson_files/Text folder.
  2. Use safe_rmtree to 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))}")
beach day.PNG        -> beach_day.png
CV final (2).docx    -> cv_final_2.docx
recipe-lasagne.txt   -> recipe_lasagne.txt
for p in sorted(downloads.iterdir()):
    if p.is_file() and p.name != tidy_name(p):
        print(f"{p.name:<26} -> {tidy_name(p)}")
CV final (2).docx          -> cv_final_2.docx
CV final.docx              -> cv_final.docx
IMG_001.jpg                -> img_001.jpg
IMG_002.jpg                -> img_002.jpg
beach day.PNG              -> beach_day.png
recipe-lasagne.txt         -> recipe_lasagne.txt
screenshot 2026-03-01.png  -> screenshot_2026_03_01.png
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")))
Renamed 7 files
['beach_day.png', 'screenshot_2026_03_01.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])
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
Cell In[59], line 3
      1 beach = downloads / "beach_day.png"
      2 new_beach = beach.rename(downloads / "beach_2026.png")
----> 3 print(beach.read_text()[:5])

File /usr/lib/python3.13/pathlib/_local.py:546, in Path.read_text(self, encoding, errors, newline)
    543 # Call io.text_encoding() here to ensure any warning is raised at an
    544 # appropriate stack level.
    545 encoding = io.text_encoding(encoding)
--> 546 return PathBase.read_text(self, encoding, errors, newline)

File /usr/lib/python3.13/pathlib/_abc.py:632, in PathBase.read_text(self, encoding, errors, newline)
    628 def read_text(self, encoding=None, errors=None, newline=None):
    629     """
    630     Open the file in text mode, read it, and close the file.
    631     """
--> 632     with self.open(mode='r', encoding=encoding, errors=errors, newline=newline) as f:
    633         return f.read()

File /usr/lib/python3.13/pathlib/_local.py:537, in Path.open(self, mode, buffering, encoding, errors, newline)
    535 if "b" not in mode:
    536     encoding = io.text_encoding(encoding)
--> 537 return io.open(self, mode, buffering, encoding, errors, newline)

FileNotFoundError: [Errno 2] No such file or directory: 'lesson_files/Downloads/beach_day.png'
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])
beach_2026.png exists: True
beach_day.png exists: False
rename() hands back the new path, so use that one: xxxxx
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}")
Prefix for the Rome photos: Rome Trip
pic_01.jpg -> rome_trip_001.jpg
pic_02.jpg -> rome_trip_002.jpg
pic_03.jpg -> rome_trip_003.jpg
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)
brand_new.txt
notes (1).txt
(downloads / "notes (1).txt").write_text("a second copy")
print(unique_path(downloads / "notes.txt").name)
(downloads / "notes (1).txt").unlink()
notes (2).txt

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.")
Because 'notes (1).txt' might already exist too.
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.")
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())
[DRY RUN] would move game_installer.dmg
[DRY RUN] would move mystery_file
[DRY RUN] would move playlist.m3u
[DRY RUN] would move setup_app.exe
4 files would move.
Old folder created? False
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()))
Moved: ['game_installer.dmg', 'mystery_file', 'playlist.m3u', 'setup_app.exe']
Old now holds: ['game_installer.dmg', 'mystery_file', 'playlist.m3u', 'setup_app.exe']

Pro tip: make dry_run=True the 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.")
Nothing to rename: Downloads is already tidy!
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)
Zipped into photos.zip
with zipfile.ZipFile(zip_path) as zf:
    names = zf.namelist()
print(len(names), "files inside:")
for name in names:
    print("  ", name)
8 files inside:
   lake/pic_01.jpg
   paris/pic_01.jpg
   paris/pic_02.jpg
   rome/day2/pic_01.jpg
   rome/day2/pic_02.jpg
   rome/rome_trip_001.jpg
   rome/rome_trip_002.jpg
   rome/rome_trip_003.jpg
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")
Photos: 13.0 KB | zip: 1.0 KB
Saved 92% of the space
restore = sandbox / "restored"
with zipfile.ZipFile(zip_path) as zf:
    zf.extractall(restore)
show_tree(restore / "rome")
rome/
    rome_trip_001.jpg
    rome_trip_002.jpg
    rome_trip_003.jpg
    day2/
        pic_01.jpg
        pic_02.jpg
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"]))
Backup name: photos_2026-03-15_0900.zip
Date-first names sort in time order: ['2025-12-01', '2026-01-09', '2026-03-15']
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)
Made Photos_2026-03-01_1830.zip
Made Photos_2026-03-08_1830.zip
Made Photos_2026-03-15_1830.zip
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)
Removed old backup Photos_2026-03-01_1830.zip
Kept: ['Photos_2026-03-08_1830.zip', 'Photos_2026-03-15_1830.zip']

Your turn#

  1. Back up the Downloads folder into Backups with a fixed date of your choice.
  2. Change prune_backups so 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:

  1. Build a fresh messy folder (with two old category folders, so duplicates happen)
  2. Decide a category for every file from its suffix
  3. Plan every move, then rehearse it with a dry run
  4. Ask for a yes, move the files, save the log and check the result
  5. 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")
25 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")
8 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))}")
beach day.PNG   -> Images
fonts.zip       -> Archives
mystery_file    -> Other
playlist.m3u    -> Other
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))
25 moves planned. First three:
   CV final (2).docx -> Documents/CV final (2).docx
   CV final.docx -> Documents/CV final.docx
   IMG_001.jpg -> Images/IMG_001.jpg
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")
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)
DRY RUN: nothing moved yet
--------------------------
Images        #       1
Total: 1 files, 0 renamed
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()]))
DRY RUN: nothing moved yet
--------------------------
Archives      ##      2
Audio         ##      2
Code          ###     3
Documents     ######  6
Images        #####   5
Installers    ##      2
Other         ##      2
Spreadsheets  ##      2
Video         #       1
Duplicate! IMG_001.jpg saved as Images/IMG_001 (1).jpg
Duplicate! notes.txt saved as Documents/notes (1).txt
Total: 25 files, 2 renamed
Loose files still there: 25
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.")
Happy with the plan? Organise for real? (y/n): y
DONE: files organised
---------------------
Archives      ##      2
Audio         ##      2
Code          ###     3
Documents     ######  6
Images        #####   5
Installers    ##      2
Other         ##      2
Spreadsheets  ##      2
Video         #       1
Duplicate! IMG_001.jpg saved as Images/IMG_001 (1).jpg
Duplicate! notes.txt saved as Documents/notes (1).txt
Total: 25 files, 2 renamed
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))
Log saved: organiser_log_2026-03-15.json
[
  {
    "file": "CV final (2).docx",
    "to": "Documents/CV final (2).docx",
    "renamed": false
  },
  {
    "file": "CV final.docx",
    "to": "Documents/CV final.docx",
    "renamed": false
  }
]
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])}")
Archives/       65.0 KB  fonts.zip, photos_backup.zip
Audio/          95.0 KB  birthday_song.mp3, podcast_ep12.mp3
Code/            4.0 KB  data.json, index.html, script.py
Documents/      53.0 KB  CV final (2).docx, CV final.docx, invoice_april.pdf
Images/        163.0 KB  IMG_001 (1).jpg, IMG_001.jpg, IMG_002.jpg
Installers/    135.0 KB  game_installer.dmg, setup_app.exe
Other/           2.0 KB  mystery_file, playlist.m3u
Spreadsheets/   11.0 KB  budget.xlsx, budget_2026.csv
Video/          80.0 KB  holiday_clip.mp4
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"]])
Run at: 2026-03-15T09:00:00
Moves in the log: 25
Renamed: ['Images/IMG_001 (1).jpg', 'Documents/notes (1).txt']
second = organise(messy, dry_run=True)
print("Second dry run finds", len(second), "files to move.")
print("Running the organiser twice is safe.")
Second dry run finds 0 files to move.
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)))
Undo: put 25 files back where they were
Loose files again: 25
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")
Re-organised 25 files. Left loose: ['organiser_log_2026-03-15.json']
Images/
    IMG_001 (1).jpg
    IMG_001.jpg
    IMG_002.jpg
    beach day.PNG
    cat_meme.gif
    screenshot 2026-03-01.png

Recap#

  • Path objects: /, 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 a human_size helper
  • read_text / write_text, csv.DictReader / DictWriter, json.dumps / json.loads
  • os.walk to tour a folder tree one folder at a time
  • shutil: copy, copy2, move, copytree, rmtree (with a sandbox guard)
  • Bulk renaming, unique_path for duplicates, and dry_run=True by default
  • zipfile and make_archive for timestamped backups, pruning the old ones

Try it yourself#

  1. Add a --by-date style option: sort files into 2026-03, 2026-02 folders using their modified date.
  2. Make the organiser skip files bigger than a size limit and list them as "too big to move" in the log.
  3. Before organising, call backup_folder on 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.