Stop Juggling Strings: A Practical Deep Dive into Python's pathlib
Learn how to handle files and directories the modern way with pathlib — joining paths, splitting names, globbing, reading and writing files, and the pitfalls that trip people up when migrating from os.path.
For years, working with files in Python meant stitching together strings with os.path.join, slicing off extensions with os.path.splitext, and squinting at nested function calls to figure out what a path actually pointed to. It worked, but it read badly and it was easy to get wrong on the day you moved code from your Mac to a Windows server. Since Python 3.4, the standard library has shipped a better answer: pathlib. It models filesystem paths as objects with methods and operators, so path manipulation becomes readable, cross-platform, and far harder to fumble.
This is a practical tour of pathlib — how to build paths, take them apart, walk directories, and read and write files — with runnable examples and the gotchas that catch people migrating from os.path.
Building paths with the / operator
The headline feature is that Path objects overload the division operator to join path components. No more nested os.path.join calls, and no worrying about which separator the operating system uses.
from pathlib import Path
# Joins use the OS-correct separator automatically
data_file = Path("data") / "raw" / "file.csv"
print(data_file) # data/raw/file.csv (data\raw\file.csv on Windows)
# Handy constructors
print(Path.cwd()) # current working directory
print(Path.home()) # user's home directory
# A string works fine as the first operand's partner
config = Path.home() / ".config" / "myapp" / "settings.toml"
print(config)
Every one of these returns a new Path; nothing touches the disk yet. A path object is just a description of a location. That distinction — describing a path versus acting on the filesystem — is worth keeping in mind as we go.
Taking a path apart
Once you have a path, its components are attributes rather than the results of separate function calls. This is where pathlib really outshines os.path for readability.
from pathlib import Path
p = Path("/home/anna/reports/q3.csv")
print(p.name) # q3.csv - filename with extension
print(p.stem) # q3 - filename without the final suffix
print(p.suffix) # .csv - the final extension
print(p.parent) # /home/anna/reports
print(list(p.parents))
# [/home/anna/reports, /home/anna, /home, /]
# Multiple extensions are handled sensibly
print(Path("archive.tar.gz").suffixes) # ['.tar', '.gz']
Just as useful are the with_* helpers, which return a modified copy — perfect for deriving an output path from an input path:
p = Path("/home/anna/reports/q3.csv")
print(p.with_suffix(".parquet")) # /home/anna/reports/q3.parquet
print(p.with_stem("q4")) # /home/anna/reports/q4.csv (3.9+)
print(p.with_name("summary.csv")) # /home/anna/reports/summary.csv
The common pattern "read report.csv, write report.parquet next to it" collapses to a single expressive line: out = src.with_suffix(".parquet").
Reading and writing files
pathlib bundles the most common read/write patterns into one-liners, so you can skip the open() boilerplate for small files. Each of these opens the file, does the transfer, and closes it for you.
from pathlib import Path
note = Path("note.txt")
# Write (creates or overwrites) and read back
note.write_text("hello\nworld\n", encoding="utf-8")
print(note.read_text(encoding="utf-8")) # hello\nworld\n
# Binary variants for non-text data
Path("blob.bin").write_bytes(b"\x00\x01\x02")
print(Path("blob.bin").read_bytes()) # b'\x00\x01\x02'
Always pass encoding="utf-8" explicitly. Without it, Python falls back to the platform default, which can differ between machines and produce mangled text or UnicodeDecodeError that only shows up in production. For a real-world config round-trip:
import json
from pathlib import Path
cfg = Path("config.json")
cfg.write_text(json.dumps({"debug": True, "retries": 3}), encoding="utf-8")
data = json.loads(cfg.read_text(encoding="utf-8"))
print(data["retries"]) # 3
When you need streaming or fine-grained control, Path.open() works exactly like the built-in open() and returns the same file object, so you lose nothing by adopting pathlib:
with Path("big.log").open(encoding="utf-8") as fh:
for line in fh:
process(line)
Creating directories and removing files
The two flags parents and exist_ok handle the cases that used to require try/except around os.makedirs.
from pathlib import Path
out = Path("output") / "2026" / "q3"
out.mkdir(parents=True, exist_ok=True) # create the whole chain, no error if it exists
# Remove a file; missing_ok avoids an error if it's already gone (3.8+)
(out / "stale.tmp").unlink(missing_ok=True)
# Remove an empty directory
# out.rmdir() # raises if the directory is not empty
Note that rmdir() only removes empty directories. To delete a directory and everything in it, reach for shutil.rmtree() — pathlib deliberately doesn't provide a recursive delete, since it's an easy way to lose data by accident.
Finding files with glob and rglob
Pattern matching over directory contents is built in. glob() searches one directory level; rglob() (or the ** pattern) recurses into subdirectories.
from pathlib import Path
project = Path(".")
# All Python files in the current directory
for py in project.glob("*.py"):
print(py)
# Every CSV anywhere beneath the current directory
for csv in project.rglob("*.csv"):
print(csv)
# Combine with stat() to answer real questions
largest = max(project.rglob("*.log"), key=lambda p: p.stat().st_size)
print("Biggest log:", largest)
Both methods return generators, so they're memory-friendly even on large trees — you iterate lazily rather than materializing a giant list. To list a single directory without a pattern, use iterdir().
Checking existence and inspecting metadata
from pathlib import Path
p = Path("config.json")
print(p.exists()) # True/False
print(p.is_file()) # True if it exists and is a regular file
print(p.is_dir()) # True if it exists and is a directory
info = p.stat()
print(info.st_size) # size in bytes
print(info.st_mtime) # last-modified time (epoch seconds)
Absolute paths, resolution, and the pure classes
Two methods turn relative paths into absolute ones. resolve() also follows symlinks and collapses .. segments, which makes it the right choice when you need a canonical, comparable path:
from pathlib import Path
p = Path("data/../data/file.csv")
print(p.resolve()) # /abs/path/to/data/file.csv (symlinks resolved)
# relative_to computes the path relative to a base
root = Path("/srv/app")
full = Path("/srv/app/static/logo.png")
print(full.relative_to(root)) # static/logo.png
If you're manipulating paths for a different operating system — say, generating Windows paths on a Linux CI box — use the pure classes, which never touch the filesystem:
from pathlib import PurePosixPath, PureWindowsPath
print(PureWindowsPath("C:/Users/anna/app").as_posix()) # C:/Users/anna/app
print(PurePosixPath("/etc/nginx/nginx.conf").parent) # /etc/nginx
Common pitfalls
Passing a Path to old APIs. Nearly every modern library and all of the standard library accept Path objects directly, thanks to the os.PathLike protocol. If you hit a stubborn function that insists on a string, wrap it once with str(path) or os.fspath(path) rather than sprinkling conversions everywhere.
Forgetting that construction is lazy. Creating a Path never validates anything. Path("/does/not/exist") succeeds happily; only when you call .read_text() or .stat() do you get a FileNotFoundError. Check with .exists() first when the file might be absent.
Assuming resolve() requires the file to exist. In modern Python, resolve() works even for non-existent paths (it resolves as far as it can), so it's safe to call before creating a file.
Reaching for string concatenation. If you ever write str(path) + "/" + name, stop — that reintroduces the separator bug pathlib exists to prevent. Use path / name instead.
Wrap-up and next steps
pathlib turns filesystem work from a grab-bag of string functions into clean, object-oriented code that reads the way you think about paths. Start small: replace os.path.join with the / operator, swap open(...).read() for Path.read_text(), and use with_suffix to derive output filenames. From there, glob/rglob will quietly replace most of your directory-walking code.
Once you're comfortable, explore the newer additions: Path.walk() (Python 3.12+) for os.walk-style traversal with path objects, and the case_sensitive argument on glob. Keep the official pathlib documentation handy — it's one of the most approachable pages in the standard library reference, and a quick skim will surface a method for almost anything you need.