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.

How Fares v2 prices a journey Each leg is priced by its own rule, then the transitions between legs are priced separately — which is where transfers, windows and capping live. Leg 1 fare_leg_rules Transfer fare_transfer_rules Leg 2 fare_leg_rules Total legs plus transitions v1 has no vocabulary for the transition, which is why it cannot express capping

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_id and no media vocabulary.
  • Mode-dependent transfers. Bus-to-bus free, bus-to-rail charged. v1’s transfers count 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

python
"""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",
    ]
Choosing which model to read Detection by required tables, and a deliberate choice when both models are present. Which fare tables does the archive carry? v1 tables only Read v1 the majority of feeds today v2 tables only Read v2 leg and transfer rules both Pick one, log it merging them prices some journeys twice

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

python
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:

text
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.

The vocabulary a v2 leg rule refers to Four supporting tables that v2 rules resolve against; a rule referencing an undefined identifier in any of them is unusable. areas.txt / stop_areas.txt where a leg starts and ends networks.txt / route_networks.txt which network a leg runs on fare_products.txt what is being sold fare_media.txt how it is paid for

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.txt and zone_id are different things. v2 areas are defined in their own table and linked through stop_areas.txt; v1 zones live in a stops.txt column. 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.txt describes 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.