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; networkx if 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.txt and stops.txt, and with the parent-station hierarchy, which pathways depend on entirely
bash
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
How Fares v1 attaches a price to a journey fare_rules.txt joins a fare product to a journey through up to four optional qualifiers, each of which resolves against a table elsewhere in the feed. fare_attributes.txt fare_id PK price / currency transfers transfer_duration fare_rules.txt fare_id FK route_id FK origin_id destination_id stops.txt zones zone_id matched to origin and destination 1 : N resolves

Step-by-Step Implementation

Step 1 — Detect which model the feed uses

python
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

python
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

python
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

python
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

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

A station read as a graph rather than a point Four layers of the pathways model, from the containing station down to the timed edges a router traverses. Station location_type 1 — a container, never a call Platforms and entrances the places a rider actually stands Pathways directed edges with a mode and a traversal time Levels the vertical dimension a lift or stair crosses

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_id missing from stops.txt while fare_rules.txt references 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_fare becomes the slowest thing in the pipeline.
  • transfers empty 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_station and 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.
What each fare model can express A grid over four ordinary fare policies and whether each generation of the GTFS fare model can represent it at all. Fares v1 Fares v2 Flat fare yes yes Zone matrix yes, by enumeration yes, by area Free transfer window per product only per leg pair Daily capping no yes Price by fare media no yes

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.

Up: GTFS Feed Architecture & Fundamentals | Home