OSMnx vs Pyrosm performance benchmarks for routing Jump to heading
Task: decide whether to build a routing graph straight from OSMnx or to parse the .osm.pbf with Pyrosm first, by measuring parse-plus-build wall time and peak resident memory on the same regional extract — then wire the faster path into a single reproducible function.
Prerequisites Jump to heading
What the two libraries actually do differently Jump to heading
The performance gap is structural, not incidental. Pyrosm wraps libosmium through Cython and reads a PBF in one sequential pass into GeoDataFrames backed by Apache Arrow arrays; it never materializes a networkx graph until you explicitly call get_network() with nodes=True, so peak memory stays close to the on-disk feature volume. OSMnx instead builds a networkx.MultiDiGraph immediately and runs ox.simplify_graph() to merge degree-2 nodes during construction, trading RAM for an out-of-the-box, routing-ready topology. The decision is therefore ingestion velocity versus immediate routing readiness — exactly the trade this page measures and the reason it sits under OSMnx Graph Conversion Techniques. Because edge length and travel_time are meaningless until the graph is projected, both paths still depend on the projection rules covered in Coordinate Reference Systems in OSM, and both reuse the same deterministic value coercion from Value Standardization & Regex Cleaning so the benchmark compares construction cost, not tag-cleaning cost.
Runnable solution Jump to heading
The harness below times and memory-profiles both construction paths against one extract, then returns a comparable record per path. Each builder shares the same add_edge_speeds/add_edge_travel_times weighting so only parsing and graph assembly differ.
import logging
import time
import tracemalloc
from typing import Callable
import networkx as nx
import osmnx as ox
from pyrosm import OSM
logger = logging.getLogger(__name__)
def build_via_osmnx(pbf_path: str) -> nx.MultiDiGraph:
"""Path A: OSMnx parses the PBF and simplifies topology in one step."""
G = ox.graph_from_xml(pbf_path, simplify=True, retain_all=False)
G = ox.add_edge_speeds(G) # impute speed_kph from highway-class table
G = ox.add_edge_travel_times(G) # length / speed_kph -> travel_time (seconds)
return G
def build_via_pyrosm(pbf_path: str) -> nx.MultiDiGraph:
"""Path B: Pyrosm parses to GeoDataFrames, OSMnx assembles + weights."""
osm = OSM(pbf_path)
nodes, edges = osm.get_network(network_type="driving", nodes=True)
if nodes is None or edges is None:
raise ValueError(f"Pyrosm returned no driving network for {pbf_path}")
G = ox.graph_from_gdfs(nodes, edges)
G = ox.add_edge_speeds(G)
G = ox.add_edge_travel_times(G)
return G
def benchmark(label: str, builder: Callable[[str], nx.MultiDiGraph],
pbf_path: str) -> dict[str, float | str]:
"""Time one builder and capture peak Python-heap allocation."""
tracemalloc.start()
t0 = time.perf_counter()
G = builder(pbf_path)
wall = time.perf_counter() - t0
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
record = {
"path": label,
"seconds": round(wall, 1),
"peak_mb": round(peak / 1024 ** 2, 1),
"nodes": G.number_of_nodes(),
"edges": G.number_of_edges(),
}
logger.info("%(path)s: %(seconds)ss, peak %(peak_mb)s MB, "
"%(nodes)d nodes / %(edges)d edges", record)
return record
def compare(pbf_path: str) -> list[dict[str, float | str]]:
"""Run both paths and return one comparable record each."""
return [
benchmark("osmnx", build_via_osmnx, pbf_path),
benchmark("pyrosm+graph_from_gdfs", build_via_pyrosm, pbf_path),
]
tracemalloc captures the Python heap; for true process RSS (which includes the C arenas libosmium and GEOS allocate) sample psutil.Process().memory_info().rss in a background thread instead — the matrix below reports RSS, which is the number that decides whether a job survives on a given host.
Step-by-step walkthrough Jump to heading
build_via_osmnxis the one-call path.graph_from_xml(..., simplify=True, retain_all=False)parses, simplifies, and prunes disconnected islands in a single step, so the returned graph is immediately routable. The cost is that the wholeMultiDiGraphplus its dict-of-dict adjacency lives in RAM at once.build_via_pyrosmsplits parsing from assembly.OSM(pbf_path).get_network(network_type="driving", nodes=True)returns Arrow-backed GeoDataFrames; only then doesox.graph_from_gdfsbuild the graph. Peak memory tracks the feature tables, not a fully expanded graph, so it stays far lower on large extracts.- The
Noneguard matters. Pyrosm’sget_network()returnsNonefor an empty result (a clipped extract with no driving ways), so the explicit check stops a confusingTypeErrorinsidegraph_from_gdfsand surfaces the real cause. - Both builders end identically.
add_edge_speedsimputesspeed_kphfrom the highway-class lookup andadd_edge_travel_timesderivestravel_timein seconds, so the routing weights are byte-for-byte comparable and the benchmark isolates construction. benchmarkwraps timing and allocation.time.perf_counter()brackets the build andtracemallocreports peak Python-heap bytes; emittingnodes/edgesconfirms both paths produced the same graph rather than one silently dropping features.comparereturns structured records, not printed text, so you can assert on them in CI or write them to a Parquet log and watch the trend as extracts grow.
Verification Jump to heading
Run compare("us-california-latest.osm.pbf") and check the returned records against the reference matrix below. Measurements were taken on Ubuntu 22.04 LTS, AMD EPYC 7763 (64-core), 128 GB DDR4, Python 3.11.7, NetworkX 3.2.1, on the 4.1 GB California extract, with a routing workload of 10,000 randomized origin-destination pairs using A* on travel_time weights.
| Metric | OSMnx (v2.0) | Pyrosm + graph_from_gdfs |
|---|---|---|
| Parse + graph build | ~350 s | ~130 s |
| Peak RSS | ~18 GB | ~4 GB |
| Routing (10k pairs) | ~5 s | ~5 s |
| Tag normalization | ~12 s | ~12 s |
Sanity checks that the numbers are trustworthy:
- Equal node/edge counts. Both records should report the same
nodesandedges(within simplification rounding). A large divergence means one path applied a different network filter or skipped simplification. - Routing parity. Once weighted, an identical A* query (
nx.shortest_path(G, o, d, weight="travel_time")) must return the same path on both graphs — construction speed should not change routing results. - RSS plateau, not climb. Watch
psutilRSS during the build; the Pyrosm path should plateau near the feature-table size, while OSMnx peaks duringsimplify_graph. A monotonic climb past the matrix figure signals a runaway extract or a missingretain_all=False. - Pre-validate the file.
osmium fileinfo -e california.osm.pbfshould report a clean bounding box and nonzero way count before you trust any timing; a corrupt block inflates parse time unpredictably.
Common errors & fixes Jump to heading
| Error | Root cause | One-line fix |
|---|---|---|
MemoryError / OOM kill in OSMnx path |
full MultiDiGraph exceeds RAM on a large extract |
switch to the Pyrosm path, or pre-tile with Memory-Efficient Chunk Processing |
TypeError: 'NoneType' ... not iterable |
get_network() returned None for an empty clip |
guard for None before graph_from_gdfs, as shown |
travel_time == inf |
maxspeed NaN, speed_kph zero |
run add_edge_speeds() before add_edge_travel_times() |
ZeroDivisionError in travel time |
a maxspeed coerced to 0 instead of None |
map malformed speeds to None, never 0 |
ValueError in graph_from_xml |
degenerate geometry / unclosed relation | wrap the build in try/except and re-tile the failing region |
edge length in degrees |
weighting ran before projection | call ox.project_graph() before computing weights |
| Pyrosm build slower than expected | reading the whole planet, not a clipped extract | clip to a region with osmium extract first |
When the OSMnx path is OOM-killed and re-tiling is impractical, swapping NetworkX for an igraph or rustworkx adjacency structure sustains routing queries under heavy memory pressure — igraph stores adjacency as contiguous C arrays and routes multi-million-edge graphs in under 2 GB. Files that fail validation or crash a parser should flow to the triage path in Error Handling in Large OSM Extracts rather than aborting the whole run.
Spec reference Jump to heading
Pyrosm reads PBF data through libosmium and exposes it as GeoDataFrames; its
get_network()filters ways by a network type before returning, and returnsNonewhen no matching features exist. OSMnx encodes a street network as a directed multigraph and derivestravel_timefrom edgelength(metres) andspeed_kph. See the Pyrosm documentation for reader configuration, the OSMnx documentation for graph simplification parameters, and the OpenStreetMapmaxspeedkey for the value conventions both libraries must normalize.
Frequently Asked Questions Jump to heading
Which is faster, OSMnx or Pyrosm, for building a routing graph?
Pyrosm parses the PBF roughly 2.7× faster and at about a quarter of the peak memory, because it reads into Arrow-backed GeoDataFrames instead of building a NetworkX graph immediately. On the 4.1 GB California extract the Pyrosm-plus-graph_from_gdfs path completed in ~130 s at ~4 GB RSS versus ~350 s at ~18 GB for OSMnx. Routing time after construction is effectively identical.
If Pyrosm is faster, why use OSMnx at all?
OSMnx gives you topology simplification, speed imputation, and travel-time computation out of the box, plus snapping and isochrone helpers. The most efficient production setup keeps both: parse with Pyrosm for speed, then hand the GeoDataFrames to ox.graph_from_gdfs and use OSMnx’s add_edge_speeds/add_edge_travel_times for weighting.
Why does the OSMnx path use so much more RAM?
OSMnx materializes a networkx.MultiDiGraph with dictionary-based adjacency and runs simplify_graph() during construction, so the whole graph plus interim node structures sits in memory at once. Pyrosm defers graph creation, keeping peak memory close to the on-disk feature volume until you explicitly request nodes and edges.
How do I route a graph that still will not fit in memory?
Tile the extract into non-overlapping cells and build per-tile graphs, or swap the NetworkX adjacency for igraph or rustworkx, which store edges as contiguous C arrays and handle multi-million-edge graphs in under 2 GB. Pre-validate each tile with osmium fileinfo so a corrupt block does not derail the run.
Related Jump to heading
- OSMnx Graph Conversion Techniques — the full extract → normalize → project → weight → validate conversion this page benchmarks.
- Async PBF Parsing with Pyrosm — overlapping disk reads with compute to push the Pyrosm path even faster.
- Memory-Efficient Chunk Processing — tiling and spill-to-disk patterns when even the Pyrosm path exceeds RAM.
- Value Standardization & Regex Cleaning — the deterministic tag coercion both builders share before weighting.
- Error Handling in Large OSM Extracts — triaging corrupt PBFs and empty network clips.
- Coordinate Reference Systems in OSM — why projection must precede any length or travel-time weight.
This guide is part of OSMnx Graph Conversion Techniques; return to that overview to follow the conversion from raw ways through normalization into a routing-ready graph.