Transfers and Trip Linking

GTFS expresses two completely different kinds of linking with two completely different mechanisms, and conflating them is the source of a whole family of routing bugs. transfers.txt is about the rider: it says whether a change between two trips is possible, recommended, guaranteed, or forbidden, and how long it takes. block_id on trips.txt is about the vehicle: it says the same bus continues from one trip into the next, so a rider staying aboard has not changed vehicles at all and should never be charged or timed as though they had.

This page covers parsing both, the validation each needs, and the graph the first one produces. It builds on the static feed structure and on the stop and stop-time model, since every transfer resolves to a pair of stops and every block resolves to an ordered set of trips.

Prerequisites

  • Python 3.9 or later with pandas; networkx if you intend to route over the transfer graph
  • A GTFS archive containing trips.txt and stop_times.txt; transfers.txt is optional and often absent
  • Times already normalised, because block validation compares trip end times against trip start times and GTFS clock strings past 24:00 do not compare correctly as text
bash
pip install pandas networkx

Concept and Spec Background

transfers.txt

Each row constrains a change between two places, and optionally between two specific trips or routes.

Column Meaning
from_stop_id / to_stop_id The stops between which the change happens
from_route_id / to_route_id Optional; narrows the rule to specific routes
from_trip_id / to_trip_id Optional; narrows the rule to specific trips
transfer_type 0 recommended, 1 timed (the vehicle waits), 2 requires min_transfer_time, 3 not possible, 4 in-seat, 5 in-seat not allowed
min_transfer_time Seconds required, when transfer_type is 2

The values that matter most in practice are 1, 2 and 3. transfer_type = 1 is a timed transfer: the departing vehicle is held for the arriving one, which is how a rural network guarantees a connection that would otherwise be missed daily. transfer_type = 2 sets a floor on the change time and is the ordinary case for a large interchange. transfer_type = 3 says the transfer is not possible, and it is the only mechanism GTFS offers for telling a routing engine that a connection which looks perfectly feasible cannot be made — two platforms separated by a fare barrier, or a change that requires leaving and re-entering a paid area.

Types 4 and 5 describe in-seat transfers, where a rider stays aboard while the vehicle changes its trip identity. That is the same physical situation a block describes, approached from the rider’s side rather than the vehicle’s, and a feed that uses both must keep them consistent.

block_id

block_id is a single optional column on trips.txt. Trips sharing a block_id are operated by the same vehicle, in the order their times imply. The specification says nothing more than that, which leaves a great deal to be inferred:

  • The order is not given. It has to be derived by sorting the block’s trips on their first departure time.
  • The continuity is not guaranteed. Where one trip ends and the next begins may be different stops, which means the vehicle deadheads between them — legal, and invisible in the feed.
  • The service day is not scoped. Two trips with the same block_id on different service days are unrelated, so block reconstruction must be done per service date, not across the whole feed.

That last point is the one most often missed. A block is a vehicle’s work on a day; reconstructing blocks across a feed that covers six months produces chains of thousands of trips that no vehicle ever ran.

Question Answered by Wrong answer if you use the other
May a rider change here transfers.txt Blocks say nothing about riders
Does the rider need to get off block_id Transfers will charge for a change that never happened
How long does the change take transfers.txt min_transfer_time Blocks have no time model
Which vehicle runs this trip block_id Transfers say nothing about vehicles
Two kinds of linking, two different questions A grid separating transfers.txt from block_id by what each describes, what it is keyed on, and the question only it can answer. transfers.txt block_id Describes the rider's change the vehicle's work Keyed on a pair of stops a set of trips on one day Answers may I change here must I get off at all Absent means unconstrained no vehicle continuity stated

Step-by-Step Implementation

Step 1 — Read transfers.txt defensively

python
from pathlib import Path
from zipfile import ZipFile

import pandas as pd

TRANSFER_DTYPES = {
    "from_stop_id": "string", "to_stop_id": "string",
    "from_route_id": "string", "to_route_id": "string",
    "from_trip_id": "string", "to_trip_id": "string",
    "transfer_type": "int8", "min_transfer_time": "float64",
}

TRANSFER_KINDS = {0: "recommended", 1: "timed", 2: "minimum time",
                  3: "not possible", 4: "in-seat", 5: "in-seat not allowed"}


def read_transfers(feed_path: Path) -> pd.DataFrame:
    with ZipFile(feed_path) as archive:
        if "transfers.txt" not in archive.namelist():
            return pd.DataFrame({c: pd.Series(dtype=d)
                                 for c, d in TRANSFER_DTYPES.items()})
        with archive.open("transfers.txt") as fh:
            return pd.read_csv(fh, dtype=TRANSFER_DTYPES,
                               keep_default_na=False, na_values=[""])

An absent transfers.txt is not an error and does not mean transfers are impossible. It means the agency has not constrained them, and the routing engine falls back to its own minimum-connection rules. Returning an empty typed frame rather than None keeps every downstream filter working without a null check.

