Catchment Areas Explained: Buffers, Isochrones and Voronoi Compared
Problem statement
"How many people does this pharmacy serve?" has no single answer. It depends on what you draw around the pharmacy, and the three common choices โ a circle, a travel-distance area and a Voronoi cell โ are not three approximations of one number. They are three different questions.
Measured for the 22 pharmacies in Chittenden County, Vermont, against the 168,323 residents in its 2020 census blocks:
- A 1 km circle around the median pharmacy held 2,528 people. The circles overlap: they counted 80,229 people in total but only 55,545 different ones.
- A 1 km walk along the street network held 803 people at the median โ a third of the circle.
- A Voronoi cell, which gives every resident to their nearest pharmacy in a straight line, held 5,850 people at the median, and 67.0% of those people live more than 1 km from their pharmacy.
The methods also disagree about which pharmacies serve the most people. The rank correlation between circle populations and Voronoi populations was 0.058 โ close to none โ and 17 of the 22 pharmacies moved five or more places between the two rankings.
Quick answer
Choose the catchment from the question:
question catchment overlaps? covers everyone?
who can reach it within a distance or time? network isochrone yes no
who is near it, roughly, with no network data? buffer yes no
whose nearest facility is it? Voronoi cell (or network no yes
nearest-facility assignment)
circles = pharmacies.buffer(1000) # reach, straight line
cells = shapely.voronoi_polygons(shapely.MultiPoint(list(pharmacies.geometry)),
extend_to=study_area.buffer(10_000), ordered=True)
Then report which one you used. A population figure without its catchment definition cannot be compared with anything.
Step-by-step solution
1. Decide between reach and assignment
Every catchment method answers one of two questions:
- Reach: which people are within a given distance or time of the facility? A resident can be within reach of several facilities, or of none.
- Assignment: which facility does each resident belong to? Every resident belongs to exactly one.
Buffers and isochrones answer reach. Voronoi cells and nearest-facility assignments answer assignment. Mixing them โ summing reach populations as if they were shares of the county โ is the most common catchment error.
2. Use a buffer only as a first approximation of reach
A buffer is a circle of fixed radius. It is fast and needs nothing but coordinates, and it ignores rivers, motorways, dead ends and lakes.
The 22 one-kilometre circles counted 80,229 people when their populations were summed, but only 55,545 different people, 33.0% of the county. 21,359 people lived inside two or three circles at once. Summed, overlapping buffers overstate total reach by a factor of 1.44 here.
3. Use a network isochrone for real reach
An isochrone follows the street network outwards to a distance or travel time. On Chittenden County's walking network, the 1 km walk catchments held a median of 803 people, 0.40 times the circle population, and three pharmacies had nobody at all within a 1 km walk: no populated block, counting its walk to the nearest footpath, was close enough by the network.
The walk catchments together reached 27,278 people, 16.2% of the county โ half the circles' figure. Straight-line distance systematically overstates how many people are close.
4. Use a Voronoi cell for assignment
A Voronoi cell is the area closer to one facility than to any other, in a straight line. The cells tile the study area with no gaps or overlaps, so every resident belongs to exactly one pharmacy and the cell populations add up to the county: 168,323.
That completeness is the point and the weakness. A cell says nothing about distance: its members lived a population-weighted median 1,378 m from their pharmacy, 67.0% lived more than 1 km away and 17.9% more than 5 km. One CVS pharmacy's cell covered 206.6 kmยฒ and 17,089 people, while only 587 people lived within a 1 km walk of it.
5. Include facilities just outside the study area
Residents near a boundary may be nearer a pharmacy across it. With the 5 pharmacies within 10 km outside the county included, their cells took 5,845 county residents. Leaving them out would have assigned those people to more distant pharmacies inside the county and inflated those catchments.
6. Compare rankings, not only totals
If the catchments are used to rank facilities โ which serves most people, which is overloaded โ check that the ranking survives a change of method:
Spearman rank correlation of pharmacy populations
buffer ~ walk 0.861
buffer ~ Voronoi 0.058
walk ~ Voronoi 0.141
Buffers and walks rank pharmacies similarly, because both measure what is close. Voronoi cells rank them by how much territory they own, which favours isolated pharmacies with large rural cells. The busiest pharmacy by circle, Lakeside Pharmacy with 15,970 people within 1 km, was second by Voronoi; the busiest by Voronoi had 1,157 people in its circle.
7. Report the definition with the number
State the method, the distance or time, the network and the facility set: "people within a 1 km walk on OSM footpaths, 22 county pharmacies". Anything less makes the number uncheckable.
Code examples
Example 1 โ buffer catchments with the overlap measured
import geopandas as gpd
def buffer_catchments(facilities, homes, radius, id_col="pid", pop_col="POP20"):
"""Population within a straight-line radius of each facility, and how much the circles overlap."""
zones = facilities[[id_col, "geometry"]].copy()
zones["geometry"] = zones.buffer(radius)
joined = gpd.sjoin(homes, zones, predicate="within")
per_facility = joined.groupby(id_col)[pop_col].sum()
unique = homes.loc[joined.index.unique(), pop_col].sum()
print(f"sum over facilities {per_facility.sum():,}; unique people {unique:,} "
f"({unique / homes[pop_col].sum():.1%}); double counting factor {per_facility.sum() / unique:.2f}")
return per_facility
sum over facilities 80,229; unique people 55,545 (33.0%); double counting factor 1.44
homes are populated census blocks as representative points, in a projected CRS in metres. The factor is the number to quote whenever buffer populations are summed.
Example 2 โ walk catchments on a street network
import networkx as nx
import osmnx as ox
import pandas as pd
def walk_catchments(G, facilities, homes, radius, id_col="pid", pop_col="POP20"):
"""Population within a network distance of each facility, including both snap legs."""
f_nodes, f_snap = ox.distance.nearest_nodes(G, facilities.geometry.x, facilities.geometry.y, return_dist=True)
h_nodes, h_snap = ox.distance.nearest_nodes(G, homes.geometry.x, homes.geometry.y, return_dist=True)
h = pd.DataFrame({"node": h_nodes, "snap": h_snap, "pop": homes[pop_col].to_numpy()}, index=homes.index)
per_facility, reached = {}, set()
for fid, node, snap in zip(facilities[id_col], f_nodes, f_snap):
dist = nx.single_source_dijkstra_path_length(G, node, cutoff=radius, weight="length")
inside = (h["node"].map(dist) + h["snap"] + snap) <= radius
per_facility[fid] = int(h.loc[inside, "pop"].sum())
reached |= set(h.index[inside])
per_facility = pd.Series(per_facility)
unique = homes.loc[list(reached), pop_col].sum()
print(f"sum over facilities {per_facility.sum():,}; unique people {unique:,} "
f"({unique / homes[pop_col].sum():.1%})")
return per_facility
sum over facilities 35,823; unique people 27,278 (16.2%)
G is a projected OSMnx walking graph. The cutoff stops each Dijkstra search at the radius, so the loop over 22 pharmacies took well under a second. Adding the snap distances from each point to its nearest node keeps a home 300 m from the nearest footpath from counting as on the network.
Example 3 โ Voronoi assignment, with the distance it hides
import numpy as np
import shapely
def voronoi_catchments(facilities, homes, study_area, margin=10_000, id_col="pid", pop_col="POP20"):
"""Each home assigned to its nearest facility by Voronoi cell, clipped to the study area."""
cells = shapely.voronoi_polygons(shapely.MultiPoint(list(facilities.geometry)),
extend_to=study_area.buffer(margin), ordered=True)
zones = gpd.GeoDataFrame({id_col: facilities[id_col].to_numpy()},
geometry=list(cells.geoms), crs=facilities.crs)
zones["geometry"] = zones.intersection(study_area)
zones = zones[~zones.is_empty]
joined = gpd.sjoin(homes, zones, predicate="within")
own = facilities.set_index(id_col).geometry.reindex(joined[id_col]).set_axis(joined.index)
distance = joined.geometry.distance(own)
w = joined[pop_col].to_numpy()
per_facility = joined.groupby(id_col)[pop_col].sum()
print(f"{len(zones)} cells; people assigned {per_facility.sum():,} "
f"({per_facility.sum() / homes[pop_col].sum():.1%}); "
f"living more than 1 km from their facility {w[distance.to_numpy() > 1000].sum() / w.sum():.1%}")
return per_facility
27 cells; people assigned 168,323 (100.0%); living more than 1 km from their facility 67.0%
ordered=True makes the i-th cell belong to the i-th point; without it, the cells come back in an internal order and attaching facility attributes by position mislabels them. Pass every facility that could be someone's nearest, including those beyond the boundary, and clip afterwards.
Explanation
Why buffers and isochrones differ so much
A circle assumes you can travel in a straight line in any direction. A street network allows only a fraction of those directions, and each detour spends part of the distance budget. Around a pharmacy on a main road, a 1 km walk reaches along the road and a short way into side streets; the circle includes houses behind it that are a 2 km walk away. Drawn as the reachable streets buffered by 50 m, the median walk catchment covered 0.66 kmยฒ against the circle's 3.14 kmยฒ.
Why overlap breaks totals
Reach catchments are defined per facility, independently of the others. A resident between two pharmacies is within reach of both, so summing reach populations counts them twice. That is correct for "how many people can reach each pharmacy?" and wrong for "how many people do the pharmacies serve between them?". The unique count answers the second question, and the ratio between the two is a direct measure of redundancy in the network of facilities.
Why Voronoi cells favour isolated facilities
A Voronoi cell's size is set by the spacing of facilities, not by how many people live near them. Pharmacies clustered in Burlington split the city into small cells; a pharmacy in a rural town inherits every farm between it and the next town. Its cell population can then exceed that of a busy urban pharmacy whose immediate neighbourhood is far denser, which is why the Voronoi ranking was almost unrelated to the circle ranking.
Why straight-line assignment is still an approximation
The nearest pharmacy in a straight line is not always the nearest by road. Lakes, rivers and a sparse rural network change who is nearest, and a network-based assignment โ computing travel time from every home to every facility and taking the minimum โ replaces the Voronoi cell when the difference matters. Voronoi is the right tool when distances are short and the network is dense, or when there is no network data at all.
Edge cases or notes
- Buffers must be in a projected CRS. A radius of 1000 in EPSG:4326 is 1000 degrees.
- Snap distances matter in rural areas. The median populated block in and around the county was 352 m from the nearest walkable node, and one in ten was more than 5 km away.
- Duplicate facilities break Voronoi. A pharmacy mapped as both a point and a building polygon gives two coincident points; drop one before building cells.
- Isochrone polygons are a drawing, not the measurement. Count people by network distance to their snapped node, as in Example 2; the polygon around reachable nodes is only for the map.
- Catchments by time need speeds. Walking at a fixed speed is distance; driving needs edge speeds, which OSMnx fills from tags and defaults.
- Zero-population catchments are real. Three pharmacies had nobody within a 1 km walk.
- Water inflates Voronoi cells. Clip to land before computing areas or densities.
Internal links
- Location analysis explained: catchments, accessibility and site selection โ where catchments fit
- How to build Voronoi service areas around facilities in Python โ the assignment catchment in detail
- How to count the population within reach of each facility โ reach catchments with population attached
- Isochrones explained: travel time areas and what they assume โ the network catchment's assumptions
- How to generate isochrones in Python โ building the polygons
- How to create buffers in GeoPandas for spatial analysis โ the straight-line version
- Network distance vs straight-line distance explained โ why the two catchments differ
- Fixing Voronoi polygons that extend forever or miss the study area โ when the cells come out wrong
FAQ
What is a catchment area?
The area, and the people in it, associated with a facility. It is defined either by reach โ everyone within a distance or travel time โ or by assignment โ everyone for whom this facility is the nearest.
Should I use a buffer or an isochrone?
An isochrone whenever a street network is available. For Chittenden County pharmacies, a 1 km walk reached a median of 803 people against 2,528 inside a 1 km circle.
Can I add up the population of buffer catchments?
Not as a count of different people. Overlapping 1 km buffers summed to 80,229 but contained 55,545 distinct people, a double-counting factor of 1.44.
What does a Voronoi catchment tell me?
Which facility is nearest to each resident in a straight line. Every resident is assigned exactly once, but distance is ignored: 67.0% of residents in the pharmacy cells lived more than 1 km from their pharmacy.
Why do the methods rank facilities differently?
Buffers and isochrones measure how many people are close; Voronoi cells measure how much territory is nearest. Their rankings of the pharmacies had a rank correlation of 0.058.
Do I need facilities outside my study area?
Yes, for assignment. Including 5 pharmacies within 10 km of the county moved 5,845 county residents into their cells instead of more distant ones.