Python

pathlib — working with the filesystem the modern way

os.path treats a file path as a string you build with function calls; pathlib treats it as an OBJECT with methods — the same operations, but composable with / instead of os.path.join, and readable instead of nested calls.

Intermediate

3 min read

A Path is an object, not a string glued together with os.path

import os
 
# the OLD way — nested function calls, read inside-out
config_path = os.path.join(os.path.dirname(__file__), "config", "settings.json")
 
from pathlib import Path
 
# the pathlib way — reads left to right, in the order it's actually built
config_path = Path(__file__).parent / "config" / "settings.json"

pathlib.Path represents a filesystem path as an object with methods and operators, instead of building one up through nested os.path function calls. The / operator is deliberately overloaded to mean "join a path segment" — Path(__file__).parent / "config" / "settings.json" reads in the same order the actual path is constructed, left to right, which is exactly what makes pathlib code easier to follow than the equivalent os.path.join chain once more than one or two segments are involved.

Reading and writing without a manual open()/close()

from pathlib import Path
 
content = Path("notes.txt").read_text()          # read the whole file as a string, one call
Path("notes.txt").write_text("updated content")    # write (and close) in one call
 
data = Path("data.json").read_bytes()               # the binary-mode equivalent

.read_text(), .write_text(), .read_bytes(), and .write_bytes() open the file, read or write the whole thing, and close it — all in one call, with no with open(...) block needed for the common "just get the whole file's contents" case. This isn't a replacement for open() when a file needs to be read incrementally (a huge file, or line by line) — covered in the input/files basics lesson — but for "read this whole small file" or "write this whole string out," it's the more direct tool.

Inspecting a path without touching the filesystem

p = Path("/home/ada/reports/2026-q3.csv")
 
p.name          # '2026-q3.csv' — the final component
p.stem           # '2026-q3'      — name without the suffix
p.suffix          # '.csv'          — just the extension
p.parent           # Path('/home/ada/reports')
p.parts             # ('/', 'home', 'ada', 'reports', '2026-q3.csv')

These properties parse the path's text — none of them touch the actual filesystem or require the file to exist. This matters: a Path object can be built and inspected purely as a string-like value (useful for constructing a path before creating the file it points to), separate from the methods below that actually check or change what's on disk.

Checking and changing what's actually on disk

p = Path("output/report.csv")
 
p.exists()          # True/False — does this path exist at all, file or directory?
p.is_file()          # True/False
p.is_dir()            # True/False
 
p.parent.mkdir(parents=True, exist_ok=True)   # create the "output" directory if it's missing
p.unlink(missing_ok=True)                        # delete the file if it exists, don't error if it doesn't

parents=True creates every missing intermediate directory in one call (the equivalent of shell mkdir -p), and exist_ok=True stops it from raising an error if the directory is already there — without both, mkdir() raises FileNotFoundError for a missing parent or FileExistsError if the directory already exists, either of which is usually not the actual bug being guarded against.

Listing and searching a directory: .iterdir() and .glob()

for item in Path("reports").iterdir():
    print(item)                # every direct child — files AND subdirectories, unfiltered
 
for csv_file in Path("reports").glob("*.csv"):
    print(csv_file)              # only files matching the pattern, in this directory
 
for csv_file in Path("reports").rglob("*.csv"):
    print(csv_file)               # the recursive version — searches every subdirectory too

.iterdir() lists everything directly inside a directory with no filtering. .glob(pattern) filters by a shell-style wildcard pattern (*.csv, report_*.json) within that one directory; .rglob(pattern) does the same search recursively through every subdirectory — the "r" is easy to misread as unrelated to recursion, but that's exactly what it means, and it's the one to reach for when a file could be nested arbitrarily deep.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What does the / operator do between two Path segments?

2. What does Path('report.csv').read_text() do?

3. What does mkdir(parents=True, exist_ok=True) do differently from a plain mkdir()?

4. What's the difference between .glob('*.csv') and .rglob('*.csv')?