Step 2 — Resolve the effective transfer for a pair of trips

python
def effective_transfer(transfers: pd.DataFrame, from_stop: str, to_stop: str,
                       from_trip: str | None = None, to_trip: str | None = None,
                       from_route: str | None = None, to_route: str | None = None):
    """The most specific transfer rule covering this change, or None."""
    def matches(column: pd.Series, value):
        return column.isna() if value is None else (column.isna() | (column == value))

    candidates = transfers[
        (transfers["from_stop_id"] == from_stop)
        & (transfers["to_stop_id"] == to_stop)
        & matches(transfers["from_trip_id"], from_trip)
        & matches(transfers["to_trip_id"], to_trip)
        & matches(transfers["from_route_id"], from_route)
        & matches(transfers["to_route_id"], to_route)
    ]
    if candidates.empty:
        return None
    specificity = candidates[["from_trip_id", "to_trip_id",
                              "from_route_id", "to_route_id"]].notna().sum(axis=1)
    return candidates.loc[specificity.idxmax()]

The same empty-means-unconstrained rule that governs fare rules governs transfers, and it goes wrong in the same way. A trip-specific rule must win over a stop-pair rule, which is what ranking on specificity achieves.

Step 3 — Reconstruct blocks, per service date

python
def block_chains(trips: pd.DataFrame, stop_times: pd.DataFrame) -> dict:
    """Ordered trip chains per (block_id, service_id)."""
    bounds = (stop_times.sort_values(["trip_id", "stop_sequence"])
              .groupby("trip_id")
              .agg(first_stop=("stop_id", "first"), last_stop=("stop_id", "last"),
                   start_s=("departure_s", "first"), end_s=("arrival_s", "last")))

    blocked = trips[trips["block_id"].notna()].join(bounds, on="trip_id")
    chains = {}
    for (block_id, service_id), group in blocked.groupby(["block_id", "service_id"]):
        chains[(block_id, service_id)] = (
            group.sort_values("start_s")[
                ["trip_id", "first_stop", "last_stop", "start_s", "end_s"]]
            .to_dict("records"))
    return chains

Grouping on (block_id, service_id) rather than on block_id alone is what scopes the chain to a single day’s work. departure_s and arrival_s are seconds since the start of the service day — the normalised form, not the raw clock strings.

Step 4 — Build the transfer graph

python
import networkx as nx


def transfer_graph(transfers: pd.DataFrame, default_seconds: float = 120.0) -> nx.DiGraph:
    graph = nx.DiGraph()
    for row in transfers.itertuples(index=False):
        kind = int(row.transfer_type)
        if kind in (3, 5):                 # not possible — an edge would be a lie
            continue
        seconds = row.min_transfer_time
        if pd.isna(seconds):
            seconds = 0.0 if kind in (1, 4) else default_seconds
        graph.add_edge(row.from_stop_id, row.to_stop_id,
                       seconds=float(seconds), kind=TRANSFER_KINDS[kind])
    return graph

Forbidden transfers are omitted from the graph rather than added with an infinite weight, so no path can accidentally traverse one. Timed and in-seat transfers get a zero cost because the connection is guaranteed by the operator.

Validation and Verification

python
def verify_transfers(transfers: pd.DataFrame, stops: pd.DataFrame,
                     trips: pd.DataFrame, routes: pd.DataFrame) -> list[str]:
    problems = []
    known_stops = set(stops["stop_id"])
    for column in ("from_stop_id", "to_stop_id"):
        missing = set(transfers[column].dropna()) - known_stops
        if missing:
            problems.append(f"{column} references unknown stops: {sorted(missing)[:5]}")

    known_trips, known_routes = set(trips["trip_id"]), set(routes["route_id"])
    for column, known in (("from_trip_id", known_trips), ("to_trip_id", known_trips),
                          ("from_route_id", known_routes), ("to_route_id", known_routes)):
        missing = set(transfers[column].dropna()) - known
        if missing:
            problems.append(f"{column} references unknown ids: {sorted(missing)[:5]}")

    needs_time = transfers["transfer_type"] == 2
    if (needs_time & transfers["min_transfer_time"].isna()).any():
        n = int((needs_time & transfers["min_transfer_time"].isna()).sum())
        problems.append(f"{n} transfer(s) of type 2 carry no min_transfer_time")
    return problems


def verify_blocks(chains: dict) -> list[str]:
    problems = []
    for (block_id, service_id), trips_in_block in chains.items():
        for earlier, later in zip(trips_in_block, trips_in_block[1:]):
            if later["start_s"] < earlier["end_s"]:
                problems.append(
                    f"block {block_id} on {service_id}: {earlier['trip_id']} ends at "
                    f"{earlier['end_s']}s but {later['trip_id']} starts at "
                    f"{later['start_s']}s — one vehicle cannot run both")
            elif later["first_stop"] != earlier["last_stop"]:
                gap = later["start_s"] - earlier["end_s"]
                problems.append(
                    f"block {block_id} on {service_id}: vehicle ends at "
                    f"{earlier['last_stop']} and starts at {later['first_stop']} "
                    f"{gap / 60:.0f} min later — deadhead or a broken block")
    return problems

