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.


OSM Walk Layer and GTFS Transit Layer Joined by Transfer Edges A time-expanded GTFS transit layer with stacked stop-time nodes at Stop A and Stop B, connected by wait edges vertically and a ride edge horizontally, sits above an OSM pedestrian walk layer whose nodes connect to the transit layer through dashed transfer/connector edges. GTFS Transit Layer — time-expanded stop-time nodes Stop A · 07:58 depart Stop A · 08:14 depart Stop A · 08:29 depart wait edge Stop B · 08:03 arrive Stop B · 08:19 arrive Stop B · 08:34 arrive Trip 1042 ride edge (5 min) OSM Walk Layer — pedestrian network Walk node 481 (snapped, 12 m) Walk node 902 (snapped, 8 m) connector edge Connector edges use walking speed and snap distance; wait/ride edges use GTFS scheduled times

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, and highway=service so 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, mondaysunday, 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_times rows within the same trip_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.txt where 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_s to 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_times within 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.