How to draw a transit isochrone from a GTFS feed
On this page
Problem statement
A transit isochrone — everywhere reachable within 30 minutes by public transport — looks like a street-network isochrone with holes in it: blobs of walkable area around every stop the rider can reach in time, strung along the lines. It is also much less stable. Shift the departure by five minutes and the shape changes, because a different set of buses is caught.
From a point in Portland's Hollywood district on TriMet's Tuesday timetable, the 30-minute isochrone covers anything from 28 to 50 square kilometres depending on which five-minute departure you draw. A single isochrone map shows one draw from that range and presents it as the answer.
Quick answer
Route from the origin with the connection scan, then around every stop reached draw a circle whose radius is the walking distance left in the budget:
import numpy as np
import geopandas as gpd
from shapely.geometry import Point
from transit_csa import build, access, csa
net = build("trimet_gtfs", "2027-01-26")
ORIGIN = (-122.6280, 45.5350)
origin = access(net, *ORIGIN)
stops = net["stops"].set_index("stop_id")
def transit_isochrone(t0, budget_s, max_walk_m=800):
reached = csa(net, origin, t0, max_time=budget_s)
speed = net["walk_speed"] / net["detour"] # effective metres per second
discs = [Point(stops.x[s], stops.y[s]).buffer(min((budget_s - t) * speed, max_walk_m))
for s, t in reached.items() if t < budget_s]
ox_, oy_ = net["to_m"].transform(*ORIGIN)
discs.append(Point(ox_, oy_).buffer(min(budget_s * speed, 2000))) # walking only
return gpd.GeoSeries(discs, crs=32610).union_all()
iso = transit_isochrone(8 * 3600, 1800)
print(f"30-min isochrone leaving 08:00: {iso.area / 1e6:.1f} km²")
30-min isochrone leaving 08:00: 35.5 km²
The walking discs are capped at 800 metres: nobody gets off a bus and walks for twenty minutes, and without the cap the isochrone fills with huge circles around stops reached early.
Route, size discs, cap the walk, union
1. Route from the origin
Run the connection scan with max_time equal to the budget. The result is the earliest arrival at every stop reachable in time.
2. Size a disc around each reached stop
The walking time left is the budget minus the arrival time. Multiply by walking speed and divide by the detour factor to get a straight-line radius.
3. Cap the walk
Cap the radius at a realistic walking distance. 800 metres is common; use less for older riders.
4. Add the walk-only area
Include the area reachable on foot from the origin without transit, or the isochrone has a hole at its own centre.
5. Union
Dissolve the discs into one polygon. For a smoother boundary, walk the street network from each stop instead of drawing circles, as in the catchment guide.
6. Draw several departures, not one
Compute the isochrone for each departure minute across a window and either show the range or show how often each place is reachable.
Isochrones at six departures and two budgets
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.
import io
import os
import zipfile
import pandas as pd
import requests
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
transit_csa.py is the connection-scan router from the travel-time guide, saved as a module.
%%writefile transit_csa.py
"""A small connection-scan router over one service day of a GTFS feed."""
import numpy as np
import pandas as pd
from pyproj import Transformer
from scipy.spatial import cKDTree
DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
def services_on(feed, date):
cal = pd.read_csv(f"{feed}/calendar.txt", dtype=str)
cd = pd.read_csv(f"{feed}/calendar_dates.txt", dtype=str)
d = pd.Timestamp(date)
ds, dow = d.strftime("%Y%m%d"), DAYS[d.dayofweek]
weekly = set(cal.loc[(cal[dow] == "1") & (cal.start_date <= ds) &
(cal.end_date >= ds), "service_id"])
ex = cd[cd.date == ds]
return (weekly | set(ex.loc[ex.exception_type == "1", "service_id"])) \
- set(ex.loc[ex.exception_type == "2", "service_id"])
def to_secs(s):
p = s.str.split(":", expand=True).astype(int)
return p[0] * 3600 + p[1] * 60 + p[2]
def build(feed, date, crs=32610, walk_speed=1.25, detour=1.3, max_transfer_m=400):
"""Connections for one service day, plus walking links between nearby stops."""
trips = pd.read_csv(f"{feed}/trips.txt", dtype=str)
trips = trips[trips.service_id.isin(services_on(feed, date))]
st = pd.read_csv(f"{feed}/stop_times.txt",
usecols=["trip_id", "arrival_time", "departure_time", "stop_id",
"stop_sequence", "pickup_type", "drop_off_type"],
dtype={"trip_id": str, "stop_id": str})
st = st[st.trip_id.isin(trips.trip_id)].sort_values(["trip_id", "stop_sequence"])
st["dep"], st["arr"] = to_secs(st.departure_time), to_secs(st.arrival_time)
nxt = st.groupby("trip_id").shift(-1)
c = pd.DataFrame({"from": st.stop_id, "to": nxt.stop_id, "dep": st.dep,
"arr": nxt.arr, "trip": st.trip_id,
"board": st.pickup_type != 1, "alight": nxt.drop_off_type != 1})
c = c.dropna(subset=["to"]).sort_values("dep").reset_index(drop=True)
stops = pd.read_csv(f"{feed}/stops.txt", dtype=str)
stops = stops[stops.location_type.fillna("0").isin(["0", ""])].copy()
to_m = Transformer.from_crs(4326, crs, always_xy=True)
stops["x"], stops["y"] = to_m.transform(stops.stop_lon.astype(float).values,
stops.stop_lat.astype(float).values)
tree = cKDTree(stops[["x", "y"]].values)
pairs = tree.query_pairs(max_transfer_m, output_type="ndarray")
ids, xy = stops.stop_id.values, stops[["x", "y"]].values
secs = np.hypot(*(xy[pairs[:, 0]] - xy[pairs[:, 1]]).T) * detour / walk_speed
walk = {}
for (i, j), w in zip(pairs, secs):
walk.setdefault(ids[i], []).append((ids[j], w))
walk.setdefault(ids[j], []).append((ids[i], w))
return {"conn": c, "walk": walk, "stops": stops, "tree": tree, "to_m": to_m,
"walk_speed": walk_speed, "detour": detour}
def access(net, lon, lat, max_walk_m=800):
"""Stops within walking distance of a point, with walking time in seconds."""
x, y = net["to_m"].transform(lon, lat)
s = net["stops"].iloc[net["tree"].query_ball_point([x, y], max_walk_m)]
secs = np.hypot(s.x - x, s.y - y) * net["detour"] / net["walk_speed"]
return dict(zip(s.stop_id, secs))
def csa(net, sources, t0, max_time=5400, transfer_slack=0):
"""Earliest arrival (seconds after t0) at every stop reachable within max_time."""
c = net["conn"]
fr, to, dep, arr = c["from"].values, c["to"].values, c.dep.values, c.arr.values
trip, board, alight = c.trip.values, c.board.values, c.alight.values
walk, INF = net["walk"], float("inf")
best = {}
def reach(stop, t):
if t < best.get(stop, INF):
best[stop] = t
for other, w in walk.get(stop, ()):
if t + w < best.get(other, INF):
best[other] = t + w
for stop, w in sources.items():
reach(stop, t0 + w)
on_board = set()
i, end = np.searchsorted(dep, t0), t0 + max_time
while i < len(dep) and dep[i] <= end:
if trip[i] in on_board or (board[i] and best.get(fr[i], INF) + transfer_slack <= dep[i]):
on_board.add(trip[i])
if alight[i]:
reach(to[i], arr[i])
i += 1
return {s: t - t0 for s, t in best.items() if t - t0 <= max_time}
def travel_time(net, result, lon, lat, max_walk_m=800):
"""Door-to-door minutes from a csa() result to a destination point."""
tail = access(net, lon, lat, max_walk_m)
t = min((result[s] + w for s, w in tail.items() if s in result), default=float("inf"))
return t / 60
Writing transit_csa.py
Example 1 — six departures, two budgets
for budget in (1800, 2700):
areas = [transit_isochrone(8 * 3600 + m * 60, budget).area / 1e6 for m in range(0, 30, 5)]
print(f"{budget // 60} min:", " ".join(f"{a:5.1f}" for a in areas), "km²")
30 min: 35.5 34.1 50.4 31.6 30.6 27.6 km²
45 min: 139.8 111.2 150.1 122.3 119.6 126.2 km²
Example 2 — how often each place is reachable
from transit_csa import travel_time
xs = np.arange(-122.74, -122.52, 0.005)
ys = np.arange(45.46, 45.60, 0.0035)
grid = [(x, y) for x in xs for y in ys]
runs = [csa(net, origin, 8 * 3600 + m * 60, max_time=1800) for m in range(0, 60, 2)]
share = np.array([np.mean([travel_time(net, r, x, y) <= 30 for r in runs]) for x, y in grid])
cell_km2 = (0.005 * 78.0) * (0.0035 * 111.2) # degrees to km at 45.5° N
print(f"cells: {len(grid)}; reachable in any departure: {(share > 0).sum() * cell_km2:.0f} km²; "
f"in half: {(share >= 0.5).sum() * cell_km2:.0f} km²; in all: {(share == 1).sum() * cell_km2:.0f} km²")
cells: 1804; reachable in any departure: 73 km²; in half: 32 km²; in all: 13 km²
The area reachable at least half the time is the honest single map. The "any departure" area is what a rider who plans perfectly around the timetable gets, and it is much larger.
Example 3 — save the isochrones with their parameters
out = gpd.GeoDataFrame(
{"minutes": [30, 45], "departure": "08:00", "service_date": "2027-01-26"},
geometry=[transit_isochrone(8 * 3600, 1800), transit_isochrone(8 * 3600, 2700)],
crs=32610)
out.to_crs(4326).to_file("hollywood_transit_isochrones.gpkg")
print(out.assign(km2=out.area / 1e6)[["minutes", "departure", "km2"]].round(1).to_string(index=False))
minutes departure km2
30 08:00 35.5
45 08:00 139.8
Holes, minutes and reach shares
Why transit isochrones have holes
Between stops, the only way to reach a place is to walk from a stop. A stop reached with little time left contributes a small disc, and places between widely spaced stops on an express route are not reachable at all even though the route runs past them. The holes are real and are what distinguishes transit access from driving access.
Why the shape depends on the minute
Catching a bus two minutes after it leaves costs a whole headway. Near the edge of the budget, that decides whether whole branches of the network are reached. The effect is largest for budgets close to the typical journey time and for origins served by infrequent routes.
Why the reach-share map is better than one isochrone
A reader looking at an isochrone assumes it describes the area they can reach. With transit, that is only true for one departure time. Showing the share of departures that reach each place turns the uncertainty into part of the map, and the 50% contour is a defensible "typical" isochrone.
Circles versus network walking and late departures
- Circles vs network walking. Circles overstate the walkable area; network catchments are better near rivers and freeways.
- Budget near journey times gives the most unstable shapes.
- Late-night departures have longer headways and much smaller isochrones.
- Grid resolution in the reach-share map trades speed for detail; 400–500 m cells suit a city.
- Edge of the feed. An isochrone that reaches the feed's boundary is truncated there.
- Store the departure time in the file; an isochrone without it is not reproducible.
Internal links
- Isochrones explained — what isochrones assume
- How to compute transit travel times from GTFS in Python — the router
- Transit accessibility explained — why departure time matters
- How to build walking catchments around transit stops — network walking instead of circles
- How to generate isochrones in Python — the street-network version
- How to find the areas nobody can reach within a travel time — isochrones as gaps
FAQ
How do I make a public transport isochrone in Python?
Route with a connection scan from the origin, then union a walking disc around every reached stop, sized by the time left and capped at a realistic walk.
Why does my transit isochrone change with departure time?
Because departures are discrete. A few minutes' shift changes which buses are caught.
What departure time should I use?
Several: compute each minute across a window and show the range or the share of departures that reach each place.
Why cap the walking distance?
Without a cap, stops reached early get enormous discs that no rider would walk.
Why are there holes in the isochrone?
Places between stops can only be reached on foot from a stop; if no stop near them is reached in time, they are not reachable.
Circles or network catchments?
Network catchments are more accurate; circles are faster and fine for a first look.