How to Measure Distance to the Nearest Facility for Every Home
Problem statement
The distance from each home to its nearest pharmacy, school or fire station is the input to most access studies. It is also easy to compute in a way that is quick, plausible and wrong: a straight-line nearest-neighbour join.
Measured for the 168,323 residents of Chittenden County, Vermont, in 2,241 populated census blocks, against 27 pharmacies in and within 10 km of the county:
- Straight line: the population-weighted median distance to the nearest pharmacy was 1,381 m.
- Road network: it was 2,342 m. For homes more than 500 m from a pharmacy, the road distance was a median 1.46 times the straight line, and up to 5.07 times.
- 13.4% of residents had a different nearest pharmacy by road than in a straight line.
The network version is not slow. One multi-source shortest-path search from all 27 pharmacies at once took 1.1 ms with SciPy โ against 54 ms for the equivalent NetworkX call and 1,253 ms for the common loop of one search per pharmacy.
Quick answer
import numpy as np
from scipy.sparse.csgraph import dijkstra
# csr: sparse matrix of edge lengths (or seconds) between node indices
dist, _, source = dijkstra(csr.T, directed=True, indices=pharmacy_nodes,
min_only=True, return_predecessors=True)
metres = dist[home_nodes] + home_snap_m # add the walk from the home to the network
nearest_node = source[home_nodes] # which pharmacy node won
min_only=True seeds all pharmacies at once and returns, for every node, the distance to the nearest one and which one it was. Searching on csr.T, the reversed graph, gives the distance from each home to a pharmacy, which is the direction people travel on a network with one-way streets.
Step-by-step solution
1. Represent homes as points inside populated blocks
Use a representative point for each populated census block, not its centroid, so every point lies inside its block. In the county that gave 2,241 points carrying 168,323 people. Weight every summary by population; a median over blocks treats a farm and a housing estate as equals.
2. Include facilities beyond the boundary
People near a county line use pharmacies across it. With the 10 km ring included, 3.4% of the county's residents turned out to have their nearest pharmacy by road outside the county. Leaving those pharmacies out overstates their distances.
3. Get the straight-line answer first, as a baseline
import geopandas as gpd
nearest = gpd.sjoin_nearest(homes, pharmacies[["pid", "geometry"]], how="left", distance_col="d_euc")
It took 3.7 ms for 2,241 homes and found no ties. It is worth keeping, because the ratio between network and straight-line distance is the most useful single diagnostic of a network result.
4. Build a sparse graph with the minimum over parallel edges
OSMnx returns a multigraph, where two nodes can be joined by several edges โ a dual carriageway, a road and a slip road. When converting to a SciPy matrix, keep the shortest of them. NetworkX's to_scipy_sparse_array sums parallel edge weights, which silently lengthens those links; building the matrix from a dictionary of minimum weights avoids it (Example 2). The county's drive graph had 9,835 nodes and 23,264 edges, and the matrix took 94 ms to build.
5. Snap homes and facilities to nodes, and keep the snap distances
home snap median 135 m, p90 569 m, max 2,562 m; pharmacy snap median 73 m, max 540 m
The snap leg is not in the network distance, so add it. For the median resident it was 5.6% of the total distance, and for one in ten more than 15.4%.
6. Run one multi-source search
scipy dijkstra, min_only, reversed graph (length) 1.1 ms
networkx multi_source_dijkstra_path_length (length) 54.1 ms
one networkx Dijkstra per pharmacy, then minimum 1,253.2 ms
All three gave identical distances to the metre. The loop is common in tutorials because it also yields a full originโdestination matrix; if only the nearest facility is needed, it does 27 searches to answer one question.
7. Count homes that cannot reach anything
One block, holding 229 people, returned an infinite distance: its nearest node sat on a fragment of the graph with no route to any pharmacy. Report such homes and fix the snapping or the graph, rather than letting infinity into a mean or dropping them silently.
8. Compare network and straight line
straight line road network
median distance 1,381 m 2,342 m
90th percentile 7,502 m 10,040 m
within 1 km 33.0% 14.6%
within 2 km 59.9% 44.1%
within 5 km 82.1% 69.9%
The network distance is longer everywhere, and proportionally longest at short range, where a detour around a block or a river doubles a trip. The drive time to the nearest pharmacy, from OSMnx free-flow speeds, was a median 2.8 minutes and at most 22.9.
Code examples
Example 1 โ straight-line nearest facility, with ties checked
import geopandas as gpd
def nearest_straight_line(homes, facilities, id_col="pid"):
"""Straight-line nearest facility per home; sjoin_nearest returns one row per tie."""
joined = gpd.sjoin_nearest(homes, facilities[[id_col, "geometry"]], how="left", distance_col="d_euc")
ties = len(joined) - len(homes)
if ties:
print(f"{ties} extra rows from ties; keeping the first facility for each home")
return joined[~joined.index.duplicated()][[id_col, "d_euc"]]
Both layers must be in the same projected CRS, in metres. Without the de-duplication, a home equidistant from two facilities appears twice and its population is counted twice in every summary.
Example 2 โ network nearest facility with SciPy
import numpy as np
import osmnx as ox
from scipy.sparse import csr_array
from scipy.sparse.csgraph import dijkstra
def graph_to_csr(G, weight="length"):
"""Sparse adjacency with the minimum weight over parallel edges."""
nodes = list(G.nodes)
index = {n: i for i, n in enumerate(nodes)}
best = {}
for u, v, data in G.edges(data=True):
key = (index[u], index[v])
best[key] = min(best.get(key, np.inf), data[weight])
rows, cols = zip(*best)
return csr_array((list(best.values()), (rows, cols)), shape=(len(nodes), len(nodes))), index
def nearest_on_network(G, homes, facilities, weight="length"):
"""Network distance from every home to its nearest facility, including both snap legs."""
A, index = graph_to_csr(G, weight)
h_nodes, h_snap = ox.distance.nearest_nodes(G, homes.geometry.x, homes.geometry.y, return_dist=True)
f_nodes, f_snap = ox.distance.nearest_nodes(G, facilities.geometry.x, facilities.geometry.y, return_dist=True)
f_idx = np.array([index[n] for n in f_nodes])
h_idx = np.array([index[n] for n in h_nodes])
dist, _, source = dijkstra(A.T, directed=True, indices=f_idx, min_only=True, return_predecessors=True)
facility_snap = {}
for i, s in zip(f_idx, f_snap):
facility_snap[i] = min(s, facility_snap.get(i, np.inf))
total = dist[h_idx] + h_snap + np.array([facility_snap.get(s, 0.0) for s in source[h_idx]])
unreachable = np.isinf(total)
print(f"{unreachable.sum()} homes cannot reach any facility ({homes.loc[unreachable, 'POP20'].sum():,} people)")
return total, source[h_idx]
1 homes cannot reach any facility (229 people)
source holds the node index of the winning facility; map it back to facility identifiers with the same index. Two facilities snapped to one node share a source, so give them the smaller snap distance, as here.
Example 3 โ compare the two, by population
def wquantile(values, weights, q):
order = np.argsort(values)
cum = np.cumsum(weights[order]) / weights.sum()
return values[order][np.searchsorted(cum, q)]
def compare_distances(straight, network, population, thresholds=(1000, 2000, 5000, 10000)):
"""Population-weighted comparison of straight-line and network nearest distances."""
ok = np.isfinite(network)
s, n, w = straight[ok], network[ok], population[ok]
print(f"median: straight {wquantile(s, w, .5):,.0f} m, network {wquantile(n, w, .5):,.0f} m")
far = s > 500
print(f"network / straight for homes beyond 500 m: median {wquantile(n[far] / s[far], w[far], .5):.2f}")
for t in thresholds:
print(f"within {t / 1000:>4.0f} km: straight {w[s <= t].sum() / w.sum():.1%}, network {w[n <= t].sum() / w.sum():.1%}")
median: straight 1,381 m, network 2,342 m
network / straight for homes beyond 500 m: median 1.46
within 1 km: straight 33.0%, network 14.6%
within 2 km: straight 59.9%, network 44.1%
within 5 km: straight 82.1%, network 69.9%
within 10 km: straight 95.3%, network 90.0%
The ratio is the check: a median near 1.0 suggests the network is missing roads or the snap is too generous, and a median above about 2 suggests missing connections.
Explanation
Why one search answers the nearest-facility question
Dijkstra's algorithm grows outwards from its sources in order of distance. Starting from every facility at once, each node is first reached from whichever facility is closest, so a single run labels every node with its nearest facility and the distance to it. The cost is one search, whatever the number of facilities; the loop multiplies it by 27.
Why the reversed graph
On a directed graph, the distance from A to B is not the distance from B to A when one-way streets are involved. A search from facilities over csr gives distances from facilities to homes. Transposing the matrix reverses every edge, so the same search gives distances from homes to facilities โ the trip residents make. For an emergency service driving out to homes, use csr as it is.
Why the nearest facility changes
A straight line picks the facility with the least displacement; a road picks the one with the shortest route. They differ wherever a river, rail line or disconnected street pattern makes the geometrically nearest facility a long way round. For 299 of the 2,240 reachable blocks, holding 13.4% of residents, the two answers named different pharmacies โ which matters for any analysis that assigns people to facilities, not just for distances.
Why the snap leg matters
Network distance starts at a node, and the nearest node can be hundreds of metres from a rural home. In towns the leg is negligible; in the countryside it is a large share of the trip. Adding it keeps rural homes from appearing closer than they are.
Edge cases or notes
- Remove duplicate facilities mapped as both a point and a building before snapping, or one pharmacy counts twice.
- Keep the largest strongly connected component if many homes return infinity, then report what was dropped.
- Travel time needs speeds.
ox.add_edge_speedsimputes missing speeds from averages by road type; state that. - Walking uses an undirected network. Use
network_type="walk"and the reversal no longer matters. - Distances in degrees are meaningless. Project the graph and the points before snapping.
- Nearest-node snapping can cross a river. Check the longest snap legs in rural areas.
- Straight-line ties are rare with real coordinates โ none here โ but must be handled when facilities share an address.
Internal links
- How to measure network distance for many originโdestination pairs โ when every pair is needed
- How to snap points to a street network in Python โ the snap step in detail
- Accessibility measures explained: nearest, cumulative and gravity โ what to do with the distances
- How to find the areas nobody can reach within a travel time โ thresholds on the same search
- How to find the nearest point in GeoPandas โ the straight-line join
- Network distance vs straight-line distance explained โ why the ratio exceeds one
- Your street network graph is disconnected or missing streets โ fixing unreachable homes
- Location analysis explained: catchments, accessibility and site selection โ the wider picture
FAQ
How do I find the nearest facility to each home by road in Python?
Build a sparse graph of the road network, snap homes and facilities to nodes, and run SciPy's dijkstra with all facility nodes as indices and min_only=True on the transposed matrix. For 2,241 homes and 27 pharmacies it took 1.1 ms.
Is straight-line distance close enough?
Usually not for short distances. The median resident was 1,381 m from a pharmacy in a straight line and 2,342 m by road, and straight lines put 33.0% of people within 1 km against 14.6% by road.
Why is a loop over facilities slow?
It runs one full shortest-path search per facility. The per-pharmacy NetworkX loop took 1,253 ms; one multi-source search gave identical distances in 1.1 ms.
Why do some homes get an infinite distance?
Their snapped node is on a part of the network with no route to any facility. One block with 229 residents did here; fix the snap or the graph and report it.
Should I add the distance from the home to the road?
Yes. It was 5.6% of the total distance for the median resident and over 15% for one in ten, concentrated in rural areas.
Which direction should the search run?
From homes to facilities for trips residents make, which means searching from the facilities on the reversed graph. For services that travel out to homes, search the graph as it is.