Network Distance vs Straight-Line Distance Explained

Problem statement

Straight-line distance is easy, fast and available in one line:

distance = origin.distance(destination)          # metres, in a projected CRS

It is also, for any question about travel, wrong. Nobody walks through buildings, and a river with a bridge every two kilometres does not care how close the far bank is.

The question is how wrong. Measured over 396 random pairs on a real city-centre street network:

circuity (network distance / straight-line distance)
  median  1.32
  p90     1.63
  max     3.99

The median trip is 32% longer than the crow flies β€” so straight-line distance understates real distance by about 24%. The 90th percentile is 63% longer, and one pair in the sample was four times longer.

That spread is the problem. A consistent 32% error could be corrected with a multiplier. A spread from 1.0 to 4.0 cannot.

Quick answer

straight-line network
cost microseconds milliseconds per pair
needs coordinates a routable graph
respects nothing one-ways, barriers, bridges
right for proximity, weights, clustering travel, access, catchments
import networkx as nx
import numpy as np
import osmnx as ox

straight = np.hypot(x1 - x2, y1 - y2)                       # projected CRS
network = nx.shortest_path_length(G, node_a, node_b, weight="length")

print(f"straight {straight:,.0f} m, network {network:,.0f} m, "
      f"circuity {network / straight:.2f}")
straight 1,842 m, network 2,431 m, circuity 1.32

The rule: use Euclidean distance when the question is about proximity, and network distance when it is about travel. A nearest-sensor query, a spatial weights matrix or a clustering eps are proximity. An accessibility index, a catchment or a service area are travel.

Circuity across 396 random pairs, with a median of 1.32, a 90th percentile of 1.63 and a maximum of 3.99.
The spread is what makes a correction factor useless. One pair in ten is over 63% longer than the crow flies.

Step-by-step solution

1. Measure the circuity of your own area

The ratio varies enormously between places. A grid-plan city is close to 1.15; a medieval core, a river city or a suburb of cul-de-sacs is much higher.

def circuity(G, n_pairs=400, min_straight=200, seed=3):
    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)}

    largest = list(max(nx.strongly_connected_components(G), key=len))
    rng = np.random.default_rng(seed)

    ratios = []
    for _ in range(n_pairs):
        a, b = rng.choice(largest), rng.choice(largest)
        if a == b:
            continue
        straight = np.hypot(xs[a] - xs[b], ys[a] - ys[b])
        if straight < min_straight:
            continue                       # short pairs have wild, uninformative ratios
        ratios.append(nx.shortest_path_length(G, a, b, weight="length") / straight)

    ratios = np.array(ratios)
    print(f"  {len(ratios)} pairs: median {np.median(ratios):.2f}, "
          f"p90 {np.percentile(ratios, 90):.2f}, max {ratios.max():.2f}")
    return ratios


ratios = circuity(G)
  396 pairs: median 1.32, p90 1.63, max 3.99

The min_straight filter matters. For two nodes 20 m apart the ratio is dominated by where the nearest junctions happen to be, and values of 5 or 10 are common and meaningless.

2. Use straight-line distance where it is genuinely right

Euclidean distance is not a lazy approximation everywhere. It is the correct measure when the question has nothing to do with travel:

  • Nearest sensor, nearest weather station β€” you want physical proximity, not a route.
  • Spatial weights β€” "neighbouring" means adjacent in space.
  • DBSCAN eps β€” clustering by location, not by accessibility.
  • Kernel density bandwidth β€” a smoothing scale, not a journey.

In all of those, substituting network distance would be a modelling error rather than an improvement.

3. Use network distance where travel is the question

  • Accessibility β€” how far is the nearest school, by the route a child takes.
  • Catchments and service areas β€” who can reach this shop.
  • Emergency response β€” the ambulance drives on roads.
  • Delivery routing β€” obviously.

The tell is whether a barrier would change the answer. If a river between two points makes them effectively far apart, you need network distance. If it does not, you do not.

4. Know when the error is worst

Circuity is not uniform. It is highest where:

  • a river, railway or motorway separates the two points and the crossing is a detour
  • the network is sparse β€” rural areas, industrial estates
  • one endpoint is in a cul-de-sac or a gated development
  • the trip is short, so a single detour dominates

And lowest on a regular grid over medium distances, where it converges toward about 1.27 β€” the ratio of Manhattan to Euclidean distance averaged over all directions.

That last figure is a useful sanity check. A measured median well below 1.2 suggests a very regular network or a bug; well above 1.5 suggests real barriers.

5. Do not apply a correction factor

The tempting shortcut is to compute Euclidean distance and multiply by the median circuity:

estimated = straight * 1.32

For a population aggregate β€” mean travel distance across thousands of trips β€” that is defensible, and the errors partly cancel.

For any individual decision it is not. The p90 is 1.63 and the maximum 3.99, so an estimate at 1.32 is badly wrong for exactly the cases that matter: the household on the wrong side of the river, the address behind the railway.

Those are the ones an accessibility study exists to find.

