Parsing GTFS transfers.txt in Python
Read transfers.txt with explicit dtypes, tolerate its absence by returning an empty typed frame, and index every rule under its (from_stop_id, to_stop_id) pair with the candidates ranked by how many of the optional trip and route qualifiers they populate. Resolution is then a dictionary probe followed by picking the top-ranked candidate. The trap, exactly as with fare rules, is that an empty qualifier means unconstrained rather than no match. The model these rules express is covered in transfers and trip linking.
Root Cause Analysis
transfers.txt looks like the simplest file in a GTFS feed. It has eight columns, most feeds that carry it have a few hundred rows, and the semantics fit in a paragraph. It nonetheless produces a disproportionate share of routing bugs, for three reasons.
The qualifiers are optional and conjunctive. A row may name only two stops, or it may additionally name the routes, the trips, or both. An unpopulated qualifier does not constrain, so a bare stop-pair rule matches every change between those stops — including changes between the specific trips that a more specific rule also covers. Both rules match; only one should apply.
Absence is meaningful in the wrong direction. A missing transfers.txt is the common case, and it means the agency has said nothing. Code that treats “no rule” as “no transfer allowed” makes most feeds unroutable. Code that treats it as “transfer allowed instantly” promises impossible connections. The correct reading is “apply your own default”, which means the loader must return something a caller can distinguish from a rule.
transfer_type = 3 is a prohibition, not a cost. It is the only way a feed can say a connection cannot be made, and it is easy to lose. A loader that filters to rows with a min_transfer_time drops every type 3 row, because prohibitions carry no time. The connection then looks unconstrained, which is the exact opposite of what the agency said.
Production-Ready Python Implementation
"""Load and index GTFS transfers.txt."""
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from zipfile import ZipFile
import pandas as pd
log = logging.getLogger("gtfs.transfers")
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",
}
QUALIFIERS = ("from_trip_id", "to_trip_id", "from_route_id", "to_route_id")
KIND_NAMES = {0: "recommended", 1: "timed", 2: "minimum time",
3: "not possible", 4: "in-seat", 5: "in-seat not allowed"}
# transfer_type values that permit the change at all.
PERMITTED = {0, 1, 2, 4}
@dataclass(frozen=True)
class TransferRule:
from_stop: str
to_stop: str
kind: int
min_seconds: float | None
from_trip: str | None
to_trip: str | None
from_route: str | None
to_route: str | None
@property
def specificity(self) -> int:
return sum(v is not None for v in
(self.from_trip, self.to_trip, self.from_route, self.to_route))
@property
def permitted(self) -> bool:
return self.kind in PERMITTED
@property
def effective_seconds(self) -> float:
"""What a router should budget for this change."""
if self.kind in (1, 4): # timed or in-seat: the operator guarantees it
return 0.0
return self.min_seconds if self.min_seconds is not None else 0.0
def describe(self) -> str:
return (f"{self.from_stop}->{self.to_stop} {KIND_NAMES.get(self.kind, '?')}"
f" ({self.effective_seconds:.0f}s, specificity {self.specificity})")
class TransferIndex:
def __init__(self, by_pair: dict[tuple[str, str], list[TransferRule]]):
self._by_pair = by_pair
def resolve(self, 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 single most specific rule covering this change, or None."""
journey = {"from_trip_id": from_trip, "to_trip_id": to_trip,
"from_route_id": from_route, "to_route_id": to_route}
for rule in self._by_pair.get((from_stop, to_stop), ()):
actual = {"from_trip_id": rule.from_trip, "to_trip_id": rule.to_trip,
"from_route_id": rule.from_route, "to_route_id": rule.to_route}
# An unpopulated qualifier does not constrain; a populated one must match.
if all(actual[q] is None or actual[q] == journey[q] for q in QUALIFIERS):
return rule
return None
def forbidden_pairs(self) -> set[tuple[str, str]]:
return {pair for pair, rules in self._by_pair.items()
if rules and rules[0].kind in (3, 5) and rules[0].specificity == 0}
def __len__(self) -> int:
return sum(len(v) for v in self._by_pair.values())
def _opt(value) -> str | None:
return None if pd.isna(value) else str(value)
def load_transfers(feed_path: Path) -> TransferIndex:
with ZipFile(feed_path) as archive:
if "transfers.txt" not in archive.namelist():
log.info("%s carries no transfers.txt — transfers are unconstrained",
feed_path.name)
return TransferIndex({})
with archive.open("transfers.txt") as fh:
frame = pd.read_csv(fh, dtype=TRANSFER_DTYPES,
keep_default_na=False, na_values=[""])
by_pair: dict[tuple[str, str], list[TransferRule]] = defaultdict(list)
missing_time = 0
for row in frame.itertuples(index=False):
kind = int(row.transfer_type)
if kind == 2 and pd.isna(row.min_transfer_time):
missing_time += 1
rule = TransferRule(
from_stop=str(row.from_stop_id), to_stop=str(row.to_stop_id),
kind=kind,
min_seconds=None if pd.isna(row.min_transfer_time)
else float(row.min_transfer_time),
from_trip=_opt(row.from_trip_id), to_trip=_opt(row.to_trip_id),
from_route=_opt(row.from_route_id), to_route=_opt(row.to_route_id),
)
by_pair[(rule.from_stop, rule.to_stop)].append(rule)
# Most specific first, so resolve() can return the first rule that matches.
for rules in by_pair.values():
rules.sort(key=lambda r: r.specificity, reverse=True)
if missing_time:
log.warning("%d transfer(s) of type 2 carry no min_transfer_time — "
"they will be treated as instantaneous", missing_time)
counts = frame["transfer_type"].value_counts().to_dict()
log.info("%s: %d transfer rule(s) over %d stop pair(s): %s",
feed_path.name, len(frame), len(by_pair),
", ".join(f"{KIND_NAMES.get(int(k), k)}={v}" for k, v in sorted(counts.items())))
return TransferIndex(dict(by_pair))
Step-by-Step Walkthrough
A missing file produces an empty index, not None. TransferIndex({}) answers every resolve with None, which is exactly what “the agency has not constrained this change” should look like to a caller. No null checks propagate outwards, and the one informational log line records why every lookup is going to miss.
Rules are sorted once, at load, most specific first. resolve then returns the first rule whose populated qualifiers all match, and that is by construction the most specific match. Sorting per query would work and would cost the same as the scan it was meant to avoid.
The match condition is actual[q] is None or actual[q] == journey[q]. This is the whole of the qualifier semantics in one line. A rule that does not name a trip matches regardless of which trip is being asked about; a rule that names one matches only that trip. Reversing the direction of the None test is the bug this line exists to make obvious.
effective_seconds folds the type into the duration. A timed or in-seat transfer costs nothing because the operator guarantees it; a minimum-time transfer costs its stated seconds; a recommended transfer with no time costs nothing. A router asking “how long does this take” gets one number without having to reimplement the enum.
Prohibitions keep their rows. TransferRule stores kind regardless of value, and permitted exposes it. Nothing filters type 3 or 5 out at load, because those rows carry the most important statement in the file. Filtering happens when a graph is built, and there the edge is omitted rather than weighted.
Type 2 with no time is warned about, not repaired. Treating it as instantaneous is the specification-neutral reading, and inventing a default here would hide a feed defect that the agency can fix.
Verification and Output
def verify(index: TransferIndex, feed_path: Path) -> list[str]:
with ZipFile(feed_path) as archive:
with archive.open("stops.txt") as fh:
stops = pd.read_csv(fh, dtype="string", usecols=["stop_id"])
with archive.open("trips.txt") as fh:
trips = pd.read_csv(fh, dtype="string", usecols=["trip_id", "route_id"])
known_stops = set(stops["stop_id"])
known_trips = set(trips["trip_id"])
known_routes = set(trips["route_id"])
problems, checked = [], 0
for rules in index._by_pair.values():
for rule in rules:
checked += 1
for stop in (rule.from_stop, rule.to_stop):
if stop not in known_stops:
problems.append(f"transfer references unknown stop {stop}")
for trip in (rule.from_trip, rule.to_trip):
if trip is not None and trip not in known_trips:
problems.append(f"transfer references unknown trip {trip}")
for route in (rule.from_route, rule.to_route):
if route is not None and route not in known_routes:
problems.append(f"transfer references unknown route {route}")
if rule.kind not in KIND_NAMES:
problems.append(f"unknown transfer_type {rule.kind}")
if rule.min_seconds is not None and rule.min_seconds < 0:
problems.append(f"negative min_transfer_time on {rule.describe()}")
log.info("verified %d transfer rule(s), %d problem(s)", checked, len(problems))
return problems
Loading a large interchange feed:
INFO gtfs.transfers: metro_20261102.zip: 1284 transfer rule(s) over 1102 stop pair(s): recommended=41, timed=18, minimum time=1198, not possible=27
WARN gtfs.transfers: 3 transfer(s) of type 2 carry no min_transfer_time — they will be treated as instantaneous
The twenty-seven prohibitions are the rows worth reading by hand. Each one is an agency telling you that a connection your routing engine would otherwise offer cannot be made, and each one is a wrong itinerary avoided.
Resolving a specific change:
rule = index.resolve("plat_2", "plat_7", from_route="M1", to_route="M4")
print(rule.describe() if rule else "unconstrained")
# plat_2->plat_7 minimum time (240s, specificity 2)
Gotchas and Edge Cases
- Two rules with identical specificity for the same pair. The sort is stable, so the file order decides. That is arbitrary but deterministic; if the two disagree on
transfer_typethe feed is contradictory and deserves a report. - A transfer whose stops are in different stations. Entirely normal — an out-of-station interchange across a road. It only becomes suspicious when the distance is large, which is the check in detecting impossible transfers.
from_route_idnaming a route thefrom_trip_iddoes not belong to. Contradictory, and the verification above does not catch it because both identifiers exist. Cross-checking the trip’s own route is worth adding on feeds that populate both.- Very large
transfers.txt. A few feeds enumerate every platform pair in every station, reaching tens of thousands of rows. The pair index handles it, but theverifyscan above is linear and should be run once at load rather than per query. - Self-transfers with a long minimum time. A rule from a stop to itself with 600 seconds is the agency saying that changing vehicles at that platform genuinely takes ten minutes, usually because it is a terminus where vehicles lay over. It is real, and dropping it makes the router promise a connection that does not exist.
Frequently Asked Questions
What should happen when transfers.txt is missing?
Nothing should break. An absent file means the agency has not constrained transfers, not that transfers are impossible. Return an empty typed frame so every downstream filter still works, and let the routing engine apply its own minimum-connection defaults.
Which rule applies when several match the same change?
The most specific: the rule populating the largest number of the optional trip and route qualifiers. A rule naming both trips beats one naming both routes, which beats a bare stop-pair rule.
Is a transfer from a stop to itself meaningful?
Yes, and it is common. It models the minimum time needed to change vehicles at the same platform. Filtering out self-loops as though they were noise removes a real constraint that a routing engine needs.
Should min_transfer_time be trusted over a computed walking time?
Yes. It is the agency’s own statement about its own station, and it accounts for things no computation can see — fare gates, platform crowding, a long ramp. Use computed times only where the feed gives none.
Related
- Reconstructing Vehicle Blocks from block_id — the other kind of linking, and why it is not a transfer
- Building a Transfer Graph for Routing — what to do with the index once it is loaded
- Up: Transfers and Trip Linking — the two mechanisms side by side
- Section: Python Parsing & Data Normalization · Home