Shortest-Path Routing with NetworkX and OSMnx

Routing looks like a one-liner until the first answer comes back wrong: a route that drives the wrong way up a one-way, a distance that is 800 m short because the origin snapped to the far side of a river, or a "shortest" path that takes four minutes longer than the obvious one. This guide is for anyone turning a pair of coordinates into a defensible travel time and a drawable line. It sits under Street Network Analysis with OSMnx in Spatial Analysis & Advanced Query Techniques, and it is the routing half of the same graph the service areas in building isochrones from a street network are cut from.

Why This Approach / What Goes Wrong

A shortest path is only defined relative to a cost, and OSMnx hands you a graph with two plausible ones. length is present on every edge from the moment the graph is built — metres, computed geodesically between incident nodes. travel_time does not exist until you ask for it, and it is an imputation: add_edge_speeds fills the gaps in OpenStreetMap's patchy maxspeed tagging, and add_edge_travel_times divides metres by metres-per-second. The two costs disagree constantly, because the shortest way through a city is a zigzag of residential streets and the fastest way is a detour onto an arterial. Choosing weight="length" when the question was "how long does the ambulance take" is the single most common routing error, and it never raises — it just returns a slower route with a smaller number attached.

Three structural traps sit underneath that choice. The first is directionality: the graph is a MultiDiGraph, so a one-way street contributes exactly one directed edge, and the drive from A to B is genuinely a different problem from the drive from B to A. Any code that collapses the graph to an undirected Graph for speed silently legalises wrong-way driving. The second is connectivity. OSMnx's default keeps the largest weakly connected component, which ignores edge direction, so a block reachable only against a one-way survives in the graph and makes the router return None for every trip into it. The third is snapping: nearest_nodes matches your coordinate to the nearest intersection by straight-line distance, not to the nearest point on the nearest street, so both endpoints carry an error of up to half a block before the router has done any work.

The same node pair returns different routes under length and travel_time A six-node street graph running from a depot node A on the left to a client node D on the right. The upper corridor A to B to C to D uses arterial streets at fifty kilometres per hour and its three edges total two thousand two hundred metres and one hundred and fifty-eight seconds. The lower corridor A to E to F to D uses residential streets at thirty kilometres per hour and totals one thousand nine hundred and ten metres and two hundred and twenty-nine seconds. Routing with weight equals length therefore returns the lower corridor, while routing with weight equals travel_time returns the upper one, trading two hundred and ninety extra metres for seventy-one saved seconds. Same pair of nodes, two weights, two routes arterial corridor · 50 km/h 620 m · 45 s 880 m · 63 s 700 m · 50 s 540 m · 65 s 760 m · 91 s 610 m · 73 s residential grid · 30 km/h A depot B C E F D client weight="length" A–E–F–D · 1910 m · 229 s · the shorter drive weight="travel_time" A–B–C–D · 2200 m · 158 s · the faster drive
Neither route is wrong — they answer different questions, and the router will never tell you which question you meant to ask.

Prerequisites

python -m pip install "osmnx>=2.1,<3" "networkx>=3.3" "geopandas>=1.0" "shapely>=2.0" "scipy>=1.11"

Everything below assumes the OSMnx 2.x module layout, where routing helpers live under ox.routing rather than the 1.x ox.speed and ox.distance locations; the full set of 1.x-to-2.x breaks is catalogued in Street Network Analysis with OSMnx.

Step-by-Step Implementation

1. Build a graph that is actually routable, then project it.

Two preparation steps decide whether routing works at all. largest_component(..., strongly=True) guarantees every node can reach every other following edge direction, which is the precondition the router needs and the weak default does not give you. Projecting to the local UTM zone then makes node coordinates metric, which matters for the snap distances and the A* heuristic later — pick the zone with the graph's own centroid rather than hard-coding one, and never reach for Web Mercator, whose scale error grows with latitude.

import networkx as nx
import osmnx as ox

ox.settings.use_cache = True

streets = ox.graph_from_point((45.0703, 7.6869), dist=2500, network_type="drive")

# Routing needs *strong* connectivity: a block you can only leave by driving the
# wrong way up a one-way survives the default weak filter and breaks the solver.
streets = ox.truncate.largest_component(streets, strongly=True)