Two points 400 metres apart across a river, with the network route running 2.6 kilometres to the nearest bridge.
The barrier case is where the correction factor fails worst, and it is the case the analysis is usually about.

Code examples

Example 1 β€” comparing the two measures on the same question

import geopandas as gpd
import networkx as nx
import numpy as np
import osmnx as ox
from scipy.spatial import cKDTree


def nearest_facility(homes, facilities, G, *, crs="EPSG:27700"):
    """Distance to the nearest facility, measured both ways."""
    Gp = ox.project_graph(G)
    nodes, _ = ox.graph_to_gdfs(Gp)

    homes_p = homes.to_crs(nodes.crs)
    facilities_p = facilities.to_crs(nodes.crs)

    # --- straight line: a KD-tree over facility locations
    tree = cKDTree(np.column_stack([facilities_p.geometry.x, facilities_p.geometry.y]))
    straight, _ = tree.query(np.column_stack([homes_p.geometry.x, homes_p.geometry.y]))

    # --- network: snap both sets to nodes, then one Dijkstra per facility
    home_nodes = ox.nearest_nodes(Gp, homes_p.geometry.x, homes_p.geometry.y)
    facility_nodes = ox.nearest_nodes(Gp, facilities_p.geometry.x, facilities_p.geometry.y)

    best = np.full(len(homes_p), np.inf)
    for node in set(facility_nodes):
        lengths = nx.single_source_dijkstra_path_length(G, node, weight="length")
        best = np.minimum(best, [lengths.get(h, np.inf) for h in home_nodes])

    out = homes.copy()
    out["straight_m"] = straight
    out["network_m"] = best
    out["circuity"] = out["network_m"] / out["straight_m"].clip(lower=1)

    reachable = np.isfinite(out["network_m"])
    print(f"  {reachable.sum():,} of {len(out):,} homes reach a facility")
    print(f"  straight: median {out.loc[reachable, 'straight_m'].median():,.0f} m")
    print(f"  network:  median {out.loc[reachable, 'network_m'].median():,.0f} m")
    print(f"  circuity: median {out.loc[reachable, 'circuity'].median():.2f}, "
          f"p90 {out.loc[reachable, 'circuity'].quantile(0.9):.2f}")
    return out


result = nearest_facility(homes, schools, G)
  4,812 of 4,850 homes reach a facility
  straight: median 486 m
  network:  median 641 m
  circuity: median 1.32, p90 1.71

Note the loop structure: one Dijkstra per facility, not per home. single_source_dijkstra_path_length computes distances from one node to every other in one pass, so twelve schools cost twelve traversals regardless of how many homes there are. Looping over homes instead would be four thousand traversals.

Example 2 β€” where the two measures disagree

def disagreement_report(result, *, threshold=1.8):
    bad = result[result["circuity"] > threshold]
    print(f"  {len(bad)} of {len(result)} homes have circuity over {threshold} "
          f"({len(bad) / len(result):.1%})")
    print(f"  for those: straight median {bad['straight_m'].median():,.0f} m, "
          f"network median {bad['network_m'].median():,.0f} m")

    within_800_straight = (result["straight_m"] <= 800).sum()
    within_800_network = (result["network_m"] <= 800).sum()
    print(f"\n  within 800 m as the crow flies: {within_800_straight:,} homes "
          f"({within_800_straight / len(result):.0%})")
    print(f"  within 800 m by the street network: {within_800_network:,} homes "
          f"({within_800_network / len(result):.0%})")
    print(f"  straight-line overstates access by "
          f"{within_800_straight / within_800_network - 1:.0%}")
    return bad


bad = disagreement_report(result)
  412 of 4,850 homes have circuity over 1.8 (8.5%)
  for those: straight median 402 m, network median 812 m

  within 800 m as the crow flies: 3,204 homes (66%)
  within 800 m by the street network: 2,318 homes (48%)
  straight-line overstates access by 38%

The last three lines are the finding. A threshold question β€” "how many homes are within 800 m of a school" β€” gives 66% by straight line and 48% by network. Reporting the first would overstate access by 38%.

Thresholds amplify circuity because they sit on the tail of the distribution. Where a mean distance is out by 32%, a headline percentage can be out by far more.

Example 3 β€” deciding which to use, in code

PROXIMITY_QUESTIONS = {
    "nearest_sensor", "spatial_weights", "clustering_eps", "kde_bandwidth",
}
TRAVEL_QUESTIONS = {
    "accessibility", "catchment", "service_area", "emergency_response", "delivery",
}


def choose_metric(question, *, has_barriers=None, n_pairs=None):
    if question in PROXIMITY_QUESTIONS:
        return "euclidean", "the question is about physical proximity, not travel"
    if question in TRAVEL_QUESTIONS:
        if n_pairs and n_pairs > 10_000_000:
            return "network (matrix)", "use one Dijkstra per source, not per pair"
        return "network", "a barrier would change the answer"
    if has_barriers:
        return "network", "barriers are present and travel is implied"
    return "euclidean", "no travel implied β€” but say which you used"


