Fare Rules and Pathways Modeling
Most GTFS pipelines stop at the schedule, and for a long time that was defensible: the fare tables were optional, rarely populated and too weak to express any real fare system, and pathways.txt did not exist. Both statements have stopped being true. GTFS-Fares v2 can represent capping, transfer windows and mode-dependent pricing, and pathways data is now published by most large metro operators because journey planners cannot give an honest interchange time without it.
This page covers both: what the fare tables actually say, how the v1 and v2 models differ and why the migration matters, how to resolve a rider’s journey to a price, and how to read pathways.txt and levels.txt as the graph they are. It builds on the static feed structure — every fare rule and every pathway is ultimately keyed on identifiers defined there.
Prerequisites
- Python 3.9 or later with
pandas;networkxif you intend to route across the station graph - A GTFS archive; the fare and pathway tables are optional, so bring one that actually carries them
- Familiarity with
routes.txtandstops.txt, and with the parent-station hierarchy, which pathways depend on entirely
pip install pandas networkx
Concept and Spec Background
Fares v1: prices attached to zone triples
The original fare model has two tables. fare_attributes.txt defines a fare product — a price, a currency, a payment method, how many transfers it allows and for how long. fare_rules.txt attaches that product to journeys, using up to four optional qualifiers.
| Column | Table | Meaning |
|---|---|---|
fare_id |
both | The product being priced; the join between the two tables |
price / currency_type |
fare_attributes.txt |
The amount and its ISO 4217 currency |
payment_method |
fare_attributes.txt |
0 = pay on board, 1 = pay before boarding |
transfers |
fare_attributes.txt |
Transfers permitted: 0, 1, 2, or empty for unlimited |
transfer_duration |
fare_attributes.txt |
Seconds the transfer entitlement lasts |
route_id |
fare_rules.txt |
Restricts the rule to one route |
origin_id / destination_id |
fare_rules.txt |
Zone identifiers, matched against stops.txt zone_id |
contains_id |
fare_rules.txt |
A zone the journey must pass through |
The qualifiers are conjunctive: a rule with route_id and origin_id set matches only journeys that use that route and start in that zone. A rule with no qualifiers at all is a flat fare that matches everything. The empty value means “unconstrained on this axis”, which is why an empty route_id is not the same as a missing rule.
The model’s limit is that it cannot express anything that depends on the sequence of a journey. Fare capping, a discount that applies only to the second leg, a transfer that is free within 90 minutes but charged after — none of these can be written down. Agencies worked around it by generating thousands of fare_rules.txt rows, which is how a feed ends up with more fare rules than trips.
Fares v2: legs and the transfers between them
The v2 model splits pricing into two questions. fare_leg_rules.txt prices a single leg — a continuous ride on one vehicle — matched on network, origin area, destination area and fare product. fare_transfer_rules.txt prices the join between two legs, which is where free transfers, time limits and capping live. Supporting tables (fare_products.txt, fare_media.txt, areas.txt, stop_areas.txt, networks.txt, route_networks.txt) carry the vocabulary the rules refer to.
The practical consequence for a pipeline is that a v2 fare cannot be computed one leg at a time. The journey has to be assembled first, then priced as a whole, because the transfer rules can change what an earlier leg costs. That is a different shape of computation, and it is covered in comparing GTFS Fares v1 and v2.
Pathways: the station as a graph
pathways.txt describes movement inside a station. Each row is a directed edge between two stops.txt entries — which, for pathways, includes entrances, generic nodes and boarding areas as well as platforms — carrying a pathway_mode (walkway, stairs, escalator, lift, fare gate, exit gate), an is_bidirectional flag, and optional traversal_time, length, stair_count and max_slope.
levels.txt supplies the vertical dimension: each level has an index, and stops reference a level_id. Together they let a planner say that changing from the eastbound platform to the northbound one means one lift, two walkways and 240 seconds.
The whole structure depends on location_type and parent_station being modelled correctly in stops.txt. A feed that publishes every platform as a standalone location_type = 0 stop with no parent cannot carry meaningful pathways, because there is no station for the pathways to be inside.
location_type |
Meaning | Can pathways reference it |
|---|---|---|
| 0 (or empty) | Stop or platform | Yes |
| 1 | Station — the parent of platforms | No, pathways connect its children |
| 2 | Entrance or exit | Yes |
| 3 | Generic node, e.g. a concourse junction | Yes |
| 4 | Boarding area within a platform | Yes |
Step-by-Step Implementation
Step 1 — Detect which model the feed uses
from pathlib import Path
from zipfile import ZipFile
V1_TABLES = {"fare_attributes.txt", "fare_rules.txt"}
V2_TABLES = {"fare_leg_rules.txt", "fare_products.txt"}
def fare_model(feed_path: Path) -> str:
with ZipFile(feed_path) as archive:
members = set(archive.namelist())
has_v1 = bool(V1_TABLES & members)
has_v2 = bool(V2_TABLES & members)
if has_v1 and has_v2:
return "both"
if has_v2:
return "v2"
if has_v1:
return "v1"
return "none"
Branch on this once, at the top of the fare loader, and never again. Code that checks for the presence of individual fare tables deeper in the pipeline ends up half-supporting both models and fully supporting neither.
Step 2 — Read the v1 tables with explicit types
import pandas as pd
FARE_ATTR_DTYPES = {
"fare_id": "string", "price": "float64", "currency_type": "string",
"payment_method": "int8", "transfers": "string", "transfer_duration": "float64",
}
FARE_RULE_DTYPES = {
"fare_id": "string", "route_id": "string",
"origin_id": "string", "destination_id": "string", "contains_id": "string",
}
def read_fares_v1(archive: ZipFile) -> tuple[pd.DataFrame, pd.DataFrame]:
with archive.open("fare_attributes.txt") as fh:
attrs = pd.read_csv(fh, dtype=FARE_ATTR_DTYPES,
keep_default_na=False, na_values=[""])
with archive.open("fare_rules.txt") as fh:
rules = pd.read_csv(fh, dtype=FARE_RULE_DTYPES,
keep_default_na=False, na_values=[""])
return attrs, rules
transfers is read as a string on purpose. Its empty value means unlimited, which is the opposite of zero; reading it as a number turns that into NaN and invites code that treats missing as none. Keep the distinction visible and convert deliberately.
price is read as a float, and it should be converted to integer minor units before any arithmetic. Adding floating-point currency is how a two-leg journey costs 4.199999999999999.
Step 3 — Resolve a journey to a fare
def match_fare(rules: pd.DataFrame, route_id: str,
origin_zone: str, destination_zone: str) -> pd.DataFrame:
"""Every v1 rule matching this journey, most specific first."""
def matches(column: pd.Series, value: str) -> pd.Series:
return column.isna() | (column == value) # empty means unconstrained
candidates = rules[
matches(rules["route_id"], route_id)
& matches(rules["origin_id"], origin_zone)
& matches(rules["destination_id"], destination_zone)
].copy()
candidates["specificity"] = (
candidates[["route_id", "origin_id", "destination_id"]].notna().sum(axis=1))
return candidates.sort_values("specificity", ascending=False)
The isna() | == idiom is the whole of v1 matching, and it is where implementations usually go wrong. An empty qualifier matches everything; a populated one must match exactly. Ranking by specificity afterwards resolves the common case of a flat fallback fare sitting alongside a handful of specific rules.
Step 4 — Load the pathway graph
PATHWAY_DTYPES = {
"pathway_id": "string", "from_stop_id": "string", "to_stop_id": "string",
"pathway_mode": "int8", "is_bidirectional": "int8",
"length": "float64", "traversal_time": "float64",
"stair_count": "float64", "max_slope": "float64", "min_width": "float64",
}
PATHWAY_MODES = {1: "walkway", 2: "stairs", 3: "moving sidewalk", 4: "escalator",
5: "elevator", 6: "fare gate", 7: "exit gate"}
def read_pathways(archive: ZipFile) -> pd.DataFrame:
if "pathways.txt" not in archive.namelist():
return pd.DataFrame({c: pd.Series(dtype=d) for c, d in PATHWAY_DTYPES.items()})
with archive.open("pathways.txt") as fh:
return pd.read_csv(fh, dtype=PATHWAY_DTYPES,
keep_default_na=False, na_values=[""])
def build_station_graph(pathways: pd.DataFrame, default_seconds: float = 60.0):
import networkx as nx
graph = nx.DiGraph()
for row in pathways.itertuples(index=False):
seconds = row.traversal_time
if pd.isna(seconds):
seconds = default_seconds
graph.add_edge(row.from_stop_id, row.to_stop_id,
seconds=float(seconds),
mode=PATHWAY_MODES.get(int(row.pathway_mode), "unknown"),
pathway_id=row.pathway_id)
if int(row.is_bidirectional or 0) == 1:
graph.add_edge(row.to_stop_id, row.from_stop_id,
seconds=float(seconds),
mode=PATHWAY_MODES.get(int(row.pathway_mode), "unknown"),
pathway_id=row.pathway_id)
return graph
A directed graph, always. is_bidirectional is a real constraint — an exit gate or a one-way escalator genuinely cannot be traversed backwards — and modelling the station as undirected produces routes through a barrier that riders cannot pass.
Validation and Verification
def verify_fares_v1(attrs: pd.DataFrame, rules: pd.DataFrame,
routes: pd.DataFrame, stops: pd.DataFrame) -> None:
orphan_rules = set(rules["fare_id"]) - set(attrs["fare_id"])
assert not orphan_rules, f"fare_rules reference unknown fare_id: {sorted(orphan_rules)[:5]}"
bad_routes = set(rules["route_id"].dropna()) - set(routes["route_id"])
assert not bad_routes, f"fare_rules reference unknown route_id: {sorted(bad_routes)[:5]}"
zones = set(stops["zone_id"].dropna())
for column in ("origin_id", "destination_id", "contains_id"):
unknown = set(rules[column].dropna()) - zones
assert not unknown, f"{column} references zones absent from stops.txt: {sorted(unknown)[:5]}"
assert (attrs["price"] >= 0).all(), "negative fare price"
def verify_pathways(pathways: pd.DataFrame, stops: pd.DataFrame) -> None:
known = set(stops["stop_id"])
for column in ("from_stop_id", "to_stop_id"):
dangling = set(pathways[column]) - known
assert not dangling, f"{column} references unknown stops: {sorted(dangling)[:5]}"
stations = set(stops.loc[stops["location_type"] == "1", "stop_id"])
on_station = (set(pathways["from_stop_id"]) | set(pathways["to_stop_id"])) & stations
assert not on_station, (
f"pathways connect to station nodes directly: {sorted(on_station)[:5]} — "
"they must connect the station's children")
The last assertion catches the single most common pathways defect. A pathway is not allowed to terminate on a location_type = 1 station, because a station is a container, not a place a rider stands. Feeds that make this mistake produce graphs where every platform in a station is one hop from every other, and the traversal times become meaningless.
Failure Modes and Edge Cases
- No fare tables at all. The majority case. Every fare function must degrade to “unpriced” rather than raising, and the absence should be reported once at load rather than at every query.
zone_idmissing fromstops.txtwhilefare_rules.txtreferences zones. The fare model is then unusable, because the rules cannot be matched to any journey. This is a broken feed and worth failing loudly.- Thousands of fare rules. A feed with a rule per origin–destination pair can carry hundreds of thousands of rows. Index on the qualifier columns before matching, or the linear scan in
match_farebecomes the slowest thing in the pipeline. transfersempty versus zero. Empty means unlimited transfers; zero means none. Conflating them either gives riders free journeys or charges them for transfers the agency includes.- Pathways with no
traversal_time. Legal, and common. A default has to be chosen, and it should be conservative — under-estimating an interchange makes a journey planner promise connections that cannot be made. - A disconnected station graph. Some feeds publish pathways for part of a station only, leaving platforms unreachable. Check connectivity per
parent_stationand report the components rather than silently routing around the hole. - Both fare models present. Read one. Merging v1 and v2 rules produces contradictions, because the same journey is priced by both under different assumptions.
Performance and Scale Notes
The fare tables are small in most feeds and enormous in a few. The distribution is bimodal: a flat-fare agency has one fare_attributes.txt row and one fare_rules.txt row, while a zonal rail operator generating every origin–destination pair can exceed 200,000 rules. For the second kind, build a dictionary keyed on the qualifier tuple once at load and look up in constant time, rather than filtering the frame per query. Matching becomes a handful of dictionary probes — one for each combination of populated and empty qualifiers — instead of a scan.
Pathway graphs are small by construction, because they are per-station: even a large interchange rarely exceeds a few hundred edges. The cost is in building one graph per station across a feed with a thousand stations, so build lazily and cache by parent_station. Where the graph is queried repeatedly for the same pairs — the usual pattern in a journey planner — precompute all-pairs shortest paths per station and store the matrix; a few hundred nodes makes that trivially affordable, and it is the difference between a graph traversal per interchange and a lookup.
Both structures are stable across feed publications far more often than the schedule is, so they cache well against the feed’s content checksum, in the same way the expanded service calendar does.
Frequently Asked Questions
Are fares required in a GTFS feed?
No. Every fare table is optional, and a large share of feeds carry none at all. A pipeline that assumes fare data exists will break on most of the feeds it meets, so treat the fare model as an enrichment layer that may be entirely absent.
What is the difference between GTFS Fares v1 and v2?
v1 attaches a price to a combination of route, origin zone, destination zone and contains-zone, which cannot express most modern fare systems. v2 replaces it with a leg-and-transfer model: fare_leg_rules.txt prices each leg of a journey and fare_transfer_rules.txt prices the joins between legs, which can represent capping, free transfers and time-limited passes.
Can a feed contain both fare models?
Yes, and a number do during migration. Read whichever your consumer supports and record which one you used; do not merge them, because the two express overlapping rules that will contradict each other.
What is pathways.txt actually for?
It turns a station from a single coordinate into a graph of platforms, concourses, stairs, lifts and fare gates, with traversal times. It is what lets a journey planner say ‘allow six minutes to change platforms here’, and it is the only part of GTFS that describes accessibility inside a station.
Related
- Parsing GTFS Fare Rules in Python — the complete v1 loader and matcher, with the indexing that makes it fast
- Modeling Station Pathways and Levels — building and validating the station interior graph
- Comparing GTFS Fares v1 and v2 — what the leg-and-transfer model can express that zone triples cannot
- Calculating Trip Fares from GTFS in Python — pricing a whole journey, transfers included
- Mastering stops.txt and stop_times.txt Relationships — the parent-station hierarchy pathways are built on