# Metric node coordinates — needed for snap distances and the A* heuristic
streets = ox.projection.project_graph(streets)

print(streets.graph["crs"])                              # EPSG:32632
print(len(streets), streets.number_of_edges())           # 3894 8721

2. Impute speeds, then derive travel times.

add_edge_speeds writes a speed_kph column: it parses each edge's maxspeed tag, and where the tag is missing it substitutes the mean of the observed values for that highway class in this graph. hwy_speeds overrides that per class, and fallback catches classes with no observed value anywhere. add_edge_travel_times then writes travel_time in seconds and rounds both attributes to one decimal.

streets = ox.routing.add_edge_speeds(
    streets,
    hwy_speeds={"residential": 30, "living_street": 20, "primary": 50},
    fallback=40,
)
streets = ox.routing.add_edge_travel_times(streets)

u, v, key, data = next(iter(streets.edges(keys=True, data=True)))
print(round(data["length"], 1), data["speed_kph"], data["travel_time"])
# 84.3 50.0 6.1

Run this after truncating the graph, not before: any edge added or restored later will be missing speed_kph, and add_edge_travel_times raises rather than guessing. Be honest with yourself about what the number means. OSMnx parses plain integers and mph suffixes and nothing else, so implicit country codes such as RO:urban or a walk value are dropped and the class mean silently takes over. Coverage varies enormously between cities — a well-mapped European core may tag maxspeed on 60% of drivable edges, a sparsely mapped one on under 5%, and in the second case almost every travel_time in your output is your own hwy_speeds dictionary reflected back at you. The values are free-flow limits regardless: no congestion, no signals, no turn delay, so treat the result as a lower bound on real drive time and calibrate against observed trips before publishing minutes to anyone.

3. Snap the endpoints, and look at how far they moved.

Addresses arrive in longitude/latitude. Project them with the graph's own CRS so the KD-tree query happens in the same metric space as the node coordinates. Shapely and GeoPandas are unconditionally x-then-y, so Point(lon, lat) is correct for EPSG:4326 regardless of that authority's declared lat-lon axis order — the same always_xy=True convention described in Coordinate Systems with PyProj.

import geopandas as gpd
from shapely.geometry import Point

stops = gpd.GeoSeries(
    [Point(7.6771, 45.0723), Point(7.7035, 45.0629)],   # (lon, lat) — depot, client
    crs="EPSG:4326",
).to_crs(streets.graph["crs"])

nodes, snap_dist = ox.distance.nearest_nodes(
    streets, X=stops.x.to_numpy(), Y=stops.y.to_numpy(), return_dist=True
)
orig, dest = nodes
print(nodes, [round(d, 1) for d in snap_dist])
# [1745200343, 258161573] [18.4, 41.2]

return_dist=True is not optional in production code. On a projected graph the distances come back in metres, and a value in the hundreds means the address landed across a motorway, a river or a rail cutting — the KD-tree measures straight lines, exactly like the cKDTree path benchmarked in sjoin_nearest vs cKDTree, and it has no idea whether the two points are connected.

4. Route once per weight, and total the real edge attributes.

route_len = ox.routing.shortest_path(streets, orig, dest, weight="length")
route_time = ox.routing.shortest_path(streets, orig, dest, weight="travel_time")


def route_totals(G, route, weight):
    """Sum the true edge attributes along a route.

    Pass the same weight you routed with: route_to_gdf resolves each parallel
    edge bundle by picking its minimum-weight member, so a mismatched weight
    can total a slightly different set of edges than the router traversed.
    """
    edges = ox.routing.route_to_gdf(G, route, weight=weight)
    return edges["length"].sum(), edges["travel_time"].sum()


for label, route in [("length", route_len), ("travel_time", route_time)]:
    metres, seconds = route_totals(streets, route, label)
    print(f"{label:>12}: {len(route):3d} nodes {metres:7.0f} m {seconds:6.0f} s")
# Expected output:
#       length:  61 nodes    2361 m    281 s
#  travel_time:  48 nodes    2604 m    243 s

The time-optimal route is 243 m longer and 38 seconds quicker, and it uses thirteen fewer intersections — the signature of an arterial detour. ox.routing.shortest_path is a thin wrapper over nx.shortest_path that swallows NetworkXNoPath and returns None instead, which is convenient in a batch and dangerous in a script that then indexes into the result.

