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.
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.
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.
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.
Internal links
- How to build a distance matrix between two layers in Python β the Euclidean machinery
- How to measure network distance for many origin-destination pairs β doing this at scale
- Street networks as graphs explained β what network distance traverses
- How to calculate the shortest path along a street network β one pair at a time
- How to snap points to a street network in Python β the error snapping introduces
- Isochrones explained β network distance turned into a catchment
- How to measure distance accurately in Python β projected versus geodesic straight lines
- Your street network graph is disconnected β unreachable pairs biasing the result
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.