OSMnx vs pgRouting for Network Analysis
The question is never "which router is better" — it is "who holds the graph". OSMnx hands you a MultiDiGraph that lives inside one Python process; pgRouting turns an edge table into a graph that lives inside PostgreSQL and answers every session that connects. This guide is for anyone who has a working OSMnx analysis and now has to decide whether it stays in a notebook or becomes a service. It sits under Street Network Analysis with OSMnx in Spatial Analysis & Advanced Query Techniques, and assumes you already route in Python as described in shortest path routing with NetworkX and OSMnx.
Why This Approach / What Goes Wrong
The two tools are not competing implementations of Dijkstra. They are competing residencies for the same graph, and every practical difference follows from that one fact.
OSMnx builds the graph by downloading OpenStreetMap data, simplifying it, and materialising every node and edge as Python objects with attribute dictionaries attached. That is why a graph is expensive per element — on the order of one to two kilobytes per edge once the node dicts, edge dicts, and Shapely geometries are counted — and why a city-scale drive network of a few hundred thousand edges comfortably occupies a gigabyte or more. It is also why the graph is free to mutate: you can attach a new attribute, delete a whole road class, or pass NetworkX a Python callable as the weight function and have it evaluated per edge during the search. Nothing has to be declared in advance.
pgRouting inverts every one of those properties. There is no graph object at rest — there is an edge table with source, target, cost and reverse_cost columns, and each call to pgr_dijkstra passes a string of SQL that pgRouting executes to materialise the edge set, builds a Boost Graph from the result in C++, runs the search, and throws the graph away. The cost of that per-query construction is the single most important thing to understand about pgRouting: on a small edge set it is invisible, on a national table it dominates, and the fix is always to narrow the edges SQL rather than to speed up the search.
Loading effort splits the same way. Getting a routable graph out of OSMnx is one function call and a cache directory, covered in downloading OSM street networks with OSMnx. Getting one into pgRouting means a server, two extensions, an edge table with a strict column contract, indexes on source and target, and a topology that actually connects — an afternoon rather than a minute, and the reason so many teams never make the jump.
Concurrency and reproducibility then pull in opposite directions. A NetworkX search is pure Python and single-threaded; two simultaneous requests inside one process serialise behind the interpreter lock, and the only way to scale is to fork the process and pay for the graph again in each worker. pgRouting inherits PostgreSQL's connection model for free. But the database is mutable shared state: an UPDATE on the cost column silently changes yesterday's answer, whereas a GraphML file written by OSMnx is a byte-stable artifact you can archive alongside the result and re-run in three years.
Prerequisites
osmnx>=2.1,<3— graph acquisition plus theox.convertandox.routingsubmodulesnetworkx>=3.3— the in-memory Dijkstra you will check the database againstgeopandas>=1.0—to_postgis()andread_postgis()for both directions of the bridgesqlalchemy>=2.0andpsycopg>=3.1— the connection layer GeoPandas delegates to- PostgreSQL 15+ with
postgis>=3.4andpgrouting>=3.5—pgr_dijkstraacceptsBIGINTvertex ids, which OSM node ids require
conda install -c conda-forge "osmnx=2.1.*" "networkx=3.3.*" "geopandas=1.0.*" "sqlalchemy=2.0.*" "psycopg=3.1.*"
The extensions are server-side and independent of the Python environment. Enable both and confirm the version before writing anything, because a pgRouting older than 3.0 has an incompatible pgr_dijkstra signature:
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pgrouting;
SELECT pgr_version(); -- 3.6.2
Step-by-Step Implementation
The hybrid pattern is worth building even if you think you only need one side, because it is the same seven steps either way: clean the graph in Python, publish the edges, route from SQL, and verify the two agree.
1. Build and weight the graph once, in OSMnx.
This is the step you keep regardless of where routing ends up. Simplification and speed imputation are far easier here than in SQL, and the result is the definition of your network.
import osmnx as ox
ox.settings.use_cache = True
delivery_area = ox.graph_from_place("Bilbao, Spain", network_type="drive")
delivery_area = ox.routing.add_edge_speeds(delivery_area) # speed_kph from maxspeed tags
delivery_area = ox.routing.add_edge_travel_times(delivery_area) # travel_time in seconds
ox.io.save_graphml(delivery_area, "bilbao_drive.graphml") # the archivable artifact
print(delivery_area) # MultiDiGraph with 8172 nodes and 18094 edges
2. Reshape the edge frame into pgRouting's column contract.
pgr_dijkstra does not care what your table is called, but it demands exactly five columns from the SQL you hand it: id, source, target, cost, reverse_cost. Two mappings need care. The id must be unique per row, and osmid is not — simplification merges several OSM ways into one edge, so osmid can be a list, and parallel edges repeat the same (u, v) pair. Number the rows instead. And because an OSMnx MultiDiGraph already emits one directed edge per legal direction — a two-way street appears twice, a one-way street once — reverse_cost is -1 on every row, which is pgRouting's way of saying "this edge does not exist backwards".
oneway never crosses, because the directed edge pair already carries it.nodes, edges = ox.convert.graph_to_gdfs(delivery_area) # edges indexed by (u, v, key)
street_edges = (
edges.reset_index()[["u", "v", "travel_time", "length", "geometry"]]
.rename(columns={"u": "source", "v": "target", "travel_time": "cost"})
)
street_edges["id"] = range(1, len(street_edges) + 1) # (u, v) is not unique — never use osmid
street_edges["reverse_cost"] = -1.0 # both directions already present as rows
street_edges = street_edges.astype({"id": "int64", "source": "int64", "target": "int64"})
assert street_edges["cost"].notna().all(), "a NULL cost removes the edge without warning"
print(street_edges.crs) # EPSG:4326
3. Publish the edges to PostGIS.
The graph stays in EPSG:4326 on purpose: travel_time and length are already metres and seconds computed by OSMnx from great-circle distances, so nothing downstream needs a projected geometry, and geographic coordinates are what a web map wants back. Use the SQLAlchemy engine described in connecting GeoPandas to PostGIS with SQLAlchemy.
from sqlalchemy import create_engine
engine = create_engine("postgresql+psycopg://gis@localhost:5432/city")
street_edges.to_postgis("street_edges", engine, if_exists="replace", index=False)
4. Index the table the way pgRouting reads it.
pgRouting scans source and target on every call and filters on geometry whenever you narrow the edge set, so all three indexes matter; the GiST mechanics are in spatial indexing in PostGIS with GiST.
ALTER TABLE street_edges ADD PRIMARY KEY (id);
CREATE INDEX street_edges_source_idx ON street_edges (source);
CREATE INDEX street_edges_target_idx ON street_edges (target);
CREATE INDEX street_edges_geom_idx ON street_edges USING GIST (geometry);
ANALYZE street_edges;
Note what you did not have to run: pgr_createTopology. That function exists to invent node ids by snapping line endpoints within a tolerance, and its tolerance is expressed in the geometry's own units — 0.0001 on a 4326 table is about 11 m at the equator and roughly 8 m at Bilbao's latitude, which is how people accidentally weld two carriageways together. Coming from OSMnx you already have authoritative node ids in u and v, so skip it entirely.
5. Route from SQL.
The first argument is a string of SQL, not a table name. directed => true is essential — with false, pgRouting ignores reverse_cost and drives the wrong way up every one-way street.
SELECT r.seq, r.node, r.agg_cost, e.geometry
FROM pgr_dijkstra(
'SELECT id, source, target, cost, reverse_cost FROM street_edges',
1288045123::bigint, -- origin OSM node id
3355019887::bigint, -- destination OSM node id
directed => true
) AS r
LEFT JOIN street_edges e ON e.id = r.edge
ORDER BY r.seq;
The final row always carries edge = -1; that is the arrival record, so it joins to no geometry and holds the total agg_cost. A LEFT JOIN keeps it, an inner join silently discards your trip total.
6. Serve it, and narrow the edge set as the table grows.
On a city table the unrestricted edges SQL is fine. Past a few million rows, materialising every edge per request becomes the bottleneck — bound it to a corridor around the origin and destination with a bounding-box predicate, which the GiST index answers instantly.
import geopandas as gpd
from sqlalchemy import text
EDGES_SQL = (
"SELECT id, source, target, cost, reverse_cost FROM street_edges "
"WHERE geometry && ST_MakeEnvelope({xmin}, {ymin}, {xmax}, {ymax}, 4326)"
)
ROUTE_SQL = text("""
SELECT r.seq, r.agg_cost, e.geometry
FROM pgr_dijkstra(:edges_sql, CAST(:origin AS bigint),
CAST(:destination AS bigint), directed => true) AS r
LEFT JOIN street_edges e ON e.id = r.edge
ORDER BY r.seq
""")
def route_geojson(origin: int, destination: int, bbox: tuple[float, float, float, float]) -> str:
xmin, ymin, xmax, ymax = (float(v) for v in bbox) # the coercion is the sanitisation
legs = gpd.read_postgis(
ROUTE_SQL, engine, geom_col="geometry",
params={
"edges_sql": EDGES_SQL.format(xmin=xmin, ymin=ymin, xmax=xmax, ymax=ymax),
"origin": int(origin),
"destination": int(destination),
},
)
if legs.empty:
raise ValueError("no path — origin and destination are in different components")
total_seconds = float(legs["agg_cost"].iloc[-1]) # arrival row, edge = -1
return legs.dropna(subset=["geometry"]).assign(total_s=total_seconds).to_json()
Note where the safety comes from. The edges SQL is itself passed as a bound parameter, and the only request-derived values inside it have been forced through float() and int() — a float cannot carry a semicolon. Never assemble that inner string from raw request text with an f-string: pgRouting hands it straight to the server for execution.
7. Decide, once, which half of the pattern you actually need.
Verification
The bridge is only trustworthy if both engines return the same number for the same origin-destination pair. Run the in-memory search and the database search side by side and assert they agree — a disagreement is almost always reverse_cost, directed, or a lost row during the reshape.
import networkx as nx
from sqlalchemy import text
origin, destination = 1288045123, 3355019887
nx_seconds = nx.shortest_path_length(delivery_area, origin, destination, weight="travel_time")
with engine.connect() as conn:
pg_seconds = conn.execute(text("""
SELECT max(agg_cost) FROM pgr_dijkstra(
'SELECT id, source, target, cost, reverse_cost FROM street_edges',
CAST(:o AS bigint), CAST(:d AS bigint), directed => true)
"""), {"o": origin, "d": destination}).scalar_one()
assert street_edges["id"].is_unique, "duplicate id — pgRouting will build the wrong graph"
assert len(street_edges) == delivery_area.number_of_edges(), "edges lost in the reshape"
assert abs(nx_seconds - float(pg_seconds)) < 1e-6, "engines disagree — check reverse_cost/directed"
print(f"NetworkX {nx_seconds:.1f}s · pgRouting {float(pg_seconds):.1f}s · {len(street_edges)} edges")
# NetworkX 486.3s · pgRouting 486.3s · 18094 edges
Edge Cases & Debugging
pgr_dijkstrareturns zero rows instead of raising. No path exists, usually because the two nodes sit in different connected components. NetworkX raisesNetworkXNoPathfor the same case, so any code path that swaps engines must handle both an exception and an empty result set.- Routes cut through one-way streets. You passed
directed => false, or leftreverse_costNULL. NULL is not-1: give every row an explicit-1.0and keep the columnNOT NULL. function pgr_dijkstra(unknown, integer, integer, ...) does not exist. The literal ids were inferred asinteger, and OSM node ids overflowint4. Cast them:1288045123::bigint, orCAST(:o AS bigint)from Python.- Every query takes seconds on a large table. The edges SQL is materialising the whole country per call. Add the bounding-box predicate from step 6, or pre-build a smaller
street_edges_metrotable; the search itself is rarely the cost. - Results changed between two runs and nothing in your code did. Someone updated the cost column. Snapshot the edge table alongside the result, or keep the GraphML as the source of truth and treat the PostGIS table as a rebuildable projection of it.
Frequently Asked Questions
Can pgRouting build isochrones as well as OSMnx?
It can find the reachable set — pgr_drivingDistance returns every vertex within a cost budget, exactly as nx.ego_graph does. What it does not solve is the hard part, which is turning that scatter of nodes into a defensible polygon; the difference between a convex hull and a buffered edge union is worth far more than the engine choice, and it is covered in building isochrones from a street network.
Do I need osm2pgrouting if I already use OSMnx?
No, and there is a good argument against it. osm2pgrouting reads a raw .osm XML extract and builds its own topology, which means the graph in the database is not the graph you simplified, consolidated and inspected in Python. Going through to_postgis publishes precisely the network you analysed. Reach for osm2pgrouting only when the extract is too large to pass through a Python process at all.
Which one is faster for a million routes?
pgRouting, and by more than the per-query overhead suggests — because pgr_dijkstra accepts arrays of start and end vertices and amortises one graph construction across the whole batch, whereas NetworkX pays full Python dispatch per search. If a million routes is the recurring workload rather than a one-off, evaluate a purpose-built engine such as Valhalla or OSRM before either of these; both of them contract-load the network once and answer in microseconds.
How do I keep the graph and the table in sync?
Treat the GraphML file as the source of truth and the PostGIS table as a derived artifact rebuilt by the script in steps 1 to 4. Version the file, tag it with the Overpass query date, and make the publish step idempotent with if_exists="replace". Two-way sync between a mutable table and an in-memory graph is a source of quiet drift that no assertion will catch after the fact.