Parsing GTFS Fare Rules in Python

Read both fare tables with explicit dtypes, convert price to integer minor units immediately, and index every rule by which qualifiers it populates — then match a journey by probing that index from the most specific qualifier combination down to the flat fallback. The trap the whole exercise exists to avoid is treating an empty route_id, origin_id or destination_id as a value to compare against: an empty qualifier means unconstrained, and getting that backwards prices every journey at the fallback fare while looking entirely plausible. The model these tables express, and its limits, are covered in fare rules and pathways modeling.

What an empty qualifier means An unpopulated qualifier column does not fail to match — it declines to constrain, which is the opposite reading and the source of most fare-matching bugs. route_id | origin | destination Empty means every route, not no route Populated means this value exactly, and nothing else rank matches by how many qualifiers are populated; most specific wins

Root Cause Analysis

GTFS Fares v1 is a filter language wearing a table’s clothing. Each row of fare_rules.txt is a predicate over a journey with four optional clauses — route, origin zone, destination zone, contains zone — and an empty clause means the predicate does not constrain that dimension. The specification says this plainly and almost every implementation gets it wrong at least once, because a CSV column full of blanks looks like missing data rather than like a wildcard.

The failure is quiet. A feed usually carries one unqualified rule as its flat fallback plus a set of qualified rules for the exceptions. If empty is treated as a value, the fallback rule stops matching anything (no journey has an empty route) and the qualified rules stop matching too (their empty columns fail to equal the journey’s real values). Depending on how the code handles no-match, every journey then comes back either unpriced or at whatever the first row happens to be. Nobody notices until someone compares a printed fare with the app.

The second structural problem is scale. Agencies that could not express their fare system in the v1 model expressed it by enumeration instead: one rule per origin–destination pair. A regional rail feed with 400 stations across 60 zones produces up to 3,600 zone pairs, and multiplied across route restrictions the rule count runs into six figures. Filtering a DataFrame per journey is then the slowest operation in the pipeline, and it is called once per itinerary leg.

Both problems have the same fix: decide the semantics once, at load, and bake them into an index.

Production-Ready Python Implementation

python
"""Load GTFS Fares v1 and resolve journeys to prices."""
from __future__ import annotations

import logging
from collections import defaultdict
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from zipfile import ZipFile

import pandas as pd

log = logging.getLogger("gtfs.fares.v1")

ATTR_DTYPES = {
    "fare_id": "string", "price": "string", "currency_type": "string",
    "payment_method": "int8", "transfers": "string", "transfer_duration": "float64",
}
RULE_DTYPES = {
    "fare_id": "string", "route_id": "string",
    "origin_id": "string", "destination_id": "string", "contains_id": "string",
}
QUALIFIERS = ("route_id", "origin_id", "destination_id")

# Currencies whose minor unit is not two decimal places.
MINOR_UNITS = defaultdict(lambda: 2, {"JPY": 0, "KRW": 0, "CLP": 0,
                                      "ISK": 0, "VND": 0, "BHD": 3, "KWD": 3})


@dataclass(frozen=True)
class Fare:
    fare_id: str
    minor_units: int          # e.g. 210 for 2.10 EUR
    currency: str
    transfers: int | None     # None means unlimited
    transfer_duration: float | None

    def format(self) -> str:
        places = MINOR_UNITS[self.currency]
        return f"{Decimal(self.minor_units) / (10 ** places):.{places}f} {self.currency}"


class FareIndex:
    """Fares v1 rules, indexed by which qualifiers each rule constrains."""

    def __init__(self, fares: dict[str, Fare],
                 buckets: dict[tuple[str, ...], dict[tuple[str, ...], str]]):
        self._fares = fares
        self._buckets = buckets
        # Most specific first: three qualifiers, then two, then one, then none.
        self._order = sorted(buckets, key=len, reverse=True)

    def match(self, route_id: str, origin_zone: str | None,
              destination_zone: str | None) -> Fare | None:
        journey = {"route_id": route_id, "origin_id": origin_zone,
                   "destination_id": destination_zone}
        for combination in self._order:
            key = tuple(journey[q] for q in combination)
            if any(v is None for v in key):
                continue          # the journey cannot satisfy this constraint
            fare_id = self._buckets[combination].get(key)
            if fare_id is not None:
                return self._fares[fare_id]
        return None

    def __len__(self) -> int:
        return sum(len(b) for b in self._buckets.values())


