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.
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
"""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
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
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:
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:
fare = index.match(route_id="RE7", origin_zone="A", destination_zone="C")
print(fare.format() if fare else "unpriced")
# 6.80 EUR
Gotchas and Edge Cases
- Stops with no
zone_id. Extremely common outside rail. Those journeys can only match unqualified or route-only rules, which theNoneskip 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_idequals itsdestination_id. Valid, and it prices travel within a single zone. It is easy to mistake for a data error and filter out. payment_method = 1with notransfer_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.txtrows. Summing them is meaningless; the loader keeps the currency on everyFareso a mixed-currency journey can be refused rather than silently added up. - Feeds where
fare_rules.txtexists but is empty. The fare products are defined and nothing is attached to any journey. Every match returnsNone, 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.
Related
- Calculating Trip Fares from GTFS in Python — pricing a multi-leg journey, transfers and all
- Comparing GTFS Fares v1 and v2 — what to do when the rule count explodes
- Up: Fare Rules and Pathways Modeling — the fare tables and what they can express
- Section: GTFS Feed Architecture & Fundamentals · Home