How to build walking catchments around transit stops
On this page
Problem statement
The standard transit catchment is a circle: 400 metres around a bus stop, 800 metres around a rail station. It is quick, it is in every planning guideline, and it assumes people walk in straight lines through buildings, rivers and freeways. The area a person can actually reach on foot within 800 metres of walking is a star-shaped patch along the street network, and it is usually much smaller than the circle.
Around a random sample of 40 stops in inner Portland β mostly a dense street grid, which is the best case for circles β the walkable network within 800 metres covered a median of 56% of the circle's area, and one stop in ten reached less than 30%.
Quick answer
Snap the stop to the walking network, run a Dijkstra search with a distance cutoff, and turn the reached edges into a polygon:
import networkx as nx
import numpy as np
import osmnx as ox
G = ox.project_graph(ox.load_graphml("pdx_walk.graphml"), to_crs=32610)
nodes, edges = ox.graph_to_gdfs(G)
def walk_catchment(G, x, y, dist=800, width=50):
node = ox.distance.nearest_nodes(G, x, y)
snap = float(np.hypot(nodes.loc[node].geometry.x - x, nodes.loc[node].geometry.y - y))
reached = nx.single_source_dijkstra_path_length(G, node, cutoff=dist - snap, weight="length")
u, v = edges.index.get_level_values(0), edges.index.get_level_values(1)
inside = edges[u.isin(reached) & v.isin(reached)]
return inside.geometry.buffer(width).union_all()
The walk to the network counts against the budget, which is why the cutoff is dist - snap. Buffering the reached streets by 50 metres approximates the front doors along them.
What you get β from The data used on this page
TriMet feed 20260927-20261007-0900, valid 20260927 to 20270227
A projected walking network, charged snaps, a cutoff
1. Get a walking network
Build it with OSMnx for the area plus a margin at least as wide as the catchment distance, so stops near the edge are not cut short. This one was built with OSMnx's walk network type from OpenStreetMap and saved as GraphML.
2. Project it
Distances must be in metres. Project the graph to a local CRS before running any search.
3. Snap each stop, and charge the snap
The nearest node can be 50 metres or more from the stop. Subtract that distance from the budget rather than ignoring it.
4. Search with a cutoff
single_source_dijkstra_path_length with cutoff returns every node within the distance. For many stops at once, multi_source_dijkstra_path_length gives the distance to the nearest stop from every node in one pass.
5. Turn reached edges into an area
Keep edges with both ends reached and buffer them. Edges with one end reached are partly walkable; including their reachable part is more exact and rarely changes the result by more than a few percent.
6. Compare with the circle
The ratio of network area to circle area β sometimes called the pedestrian shed ratio β is a direct measure of how walkable the surroundings are. It is also a warning about how much a circle-based figure overstates.
Walking catchments for Portland's stops
The data used on this page
TriMet's GTFS feed for Portland, Oregon, downloaded from developer.trimet.org and unzipped to trimet_gtfs/. TriMet republishes it every few weeks, so counts of trips and stop times move between versions; the setup prints the version the numbers on this page came from. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.
import io
import os
import zipfile
import pandas as pd
import requests
# 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-stop-catchment-walking-python-2026-10-10.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
if not os.path.exists("trimet_gtfs"):
feed = requests.get("https://developer.trimet.org/schedule/gtfs.zip", timeout=600)
zipfile.ZipFile(io.BytesIO(feed.content)).extractall("trimet_gtfs")
info = pd.read_csv("trimet_gtfs/feed_info.txt", dtype=str).iloc[0]
print(f"TriMet feed {info.feed_version}, valid {info.feed_start_date} to {info.feed_end_date}")
TriMet feed 20260927-20261007-0900, valid 20260927 to 20270227
The street network is a walking network for inner Portland from OpenStreetMap, built with OSMnx and saved as GraphML. overpass-api.de refuses connections from here, so the query goes to the maps.mail.ru mirror of the same API; OpenStreetMap is edited continuously, so node counts move a little between downloads.
import os
import osmnx as ox
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api" # overpass-api.de mirror
PDX = (-122.75, 45.46, -122.56, 45.57) # west, south, east, north: inner Portland
if not os.path.exists("pdx_walk.graphml"):
ox.save_graphml(ox.graph_from_bbox(PDX, network_type="walk"), "pdx_walk.graphml")
G_raw = ox.load_graphml("pdx_walk.graphml")
print(f"walk network: {len(G_raw):,} nodes, {G_raw.number_of_edges():,} edges")
walk network: 51,032 nodes, 148,302 edges
Example 1 β one stop
import pandas as pd
import geopandas as gpd
stops = pd.read_csv("trimet_gtfs/stops.txt", dtype=str)
stops = stops[stops.location_type.fillna("0") == "0"]
stops = gpd.GeoDataFrame(stops, crs=4326, geometry=gpd.points_from_xy(
stops.stop_lon.astype(float), stops.stop_lat.astype(float))).to_crs(32610)
s = stops.set_index("stop_id").loc["1435"]
poly = walk_catchment(G, s.geometry.x, s.geometry.y)
circle = np.pi * 800 ** 2
print(f"{s.stop_name}: network {poly.area / 1e6:.2f} kmΒ² vs circle {circle / 1e6:.2f} kmΒ²"
f" -> ratio {poly.area / circle:.2f}")
SE Division & 20th Ave: network 1.28 kmΒ² vs circle 2.01 kmΒ² -> ratio 0.64
Example 2 β a sample of stops across inner Portland
from shapely.geometry import box
xmin, ymin, xmax, ymax = nodes.total_bounds
inner = box(xmin + 1500, ymin + 1500, xmax - 1500, ymax - 1500) # keep clear of the edge
sample = stops[stops.within(inner)].sample(40, random_state=1)
ratios = pd.Series({sid: walk_catchment(G, p.x, p.y).area / circle
for sid, p in zip(sample.stop_id, sample.geometry)})
print("pedestrian shed ratio, 40 stops:", ratios.quantile([0.1, 0.5, 0.9]).round(2).to_dict())
worst = ratios.idxmin()
print("least walkable:", sample.set_index("stop_id").stop_name[worst], round(ratios.min(), 2))
pedestrian shed ratio, 40 stops: {0.1: 0.3, 0.5: 0.56, 0.9: 0.65}
least walkable: SW Terwilliger & Ralston Dr 0.16
Example 3 β catchments for every stop at once
src = list(set(ox.distance.nearest_nodes(G, stops.geometry.x, stops.geometry.y)))
dist = nx.multi_source_dijkstra_path_length(G, src, cutoff=800, weight="length")
walkable = nodes.loc[list(dist)]
print(f"{len(walkable):,} of {len(nodes):,} network nodes are within 800 m of a stop "
f"({len(walkable) / len(nodes):.1%})")
50,187 of 51,032 network nodes are within 800 m of a stop (98.3%)
One search from all stops together answers the coverage question without building a polygon per stop.
Why the circle overstates
Why the circle overstates
A circle counts every point within 800 metres as the crow flies. Walking follows streets, so reaching a point at a diagonal on a grid takes up to β2 times its straight-line distance, and anything across a river, rail line or freeway may be far longer. Even on Portland's fine grid the network patch is little more than half the circle, and the least walkable stop in the sample, on the winding streets of the south-west hills, reached 16% of it.
Why snap distance belongs in the budget
A stop on a wide arterial may snap to a node on the far side of the road, or to the end of a footpath 60 metres away. If that walk is free, the catchment is a snap-distance larger in every direction than it should be. Charging it keeps catchments honest for the stops that are hardest to reach.
Why the buffer width matters less than it seems
Buffering reached edges by 25 or 75 metres changes a catchment's area, but it changes every catchment by a similar proportion, and it hardly changes which buildings or blocks fall inside. For counting population, what matters is the network distance to each block, not the polygon β which is why the population guide measures distance per block instead.
Network margins, freeways and hills
- Network margin. Build the graph with a margin at least the catchment distance, or edge stops are clipped.
- Pedestrian ways β footpaths, cut-throughs, stairs β change catchments a lot; check that the network includes them.
- Freeways and motorways are excluded from walking networks, which is correct; crossings must exist as ways.
- Station entrances matter for rail: snap to the entrance, not the platform centroid.
- Hills make distance a poor proxy for effort; see the network-distance guide.
- Accessibility needs reduce practical walking distances; 400 metres is a common alternative.
Internal links
- How to measure the population served by frequent transit β using these distances
- Network distance vs straight-line distance explained β why circles overstate
- How to build a street network graph in Python with OSMnx β getting the network
- How to snap points to a street network in Python β the snap step
- Catchment areas explained β buffers, isochrones and Voronoi
- How to generate isochrones in Python β the same idea by time
FAQ
How do I make a walking catchment around a bus stop?
Snap the stop to a projected walking network, run Dijkstra with a distance cutoff, and buffer the reached streets.
Why not use a 400 m or 800 m buffer?
It assumes straight-line walking. The network catchment is typically half to two-thirds of the circle, and much less near barriers.
Should the walk to the network count?
Yes. Subtract the snap distance from the budget.
How do I do this for thousands of stops?
Run one multi-source Dijkstra from all stops; it gives every node's distance to its nearest stop in a single pass.
What buffer width should I use on the edges?
Around 50 metres. It changes areas, but hardly changes which blocks are inside.
What is a pedestrian shed ratio?
Network catchment area divided by circle area β a measure of how walkable the surroundings are.