def _to_minor_units(price: str, currency: str) -> int:
    places = MINOR_UNITS[currency]
    return int((Decimal(price) * (10 ** places)).to_integral_value())


def load_fares_v1(feed_path: Path) -> FareIndex | None:
    with ZipFile(feed_path) as archive:
        members = set(archive.namelist())
        if not {"fare_attributes.txt", "fare_rules.txt"} <= members:
            log.info("%s carries no Fares v1 tables — journeys stay unpriced",
                     feed_path.name)
            return None
        with archive.open("fare_attributes.txt") as fh:
            attrs = pd.read_csv(fh, dtype=ATTR_DTYPES,
                                keep_default_na=False, na_values=[""])
        with archive.open("fare_rules.txt") as fh:
            rules = pd.read_csv(fh, dtype=RULE_DTYPES,
                                keep_default_na=False, na_values=[""])

    fares: dict[str, Fare] = {}
    for row in attrs.itertuples(index=False):
        currency = str(row.currency_type)
        fares[str(row.fare_id)] = Fare(
            fare_id=str(row.fare_id),
            minor_units=_to_minor_units(str(row.price), currency),
            currency=currency,
            # An EMPTY transfers value means unlimited, which is the opposite of 0.
            transfers=None if pd.isna(row.transfers) else int(row.transfers),
            transfer_duration=None if pd.isna(row.transfer_duration)
            else float(row.transfer_duration),
        )

    buckets: dict[tuple[str, ...], dict[tuple[str, ...], str]] = defaultdict(dict)
    dropped = 0
    for row in rules.itertuples(index=False):
        values = {q: getattr(row, q) for q in QUALIFIERS}
        combination = tuple(q for q in QUALIFIERS if not pd.isna(values[q]))
        key = tuple(str(values[q]) for q in combination)
        fare_id = str(row.fare_id)
        if fare_id not in fares:
            log.error("fare_rules row references unknown fare_id %s — skipped", fare_id)
            dropped += 1
            continue
        existing = buckets[combination].get(key)
        if existing is not None and existing != fare_id:
            log.warning("two fares (%s, %s) match the same journey %s — keeping the first",
                        existing, fare_id, dict(zip(combination, key)))
            continue
        buckets[combination][key] = fare_id

    index = FareIndex(fares, dict(buckets))
    log.info("%s: %d fare product(s), %d rule(s) in %d qualifier bucket(s), %d dropped",
             feed_path.name, len(fares), len(index), len(buckets), dropped)
    return index
How a rule is filed and found Rules are bucketed by which qualifiers they populate, so matching probes from the most specific bucket down to the flat fallback. Which qualifiers does this rule populate? route and both zones Three-key bucket probed first zones only Two-key bucket probed next none at all Fallback bucket the flat fare, probed last

Step-by-Step Walkthrough

price is read as a string and converted through Decimal. Reading it as a float loses the exact value before it can be converted; going through Decimal and multiplying by the currency’s minor-unit factor gives an integer that adds up correctly. MINOR_UNITS covers the currencies that do not use two decimal places — yen and won have none, dinars have three — because a feed priced in JPY multiplied by 100 is off by two orders of magnitude.

transfers keeps its None. An empty value means unlimited transfers, and the dataclass stores None to say so. Any consumer that treats None as zero will charge riders for transfers the agency includes in the fare, which is the kind of bug that reaches a complaints inbox rather than a test suite.

Each rule is filed under the combination of qualifiers it populates. A rule constraining only route_id goes in the ("route_id",) bucket; one constraining route and both zones goes in the three-element bucket. The bucket key is the tuple of values. This is what turns matching from a scan into a dictionary probe.

_order sorts buckets by length, descending. Probing the three-qualifier bucket first and the empty bucket last implements most-specific-wins without any comparison logic at match time. The specification does not define a precedence order, but every feed is built assuming specific beats general, and pricing the other way round produces fares that are obviously wrong to anyone who knows the network.

A journey with an unknown zone skips the buckets that need it. if any(v is None for v in key): continue handles the common case where a stop has no zone_id — the journey simply cannot satisfy a zone-constrained rule, so those buckets are passed over rather than matched against a None.

Duplicate keys are reported, not merged. Two different fare_id values matching the identical journey is a contradiction in the feed. Keeping the first is arbitrary but deterministic; the warning is the part that matters.

