Benchmarks
pathlib_next includes a small benchmark harness at benchmarks/bench.py
for checking hot-path behavior across local, in-memory, HTTP, and SFTP
implementations.
Run It
python benchmarks/bench.py
Optional stress case:
PATHLIB_NEXT_BENCH_SFTP_RECURSIVE=1 python benchmarks/bench.py
That opt-in flag enables the recursive SFTP probe rows, which are more expensive and are best treated as CI/manual benchmark runs rather than something to trust on a loaded development machine.
Narrow SFTP probes:
python benchmarks/bench.py sftp-recursive
python benchmarks/bench.py sftp-recursive-copy
python benchmarks/bench.py sftp-batch
python benchmarks/bench.py syncer
Use these when investigating SFTP behavior; --help is safe and prints
available benchmark subcommands without running benchmark work.
What It Covers
- URI parse / compose cost
- generic path joins and name/suffix access
MemPathrecursive globLocalPathvspathlib.Pathon common local operations- HTTP directory traversal and parser throughput
paramikovsasyncsshfor the same loopback SFTP workload
The SFTP comparison uses an in-process loopback server so both client backends hit the same filesystem with the same fixture tree. That keeps the comparison focused on backend overhead and request behavior rather than WAN latency.
Normal sftp:// usage defaults to the system OpenSSH client config on both
backends. The benchmark harness explicitly disables SSH config/key discovery
for its asyncssh comparison so both backends are measured with similarly
minimal connection setup instead of inheriting machine-specific SSH client
state.
Sample Results
These numbers are from a local Windows run on July 12, 2026 with
.venv/3.12.10. Treat them as a shape-of-performance snapshot, not a stable
cross-machine contract.
| Benchmark Case | Time / Metric |
|---|---|
| URI Parse (10k) | 0.0790s |
| URI Parse, unique URIs, forced (us/parse) | 27.24us |
| URI Parse+Compose, unique URIs (us/round-trip) | 40.30us |
| Path Join (10k) | 0.1599s |
| Segments/Name Access (10k) | 0.0014s |
| Suffix/Stem Access (10k) | 0.0010s |
| Glob 1k MemPath (20) | 0.5281s |
| LocalPath vs Stdlib (2k stat) | Local: 0.0388s, Stdlib: 0.0332s |
| LocalPath construct path (10k) | Local: 0.0175s, Stdlib: 0.0184s |
| LocalPath join path (10k) | Local: 0.0498s, Stdlib: 0.0456s |
| LocalPath stat() file (2k) | Local: 0.0296s, Stdlib: 0.0328s |
| LocalPath read_bytes() 64 KiB | Local: 0.0002s, Stdlib: 0.0003s |
| LocalPath iterdir() 8 entries | Local: 0.0003s, Stdlib: 0.0003s |
| LocalPath glob('*/.txt') 240 files | Local: 0.0143s, Stdlib: 0.0120s |
| HTTP Glob (10) | 1.0840s |
| HTTP Walk (10) | 0.4492s |
HTTP dir listing parse, Apache <pre> (n=1000) |
76.4667ms/parse |
HTTP dir listing parse, nginx <table> (n=1000) |
140.1685ms/parse |
SFTP warm stat() |
paramiko: 0.0016s, asyncssh: 0.0031s |
SFTP iterdir() 72 entries |
paramiko: 0.1131s, asyncssh: 0.1961s |
SFTP walk() 80 files |
paramiko: 0.3791s, asyncssh: 0.4584s |
SFTP glob('**/*.txt') 80 files |
paramiko: 0.7624s, asyncssh: 1.0949s |
SFTP read_bytes() small file |
paramiko: 0.0034s, asyncssh: 0.0092s |
SFTP read_bytes() 64-file batch |
paramiko: 0.2463s, asyncssh: 0.6104s |
SFTP stat() 64-file batch |
paramiko: 0.0756s, asyncssh: 0.1695s |
SFTP write_bytes() 256 KiB |
paramiko: 0.0140s, asyncssh: 0.0150s |
SFTP mkdir() leaf dir |
paramiko: 0.0018s, asyncssh: 0.0032s |
SFTP rename() file |
paramiko: 0.0034s, asyncssh: 0.0049s |
SFTP unlink() file |
paramiko: 0.0016s, asyncssh: 0.0032s |
SFTP copy() single 256 KiB file |
paramiko: 0.0414s, asyncssh: 0.0544s |
SFTP rm(recursive=True) 9-file tree |
paramiko: 0.3291s, asyncssh: TimeoutError |
SFTP cold connect + stat() |
paramiko: 0.0288s, asyncssh: 0.0552s |
CI Snapshot
GitHub Actions run Test #3 on July 12, 2026 also completed the benchmark
job successfully on ubuntu-latest, windows-latest, and macos-latest.
Those runner artifacts are a better cross-machine comparison than the local
developer box.
Selected CI results:
| Case | Ubuntu | Windows | macOS |
|---|---|---|---|
| URI Parse (10k) | 0.0325s | 0.0517s | 0.0582s |
| LocalPath stat() file (2k) | 0.0105s vs stdlib 0.0132s | 0.0054s vs stdlib 0.0052s | 0.0030s vs stdlib 0.0028s |
| HTTP Walk (10) | 0.0994s | 0.0949s | 0.1207s |
SFTP iterdir() 72 entries |
p: 0.0143s, a: 0.0157s | p: 0.0029s, a: 0.0041s | p: 0.0061s, a: 0.0074s |
SFTP read_bytes() 64-file batch |
p: 0.0540s, a: 0.1133s | p: 0.0735s, a: 0.1423s | p: 0.0783s, a: 0.1774s |
SFTP write_bytes() 256 KiB |
p: 0.0033s, a: 0.0038s | p: 0.0041s, a: 0.0049s | p: 0.3289s, a: 0.0054s |
SFTP copy() single 256 KiB file |
p: 0.0082s, a: 0.0095s | p: 0.0086s, a: 0.0137s | p: 0.3348s, a: 0.0154s |
SFTP cold connect + stat() |
p: 0.0049s, a: 0.0069s | p: 0.0126s, a: 0.0121s | p: 0.0874s, a: 0.0095s |
SFTP rm(recursive=True) 9-file tree |
p: 0.0546s, a: TimeoutError | p: 0.0429s, a: TimeoutError | p: 0.0494s, a: TimeoutError |
Legend: p = paramiko, a = asyncssh.
Current Takeaways
- Post-fix note, July 12, 2026:
asyncsshrecursive remove now has a backend-native bounded async implementation, and generic recursiverm()now reuses listing metadata for backends such as paramiko SFTP. On a local Windows.venv/3.12.10run,python benchmarks/bench.py sftp-recursivecompleted the 9-file remove probe: paramiko0.1531s, asyncssh0.2293s(paramiko/asyncssh=0.67x). The older timeout rows above are pre-fix snapshots. - On the same machine,
python benchmarks/bench.py sftp-batchreported: 64-file reads paramiko0.1688svs asyncssh0.3664s; 64-file stats paramiko0.0526svs asyncssh0.1058s; single unlink paramiko0.0014svs asyncssh0.0026s. - After native asyncssh recursive copy,
python benchmarks/bench.py sftp-recursive-copycompleted locally: paramiko0.1162s, asyncssh0.1698sfor the 4-file tree. Asyncssh scaling probes reportedmc=1: 0.2631sandmc=4: 0.2921s, so higher concurrency did not help this tiny loopback fixture. python benchmarks/bench.py sftp-recursive-largeon a local Windows.venv/3.12.10run reported a mixed result for a 128-file tree: recursive copy favored asyncssh (paramiko 13.8991s,asyncssh 10.9022s), while recursive remove favored paramiko (paramiko 2.8129s,asyncssh 4.4896s). Asyncssh copy scaling was counterintuitive on loopback:max_concurrency=1was fastest at7.6068s, with4at14.2806sand8at13.3736s. Treat this as workload-specific follow-up evidence, not as a default tuning decision.- 2026-07-18 (0.8.3):
AsyncsshSftpBackenddefaultmax_concurrencyraised 8 → 16. A cleaner 128-file loopback sweep ofmc ∈ {1,2,4,8,16}(median of 3, py3.14) showed recursive copy improving monotonically with concurrency (mc=11.66s→ mc=81.47s≈ 1.13x → mc=161.42s, a further ≈3%), and recursive remove flat within noise (median spread0.498..0.551, mc=16 marginally best). This supersedes the earlier "mc=1 fastest" loopback reading (from the older code path / a different venv). 16 stays within asyncssh's SFTP request window. Loopback only — no per-op latency; a high-latency remote link may favour higher concurrency still, so 16 is a safe modest default, not a tuned optimum. Override per backend viaAsyncsshSftpBackend(max_concurrency=…). python benchmarks/bench.py synceron the same local run reported PathSyncer copy of 128 local files at0.4524s, dry-run at0.0534s, and remove-missing plus copy at0.9260s. This suggests metadata reuse may be worth investigating before adding parallel sync behavior.- After PathSyncer metadata reuse, a later local
syncerrun reported copy at0.4390s, dry-run at0.0535s, and remove-missing plus copy at0.4764s. Treat this as a meaningful remove-missing improvement and a roughly neutral copy/dry-run result; local variance produced one slower outlier run. - S3 recursive delete now uses provider-native
delete_objectsbatching for prefixed trees while guarding bucket-root recursive delete. This is based on fake-client call-shape tests rather than live AWS timing. python benchmarks/bench.py recursive-matrixon a local Windows.venv/3.12.10verification run reported:LocalPathrecursive copy0.3115s,LocalPathrecursive remove0.3363s,MemPathrecursive copy0.0377s, andMemPathrecursive remove0.0197sfor a 33-file tree. An earlier same-machine run was faster, so treat local filesystem timings as noisy and use the command primarily for trend checks. The same command reported the fake S3 recursive delete call shape as onehead_object, onelist_objects_v2, onedelete_objects, and 34 deleted keys including the marker. GCS reported one exact-objectreload, onelist_blobs, and 34 per-blob deletes. Azure reported one exact-object property check, onelist_blobs, onedelete_blobsbatch call, and no per-blob delete calls on the fake surface.LocalPathis competitive withpathlib.Pathon several hot local operations in this run, but still trails on recursive globbing and the sampledread_bytes()case.- Across the three CI runners,
paramikostill wins most completed sync-style SFTP operations, especially directory traversal and many-small- file workloads. asyncssh's recursive remove probe timed out on all three CI runners, not just on the local Windows machine, which makes this look like a backend or benchmark-shape issue rather than pure local machine noise.- The macOS artifact showed large
paramikoslowdowns on the single-filewrite_bytes()andcopy()cases plus cold connect. That is worth a follow-up sanity check before treating those particular macOS numbers as a stable performance signal. - On this run, every completed sync-style SFTP measurement still favored
paramiko, includingiterdir(),glob(), batchedread_bytes(), and batchedstat(). - The current recursive remove probe is a useful warning sign:
paramikocompleted the 9-file tree removal, whileasyncsshtimed out on this loaded Windows machine. - Even after aligning auth/config behavior, the loopback SFTP comparison
still points at the same design tradeoff:
asyncsshis going through a sync-to-async bridge on every small operation, while paramiko is already a sync client. - The broad SFTP benchmark is useful for backend comparison even when the operation itself is exposed synchronously, because backend internals still affect overall throughput.
Caveats
- Benchmark output is environment-sensitive: Python version, OS, filesystem, CPU, and installed extras all matter.
- The benchmark disables OpenSSH config/key discovery only for the loopback backend comparison, to avoid machine-specific SSH client state skewing the numbers.
- For regressions, compare before/after runs on the same machine and Python version rather than comparing absolute times across environments.