Street Network Routing Returns Absurd Distances
Problem statement
Routing succeeds. The numbers are nonsense:
- A trip across town returns 0.05, when you expected about 5,000.
- A 200 m walk returns 4.2 km.
- Every route in a batch is roughly the same length, regardless of how far apart the endpoints are.
- The total is right and the route on a map goes the wrong way down a one-way street.
- Two identical calls return different numbers.
No exception is raised, so nothing flags it. Each has a distinct cause, and the first one is by far the most common:
print(nx.shortest_path_length(G, origin, destination, weight="length"))
0.04372
That is degrees. The graph was projected β or rather, it was not β and the length attribute got recomputed somewhere along the way in the coordinate units rather than metres.
Quick answer
Check the four things that produce plausible-looking wrong numbers:
import numpy as np
lengths = np.array([d.get("length", np.nan) for *_, d in G.edges(data=True)])
print(f"crs {G.graph.get('crs')}")
print(f"edge length median {np.nanmedian(lengths):.4f}, max {np.nanmax(lengths):.1f}")
print(f"missing length {np.isnan(lengths).sum()} edges")
print(f"weight passed {weight!r}")
crs epsg:4326
edge length median 51.1350, max 1065.3
missing length 0 edges
weight passed 'length'
A median edge length of 51 is metres. A median of 0.0008 would be degrees.
| Symptom | Cause | Check |
|---|---|---|
| distances near 0.001β1 | lengths are in degrees | median edge length |
| distances 10β30% too long | weight omitted β minimising edge count |
pass weight explicitly |
| all routes similar length | weight is a string NetworkX cannot find |
nx.shortest_path_length vs manual sum |
| route ignores one-ways | graph converted to undirected | G.is_directed() |
| length of the wrong route | summed a different weight than you routed on | sum along the route you took |
| totals differ between runs | parallel edges, [0] picking arbitrarily |
min() over G[u][v].values() |
Step-by-step solution
1. Check the units of length
lengths = [d["length"] for *_, d in G.edges(data=True)]
print(f"median {np.median(lengths):.4f}, min {min(lengths):.4f}, max {max(lengths):.1f}")
median 51.1350, min 0.5288, max 1065.3
A median around 50 with a maximum around 1,000 is metres, and it is what OSMnx produces β it computes great-circle lengths regardless of the graph's CRS.
A median around 0.0008 means the lengths were recomputed from coordinates on an unprojected graph. That happens when you rebuild the graph from GeoDataFrames, or add a custom cost:
# WRONG on an EPSG:4326 graph β degrees
for u, v, k, d in G.edges(keys=True, data=True):
d["cost"] = d["geometry"].length
# RIGHT
Gp = ox.project_graph(G)
for u, v, k, d in Gp.edges(keys=True, data=True):
d["cost"] = d["geometry"].length
shapely's .length is Euclidean in whatever units the coordinates are. On EPSG:4326 that is degrees, and one degree becomes 1.0 rather than 111,000 m.
2. Confirm the weight is actually being used
This one is nasty. If you pass a weight name that no edge has, NetworkX does not raise β it treats every edge as weight 1:
for weight in ("length", "travel_time", "lenght"): # note the typo
d = nx.shortest_path_length(G, origin, destination, weight=weight)
print(f" weight={weight!r:14} -> {d:,.2f}")
weight='length' -> 4,460.77
weight='travel_time' -> 371.79
weight='lenght' -> 37.00
Thirty-seven. That is the number of edges, silently, because the misspelled attribute is missing everywhere and NetworkX falls back to 1 per edge.
The tell is that every route in a batch comes back as a small integer-ish number in a narrow range. Assert the attribute exists:
missing = sum(1 for *_, d in G.edges(data=True) if weight not in d)
if missing:
raise KeyError(f"{missing} of {len(G.edges)} edges have no {weight!r} attribute")
3. Sum along the route you actually took
shortest_path_length(weight="length") gives the length of the length-optimal route. If you routed on travel_time, that is a different route:
route = nx.shortest_path(G, origin, destination, weight="travel_time")
wrong = nx.shortest_path_length(G, origin, destination, weight="length")
right = sum(min(d["length"] for d in G[u][v].values())
for u, v in zip(route[:-1], route[1:]))
print(f" length of the length-optimal route: {wrong:,.0f} m")
print(f" length of the route we took: {right:,.0f} m")
length of the length-optimal route: 4,461 m
length of the route we took: 4,687 m
Reporting 4,461 m for a journey that is 4,687 m is a 5% understatement, and it is entirely invisible β both numbers are plausible and both come from correct function calls.
4. Check the graph is still directed
print(f"directed: {G.is_directed()}, edges: {len(G.edges):,}")
directed: True, edges: 11,105
G.to_undirected() halves the edge count and discards one-way restrictions. Routes then go the wrong way up one-way streets and come out shorter than reality.
It happens by accident more often than you would expect β some NetworkX algorithms require an undirected graph and people convert without converting back. nx.Graph(G) does it too.
For a pedestrian analysis an undirected graph is defensible. For driving it is wrong, and the error is systematic: every route is at most as long as the correct one, never longer.
5. Handle parallel edges deterministically
parallel = sum(1 for u, v, k in G.edges(keys=True) if k > 0)
print(f"{parallel} parallel edges of {len(G.edges):,}")
28 parallel edges of 11,105
Where two nodes are joined by more than one edge, G[u][v][0] picks whichever happened to be stored first. That is not necessarily the one the router used, and it is not stable across graph rebuilds:
# fragile
metres = sum(G[u][v][0]["length"] for u, v in pairs)
# deterministic and correct
metres = sum(min(d["length"] for d in G[u][v].values()) for u, v in pairs)
Twenty-eight edges out of eleven thousand is a quarter of a percent, which is exactly why this bug is hard to find: it changes the total by a fraction of a percent, on some routes, sometimes.
Code examples
Example 1 β a routing wrapper that validates its inputs
import networkx as nx
import numpy as np
import osmnx as ox
class RoutingError(RuntimeError):
pass
def validated_router(G, *, weight="length", expect_metres=True):
"""Return a routing function, after checking the graph can produce sane numbers."""
if not G.is_directed():
raise RoutingError("graph is undirected β one-way restrictions are lost")
missing = sum(1 for *_, d in G.edges(data=True) if weight not in d)
if missing:
raise RoutingError(
f"{missing:,} of {len(G.edges):,} edges have no {weight!r} attribute. "
f"NetworkX would silently use weight 1 per edge and return an edge count."
)
values = np.array([min(d[weight] for d in G[u][v].values())
for u, v in {(u, v) for u, v, _ in G.edges(keys=True)}])
median = np.median(values)
if expect_metres and weight == "length" and median < 1.0:
raise RoutingError(
f"median edge {weight} is {median:.6f} β that is degrees, not metres. "
f"Project the graph before recomputing lengths."
)
if np.isnan(values).any() or (values < 0).any():
raise RoutingError(f"{weight} contains NaN or negative values")
parallel = sum(1 for u, v, k in G.edges(keys=True) if k > 0)
print(f" {len(G.nodes):,} nodes, {len(G.edges):,} edges, "
f"median {weight} {median:,.2f}, {parallel} parallel edges")
def route(origin, destination):
if origin == destination:
return {"nodes": [origin], "cost": 0.0, "length_m": 0.0}
path = nx.shortest_path(G, origin, destination, weight=weight)
pairs = list(zip(path[:-1], path[1:]))
cost = sum(min(d[weight] for d in G[u][v].values()) for u, v in pairs)
metres = sum(min(d["length"] for d in G[u][v].values()) for u, v in pairs)
return {"nodes": path, "cost": round(cost, 2), "length_m": round(metres, 1)}
return route
route = validated_router(G, weight="travel_time")
result = route(origin, destination)
print(f" {result['length_m']:,.0f} m in {result['cost'] / 60:.2f} min "
f"over {len(result['nodes'])} nodes")
4,902 nodes, 11,105 edges, median travel_time 5.16, 28 parallel edges
4,687 m in 6.20 min over 53 nodes
Three checks at construction time β directed, attribute present, units plausible β and the returned function always reports both the optimised cost and the true length. That combination removes four of the six failure modes.
Example 2 β catching the degrees case in the act
def compare_length_sources(G):
"""OSMnx length vs recomputed geometry length, on both graph CRSs."""
Gp = ox.project_graph(G)
rows = []
for label, graph in (("EPSG:4326", G), ("projected", Gp)):
stored, computed = [], []
for u, v, k, d in graph.edges(keys=True, data=True):
if "geometry" not in d:
continue
stored.append(d["length"])
computed.append(d["geometry"].length)
rows.append({
"graph": label,
"crs": graph.graph["crs"],
"median_stored": round(float(np.median(stored)), 4),
"median_geometry": round(float(np.median(computed)), 4),
"ratio": round(float(np.median(stored) / np.median(computed)), 1),
})
import pandas as pd
print(pd.DataFrame(rows).to_string(index=False))
compare_length_sources(G)
graph crs median_stored median_geometry ratio
EPSG:4326 epsg:4326 62.3870 0.0008 82557.8
projected EPSG:32630 62.3870 62.4896 1.0
The stored length is 62.4 m on both graphs, because OSMnx computes it geodetically and it does not change under projection. geometry.length is 0.0008 on the unprojected graph and 62.5 on the projected one.
A ratio of 82,000 is the signature. Any custom cost derived from geometry.length on an EPSG:4326 graph is off by that factor, and a route computed with it will look like a small decimal.
Example 3 β a sanity check against straight-line distance
def sanity_check_routes(G, pairs, *, weight="length", min_circuity=0.95, max_circuity=4.0):
"""Every network distance should exceed the straight line, by a plausible factor."""
Gp = ox.project_graph(G)
xs = {n: d["x"] for n, d in Gp.nodes(data=True)}
ys = {n: d["y"] for n, d in Gp.nodes(data=True)}
suspect = []
ratios = []
for origin, destination in pairs:
straight = np.hypot(xs[origin] - xs[destination], ys[origin] - ys[destination])
if straight < 200:
continue
network = nx.shortest_path_length(G, origin, destination, weight=weight)
ratio = network / straight
ratios.append(ratio)
if ratio < min_circuity:
suspect.append((origin, destination, ratio, "shorter than the crow flies"))
elif ratio > max_circuity:
suspect.append((origin, destination, ratio, "implausibly circuitous"))
ratios = np.array(ratios)
print(f" {len(ratios)} pairs: circuity median {np.median(ratios):.2f}, "
f"p90 {np.percentile(ratios, 90):.2f}, max {ratios.max():.2f}")
for origin, destination, ratio, why in suspect[:5]:
print(f" β {origin} -> {destination}: ratio {ratio:.2f} β {why}")
if not suspect:
print(f" β all ratios between {min_circuity} and {max_circuity}")
return ratios
ratios = sanity_check_routes(G, pairs)
396 pairs: circuity median 1.32, p90 1.63, max 3.99
β all ratios between 0.95 and 4.0
A network distance shorter than the straight line is geometrically impossible and is the definitive proof of a units error or an undirected shortcut. A ratio above 4 is possible but rare enough to be worth looking at.
This check costs one traversal per pair and catches every one of the six failure modes at once, because all of them distort the ratio.
Explanation
Why the degrees error is so easy to introduce
OSMnx computes length geodetically when it builds the graph, so it is metres regardless of the graph's CRS. That is convenient and it lulls you into thinking the CRS does not matter.
It matters the moment you compute anything yourself. shapely's .length, .distance() and .buffer() all work in coordinate units, so on an EPSG:4326 graph they are degrees β off by a factor of about 82,000 at this latitude.
The result does not look wrong in an obvious way. A route "length" of 0.044 is not obviously a broken 4,900 m; it is just a small number. And if every route is scaled by the same factor, relative comparisons still work, so a whole analysis can be internally consistent and expressed in the wrong unit.
Why a misspelled weight is worse than an exception
NetworkX's weight parameter accepts an attribute name and falls back to 1 where the attribute is absent. That is deliberate β it lets you route on a partially-weighted graph β and it means a typo produces a hop count rather than an error.
The number that comes back is small, positive and monotone in distance, so it correlates with what you wanted. Plotted against straight-line distance it even looks like a reasonable relationship, because more distant pairs do take more edges.
The only reliable defence is asserting the attribute exists on every edge before routing, as in Example 1.
Why the route and its length can disagree
shortest_path_length(G, o, d, weight="length") runs its own Dijkstra on length and returns that route's cost. It has no knowledge of the route you obtained with a different weight.
So routing on travel_time and then asking for shortest_path_length(weight="length") gives you two different journeys: the one you would drive, and the shortest one you would not. Here they differ by 226 m β 5%.
The correct form is always to sum along the route you actually have. It is more code and it is the only way the numbers describe one journey.
Why parallel edges make results non-deterministic
A multigraph stores parallel edges under integer keys assigned in insertion order. Rebuild the graph β from GeoDataFrames, from GraphML, after a simplification β and the order can change.
G[u][v][0] therefore picks a different edge, and if the parallel edges differ in length (a dual carriageway where one side is longer), the total changes. Twenty-eight parallel edges in eleven thousand means the effect is small and intermittent, which makes it maddening to track down.
min(d["length"] for d in G[u][v].values()) is deterministic and matches what Dijkstra used, since Dijkstra also takes the cheapest parallel edge.
Edge cases or notes
- OSMnx
lengthis metres on any graph CRS. Anything you compute with shapely is in coordinate units. - A misspelled
weightreturns the edge count, silently. Assert the attribute exists. shortest_path_lengthwith a different weight describes a different route. Sum along the route you took.nx.Graph(G)andG.to_undirected()discard one-way restrictions and shorten every driving route.- Use
min()over parallel edges, not[0]β the key order is not stable. - Network distance below straight-line distance is impossible. It is the cleanest single sanity check available.
- Circuity above about 4 is possible but worth inspecting β usually a barrier or a stranded endpoint.
- Turn restrictions are not modelled by OSMnx, so routes are slightly optimistic even when everything else is right.
Internal links
- How to calculate the shortest path along a street network β the correct routing pattern
- NetworkX says no path exists between two nodes β the failure that raises instead
- Street networks as graphs explained β multigraphs, direction, and stored lengths
- Network distance vs straight-line distance explained β the circuity sanity check
- Your street network graph is disconnected β stranded endpoints distorting a batch
- How to snap points to a street network in Python β projecting before measuring
- Projected vs geographic CRS explained β why degrees are not distances
- How to measure distance accurately in Python β the general units problem
FAQ
Why is my route length a small decimal like 0.04?
The lengths are in degrees. Something recomputed them from geometry on an unprojected graph β shapely.length works in coordinate units. Project the graph first.
Why are all my routes about the same length?
The weight attribute name does not exist on the edges, so NetworkX used 1 per edge and returned a hop count. Assert the attribute is present before routing.
Why does my route go the wrong way up a one-way street?
The graph was converted to undirected somewhere. G.is_directed() should be True, and the edge count should be roughly double the number of street segments.
The route looks right but the length seems short. Why?
You probably called shortest_path_length with a different weight than you routed on, so it reported a different route's cost. Sum the edges of the route you actually took.
Why do two runs give slightly different totals?
Parallel edges. G[u][v][0] picks by insertion order, which is not stable across rebuilds. Use min() over G[u][v].values().
What is the quickest sanity check?
Compare network distance to straight-line distance. It must be greater β a ratio below 1 is geometrically impossible and proves a units or direction error.
Are OSMnx edge lengths in degrees on an EPSG:4326 graph?
No. OSMnx computes them geodetically, so length is metres regardless of the CRS. Only lengths you compute yourself inherit the coordinate units.