5. Rebuild the node list as a LineString.

A route is a list of integers; nothing downstream can draw that. route_to_gdf gives you the ordered edges with their geometries, and the one-liner linemerge(edges.geometry.tolist()) works when the edges are contiguous — but it discards travel direction and degrades to a MultiLineString at the first gap. When the direction of travel matters (arrows on a map, animating a vehicle, splitting the trip at a midpoint), assemble the coordinates yourself and orient each segment to start at its u node.

from shapely.geometry import LineString


def route_linestring(G, route, weight="travel_time"):
    """Concatenate the route's edge geometries into one directed LineString."""
    coords = []
    for u, v in zip(route[:-1], route[1:]):
        key = min(G[u][v], key=lambda k: G[u][v][k][weight])
        data = G[u][v][key]
        ux, uy = G.nodes[u]["x"], G.nodes[u]["y"]
        if "geometry" in data:
            seg = list(data["geometry"].coords)
        else:                                   # straight edge: no stored geometry
            seg = [(ux, uy), (G.nodes[v]["x"], G.nodes[v]["y"])]
        # Edge geometry is not guaranteed to run u -> v; flip it if it does not
        if (seg[0][0] - ux) ** 2 + (seg[0][1] - uy) ** 2 > (seg[-1][0] - ux) ** 2 + (seg[-1][1] - uy) ** 2:
            seg.reverse()
        coords.extend(seg if not coords else seg[1:])
    return LineString(coords)


line = route_linestring(streets, route_time)
trip = gpd.GeoDataFrame({"trip_id": ["depot-to-client"]}, geometry=[line],
                        crs=streets.graph["crs"])
trip.to_crs("EPSG:4326").to_file("route.geojson", driver="GeoJSON")

print(f"{line.length:.0f} m planar vs {route_totals(streets, route_time, 'travel_time')[0]:.0f} m geodesic")
# 2603 m planar vs 2604 m geodesic

Those two numbers differ by the UTM scale factor — about one part in two and a half thousand. Report the summed length attribute, which OSMnx computed geodesically at build time; treat the projected geometry as a drawing, not a measurement.

The snap, solve and rebuild pipeline from coordinates to a mappable line A four-stage left-to-right pipeline. Stage one takes two longitude-latitude coordinates as a GeoSeries in EPSG:4326 and reprojects them to the graph CRS. Stage two calls nearest_nodes, which queries a KD-tree over the node coordinates and returns node ids plus a snap distance in metres. Stage three calls shortest_path, which runs Dijkstra over the chosen weight and returns an ordered list of node ids. Stage four calls route_to_gdf and rebuilds the ordered edges into a single LineString. Beneath each stage a caveat is attached: Shapely coordinates are always x then y and never latitude first; the snapped node is the corner and not the address; direction matters because the return trip is a different route; and each edge geometry must be flipped if it does not start at its u node. The final strip writes the result out as GeoJSON in EPSG:4326. From two coordinates to a mappable LineString 1 · two coordinates GeoSeries(EPSG:4326) .to_crs(graph CRS) 2 · snap to nodes nearest_nodes() KD-tree · metres out 3 · solve shortest_path() → [n0, n1, … nk] 4 · rebuild route_to_gdf() → LineString x is longitude Shapely is always (x, y), never (lat, lon) snap error the node is the corner, not the address direction matters the return trip is a different route flip each segment whose geometry does not start at u trip.to_crs("EPSG:4326").to_file("route.geojson", driver="GeoJSON") one feature, one geometry — ready for a web map Node ids are an intermediate representation only; every consumer downstream needs the geometry back.
Each stage adds a failure mode of its own — the snap moves the endpoint, the solve depends on direction, and the rebuild depends on which way each stored edge geometry happens to run.

6. Swap Dijkstra for A* when the graph gets large.

nx.shortest_path runs Dijkstra, which expands outward from the origin in every direction until the destination is settled. A* adds a heuristic estimate of the remaining cost and steers the frontier toward the target, but only stays correct if that heuristic never over-estimates. On a projected graph the straight-line distance is admissible for weight="length" by definition. For weight="travel_time" you must convert those metres to seconds at the graph's top speed, otherwise the estimate can exceed the true remaining time and A* will happily return a suboptimal route.

import math
import timeit

