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.
/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.
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.
glob and rglobreports = 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".
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
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.
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.