contains_id is deliberately absent from QUALIFIERS. It is not a property of the journey’s endpoints but of its path, so it cannot be resolved without the full itinerary. Rules that use it need the whole-journey pricing path instead, and a loader that silently ignored the column would price those journeys wrongly rather than declining to.

Verification and Output

python
def verify(index: FareIndex, feed_path: Path) -> None:
    with ZipFile(feed_path) as archive:
        with archive.open("stops.txt") as fh:
            stops = pd.read_csv(fh, dtype="string")
        with archive.open("routes.txt") as fh:
            routes = pd.read_csv(fh, dtype="string")

    zones = set(stops.get("zone_id", pd.Series(dtype="string")).dropna())
    known_routes = set(routes["route_id"])

    for combination, bucket in index._buckets.items():
        for key in bucket:
            for qualifier, value in zip(combination, key):
                if qualifier == "route_id":
                    assert value in known_routes, f"unknown route_id in a fare rule: {value}"
                else:
                    assert value in zones, f"unknown zone in a fare rule: {value}"

    for fare in index._fares.values():
        assert fare.minor_units >= 0, f"{fare.fare_id} has a negative price"
        assert len(fare.currency) == 3, f"{fare.fare_id} has a malformed currency"

Output on a zonal feed:

text
INFO gtfs.fares.v1: regional_20261102.zip: 14 fare product(s), 3612 rule(s) in 3 qualifier bucket(s), 0 dropped

Three buckets is the shape to expect: a flat fallback in the empty bucket, a set of zone-pair rules, and a handful of route-specific overrides. A feed reporting one bucket with thousands of rules in it is enumerating a single dimension, which usually means the fare system does not fit v1 at all — the case Fares v2 exists for.

Matching a journey:

python
fare = index.match(route_id="RE7", origin_zone="A", destination_zone="C")
print(fare.format() if fare else "unpriced")
# 6.80 EUR
Fare rule counts across four kinds of operator How many fare_rules.txt rows each kind of agency publishes: enumeration is how a zonal operator works around what the v1 model cannot express. Regional rail, zonal 203400 every origin-destination pair Metro with zones 3612 zone pairs plus route overrides Urban bus, distance bands 148 a handful of bands Flat-fare shuttle 1 one rule, no qualifiers index by qualifier combination, or the first bar makes matching the slowest step

Gotchas and Edge Cases

  • Stops with no zone_id. Extremely common outside rail. Those journeys can only match unqualified or route-only rules, which the None skip handles — but if the feed’s whole fare model is zonal, the result is that nothing prices, and that deserves an explicit report rather than a stream of unpriced journeys.
  • A rule whose origin_id equals its destination_id. Valid, and it prices travel within a single zone. It is easy to mistake for a data error and filter out.
  • payment_method = 1 with no transfer_duration. Pay-before-boarding with an unlimited transfer window. Legal, and it means the fare entitlement has no expiry, which downstream code should represent as infinity rather than as zero.
  • Prices in a currency the feed never declares consistently. Some multi-agency feeds mix currencies across fare_attributes.txt rows. Summing them is meaningless; the loader keeps the currency on every Fare so a mixed-currency journey can be refused rather than silently added up.
  • Feeds where fare_rules.txt exists but is empty. The fare products are defined and nothing is attached to any journey. Every match returns None, correctly, but the log line showing zero rules is what tells you why.

Frequently Asked Questions

What does an empty route_id in fare_rules.txt mean?

It means the rule is unconstrained on that axis — it applies to every route, not to no route. Treating an empty qualifier as a value to match against is the single most common fare-matching bug, and it silently prices every journey at the fallback fare.

Which rule wins when several match?

The most specific one: the rule with the largest number of populated qualifiers. The specification does not state a precedence order, but every real feed is built on the assumption that a route-and-zone rule overrides a flat fallback, and pricing any other way produces obviously wrong fares.

Why convert price to integer minor units?

Because floating-point currency does not add up. Two legs at 2.10 summed as floats give 4.199999999999999, which rounds and displays inconsistently across languages. Store 210 as an integer and divide only at the point of display.

How do I price a journey the feed has no rule for?

Return an explicit unpriced result rather than zero or the fallback fare. Zero tells the rider the journey is free; the fallback tells them a price the agency never published. Both are worse than saying the fare is unknown.