top_speed_m_s = max(d["speed_kph"] for _, _, d in streets.edges(data=True)) / 3.6


def seconds_as_the_crow_flies(node, target):
    """Admissible heuristic: no route can beat a straight line at the top speed."""
    dx = streets.nodes[node]["x"] - streets.nodes[target]["x"]
    dy = streets.nodes[node]["y"] - streets.nodes[target]["y"]
    return math.hypot(dx, dy) / top_speed_m_s


route_astar = nx.astar_path(streets, orig, dest,
                            heuristic=seconds_as_the_crow_flies, weight="travel_time")

cost = lambda r: sum(min(streets[u][v].values(), key=lambda d: d["travel_time"])["travel_time"]
                     for u, v in zip(r[:-1], r[1:]))
assert abs(cost(route_astar) - cost(route_time)) < 1e-6   # same cost, maybe different ties

runs = 20
dij = timeit.timeit(lambda: nx.shortest_path(streets, orig, dest, weight="travel_time"),
                    number=runs) / runs
ast = timeit.timeit(lambda: nx.astar_path(streets, orig, dest,
                                          heuristic=seconds_as_the_crow_flies,
                                          weight="travel_time"), number=runs) / runs
print(f"dijkstra {dij * 1000:.1f} ms | a* {ast * 1000:.1f} ms")
# dijkstra 41.3 ms | a* 18.7 ms

The heuristic is weakest exactly where the speed spread is widest: dividing by a 130 km/h motorway limit makes the estimate for a 30 km/h residential grid so loose that A* degenerates toward Dijkstra. A* earns its keep on regional graphs where the origin and destination are far apart relative to the extract; on a few thousand nodes the difference is real but rarely decisive, and it is never worth the extra code if you are about to batch the calls anyway.

The region each algorithm settles before it reaches the destination Two panels over the same street extract. In the left panel, Dijkstra's settled region is a circle centred on the origin whose radius reaches the destination, so it runs off every edge of the extract and covers almost the whole graph. In the right panel, A* settles only a narrow elliptical corridor stretched along the straight line between origin and destination, guided by a heuristic equal to the straight-line distance divided by the graph's top speed. A statistics strip beneath reports roughly three thousand five hundred nodes settled by Dijkstra against twelve hundred by A star, forty-one milliseconds against nineteen, and an identical returned route. What each algorithm settles before it stops Dijkstra · uniform frontier origin destination expands in every direction until the destination is settled A* · heuristic-guided corridor h(n) = straight line ÷ top speed origin destination explores a corridor toward the target, returning the same route nodes settled 3,486 vs 1,194 mean of 20 runs 41 ms vs 19 ms route returned identical Indicative figures on a 3,894-node projected drive graph; an inadmissible heuristic buys more speed and a wrong answer.
A\* is not a different answer, only a smaller search — and it stays the same answer only while the heuristic under-estimates the remaining cost.

7. Batch many pairs without paying per pair.

ox.routing.shortest_path accepts iterables for orig and dest and solves them in parallel, with cpus=None meaning every core. That is the right tool for unrelated pairs, but each worker process receives a full pickled copy of the graph, so a large graph times sixteen cores is a memory problem, not a speed-up.

When the pairs share an origin — one depot to every customer, one clinic to every census tract — a single-source solve answers all of them in one Dijkstra instead of n. Because the graph is directed, "depot to everyone" and "everyone to the depot" are different problems: run the second one on a reversed view, which NetworkX gives you for free without copying the graph.

customers = list(streets.nodes)[:250]          # stand-in for snapped delivery points

# Unrelated pairs: parallel, one process per core
pairs_out = ox.routing.shortest_path(streets, [orig] * 5, customers[:5],
                                     weight="travel_time", cpus=1)

# Shared origin: one solve answers every destination, with a 30-minute horizon
out_cost, out_path = nx.single_source_dijkstra(streets, orig, cutoff=1800,
                                               weight="travel_time")

# Shared destination: reverse the edge directions, not the graph in memory
inbound = streets.reverse(copy=False)          # read-only view
in_cost, in_path = nx.single_source_dijkstra(inbound, orig, cutoff=1800,
                                              weight="travel_time")

