Multi-modal trip planning answers a question neither a road router nor a transit schedule can answer alone: how does a rider get from an arbitrary address to an arbitrary destination using a mix of walking and scheduled transit? This section of the Routing API Automation & Fleet Integration guide covers the automation pipeline that fuses a GTFS static feed with an OSM pedestrian graph — parsing the feed into pandas DataFrames, resolving service calendars, building a time-expanded transit graph, and joining it to the walk network through snapped connector edges. The result is a single queryable graph that supports walk-only, transit-only, and mixed itineraries without hand-rolling a separate scheduling engine.
Prerequisites
Confirm these before parsing a feed — GTFS files vary in completeness between agencies, and the join to the OSM walk graph depends on both datasets covering the same geographic extent.
System requirements
| Resource | Minimum | Recommended |
|---|---|---|
| Python | 3.9 | 3.11+ |
| RAM | 4 GB | 16 GB+ for metro feeds with dense stop_times.txt |
| Storage | 2× feed zip size | SSD; unzipped stop_times.txt can exceed 1 GB for large agencies |
Python environment
# Install pipeline dependencies (Python 3.9+)
pip install pandas numpy scipy geopandas shapely networkx osmnx
Data sources
- A GTFS static feed (zip) from the agency’s open-data portal or an aggregator such as transit.land or Mobility Database.
- An OSM pedestrian extract covering the same bounding box as the feed’s stops, built with the same directed-graph pipeline used for building directed graphs from OSM PBF files. Filter to
highway=footway,highway=path,highway=pedestrian,highway=residential, andhighway=serviceso the walk graph reflects real pedestrian access rather than the full road network. - Optionally, a road-routing engine for the driving/cycling legs of a trip — see Valhalla configuration for multi-modal analysis if you want Valhalla’s own multimodal costing instead of a hand-built graph join.
Conceptual Architecture
The GTFS Feed Model
A GTFS static feed is a zip of relational CSV files. The pipeline only needs a subset of them, but the foreign keys between files determine how the graph gets built:
| File | Purpose | Key columns |
|---|---|---|
stops.txt |
Physical stop/station locations | stop_id, stop_lat, stop_lon, parent_station |
routes.txt |
Route metadata (bus, rail, ferry) | route_id, route_type |
trips.txt |
A scheduled run of a route on a service pattern | trip_id, route_id, service_id |
stop_times.txt |
Ordered stop visits per trip | trip_id, stop_id, arrival_time, departure_time, stop_sequence |
calendar.txt |
Weekly service patterns | service_id, monday…sunday, start_date, end_date |
calendar_dates.txt |
Exceptions to calendar.txt |
service_id, date, exception_type |
transfers.txt |
Explicit transfer rules between stops | from_stop_id, to_stop_id, transfer_type, min_transfer_time |
frequencies.txt |
Headway-based service (no fixed stop_times) |
trip_id, start_time, end_time, headway_secs |
stop_times.txt is the largest file by an order of magnitude — a mid-size metro feed can carry tens of millions of rows, so every transform in the implementation section below stays vectorized in pandas rather than iterating row by row.
The Time-Expanded Graph
The core data structure is a time-expanded graph: instead of one node per stop, you create one node per (stop_id, arrival_or_departure_time) pair. Three edge types connect these nodes:
- Ride edges connect consecutive
stop_timesrows within the sametrip_id, weighted by the scheduled travel duration between them. - Wait edges connect a stop’s own time-nodes in chronological order, so a rider arriving at 08:03 can “wait” until the 08:14 departure without leaving the stop.
- Transfer edges connect time-nodes at different stops within walking distance, sourced from
transfers.txtwhere present and inferred from a spatial nearest-neighbor search where absent.
This structure is what powers earliest-arrival queries — an ordinary Dijkstra or A* search over a time-expanded graph, weighted by wall-clock time rather than distance, returns the fastest itinerary respecting the schedule. Integrating GTFS schedules into trip planning covers the earliest-arrival scan itself in depth; this page focuses on building the feed pipeline and joining it to the walk graph that makes the first and last mile of every itinerary possible.
Joining the Walk Layer
Neither GTFS stops nor road-network nodes are useful in isolation for door-to-door planning — a rider does not start their trip standing on a transit stop. The walk layer, built the same way as the pedestrian network described in implementing multi-modal transit layers, supplies routable nodes for arbitrary origins and destinations. Each GTFS stop is snapped to its nearest routable walk node using a KD-tree over projected coordinates, and a connector edge is inserted with a weight equal to snap_distance_m / walking_speed_mps. The combined graph is then a single networkx.MultiDiGraph where a query can enter through any walk node, traverse connector edges into the transit layer, ride and transfer through time-expanded nodes, and exit back through connector edges near the destination.
Step-by-Step Implementation
1. Load and Validate the GTFS Feed
# requires: pandas (pip install pandas)
import zipfile
import pandas as pd
from pathlib import Path
REQUIRED_FILES = ["stops.txt", "routes.txt", "trips.txt", "stop_times.txt", "calendar.txt"]
def load_gtfs(feed_path: str) -> dict[str, pd.DataFrame]:
"""Load required GTFS tables from a feed zip into a dict of DataFrames."""
feed_path = Path(feed_path)
with zipfile.ZipFile(feed_path) as zf:
present = set(zf.namelist())
missing = [f for f in REQUIRED_FILES if f not in present]
if missing:
raise FileNotFoundError(f"Feed is missing required files: {missing}")
tables = {
"stops": pd.read_csv(zf.open("stops.txt"), dtype={"stop_id": str, "parent_station": str}),
"routes": pd.read_csv(zf.open("routes.txt"), dtype={"route_id": str}),
"trips": pd.read_csv(zf.open("trips.txt"), dtype={"trip_id": str, "route_id": str, "service_id": str}),
"stop_times": pd.read_csv(
zf.open("stop_times.txt"),
dtype={"trip_id": str, "stop_id": str},
usecols=["trip_id", "stop_id", "arrival_time", "departure_time", "stop_sequence"],
),
"calendar": pd.read_csv(zf.open("calendar.txt"), dtype={"service_id": str}),
}
if "calendar_dates.txt" in present:
tables["calendar_dates"] = pd.read_csv(zf.open("calendar_dates.txt"), dtype={"service_id": str})
if "transfers.txt" in present:
tables["transfers"] = pd.read_csv(zf.open("transfers.txt"), dtype={"from_stop_id": str, "to_stop_id": str})
if "frequencies.txt" in present:
tables["frequencies"] = pd.read_csv(zf.open("frequencies.txt"), dtype={"trip_id": str})
# Referential integrity: every stop_id in stop_times must exist in stops
orphaned = set(tables["stop_times"]["stop_id"]) - set(tables["stops"]["stop_id"])
if orphaned:
raise ValueError(f"{len(orphaned)} stop_id values in stop_times.txt are missing from stops.txt")
return tables
feed = load_gtfs("agency_gtfs.zip")
print({name: len(df) for name, df in feed.items()})
2. Filter Active Service by Date
# requires: pandas (pip install pandas)
import pandas as pd
def active_service_ids(calendar: pd.DataFrame, calendar_dates: pd.DataFrame | None, query_date: pd.Timestamp) -> set[str]:
"""Resolve which service_id values run on a given local date."""
weekday_col = query_date.strftime("%A").lower() # e.g. "monday"
in_range = calendar[
(calendar["start_date"].astype(str) <= query_date.strftime("%Y%m%d"))
& (calendar["end_date"].astype(str) >= query_date.strftime("%Y%m%d"))
& (calendar[weekday_col] == 1)
]
active = set(in_range["service_id"])
if calendar_dates is not None:
date_str = int(query_date.strftime("%Y%m%d"))
day_exceptions = calendar_dates[calendar_dates["date"] == date_str]
added = set(day_exceptions.loc[day_exceptions["exception_type"] == 1, "service_id"])
removed = set(day_exceptions.loc[day_exceptions["exception_type"] == 2, "service_id"])
active = (active | added) - removed
return active
query_date = pd.Timestamp("2026-07-14") # a Tuesday
service_ids = active_service_ids(feed["calendar"], feed.get("calendar_dates"), query_date)
active_trips = feed["trips"][feed["trips"]["service_id"].isin(service_ids)]
print(f"{len(active_trips)} trips active on {query_date.date()}")
Resolve exceptions after the weekday mask, not before — a service that runs every weekday but is cancelled on a single holiday only shows up correctly if calendar_dates.txt exception_type 2 rows are subtracted last.
3. Build the Stop-Time Sequence Graph
# requires: pandas, numpy (pip install pandas numpy)
import pandas as pd
import numpy as np
def build_ride_edges(stop_times: pd.DataFrame, active_trip_ids: set[str]) -> pd.DataFrame:
"""Vectorize consecutive stop_times rows per trip into ride edges."""
st = stop_times[stop_times["trip_id"].isin(active_trip_ids)].copy()
st["arrival_s"] = pd.to_timedelta(st["arrival_time"]).dt.total_seconds()
st["departure_s"] = pd.to_timedelta(st["departure_time"]).dt.total_seconds()
st = st.sort_values(["trip_id", "stop_sequence"])
# shift() within each trip group avoids a Python-level loop over rows
grouped = st.groupby("trip_id", sort=False)
st["next_stop_id"] = grouped["stop_id"].shift(-1)
st["next_arrival_s"] = grouped["arrival_s"].shift(-1)
ride_edges = st.dropna(subset=["next_stop_id"]).copy()
ride_edges["duration_s"] = ride_edges["next_arrival_s"] - ride_edges["departure_s"]
ride_edges = ride_edges[ride_edges["duration_s"] > 0]
return ride_edges[["trip_id", "stop_id", "departure_s", "next_stop_id", "next_arrival_s", "duration_s"]]
ride_edges = build_ride_edges(feed["stop_times"], set(active_trips["trip_id"]))
print(ride_edges.head())
4. Snap GTFS Stops to the OSM Walk Graph
# requires: geopandas, scipy, networkx, osmnx (pip install geopandas scipy networkx osmnx)
import geopandas as gpd
import numpy as np
from scipy.spatial import cKDTree
WALK_SPEED_MPS = 1.3 # ~4.7 km/h, conservative urban walking pace
MAX_SNAP_M = 120 # reject stops that snap further than this
def snap_stops_to_walk_graph(stops: pd.DataFrame, walk_nodes_gdf: gpd.GeoDataFrame) -> pd.DataFrame:
"""Match each GTFS stop to its nearest routable walk-graph node."""
stop_pts = gpd.GeoDataFrame(
stops,
geometry=gpd.points_from_xy(stops["stop_lon"], stops["stop_lat"]),
crs="EPSG:4326",
).to_crs(walk_nodes_gdf.crs)
walk_coords = np.column_stack([walk_nodes_gdf.geometry.x, walk_nodes_gdf.geometry.y])
tree = cKDTree(walk_coords)
stop_coords = np.column_stack([stop_pts.geometry.x, stop_pts.geometry.y])
dist, idx = tree.query(stop_coords, k=1)
stops = stops.copy()
stops["walk_node_id"] = walk_nodes_gdf.iloc[idx]["osmid"].values
stops["snap_distance_m"] = dist
stops["snap_ok"] = stops["snap_distance_m"] <= MAX_SNAP_M
stops["connector_weight_s"] = stops["snap_distance_m"] / WALK_SPEED_MPS
unsnapped = (~stops["snap_ok"]).sum()
if unsnapped:
print(f"[WARN] {unsnapped} stops exceeded {MAX_SNAP_M} m snap distance")
return stops
# walk_nodes_gdf comes from osmnx.graph_to_gdfs(walk_graph, edges=False).reset_index()
snapped_stops = snap_stops_to_walk_graph(feed["stops"], walk_nodes_gdf)
This mirrors the nearest-node matching used for snapping bike-share stations to OSM nodes — both problems are the same nearest-routable-node search, just against different point datasets.
5. Construct Transfer Edges Between Stops
# requires: pandas, scipy (pip install pandas scipy)
import pandas as pd
import numpy as np
from scipy.spatial import cKDTree
MAX_TRANSFER_M = 250 # inferred transfer radius when transfers.txt is silent
def build_transfer_edges(stops: pd.DataFrame, transfers: pd.DataFrame | None) -> pd.DataFrame:
"""Combine explicit transfers.txt rows with inferred nearby-stop transfers."""
explicit = pd.DataFrame(columns=["from_stop_id", "to_stop_id", "walk_time_s", "source"])
if transfers is not None:
explicit = transfers.rename(columns={"min_transfer_time": "walk_time_s"})
explicit["walk_time_s"] = explicit["walk_time_s"].fillna(120)
explicit["source"] = "transfers.txt"
explicit = explicit[["from_stop_id", "to_stop_id", "walk_time_s", "source"]]
coords = np.column_stack([stops["stop_lon"], stops["stop_lat"]])
tree = cKDTree(coords)
pairs = tree.query_pairs(r=MAX_TRANSFER_M / 111_000) # rough degrees conversion for a coarse pre-filter
inferred_rows = []
for i, j in pairs:
a, b = stops.iloc[i], stops.iloc[j]
dist_m = np.hypot(a["stop_lon"] - b["stop_lon"], a["stop_lat"] - b["stop_lat"]) * 111_000
if dist_m <= MAX_TRANSFER_M:
walk_s = dist_m / WALK_SPEED_MPS
inferred_rows.append((a["stop_id"], b["stop_id"], walk_s, "inferred"))
inferred_rows.append((b["stop_id"], a["stop_id"], walk_s, "inferred"))
inferred = pd.DataFrame(inferred_rows, columns=["from_stop_id", "to_stop_id", "walk_time_s", "source"])
combined = pd.concat([explicit, inferred], ignore_index=True)
return combined.sort_values("source").drop_duplicates(subset=["from_stop_id", "to_stop_id"], keep="first")
transfer_edges = build_transfer_edges(feed["stops"], feed.get("transfers"))
Explicit rows from transfers.txt take priority over inferred ones because agencies use them to encode real-world constraints (a locked gate, a level change) that a straight-line distance check cannot see.
6. Assemble the Combined Graph
# requires: networkx (pip install networkx)
import networkx as nx
def assemble_multimodal_graph(walk_graph: nx.MultiDiGraph, ride_edges: pd.DataFrame,
snapped_stops: pd.DataFrame, transfer_edges: pd.DataFrame) -> nx.MultiDiGraph:
"""Compose the OSM walk graph with the GTFS time-expanded layer."""
G = walk_graph.copy()
# Ride edges: (stop_id, departure_s) -> (next_stop_id, next_arrival_s)
for row in ride_edges.itertuples(index=False):
u = (row.stop_id, row.departure_s)
v = (row.next_stop_id, row.next_arrival_s)
G.add_edge(u, v, weight=row.duration_s, kind="ride", trip_id=row.trip_id)
# Transfer edges between time-expanded stop nodes
for row in transfer_edges.itertuples(index=False):
G.add_edge((row.from_stop_id, "any"), (row.to_stop_id, "any"), weight=row.walk_time_s, kind="transfer")
# Connector edges joining each stop's time-nodes back to its snapped walk node
for row in snapped_stops[snapped_stops["snap_ok"]].itertuples(index=False):
G.add_edge(row.walk_node_id, (row.stop_id, "any"), weight=row.connector_weight_s, kind="connector")
G.add_edge((row.stop_id, "any"), row.walk_node_id, weight=row.connector_weight_s, kind="connector")
return G
multimodal_graph = assemble_multimodal_graph(walk_graph, ride_edges, snapped_stops, transfer_edges)
print(f"Combined graph: {multimodal_graph.number_of_nodes()} nodes, {multimodal_graph.number_of_edges()} edges")
Configuration Reference
| Parameter | Default | Effect |
|---|---|---|
WALK_SPEED_MPS |
1.3 m/s | Governs connector and inferred transfer edge weights; lower for accessibility-constrained routing |
MAX_SNAP_M |
120 m | Rejects stops too far from any routable walk node; flags data-quality gaps |
MAX_TRANSFER_M |
250 m | Radius for inferring transfers when transfers.txt is absent or incomplete |
min_transfer_time |
feed-supplied | Overrides computed walk time for a specific stop pair in transfers.txt |
| time bucket width | none (per-second) | Round arrival_s/departure_s to a bucket (e.g. 60 s) to shrink node count on dense feeds |
| service horizon | full day | Restrict the active stop_times window to a rolling range (e.g. query time to +4 h) to bound graph size |
frequencies.txt expansion |
disabled | Must be explicitly expanded into synthetic trips or headway-based routes vanish from the graph |
Production Optimization and Scaling
Cache the Parsed Feed
Re-parsing a multi-gigabyte stop_times.txt on every process start is wasteful. Persist the intermediate DataFrames as Parquet after step 1–3 and reload from cache unless feed_info.txt’s feed_version changes:
# requires: pandas, pyarrow (pip install pandas pyarrow)
ride_edges.to_parquet("cache/ride_edges.parquet", index=False)
snapped_stops.to_parquet("cache/snapped_stops.parquet", index=False)
Bound the Time-Expanded Graph
A full day’s stop_times for a metro feed can produce a graph with millions of nodes, most of which are irrelevant to any single query. Two mitigations keep memory and query latency in check:
- Time bucketing: round
arrival_s/departure_sto the nearest 60-second bucket before building nodes. This collapses near-simultaneous stop_times into shared nodes with negligible accuracy loss for trip planning. - Rolling horizon: only materialize
stop_timeswithin a bounded window (e.g. query time to query time + 4 hours) rather than the full service day, and rebuild the window as needed for later-departure queries.
Prefer Connection Scan or RAPTOR at Scale
For interactive query latency against a large feed, materializing the full time-expanded graph and running Dijkstra per request does not scale past a few hundred queries per second. A Connection Scan Algorithm or RAPTOR-style round-based search operates directly over the sorted ride_edges table without building graph objects per query, and both parallelize cleanly across origin batches. Keep the graph-based approach from this page for development, validation, and low-volume batch jobs; move to a connection-scan implementation once query volume justifies the added complexity.
Refresh Feeds on a Schedule
GTFS feeds change on agency-controlled release cycles, typically ranked by feed_info.txt’s feed_start_date/feed_end_date. Poll the feed URL nightly, compare feed_version, and only re-run the full parse-and-snap pipeline when the version changes — most nights it will not, and the cached Parquet tables from the previous run remain valid.
Layer in Real-Time Updates
Static GTFS gives you the schedule; GTFS-realtime TripUpdate and Alert feeds give you delays and cancellations. Apply realtime deltas to departure_s/arrival_s at query time rather than rebuilding the static graph — recompute only the affected ride edges’ durations and re-run the earliest-arrival search for in-flight queries.
Validation and Testing
# requires: pandas, networkx (pip install pandas networkx)
import pandas as pd
import networkx as nx
def check_no_negative_durations(ride_edges: pd.DataFrame) -> None:
"""Ride edges must never have non-positive scheduled duration."""
bad = ride_edges[ride_edges["duration_s"] <= 0]
assert bad.empty, f"{len(bad)} ride edges have non-positive duration"
print("[PASS] All ride edges have positive duration")
def check_snap_distances(snapped_stops: pd.DataFrame, max_m: float = 120) -> None:
"""Flag the share of stops that failed to snap within threshold."""
fail_rate = (~snapped_stops["snap_ok"]).mean()
assert fail_rate < 0.05, f"{fail_rate:.1%} of stops exceeded the snap-distance threshold"
print(f"[PASS] {fail_rate:.1%} of stops exceeded snap threshold (< 5% budget)")
def check_known_itinerary(G: nx.MultiDiGraph, origin, destination, max_minutes: float) -> None:
"""A known origin/destination pair should resolve within a sane time budget."""
length = nx.shortest_path_length(G, source=origin, target=destination, weight="weight")
assert length <= max_minutes * 60, f"Itinerary took {length/60:.1f} min, expected <= {max_minutes} min"
print(f"[PASS] Known itinerary resolved in {length/60:.1f} min")
def check_graph_connectivity(G: nx.MultiDiGraph, sample_walk_nodes: list) -> None:
"""Sampled walk nodes should reach at least one transit time-node."""
reachable = 0
for node in sample_walk_nodes:
if any(isinstance(t, tuple) for t in nx.descendants(G, node)):
reachable += 1
rate = reachable / len(sample_walk_nodes)
assert rate > 0.9, f"Only {rate:.1%} of sampled walk nodes can reach the transit layer"
print(f"[PASS] {rate:.1%} of sampled walk nodes reach the transit layer")
check_no_negative_durations(ride_edges)
check_snap_distances(snapped_stops)
Sanity metrics to monitor in production: snap-distance failure rate per feed refresh (a sudden spike usually means the OSM extract’s bounding box drifted out of sync with a feed’s service area expansion), the count of frequencies.txt trips successfully expanded versus declared, and the P95 query latency of the earliest-arrival search as the time-expanded graph grows with each feed update.
Troubleshooting
KeyError or empty merge when joining stop_times and stops
The feed has stop_id values in stop_times.txt that are missing from stops.txt, usually because the feed uses parent_station grouping inconsistently or the zip was published mid-update. Run a set-difference check between the two columns immediately after loading (see step 1) and either drop orphaned rows or reconcile them against parent_station before building any edges.
Frequency-based routes never appear in the graph
Feeds that use frequencies.txt define headway-based service — a bus every 8 minutes between start_time and end_time — instead of one row per departure in stop_times.txt. If you skip expanding frequencies.txt into synthetic trip instances at the declared interval before running step 3, those routes silently vanish from the ride-edge table with no error raised.
The planner resolves the wrong day's service
Two causes account for nearly all of these bugs: calendar_dates.txt exceptions applied before the weekday mask instead of after, and a query date that was never localized to the feed’s timezone from feed_info.txt. A trip planner running in UTC against a feed published in America/Chicago will misclassify any query near midnight local time.
Stops fail to snap onto the OSM walk graph
Either the stop coordinates fall outside the OSM extract’s bounding box, or the nearest pedestrian node is beyond MAX_SNAP_M because the station is mapped as a building outline rather than a routable highway=footway. Widen the extract to cover the feed’s full service area, or add a fallback that snaps to the nearest drivable node with an access penalty rather than dropping the stop entirely.
Query latency degrades as the feed grows
A full time-expanded graph scales with stop_times row count, not stop count — a schedule refresh that adds evening service can double node count without changing the number of physical stops. Apply time bucketing and a rolling horizon window (see Production Optimization) before query latency becomes a problem, and consider migrating to a connection-scan search once per-request Dijkstra calls exceed your latency budget.
Related
- Integrating GTFS schedules into trip planning — the earliest-arrival scan over the time-expanded graph built here, with service-day filtering and transfer minimization
- Snapping bike-share stations to OSM nodes — the same nearest-routable-node matching applied to GBFS station data instead of GTFS stops
- Implementing multi-modal transit layers — building the OSM pedestrian graph this pipeline snaps GTFS stops onto
- Valhalla configuration for multi-modal analysis — an alternative approach using a routing engine’s built-in multimodal costing instead of a hand-built graph join