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.

Where the graph lives: Python process versus PostGIS server Two architectures side by side. On the left the graph is resident in Python: one Overpass download through ox.graph_from_place produces a MultiDiGraph held in RAM at roughly one to two kilobytes per edge inside a single process, and nx.shortest_path searches it single-threaded under the interpreter lock, serving one analyst in one kernel. On the right the graph is resident in PostGIS: a street_edges table with source, target, cost and geometry columns is read by pgr_dijkstra, which builds a Boost graph in C plus plus for every query, and a FastAPI layer fans the results out to many concurrent sessions over pooled connections. Both use the same edges and the same weights. Same edges, same weights — different residency Graph resident in the Python process Graph resident in PostGIS ox.graph_from_place(...) one Overpass download, cached street_edges table source · target · cost · geometry MultiDiGraph held in RAM ~1–2 KB per edge · dies with the process pgr_dijkstra(...) C++ Boost graph rebuilt per query nx.shortest_path(G, ...) pure Python · one thread · GIL-bound any client, any language pooled connections · concurrent sessions one analyst · one kernel · a file you can archive weight can be an arbitrary Python callable shared, transactional, always on cost is an SQL expression, re-read every call The routing maths is identical — what differs is who holds the graph and how many callers it must answer
Every practical difference between the two — memory, concurrency, reproducibility, cost flexibility — falls out of where the graph physically sits.

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.

Which engine each requirement selects A seven-row comparison matrix with one column for OSMnx plus NetworkX and one for pgRouting inside PostGIS, a tick marking the winner of each row. OSMnx wins one-off analysis in a notebook, a weight that is a Python callable you change per run, betweenness and centrality metrics, and a citable graph artifact that never drifts. pgRouting wins routes served to an application with many callers, a network larger than comfortable RAM, and a cost joined to a live closures or traffic table. No row is a genuine tie, which is why the split runs cleanly along the line between analysis and service. Pick the row that matches your requirement, not the tool you already know Requirement OSMnx + NetworkX pgRouting in PostGIS One-off analysis inside a notebook Routes served to an application, many callers at once Network larger than the RAM you can give one process Weight is a Python callable you rewrite between runs Cost joined to a live closures or traffic table Betweenness, centrality and other whole-graph metrics An archived graph artifact that cannot drift under you Not one row is a tie — the split runs along analysis versus service, which is why so many projects need both.
Read the matrix by requirement: if ticks land in both columns, you are describing the hybrid pattern rather than a choice.

Prerequisites

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".

Mapping the OSMnx edge frame onto the pgRouting edges SQL contract A column mapping between two schemas. On the left the OSMnx edges GeoDataFrame carries u and v as bigints, a key, an osmid that may be an integer or a list, travel_time in seconds, oneway as a boolean, and geometry in EPSG 4326. On the right the pgRouting edges SQL contract needs source, target, a unique bigint id, cost, reverse_cost and a geometry column. u maps to source and v to target one to one; key and osmid together map to id, which must be a fresh row number rather than osmid because parallel edges repeat the same u and v pair; travel_time becomes cost; oneway is not translated because reverse_cost is minus one on every row; geometry passes through unchanged. edges GeoDataFrame · OSMnx edges SQL contract · pgRouting u bigint v bigint key int osmid int | list travel_time seconds oneway bool geometry EPSG:4326 source bigint target bigint id bigint UNIQUE cost double reverse_cost -1.0 geometry EPSG:4326 row number, not osmid oneway already encoded Parallel edges repeat (u, v), so id must be freshly numbered — and a NULL in cost drops that edge from the graph in silence.
Six columns and one deliberate omission: 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.

Decision path from three questions to the hybrid pattern A three-question decision chain. First: is the network bigger than the RAM one process can hold? If yes, the database is the only home and pgRouting or a dedicated engine loads it directly. If no, second question: do concurrent callers need routes on demand? If no, stay entirely in OSMnx and NetworkX and archive the graph as GraphML. If yes, third question: is the cleaning, weighting and quality checking still done in Python? If no, run pgRouting alone and load with osm2pgrouting. If yes, the answer is the hybrid pattern: clean and analyse in OSMnx, publish the edge table to PostGIS, and serve routes with pgr_dijkstra. Three questions, and most real projects land on the last row Is the network bigger than the RAM one process can be given? Database only the graph never fits — load straight into pgRouting Do concurrent callers need routes on demand? OSMnx + NetworkX only archive the GraphML and skip the database entirely Is the cleaning, weighting and QA still done in Python? pgRouting alone load with osm2pgrouting and keep the graph server-side The hybrid pattern clean and analyse in OSMnx → publish the edge table to PostGIS → serve routes with pgr_dijkstra yes no no no yes yes
Only the first question is a hard constraint; the other two are about who consumes the routes, which is why the hybrid row is the common landing spot.

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

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.