reachable = [c for c in customers if c in out_cost]
asymmetric = [c for c in reachable if c in in_cost and abs(out_cost[c] - in_cost[c]) > 30]
print(len(reachable), "reachable within 30 min;", len(asymmetric), "differ by >30 s each way")
# 231 reachable within 30 min; 47 differ by >30 s each way

Those 47 asymmetric pairs are the one-way system made visible: the same physical trip costs measurably more in one direction. Note that in_path lists run from the origin in the reversed graph, so reverse each list to recover the real drive order.

The same idea scales to a full cost matrix. A naive n×m matrix costs n×m separate solves; one solve per distinct origin costs n, and single_source_dijkstra_path_length skips building the path dictionaries you are going to discard. Add a cutoff in the units of the weight — seconds for travel_time — so the frontier stops instead of settling the whole graph for destinations you would reject anyway.

import numpy as np

depots, clients = customers[:8], customers[8:60]
matrix = np.full((len(depots), len(clients)), np.inf)

for i, source in enumerate(depots):
    reach = nx.single_source_dijkstra_path_length(streets, source, cutoff=900,
                                                  weight="travel_time")
    for j, target in enumerate(clients):
        if target in reach:
            matrix[i, j] = reach[target]

print(np.isfinite(matrix).sum(), "of", matrix.size, "pairs inside 15 minutes")
# 341 of 416 pairs inside 15 minutes

Rows that come back entirely infinite are the tell for a depot stranded behind a one-way loop or left over from a weakly connected component — a cheaper diagnostic than inspecting the graph.

Verification

Three assertions catch nearly every routing bug: that the route is a legal walk in the directed graph, that the cost you report is the cost the solver actually minimised, and that each weight really did win on its own metric.

# 1. Every consecutive pair is a real directed edge — catches wrong-way routes
assert route_time[0] == orig and route_time[-1] == dest
assert all(streets.has_edge(u, v) for u, v in zip(route_time[:-1], route_time[1:])), \
    "Route traverses an edge that does not exist in this direction"

# 2. The optimum is a lower bound on any route's cost under the same weight
best_m = nx.shortest_path_length(streets, orig, dest, weight="length")
best_s = nx.shortest_path_length(streets, orig, dest, weight="travel_time")
m_by_len, s_by_len = route_totals(streets, route_len, "length")
m_by_time, s_by_time = route_totals(streets, route_time, "travel_time")
assert best_m <= m_by_time + 1e-6 and best_s <= s_by_len + 1e-6

# 3. The endpoints did not snap into the next neighbourhood
assert max(snap_dist) < 150, f"Endpoint moved {max(snap_dist):.0f} m during snapping"

print(f"detour of {m_by_time - m_by_len:.0f} m buys {s_by_len - s_by_time:.0f} s")
# detour of 243 m buys 38 s

Edge Cases & Debugging

Frequently Asked Questions

Should I route on the projected graph or the lat-lon one? Distance routing gives the identical answer either way, because length is a stored geodesic attribute rather than something measured off the coordinates. Everything else needs metres: nearest_nodes returns degrees as distances on an unprojected graph (and needs scikit-learn rather than SciPy to do it), the A* heuristic is nonsense, and any buffer or area you derive from the result is wrong. Project once, immediately after truncating.

Why does my route ignore turn restrictions and traffic lights? Because the graph has no concept of them. Costs live on edges, so a solver cannot charge for the manoeuvre between two edges — no left-turn penalty, no banned turn, no signal delay. Approximating them means expanding to a line graph where each turn becomes its own edge, which multiplies the graph size; past that point a purpose-built engine is the honest answer, and the trade-off is laid out in OSMnx vs pgRouting for network analysis.

How do I get a second-best or alternative route? ox.routing.k_shortest_paths(streets, orig, dest, k=3, weight="travel_time") yields routes in ascending cost order using Yen's algorithm. Expect near-duplicates: the second-best path often differs from the best by a single block, so filter the results on geometric dissimilarity rather than presenting them as genuine alternatives.

Can I route to a point that is not an intersection? Not directly — every node in the graph is a junction. ox.distance.nearest_edges gives you the closest street segment instead, and you can interpolate the position along it and add the partial edge cost to the route total by hand. The pragmatic alternative is to accept the snap error and report it: on a dense urban network it is typically 10–50 m, which is smaller than the uncertainty in the imputed speeds anyway.