Comparing GTFS Fares v1 and v2
Fares v1 prices a journey by matching it against a route and a triple of zones; Fares v2 prices each leg separately and then prices the joins between legs, which is where transfers, time windows and capping live. That difference is not a refinement — it is the difference between a model that can describe a modern fare system and one that cannot. Detect which model a feed carries, commit to one, and record the choice; never merge them. The tables themselves are introduced in fare rules and pathways modeling.
Root Cause Analysis
Fares v1 was designed for a fare system that could be described one ride at a time. Its rule is a predicate over four attributes of a single boarding — route, origin zone, destination zone, contains zone — and its output is one price. Everything else about the fare is squeezed into fare_attributes.txt: how many transfers the ticket allows, and for how long.
That works for a flat fare and for a simple zone matrix. It fails for everything a real agency has added since. Consider four ordinary policies:
- A free transfer within 90 minutes. v1 can say “this fare allows one transfer lasting 5,400 seconds”, but it cannot say the second leg is free while a third is charged, nor that the window depends on which modes are involved.
- Daily capping. After four journeys the day is capped. v1 has no concept of a day, of an accumulated total, or of a rider.
- Different prices by fare media. Contactless costs less than a paper ticket. v1 has one price per
fare_idand no media vocabulary. - Mode-dependent transfers. Bus-to-bus free, bus-to-rail charged. v1’s
transferscount is per fare product, not per pair of legs.
Agencies responded to these limits by enumeration. If a rule cannot express a relationship, generate one rule for every combination the relationship implies. That is how a single regional operator’s fare_rules.txt reaches six figures, and the resulting file states what the price is for every case while stating nothing about why — the policy is gone, only its outputs remain.
Fares v2 restores the policy. fare_leg_rules.txt prices a leg by network, origin area, destination area and fare product; fare_transfer_rules.txt prices the transition between two legs, with a duration limit and a transfer count. Capping is expressed as a transfer rule that reduces the marginal cost to zero after a threshold. The vocabulary tables — areas.txt, stop_areas.txt, networks.txt, route_networks.txt, fare_products.txt, fare_media.txt — give those rules something to refer to.
Production-Ready Python Implementation
"""Detect a feed's fare model, and price a journey under whichever one it uses."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from zipfile import ZipFile
import pandas as pd
log = logging.getLogger("gtfs.fares.model")
V1_REQUIRED = {"fare_attributes.txt", "fare_rules.txt"}
V2_REQUIRED = {"fare_leg_rules.txt", "fare_products.txt"}
V2_OPTIONAL = {"fare_transfer_rules.txt", "areas.txt", "stop_areas.txt",
"networks.txt", "route_networks.txt", "fare_media.txt"}
@dataclass(frozen=True)
class FareModel:
kind: str # "v1", "v2", "both", "none"
tables: frozenset[str]
selected: str # the model this pipeline will actually use
@property
def can_express_transfers(self) -> bool:
return self.selected == "v2" and "fare_transfer_rules.txt" in self.tables
@property
def can_express_capping(self) -> bool:
return self.can_express_transfers
def detect_fare_model(feed_path: Path, prefer: str = "v2") -> FareModel:
with ZipFile(feed_path) as archive:
members = frozenset(archive.namelist())
has_v1 = V1_REQUIRED <= members
has_v2 = V2_REQUIRED <= members
kind = ("both" if has_v1 and has_v2
else "v2" if has_v2 else "v1" if has_v1 else "none")
if kind == "both":
selected = prefer
log.warning("%s carries BOTH fare models; using %s and ignoring the other "
"— merging them would price some journeys twice",
feed_path.name, selected)
else:
selected = kind
present = sorted((V2_OPTIONAL | V1_REQUIRED | V2_REQUIRED) & members)
log.info("%s: fare model %s (using %s), tables: %s",
feed_path.name, kind, selected, ", ".join(present) or "none")
return FareModel(kind=kind, tables=members, selected=selected)
def unsupported_policies(model: FareModel) -> list[str]:
"""Fare policies this feed's model provably cannot represent."""
if model.selected != "v1":
return []
return [
"free or discounted transfers between specific leg pairs",
"time-limited transfer windows that differ by mode",
"daily or weekly capping",
"prices that vary by fare media",
]
Step-by-Step Walkthrough
Detection is by required tables, not by any single file. fare_leg_rules.txt without fare_products.txt is an incomplete v2 model — the leg rules would reference products that do not exist. Requiring both prevents a half-migrated feed from being read as v2 and then failing on every lookup.
A feed with both models selects one and says so, at warning level. This is the most important line in the module. Feeds in migration commonly leave the v1 tables in place, unchanged and increasingly stale, while v2 is developed alongside. Reading both and summing produces double charges; reading both and preferring per-journey produces inconsistent prices for identical journeys. Picking one and logging the decision means the behaviour is at least explicable.
prefer defaults to v2 but is a parameter. A consumer that has not implemented the leg-and-transfer model should pass prefer="v1" rather than silently producing nothing. The choice belongs to the consumer, not to the detector.
can_express_transfers checks for fare_transfer_rules.txt specifically. A v2 feed without it prices legs independently, which is v1’s expressiveness with v2’s tables. Knowing that in advance stops a pipeline from promising capping support it cannot deliver on this feed.
unsupported_policies returns a list rather than a boolean. When a v1 feed cannot price a journey correctly, the useful output is which policy is missing, so the answer can be presented as “transfers not included in this price” rather than as a wrong number.
Verification and Output
def verify_v2_vocabulary(feed_path: Path) -> list[str]:
"""Every identifier a v2 leg rule references must be defined somewhere."""
problems: list[str] = []
with ZipFile(feed_path) as archive:
def maybe(name: str) -> pd.DataFrame:
if name not in archive.namelist():
return pd.DataFrame()
with archive.open(name) as fh:
return pd.read_csv(fh, dtype="string", keep_default_na=False,
na_values=[""])
legs = maybe("fare_leg_rules.txt")
products = maybe("fare_products.txt")
areas = maybe("areas.txt")
networks = maybe("networks.txt")
if legs.empty:
return ["no fare_leg_rules.txt to verify"]
known_products = set(products.get("fare_product_id", pd.Series(dtype="string")))
unknown = set(legs.get("fare_product_id", pd.Series(dtype="string")).dropna()) - known_products
if unknown:
problems.append(f"leg rules reference {len(unknown)} unknown fare product(s)")
known_areas = set(areas.get("area_id", pd.Series(dtype="string")))
for column in ("from_area_id", "to_area_id"):
refs = set(legs.get(column, pd.Series(dtype="string")).dropna())
missing = refs - known_areas
if missing:
problems.append(f"{column} references {len(missing)} undefined area(s)")
known_networks = set(networks.get("network_id", pd.Series(dtype="string")))
net_refs = set(legs.get("network_id", pd.Series(dtype="string")).dropna())
if net_refs - known_networks:
problems.append(f"leg rules reference {len(net_refs - known_networks)} "
"network(s) defined nowhere")
return problems
Detection output across a handful of real feeds:
INFO gtfs.fares.model: metro_20261102.zip: fare model v2 (using v2), tables: areas.txt, fare_leg_rules.txt, fare_media.txt, fare_products.txt, fare_transfer_rules.txt, networks.txt, route_networks.txt, stop_areas.txt
INFO gtfs.fares.model: county_bus.zip: fare model v1 (using v1), tables: fare_attributes.txt, fare_rules.txt
WARN gtfs.fares.model: regional_rail.zip carries BOTH fare models; using v2 and ignoring the other — merging them would price some journeys twice
INFO gtfs.fares.model: shuttle.zip: fare model none (using none), tables: none
The four lines cover the four cases a pipeline meets, and the fourth is the most common of all: no fare data at all.
Gotchas and Edge Cases
- A v2 feed with no
fare_transfer_rules.txt. Legal, and it means every leg is priced independently — the rider is charged the full fare for each. If the agency actually offers free transfers, the feed understates nothing and overstates the total, which is the wrong direction to be wrong in. areas.txtandzone_idare different things. v2 areas are defined in their own table and linked throughstop_areas.txt; v1 zones live in astops.txtcolumn. A stop can belong to several areas and to exactly one zone, so the two are not interchangeable and a converter that maps one onto the other loses information.- Fare media without prices.
fare_media.txtdescribes how a fare is paid for, not what it costs. A feed can define media and still price everything identically; the media distinction only bites when leg rules reference it. - Enumerated v1 rules being read as policy. A feed with 200,000 rules is describing outputs, not intent. Do not try to infer a zone structure from them — index them and match, as in parsing GTFS fare rules in Python, and leave the policy question to the agency.
- A feed that switches models between publications. Detection catches it, but any cached fare index keyed only on the feed checksum will happily serve prices computed under the old model. Include the selected model in the cache key.
Frequently Asked Questions
Is Fares v1 deprecated?
Not formally, and it is still what most feeds carry. Fares v2 is the direction of travel and is what large agencies are migrating to, but a pipeline that only supports v2 will be unable to price the majority of feeds it encounters for some years yet.
Can I convert a v1 fare model into v2?
Mechanically, partially: a flat fare or a simple zone matrix maps onto fare_leg_rules.txt cleanly. What does not convert is anything v1 encoded by enumeration to work around its own limits, because the intent behind those thousands of rules is not recoverable from the rows.
What does v2 express that v1 genuinely cannot?
Anything that depends on the relationship between legs: free or discounted transfers, time-limited transfer windows, daily and weekly capping, and pricing that differs by fare media. v1 has no vocabulary for a journey as a sequence, so none of these can be written down.
If a feed has both, which should I read?
Whichever your consumer implements fully, and only one. The two models overlap and will disagree, because v1 rules are usually left in place unchanged while v2 is developed alongside them. Merging them prices some journeys twice and others not at all.
Related
- Parsing GTFS Fare Rules in Python — the v1 loader and matcher in full
- Calculating Trip Fares from GTFS in Python — pricing a journey once the model is chosen
- Up: Fare Rules and Pathways Modeling — both models side by side
- Section: GTFS Feed Architecture & Fundamentals · Home