Stop Passing Strings Around: Making pathlib.Path Your Default Filesystem API
Why pathlib.Path should be your default filesystem API: typed paths, the / operator, high-level I/O methods, and the honest trade-offs at string boundaries and hot loops.
07 Sept 2026, 23:55 UTC

Somewhere in your codebase there is a function like def load_config(path) where path is a string, and three call sites later someone does os.path.join(os.path.dirname(path), 'backup', name + '.bak') and hopes the separators work out on Windows. That code usually does work — until it doesn't, and the failure shows up as a file written to the wrong directory on one platform.
The fix isn't a linter rule or more tests. It's a design decision: treat paths as pathlib.Path objects everywhere inside your application, and only convert to strings at the boundaries that demand them. This post makes the case for that default, shows what it looks like in practice, and covers where it genuinely costs you something.
Why strings are a weak path type
A string accepts any operation a string accepts. You can concatenate a path with + '.tmp', split it on '/', or pass it to a function expecting a URL. Nothing stops you, and nothing warns you. os.path helps, but it keeps the data as a string and pushes correctness onto the caller remembering to use the right helper in the right order.
A Path object flips that. The type itself says "this is a filesystem path," and the valid operations are methods on the object. Static analyzers can flag a function that receives a Path where a database connection string was expected. With a plain string, that mistake is invisible until runtime.
What the object-oriented API actually buys you
Three concrete improvements, in order of daily impact:
- Joining with
/.base / 'sub' / 'file.txt'reads left to right and never makes you think about separators. Compare withos.path.join(os.path.join(base, 'sub'), 'file.txt'), which nests in the opposite direction you read. - High-level I/O methods.
p.read_text()andp.write_text(data)replace thewith open(...) as f:ceremony for the common case of slurping or writing a whole file. Intent is explicit: one file, one operation. - Cross-platform behavior for free.
Pathpicks the right concrete flavor (PosixPathorWindowsPath) for the platform it runs on, handling drive letters and separators without conditional logic in your code.
One underappreciated property: constructing and combining Path objects performs no filesystem access. You can build elaborate path expressions cheaply, and only pay for I/O when you call something like exists(), read_text(), or mkdir(). That makes it safe to pass partially-built paths around and let the lowest layer decide what to touch.
A worked example: a small file organizer
Here's a realistic before/after. The task: move .log files from an inbox directory into a dated archive folder, returning the list of moved files.
# Before: os.path and strings
import os, shutil
def archive_logs(inbox, archive_root, day):
dest_dir = os.path.join(archive_root, day)
os.makedirs(dest_dir, exist_ok=True)
moved = []
for name in os.listdir(inbox):
if name.endswith('.log'):
src = os.path.join(inbox, name)
shutil.move(src, os.path.join(dest_dir, name))
moved.append(os.path.join(dest_dir, name))
return moved# After: pathlib
from pathlib import Path
import shutil
def archive_logs(inbox: Path, archive_root: Path, day: str) -> list[Path]:
dest_dir = archive_root / day
dest_dir.mkdir(parents=True, exist_ok=True)
moved = []
for src in inbox.glob('*.log'):
dest = dest_dir / src.name
shutil.move(src, dest)
moved.append(dest)
return movedThe second version is shorter, but the real win is structural: glob('*.log') replaces the listdir-plus-suffix-filter, the signature tells callers exactly what to pass, and every returned value is still a Path the caller can keep operating on (moved[0].stem, moved[0].parent) without re-parsing strings.
You can verify the behavior in a REPL in a few seconds:
>>> from pathlib import Path
>>> p = Path('a') / 'b' / 'c.txt'
>>> print(p) # a/b/c.txt on POSIX, a\b\c.txt on Windows
>>> p.name, p.stem, p.suffix
('c.txt', 'c', '.txt')Run this on your target platforms — the printed separator is the quickest sanity check that the flavor selection works as expected.
The trade-offs you should actually weigh
Boundary friction is real. Some third-party libraries and older stdlib corners still want strings. The convention that works: accept and return Path everywhere in your own code, and call str(path) at the exact call site that needs it. Note that shutil.move, open(), and most modern stdlib functions accept path-like objects directly, so conversions are rarer than you'd expect.
resolve() changes semantics. It follows symlinks and normalizes .. segments. That's what you want for canonicalization, but in security-sensitive code — say, checking that a user-supplied path stays inside an allowed directory — be deliberate about whether you compare the resolved path or the original. Resolving both sides of the comparison is usually the correct move, but do it consciously.
Hot loops. If you're scanning millions of paths, Path object construction costs more than raw string slicing. os.scandir() with string operations can be measurably faster in tight traversal loops. This matters in a tiny fraction of programs; if yours is one, benchmark before rewriting, and consider keeping the os-level code in that one hot function while the rest of the app stays on Path.
How to adopt it without a big-bang rewrite
Don't migrate the whole repo. Pick a rule for new code: function signatures use Path, and os.path doesn't appear in new modules. At the edges of old code, convert once — Path(old_string) on the way in, str(p) only if a legacy callee insists. Within a few months the string-based code is quarantined to legacy modules, and everything new gets the typed, readable API.
The actionable step for this week: find one function that builds a path with os.path.join and then does I/O with it. Rewrite it with Path, run it on both Linux and Windows if you can, and diff the behavior. That single exercise will show you the readability gain and the boundary friction in about fifteen minutes — which is all the evidence you need to set the default.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.