How to compute transit travel times from GTFS in Python
On this page
Problem statement
Street-network routing is a shortest-path problem on a static graph. Transit routing is not: an edge between two stops exists only at the minutes a vehicle leaves, so the graph changes every minute and the answer depends on when you start. Dijkstra on a graph of stops with average ride times gives numbers that look reasonable and ignore the one thing that makes transit slow — waiting.
The connection scan algorithm handles this directly and fits in a hundred lines of Python. On TriMet's Tuesday timetable it builds 294,988 connections in about three seconds and answers a one-origin, 90-minute query in under a tenth of a second.
Quick answer
Sort every stop-to-stop hop of every trip by departure time, then scan once from the start time, keeping the earliest arrival at each stop:
from transit_csa import build, access, csa, travel_time
net = build("trimet_gtfs", "2027-01-26") # one service day
origin = access(net, -122.6280, 45.5350) # stops within 800 m, walking seconds
result = csa(net, origin, t0=8 * 3600) # leave at 08:00
print(f"{len(net['conn']):,} connections;", len(origin), "access stops;", sum(v <= 1800 for v in result.values()),
"stops within 30 min;", sum(v <= 3600 for v in result.values()), "within 60")
print(f"to downtown: {travel_time(net, result, -122.6794, 45.5189):.1f} min")
294,988 connections; 38 access stops; 704 stops within 30 min; 3328 within 60
to downtown: 28.5 min
transit_csa.py is the module in Example 1 below: a timetable, walking links between stops within 400 metres, and a scan.
Connections, walking links, a scan per departure
1. Build connections for one service day
A connection is one hop of one trip: from a stop at a departure time to the next stop at an arrival time. Resolve the date first, then turn consecutive stop times of each trip into connections, keeping the pickup and drop-off flags.
2. Add walking links between stops
Riders transfer on foot. Link every pair of stops within a few hundred metres, with a walking time from distance × detour factor ÷ speed.
3. Seed the origin
Find stops within walking distance of the origin and give each an arrival time of the start time plus the walk.
4. Scan
Go through connections in departure order from the start time. A connection is usable if its trip is already boarded, or its departure stop was reached by its departure time. Using it improves the arrival time at its next stop and at stops within walking distance of that one.
5. Add the final walk
Door-to-door time to a destination point is the best arrival at any stop near it plus the walk from that stop.
6. Repeat over a departure window
One start time is one sample. For accessibility, run every minute across an hour and summarise, as the accessibility guide explains.
A connection-scan router and its matrix
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
Example 1 — the router, saved as transit_csa.py
%%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
The walking links are relaxed only one step from each improved stop, which is enough for transfers but not for chains of walks; that keeps the scan fast and slightly conservative.
Example 2 — a small origin–destination matrix
import numpy as np
import pandas as pd
places = {"Hollywood": (-122.6280, 45.5350), "Downtown": (-122.6794, 45.5189),
"Sellwood": (-122.6516, 45.4663), "St Johns": (-122.7536, 45.5905),
"Gateway": (-122.5636, 45.5307)}
matrix = pd.DataFrame(index=places, columns=places, dtype=float)
for o, (olon, olat) in places.items():
src = access(net, olon, olat)
runs = [csa(net, src, 8 * 3600 + m * 60, max_time=7200) for m in range(0, 60, 2)]
for d, (dlon, dlat) in places.items():
matrix.loc[o, d] = 0 if o == d else np.median([travel_time(net, r, dlon, dlat) for r in runs])
print(matrix.round(0).astype(int).to_string())
Hollywood Downtown Sellwood St Johns Gateway
Hollywood 0 29 56 60 23
Downtown 29 0 44 55 31
Sellwood 58 44 0 86 59
St Johns 60 54 89 0 66
Gateway 22 31 57 67 0
Median door-to-door minutes over departures every two minutes from 08:00 to 08:58. The matrix is not symmetric: the timetable and the walking legs differ by direction.
Example 3 — check it against the timetable
st = pd.read_csv("trimet_gtfs/stop_times.txt", dtype={"trip_id": str, "stop_id": str},
usecols=["trip_id", "stop_id", "stop_sequence", "departure_time", "arrival_time",
"pickup_type"])
trip = net["conn"].trip.iloc[len(net["conn"]) // 2]
calls = st[st.trip_id == trip].sort_values("stop_sequence")
a, b = calls[calls.pickup_type != 1].iloc[0], calls.iloc[-1] # first stop you can board at
c = net["conn"]
t0 = int(c.loc[(c.trip == trip) & (c["from"] == a.stop_id), "dep"].min())
r = csa(net, {a.stop_id: 0}, t0, max_time=3 * 3600) # longer than any trip
print(f"trip {trip}: timetable {a.departure_time} -> {b.arrival_time}, "
f"router arrives after {r[b.stop_id] / 60:.1f} min")
trip 18100199: timetable 13:35:00 -> 14:40:00, router arrives after 43.4 min
Starting from one stop, the router can only match or beat the trip it started on; arriving later than the timetable would be a bug. Here it beats it: the trip takes 65 minutes from the first stop a rider can board at to the last, and the router gets there in 43.4 by leaving it part-way for another trip or a walk. On a trip with no faster alternative the two times are equal, which is the exact check. The check starts from the first stop where a rider can board, because a trip's first call can be drop-off only. The result records only arrival times; add a predecessor map to csa if you need the itinerary that produced one.
Why a scan beats a graph search
Why a scan beats a graph search here
In a time-dependent network, the edge you can take depends on when you arrive. Connection scan avoids building that graph at all: because connections are sorted by departure, any connection it reaches is already feasible or never will be. The algorithm is one loop over an array, which is why it runs in a tenth of a second in pure Python.
Why walking links matter as much as the timetable
Without walking links, a rider can transfer only at the exact stop where they alighted. Real transfers cross the street, walk round the corner to a parallel route or cut through a station. Linking stops within 400 metres captures most of these; the detour factor turns straight-line distance into a plausible walking distance.
What this router leaves out
It minimises arrival time only. It does not limit the number of transfers, charge a transfer penalty, model fares, or model the time needed to change platforms beyond the walking link. Production tools — R5 through r5py, OpenTripPlanner — handle those and use street networks for the walking legs. The point of the small version is that every assumption is visible, and the next fix shows how much each one moves the answer.
Frequency trips, overnight journeys and slack
- Frequency-based trips in
frequencies.txtmust be expanded into explicit trips first. - Overnight journeys need the next service day's connections appended with times shifted by 24 hours.
- Transfer slack. Real transfers need a minute or two; pass
transfer_slack. - Walking network. Straight-line walking with a detour factor is an approximation; a street network is better for access and egress.
- Performance. For thousands of origins, numba or a compiled router is worth it; the algorithm does not change.
- Validate a few journeys against the agency's own trip planner.
Internal links
- Transit accessibility explained — why one departure time is not enough
- Transit travel times come out too short — the assumptions that flatter a network
- How to draw a transit isochrone from a GTFS feed — the same result as an area
- How to find the trips running on a given date in GTFS — the date filter inside
build - How to measure network distance for many origin-destination pairs — the street-network version
- How to calculate the shortest path along a street network — static routing for comparison
FAQ
How do I calculate public transport travel time in Python?
Build connections from GTFS stop times for one service day, add walking links between nearby stops, and run a connection scan from the start time.
Why not use Dijkstra?
Transit edges exist only at departure times. A static graph with average ride times ignores waiting, which is most of what makes transit slow.
How fast is connection scan in Python?
On a mid-sized city's timetable, about a tenth of a second per origin and start time.
Does this handle transfers?
Yes, through walking links between stops within 400 metres. It does not limit or penalise them.
Should I use r5py or OpenTripPlanner instead?
For production work, often yes. A small router is best for understanding and for checking what a bigger tool assumes.
Why is the matrix not symmetric?
Timetables and walking legs differ by direction.