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()
Six routing symptoms mapped to their cause and the check that identifies each.
None of these raises. Every one produces a number a reader would accept.

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.

A misspelled weight name causing NetworkX to fall back to unit weights, returning the edge count instead of a distance.
A typo in the weight name returns the number of edges. No exception, and the number looks like a small distance.

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.

Stored OSMnx length of 62.4 metres against a recomputed geometry length of 0.0008 on the unprojected graph, a factor of 82000.
OSMnx's stored length is geodetic and correct on both graphs. Anything you compute yourself is not.

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 length is metres on any graph CRS. Anything you compute with shapely is in coordinate units.
  • A misspelled weight returns the edge count, silently. Assert the attribute exists.
  • shortest_path_length with a different weight describes a different route. Sum along the route you took.
  • nx.Graph(G) and G.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.

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.