Street Network Routing Returns Absurd Distances

On this page

Problem statement

Routing succeeds. The numbers are nonsense:

  • A trip across town returns 0.05, when you expected about 4,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:

nodes, edges = ox.graph_to_gdfs(G)
edges["length"] = edges.geometry.length          # "recompute" lengths from geometry β€” on EPSG:4326
G_bad = ox.graph_from_gdfs(nodes, edges)
print(nx.shortest_path_length(G_bad, origin, destination, weight="length"))
0.04806648554008417

That is degrees. The lengths were recomputed from the edge geometries on a graph that was never projected, so they came out 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 50.4670, 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 that produce plausible but wrong numbers. none of these raisesSymptomCheckdistances near 0.001 to 1median edge length β€” degrees, notmetresevery route a similar small numberweight name missing β€” it countededgesdistances 10-30% too longweight omitted entirelyroute ignores one-way streetsG.is_directed() β€” it was convertedlength of the wrong routesum along the route you actuallytooktotals differ between runsparallel edges β€” use min(), not[0]Network distance below straight-line distance is impossible β€” the cleanest check spatialworkflow.io
None of these raises. Every one produces a number a reader would accept.

Length units, the weight used, the route summed

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 50.4670, 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,060.72
  weight='travel_time'  -> 314.73
  weight='lenght'       -> 44.00

Seventy-five. 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,061 m
  length of the route we took:        4,155 m

Reporting 4,061 m for a journey that is 4,155 m is a 2.3% 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,270

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,270

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.

The same route measured with a correct weight name, a different weight, and a misspelled one. a typo returns the edge count, silentlyweight='length'4,060.72 mweight='travel_time'314.73 sweight='lenght' (typo)44.00 β€” the edge count021004200NetworkX falls back to weight 1 per edge when the attribute is absent spatialworkflow.io
A typo in the weight name returns the number of edges. No exception, and the number looks like a small distance.

A validating routing wrapper and a straight-line check

The data used on this page

OpenStreetMap's drive network within 2.5 km of Manchester Town Hall, through OSMnx, restricted to its largest strongly connected component and given speeds and travel times. The origin and destination are the pair, of 300 seeded random ones, where routing on time instead of length changes the route most; Example 3's pairs are 400 more. Overpass via the maps.mail.ru mirror while overpass-api.de was refusing connections. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.

import io
import networkx as nx
import numpy as np
import os
import osmnx as ox
import requests
import zipfile

# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
    reply = requests.get("https://data.spatialworkflow.io/osm-cache-routing-returns-absurd-distances-2026-10-10.zip", timeout=300)
    reply.raise_for_status()
    with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
        archive.extractall("cache")

ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
ox.settings.overpass_rate_limit = False
ox.settings.http_user_agent = "spatialworkflow.io guide examples (https://www.spatialworkflow.io/)"
G = ox.graph_from_point((53.4808, -2.2426), dist=2500, network_type="drive")
G = G.subgraph(max(nx.strongly_connected_components(G), key=len)).copy()
G = ox.add_edge_travel_times(ox.add_edge_speeds(G))
weight = "length"


def cost(route, attr):
    return sum(min(d[attr] for d in G[u][v].values()) for u, v in zip(route[:-1], route[1:]))


rng = np.random.default_rng(0)
nodes_list = list(G.nodes)
best = None
for a, b in rng.choice(nodes_list, size=(300, 2)):
    saving = cost(nx.shortest_path(G, a, b, weight="length"), "travel_time") / \
             cost(nx.shortest_path(G, a, b, weight="travel_time"), "travel_time")
    if best is None or saving > best[0]:
        best = (saving, a, b)
origin, destination = best[1], best[2]
pairs = [tuple(p) for p in rng.choice(nodes_list, size=(400, 2))]
print(f"{len(G.nodes):,} nodes; origin {origin}, destination {destination}")
4,985 nodes; origin 975728875, destination 2502686149

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,985 nodes, 11,270 edges, median travel_time 5.06, 28 parallel edges
  4,155 m in 5.25 min over 67 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        61.9141           0.0007 82635.7
projected EPSG:32630        61.9141          62.0424     1.0

The stored length has a median of 61.9 m on both graphs, because OSMnx computes it geodetically and it does not change under projection. geometry.length is 0.0007 on the unprojected graph and 62.0 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.33, p90 1.68, max 3.63
  βœ“ 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.

Degrees, misspelled weights and parallel edges

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.

OSMnx stored length against a recomputed geometry length on projected and unprojected graphs. OSMnx length is metres on any CRS; yours is notstored lengthgeometry.lengthratioEPSG:4326 graphβœ“61.91 mβœ•0.0007βœ•82,636projected graphβœ“62.20 mβœ“62.30βœ“1.0Any custom cost from shapely .length on a 4326 graph is off by ~82,000 spatialworkflow.io
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 94 m β€” 2.3%.

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.

Undirected copies and distances below straight-line

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