for q in ("nearest_sensor", "accessibility", "catchment", "spatial_weights"):
    metric, why = choose_metric(q)
    print(f"  {q:22} -> {metric:18} ({why})")
  nearest_sensor         -> euclidean          (the question is about physical proximity, not travel)
  accessibility          -> network            (a barrier would change the answer)
  catchment              -> network            (a barrier would change the answer)
  spatial_weights        -> euclidean          (the question is about physical proximity, not travel)

Encoding the decision makes it reviewable. The most common mistake is not choosing wrong β€” it is not choosing at all, and defaulting to whichever was easier to compute.

Explanation

Why circuity converges toward 1.27 on a grid

On a perfect grid you can only travel along two axes, so the distance between two points is the Manhattan distance |dx| + |dy| rather than √(dx² + dy²).

Averaged over all directions, the ratio of Manhattan to Euclidean distance is 4/Ο€ β‰ˆ 1.273. Real grid cities land close to it β€” Manhattan itself, Chicago, Barcelona's Eixample.

The measured median of 1.32 in a British city centre is above that, which is what an irregular medieval street pattern plus a river produces. A figure well below 1.2 would suggest a very regular network or a measurement bug; well above 1.5 suggests substantial barriers.

Why a correction factor fails on the cases that matter

Multiplying Euclidean distance by the median circuity gives an unbiased estimate on average. The distribution is what defeats it:

  median  1.32
  p90     1.63
  max     3.99

The error is small for typical pairs and large for atypical ones. And an accessibility analysis is almost always about the atypical ones β€” the households cut off by a railway, the estate with one entrance, the village on the wrong side of a river.

Applying a flat 1.32 makes those cases look average, which is the opposite of what the analysis is for. Use it for a population mean if you must; never for identifying who is underserved.

Homes within 800 metres of a school: 66 percent by straight line and 48 percent by network distance.
A 32% median distance error becomes a 38% error in the headline percentage, because the threshold sits on the tail.

Why network distance costs so much more

Euclidean distance is arithmetic on two coordinate pairs β€” nanoseconds, and a KD-tree finds the nearest of a million in microseconds.

Network distance is a graph traversal. Dijkstra from one source to all nodes is O(E + V log V), which on a 5,000-node graph is milliseconds β€” but the naive approach of one traversal per origin-destination pair multiplies that by the pair count.

The structural fix is to traverse once per source and read off every destination:

lengths = nx.single_source_dijkstra_path_length(G, source, weight="length")

For 5,000 homes and 12 schools that is 12 traversals rather than 60,000. See measuring network distance for many pairs.

Why the graph must be strongly connected first

An unreachable pair returns inf or raises, and in a mixed batch that shows up as a handful of homes with no measured distance. If you then take a median, those rows are silently excluded and the result is biased toward the well-connected.

Restricting the graph to its largest strongly connected component before measuring anything removes the whole class of problem β€” see your street network graph is disconnected. The reachable.sum() line in Example 1 exists to make any remaining losses visible.

Edge cases or notes

  • Circuity is meaningless for very short pairs. Filter below about 200 m, or the ratio is dominated by junction spacing.
  • Snapping adds error. A home 40 m from its nearest node inherits that offset β€” see how to snap points to a street network.
  • Network distance is asymmetric on a directed graph. Aβ†’B and Bβ†’A can differ, and for one-way systems they routinely do.
  • Travel time is not proportional to distance. A longer route on faster roads is often quicker β€” optimise on the weight you actually care about.
  • Barriers may not be in the network. A private estate road that OSM includes but a delivery driver cannot use will understate real distance.
  • A flat correction factor is acceptable for a population mean and never for identifying individuals.
  • Grid cities converge to 4/Ο€ β‰ˆ 1.273, which is a useful reference point.
  • Measure circuity on your own area. It varies from about 1.15 to over 2 between cities.

FAQ

How much longer is network distance than straight-line?

On the city-centre network measured here, a median of 32% longer, a 90th percentile of 63%, and a maximum of nearly four times. It varies a lot between places β€” measure your own.

Can I just multiply straight-line distance by a factor?

For a population mean, defensibly. For individual cases, no β€” the spread runs from 1.0 to 4.0, and the outliers are exactly the households an accessibility study exists to find.

When is straight-line distance the right answer?

Whenever the question is about proximity rather than travel: nearest sensor, spatial weights, clustering distances, kernel bandwidths. Substituting network distance there would be a modelling error.

Why is circuity about 1.27 on a grid?

Because grid travel is Manhattan distance, and averaged over all directions the ratio of Manhattan to Euclidean distance is 4/Ο€ β‰ˆ 1.273.

Why does the difference matter more for thresholds?

Because a threshold sits on the tail of the distribution. A 32% median distance error produced a 38% error in "homes within 800 m" in the example here.

Is network distance symmetric?

Not on a directed graph. A→B and B→A can differ, and in one-way systems they routinely do.

How do I compute it without it taking forever?

One Dijkstra per source rather than one per pair. single_source_dijkstra_path_length gives distances to every node in one traversal.