Plate 53
pathlib vs os.path vs str: Path Ops Lab
A hands-on Linux localhost benchmark of pathlib and os.path for joins, filesystem checks, stats, and recursive scans.
Aditya Challa5 min read
Intro — what this post promises
Is pathlib.Path “just as fast” as os.path? Folklore says yes for modern Python. This lab times join, exists, stat, and glob/walk on a small real tree under /tmp on Linux localhost.
Arms:
- Join:
os.path.join, string'/'concat,Path.joinpath,Path /. - Exists / missing:
os.path.exists,Path.exists,os.stattry/except. - Stat / size:
os.stat,Path.stat,os.path.getsize. - Walk:
Path.rglob('*.txt'),glob.iglob(..., recursive=True),os.walkcount.
Related links:
- tempfile NamedTemporaryFile localhost lab
- atomic rename vs overwrite localhost lab
- fsync vs fdatasync localhost lab
- posix fadvise sequential vs random localhost lab
- dataclass vs slots vs dict localhost lab
- json vs orjson vs msgpack localhost lab
- python re vs str methods localhost lab
- zstd vs gzip vs lz4 compression localhost lab
Lab honesty (1 Oct 2026 IST): Python 3.13.5. Tree ~92 entries at /tmp/pathlab_tree. Affiliates: 0. Hot-cache microbench — not NFS / cold-page cold-start.
Verdict up front: join is where pathlib hurts. Path / ~0.48M/s vs os.path.join ~2.20M (~0.22×) vs raw str slash ~20.4M. exists ~0.86× and stat ~0.83× vs os.path — modest. os.walk count (~9.5k/s) beat rglob (~4.0k/s) on this tree.
What we are not claiming
- pathlib is “bad” — ergonomics and type safety still win for app code.
- str slash is portable — it is a Unix-shaped microbench cheat.
- These numbers transfer to network filesystems or antivirus-scanned Windows paths.
- A single join in a request handler matters — it does not. Tight loops (millions of path builds) do.
Lab topology
The tree is rebuilt at the start of run_lab.py so paths are real and exists hits. Join arms construct the same logical path under the tree root. Script: lab-evidence/47-pathlib-vs-ospath/results/run_lab.py.
Lead table — path join (p50 ops/s)
| Arm | ops/s | ns/op | vs os.path.join |
|---|---|---|---|
str '/' concat | 20 421 345 | 49 | 9.3× |
os.path.join | 2 204 063 | 454 | 1.00× |
Path.joinpath | 982 270 | 1018 | 0.45× |
Path / (truediv) | 478 360 | 2090 | 0.22× |
Path / allocates and normalizes PosixPath objects (~2090 ns/op here). Fine in app code; painful in a tight inner loop that only needs a string for open(). Prefer joinpath over chained / if you stay on Path — still ~0.45× os.path.join on this box.
Exists and stat
| Arm | ops/s | vs baseline |
|---|---|---|
os.path.exists (hit) | 707 440 | 1.00× |
Path.exists (hit) | 606 946 | 0.86× |
os.stat try (exists) | 725 282 | 1.03× |
os.path.exists (miss) | 821 352 | — |
Path.exists (miss) | 567 037 | — |
os.stat | 760 406 | 1.00× |
Path.stat | 632 771 | 0.83× |
os.path.getsize | 716 735 | 0.94× |
Syscall dominates; pathlib’s Python wrapper is a ~15–20% tax here — not a cliff like join. Missing-path exists was slightly faster for os.path (no inode payload) and slightly slower for pathlib on this run — treat that as noise band, not a product claim.
Glob / walk — count *.txt
| Arm | scans/s | notes |
|---|---|---|
os.walk + endswith | 9 507 | count 80 matches |
glob.iglob recursive | 4 072 | stdlib glob |
Path.rglob('*.txt') | 3 985 | materialize list |
Path.glob under a/ | 13 503 | smaller subtree |
On this ~92-entry tree, a tight os.walk loop was ~2.4× rglob. Larger trees and pattern complexity will shift absolute numbers; the relative tax of Path object churn remains the story. glob.iglob landed next to rglob (~4.1k/s) when both materialize matches.
Constructor overhead (bonus)
| Arm | ops/s |
|---|---|
| str identity | 35.4M |
Path(root) | 1.74M |
Path(root, 'a', '1', 'f1.txt') | 1.23M |
Building Path objects is cheap next to disk — expensive next to string join in a hot path. If you construct once and reuse, cost amortizes; if you rebuild Path on every row of a large scan, you pay the join cliff again.
Pitfalls
- Benchmarking only exists/stat and concluding “pathlib is free” — join is the cliff.
- Using str slash in production Windows code — portability tax.
- Timing one cold walk — we use repeats; first-call noise is real on bigger trees.
- Comparing rglob list materialization to a streaming walk — fair for “give me all matches,” not for early-exit search.
- Over-optimizing one join in a web handler — measure the whole request; this lab is for ingest/scan loops.
When to pick what
| Need | Prefer |
|---|---|
| App / library path logic | pathlib |
| Hot join loop → string for syscall | os.path.join or careful str |
| Massive recursive scan | os.walk / os.scandir |
| Readable one-off scripts | Path — clarity wins |
Reproduce
Evidence: /workspace/lab-evidence/47-pathlib-vs-ospath/results/.
Closing
pathlib is not free on join: Path / ~0.48M vs os.path.join ~2.2M (~0.22×) vs str ~20M on this box. exists/stat stay in the ~0.83–0.86× band. Walking: os.walk ~9.5k/s vs rglob ~4.0k/s on a ~92-entry tree. Use Path for clarity; drop to os.path / os.walk when a profiler shows path construction in the hot path — and measure, don’t folklore.
Lab evidence
What I found running this
Lab 1 Oct 2026 IST. Python 3.13.5; tree ~92 entries under /tmp/pathlab_tree. Join: str slash 20.4M; os.path.join 2.20M; Path.joinpath 0.98M; Path/ 0.48M (~0.22x). exists: os.path 707k vs pathlib 607k (~0.86x). stat: os.stat 760k vs Path.stat 633k (~0.83x). rglob *.txt 3985/s vs os.walk 9507/s. Affiliates: 0. Evidence: lab-evidence/47-pathlib-vs-ospath/.
Related links
Plate 17
platform vs os.uname Inventory: Localhost Lab
Hands-on platform.platform vs os.uname host inventory lab: real ops/s plus cache notes, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026
Plate 75
uuid.uuid4 vs uuid.uuid1: Localhost Lab
Hands-on uuid.uuid4 vs uuid.uuid1 ID generation lab: real ops/s plus version/node checks, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026
Plate 50
signal vs threading.Event Wakeup: Localhost Lab
Hands-on signal SIGUSR1 vs threading.Event wakeup lab: real p50 latency in microseconds, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026