The overlap check is the one worth running on every feed. Two trips in the same block that overlap in time is a physical impossibility, no schema validator reports it, and it is common enough in feeds where blocks are assigned by a separate scheduling system from the one that generates the timetable.

A block read as one vehicle's day Four trips run by the same vehicle in sequence, with a deadhead between the second and third where the vehicle repositions out of service. trip 1 06:10–07:04 trip 2 07:15–08:11 trip 3 08:40–09:32 trip 4 09:45–10:38 a gap between where one trip ends and the next begins is a grouped by block_id AND service_id — a block is one vehicle on one day

Failure Modes and Edge Cases

  • Blocks reconstructed across service days. Grouping on block_id alone chains together every Monday’s work with every Tuesday’s. The result is a block containing hundreds of trips that no vehicle ran, and the overlap check then fires on every one of them.
  • transfer_type = 3 treated as an ordinary edge. Adding a forbidden transfer to the graph with a large weight lets a desperate routing query traverse it anyway. Omit the edge.
  • In-seat transfers disagreeing with blocks. A feed carrying both transfer_type = 4 rows and block_id values should have them agree. Where they do not, the block is usually the more reliable signal, because it comes from the vehicle scheduling system.
  • min_transfer_time of zero on a real interchange. Legal, and it means the change is instantaneous. On a station with platforms hundreds of metres apart it is wrong, and it is worth cross-checking against pathway traversal times where the feed has them.
  • Transfers between a stop and itself. Common and legal — it models the minimum time to change vehicles at the same platform. Filtering out self-loops removes real constraints.
  • Deadheads inside a block. A gap between where one trip ends and the next begins is normal, not an error. It becomes an error only when the gap is too short for the vehicle to have covered the distance, which needs the route geometry to detect.
What each transfer_type permits The four values a consumer actually meets, arranged by how much they constrain the change a rider may make. RECOMMENDED TIMED MINIMUM TIME NOT POSSIBLE the vehicle waits a floor in seconds the change cannot be made

Performance and Scale Notes

transfers.txt is small in almost every feed — a few hundred rows, occasionally a few thousand at a large multimodal interchange. The linear filter in effective_transfer is therefore fine for one-off queries and disastrous inside a routing loop, where it will be called millions of times. Build a dictionary keyed on (from_stop_id, to_stop_id) once, holding the candidate rows sorted by specificity, and the query becomes a probe.

Block reconstruction is the heavier operation, because it needs first and last stop times per trip, which means an aggregation over the whole of stop_times.txt — the largest table in the feed. Do it once per feed version and cache it; the block structure changes only when the agency republishes. On a feed with 1.8 million stop times the aggregation runs in roughly four seconds with pandas and under a second if the table is already stored as partitioned Parquet, because only four columns are read.

The transfer graph itself is small enough to hold entirely in memory for any single agency — a few thousand nodes and perhaps ten thousand edges even where proximity edges are derived — but it is worth building once per feed version rather than per request. Its inputs change only when the agency republishes, so it caches against the same content checksum used for feed version control, and a cold rebuild is cheap enough that no incremental update logic is justified. Where a routing engine queries the same stop pairs repeatedly, precomputing shortest paths within each station and storing the result turns every interchange question into a lookup.

For multi-agency batches, remember that block_id values are only unique within a feed. Merging two agencies without namespacing the block column chains together vehicles from different cities — a defect that produces spectacular overlap reports and takes a while to trace back to its cause.

Frequently Asked Questions

What is the difference between a transfer and a block?

A transfer is about the rider: it says how, and how quickly, a person may change from one trip to another. A block is about the vehicle: it says the same physical vehicle continues from one trip into the next, so a rider who stays aboard has not transferred at all. They are modelled in different files and answer different questions.

Is transfers.txt required?

No, and most feeds omit it. Its absence does not mean transfers are impossible — it means the agency has not constrained them, and a routing engine should fall back to its own minimum-connection rules based on the stops’ proximity and hierarchy.

What does transfer_type 3 mean?

Transfer not possible. It is the most valuable value in the file, because it is the only way an agency can tell a routing engine that a connection which looks feasible on paper — two platforms in the same station, ten minutes apart — cannot actually be made.

Can two trips in the same block overlap in time?

No. A block is one vehicle, and a vehicle cannot run two trips at once. Overlapping trips in a block are always a data defect, and they are one of the few GTFS errors that a schema validator will never catch.

Up: Python Parsing & Data Normalization | Home