A hands-on Linux localhost benchmark of pathlib and os.path for joins, filesystem checks, stats, and recursive scans.
6 min read
pathlib is slower than os.path mainly when building paths: on Python 3.13.5, Path / ran about 0.48M joins/s against 2.2M for os.path.join (about 0.22x), while exists and stat were only about 15-20% slower because the system call dominates. Keep pathlib for readable app code, and fall back to os.path.join or os.walk only where a profiler shows path building in a hot loop.
Arms:
os.path.join, string '/' concat, Path.joinpath, Path /.os.path.exists, Path.exists, os.stat try/except.os.stat, Path.stat, os.path.getsize.Path.rglob('*.txt'), glob.iglob(..., recursive=True), os.walk count.Related links:
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.
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.
| 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.
| 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.
*.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.
| 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.
| 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 |
Evidence: /workspace/lab-evidence/47-pathlib-vs-ospath/results/.
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.
For joins, clearly: Path / about 0.48M ops/s, Path.joinpath 0.98M, os.path.join 2.20M. For Path.exists and Path.stat the gap is small (about 0.86x and 0.83x of os.path).
On this roughly 92-entry tree, yes: os.walk with endswith did about 9.5k scans/s vs about 4.0k for Path.rglob('*.txt'). glob.iglob landed next to rglob.
No. A single join in a request handler doesn't matter. The tax shows up in tight loops building millions of paths, such as ingest or scan jobs.
Lab evidence
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/.
Explore the product
Untested by us. Check what's included, the license and refund terms before you buy.
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.
Summary1 Oct 20264 min read
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.
Summary1 Oct 20263 min read
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.
Summary1 Oct 20264 min read