Stop Using os.path: A Practical Deep Dive into Python's pathlib
Learn how pathlib replaces the clunky os.path string-juggling with clean, object-oriented file paths — building paths, globbing, reading and writing files, and the pitfalls to avoid.
For years, working with file paths in Python meant stitching strings together with os.path.join, checking existence with os.path.exists, and splitting extensions with os.path.splitext. It worked, but it was verbose, error-prone, and treated paths as dumb strings. Since Python 3.4, the standard library has offered a far better tool: pathlib. It models filesystem paths as rich objects with methods and operators that read like plain English. If you are still reaching for os.path by habit, this guide will show you why pathlib deserves to be your default.
Why paths deserve to be objects
A path is more than a string. It has parents, a name, a suffix, and a set of operations you naturally want to perform on it: join, resolve, check, iterate. The os.path module scatters these across standalone functions, so your code becomes a soup of nested calls. Compare the two styles for building a path to a config file inside a user's home directory:
import os
from pathlib import Path
# The old way
config_old = os.path.join(os.path.expanduser("~"), ".myapp", "config.toml")
# The pathlib way
config_new = Path.home() / ".myapp" / "config.toml"
The / operator is the headline feature. It is overloaded to join path segments, so building nested paths reads naturally and works identically on Windows, macOS, and Linux — pathlib handles the separator for you.
Creating and inspecting paths
You create a path with the Path constructor. Nothing touches the filesystem until you ask it to — a Path object is just a description of a location that may or may not exist.
from pathlib import Path
p = Path("/home/anna/projects/report.tar.gz")
print(p.name) # report.tar.gz
print(p.stem) # report.tar
print(p.suffix) # .gz
print(p.suffixes) # ['.tar', '.gz']
print(p.parent) # /home/anna/projects
print(p.parts) # ('/', 'home', 'anna', 'projects', 'report.tar.gz')
Notice how stem and suffix replace the awkward os.path.splitext, and suffixes gives you every extension for files like tarballs. To swap an extension, use with_suffix; to change the filename, use with_name:
print(p.with_suffix(".zip")) # /home/anna/projects/report.tar.zip
print(p.with_name("summary.txt")) # /home/anna/projects/summary.txt
Absolute paths, resolving, and the current directory
Relative paths become absolute with resolve(), which also collapses .. segments and follows symlinks. Use Path.cwd() for the working directory and Path.home() for the user's home.
from pathlib import Path
rel = Path("data/../data/input.csv")
print(rel.resolve()) # e.g. /home/anna/data/input.csv (fully normalized)
print(Path.cwd()) # current working directory
A common real-world need is to locate a file relative to the current script rather than the current working directory. The idiom is Path(__file__).resolve().parent:
from pathlib import Path
HERE = Path(__file__).resolve().parent
data_file = HERE / "data" / "seed.json"
Checking what exists
The query methods return booleans and never raise for a missing path, so they are safe to call directly.
from pathlib import Path
p = Path("config.toml")
print(p.exists()) # True if the path exists at all
print(p.is_file()) # True only if it is a regular file
print(p.is_dir()) # True only if it is a directory
print(p.is_symlink()) # True if it is a symbolic link
Reading and writing without boilerplate
One of the most underrated features is that Path objects can read and write files directly for the common small-file case, sparing you an explicit open() and with block:
from pathlib import Path
note = Path("note.txt")
# Write text (creates or overwrites the file, handles closing for you)
note.write_text("Hello, pathlib!\n", encoding="utf-8")
# Read it back
content = note.read_text(encoding="utf-8")
print(content)
# Binary variants exist too
Path("blob.bin").write_bytes(b"\x00\x01\x02")
data = Path("blob.bin").read_bytes()
For anything larger, streaming, or line-by-line processing, keep using the context-manager form — Path supports it via open():
with Path("big.log").open("r", encoding="utf-8") as f:
for line in f:
process(line)
Creating and removing directories
The mkdir method takes two flags that eliminate a whole class of bugs. parents=True creates intermediate directories (like mkdir -p), and exist_ok=True makes the call idempotent instead of raising FileExistsError.
from pathlib import Path
out = Path("build/reports/2026")
out.mkdir(parents=True, exist_ok=True)
# Remove a single empty directory or a file
(out / "temp.txt").write_text("scratch")
(out / "temp.txt").unlink() # delete a file
out.rmdir() # only works on an empty directory
Note that rmdir only removes empty directories and unlink only removes files. To delete a directory tree with contents, reach for shutil.rmtree — pathlib deliberately does not offer a recursive-delete footgun.
Globbing: finding files by pattern
This is where pathlib really shines. The glob method matches within one directory, while rglob (recursive glob) walks the entire tree. Both return generators, so they are memory-efficient even over large directories.
from pathlib import Path
project = Path("src")
# All Python files directly in src/
for py in project.glob("*.py"):
print(py)
# All Python files anywhere under src/, at any depth
for py in project.rglob("*.py"):
print(py)
# Combine with a comprehension to build a list
tests = [p for p in project.rglob("test_*.py")]
print(f"Found {len(tests)} test files")
To simply list everything in a directory, use iterdir(), which yields each child path:
for child in Path(".").iterdir():
kind = "dir " if child.is_dir() else "file"
print(kind, child.name)
Metadata with stat()
When you need file size, modification time, or permissions, stat() returns the same information as os.stat, now hanging off the path object:
from pathlib import Path
from datetime import datetime
p = Path("note.txt")
info = p.stat()
print(f"Size: {info.st_size} bytes")
print(f"Modified: {datetime.fromtimestamp(info.st_mtime)}")
Common pitfalls
Forgetting that paths are lazy. Creating Path("missing.txt") never fails, even if the file does not exist. The error only appears when you try to read, write, or stat it. Check with exists() first when that matters.
Confusing / precedence. The / operator needs a Path on the left side. "folder" / Path("file") works, but "a" / "b" is just string division and raises TypeError. Start the chain with a Path.
Mixing str and Path in old APIs. Most of the standard library accepts Path objects directly now, but a few third-party libraries still expect strings. When in doubt, wrap with str(my_path) at the boundary.
Assuming resolve() requires the file to exist. Since Python 3.6, resolve() works on non-existent paths by default (it used to be strict). Pass strict=True if you actually want it to raise for missing targets.
Wrap-up and next steps
pathlib turns filesystem work from string manipulation into clear, object-oriented code. The / operator for joining, read_text/write_text for quick I/O, and rglob for searching cover the vast majority of everyday needs, and they behave consistently across operating systems. A good habit is to import Path at the top of any script that touches files and let os.path fade into legacy code.
From here, explore shutil for higher-level copy and move operations, tempfile for scratch files and directories (its functions return paths you can wrap), and the PurePath classes in pathlib for manipulating paths of a different OS without touching your own filesystem. Once pathlib becomes muscle memory, you will wonder how you ever tolerated the old way.