The Quiet Terminal

← Practical Tips

pathlib: the file-handling upgrade I wish I'd found sooner

2026-08-21 · 4 min read

For a long time I handled files with os.path: build a path by joining strings, check it with os.path.exists(), split extensions with os.path.splitext(). It worked, but every script turned into a pile of little string operations that were easy to get wrong — especially on Windows, where I once shipped a tool that worked everywhere except my own laptop.

Then I finally sat down with pathlib. Paths become objects, most of the old tricks have a cleaner equivalent, and the code starts to read like what it actually does. This post is the tour I wish I'd had.

Paths are objects, so you can join them with /

The killer feature is right at the start. Instead of this:

import os
path = os.path.join(os.path.expanduser("~"), "Documents", "notes.txt")

you write this:

from pathlib import Path
path = Path.home() / "Documents" / "notes.txt"

The / operator joins path segments, and it knows which separator your operating system wants. Same code, no slashes to babysit, and it reads left to right like the path it produces.

Reading and writing in one line

For small files, the old open/with/close dance collapses into two method calls:

config = Path("settings.txt").read_text(encoding="utf-8")

Path("last_run.log").write_text("done at 22:41\n", encoding="utf-8")

Don't reach for this with a 2 GB file — read_text() loads everything into memory. But for config files, small reports, and cache stamps, it removes five lines of ceremony.

Finding files: glob and rglob

reports = Path("data").glob("*.csv")        # files directly in data/
archives = Path("data").rglob("*.zip")      # files anywhere below data/

Both return generators, so loop them or wrap in sorted() if order matters. I use rglob constantly to answer "where on earth did this project put its JSON files".

The parts of a filename, for free

p = Path("report_2026_final_v3.pdf")

p.name      # report_2026_final_v3.pdf
p.stem      # report_2026_final_v3
p.suffix    # .pdf
p.parent    # the directory it lives in

Compare that with the splitext / basename / dirname juggling. My favorite trick is with_suffix(), which swaps the extension without touching anything else:

csv_version = p.with_suffix(".csv")   # report_2026_final_v3.csv

A real script: monthly PDF cleanup

Here's the kind of task pathlib is made for. My Downloads folder accumulates invoices; once a month I want them moved into a dated subfolder. The whole script:

from pathlib import Path
from datetime import date

downloads = Path.home() / "Downloads"
target = downloads / "invoices" / date.today().strftime("%Y-%m")
target.mkdir(parents=True, exist_ok=True)

for pdf in downloads.glob("*.pdf"):
    if pdf.name.startswith("invoice_"):
        dest = target / pdf.name
        if not dest.exists():
            pdf.rename(dest)
            print("moved:", pdf.name)

Notice what's doing the heavy lifting: mkdir(parents=True, exist_ok=True) creates the whole chain of folders without complaining if it already exists, and rename() moves across directories. Before pathlib I would have written a try/except FileNotFoundError around os.makedirs and a shutil call for the move. This version says what it does on every line.

Is os.path wrong, then?

No — plenty of mature code uses it, and a few corners (like path length limits on Windows) still send you back to os now and then. But for new code, pathlib is the better default: fewer moving parts, better names, and paths you can pass around as values instead of strings you keep re-joining. The official docs call the old style "legacy" for a reason.