Read route_type from routes.txt, map integers 0–7 with a direct dictionary lookup, and handle extended codes 100–1799 with floor division (route_type // 100) to extract a category prefix. Apply pd.to_numeric(errors="coerce") before any mapping to surface NaN values from malformed CSV exports, and write unrecognised integers to a structured log rather than silently dropping them.

Mapping GTFS Route Types to Standard Categories

Why Route Type Normalization Breaks in Production

The route_type field is mandatory in routes.txt per the GTFS specification, but real agency feeds treat it inconsistently. Three failure patterns appear repeatedly across production ingestion pipelines:

Spec ambiguity between base and extended codes. The base range (0–7) and the extended range (100–1799) overlap in meaning — both 3 (Bus) and 400 (Urban Rail Bus) describe a bus, but some aggregators reject routes that use extended codes where a base code was expected. Without explicit normalization you end up with routing engines that silently miscategorize rail-bus and commuter-rail routes.

Agency-specific custom integers. Some operators export values outside either range — for example 99 for airport shuttles or 50 for on-demand microtransit — that are not defined anywhere in the GTFS specification. A normalization function that relies on exhaustive elif chains will produce silent KeyError misses or assign every unknown value the same bucket.

String encoding of numeric fields. Several agencies export route_type as a quoted string ("3" instead of 3) or include a byte-order mark at the start of the CSV that causes the column header to parse as route_type. Both issues cause pandas to infer object dtype for what should be a numeric column, making numeric comparisons fail without raising an exception.

Understanding how route_type propagates through the GTFS static feed structure — from routes.txt into trips.txt via route_id, and from there into the stops.txt and stop_times.txt relationship — is essential context: a wrong mode label on a route silently affects every trip and stop-time event associated with it.

Route Type Code Reference

The diagram below shows the two-tier code structure: base codes covering the main transport modes, and extended hundreds-ranges providing sub-modal detail.

GTFS route_type code structure Two-tier diagram showing GTFS base route type codes 0 through 7 in the top row and selected extended code ranges 100 through 1799 in the bottom two rows, illustrating which extended ranges map to which base category. Base codes (0–7) Extended codes (100–1799) — selected ranges 0 Tram / LRT 1 Subway 2 Heavy Rail 3 Bus 4 Ferry 5 Cable Tram 6 Aerial Lift 7 Funicular 100–199 Rail 200–299 Coach / Intercity 300–399 Suburban Bus 400–499 Urban Rail Bus 700–799 Bus 900–999 Tram / Streetcar 1000–1099 Water Transport 1200–1299 Ferry 1300–1399 Aerial Lift 1400–1499 Funicular 1500–1599 Taxi / Shared 1700–1799 On-Demand

The table below maps every base code to its canonical label and the extended hundreds-prefix ranges that share the same parent category.

route_type Extended prefix (// 100) Standard label Typical modes
0 9 (900–999) Tram / Light Rail Streetcar, light rail, tram
1 Subway / Metro Underground metro
2 1 (100–199) Rail Intercity, regional rail, commuter
3 2, 3, 4, 5, 7 Bus Coach, suburban, urban, express
4 10, 12 Ferry Water bus, passenger ferry
5 Cable Tram Street-running cable car
6 13 (1300–1399) Aerial Lift Gondola, cable car, chairlift
7 14 (1400–1499) Funicular Inclined railway
15 (1500–1599) Taxi / Shared On-demand taxi, rideshare
17 (1700–1799) Demand-Responsive Microtransit, dial-a-ride

Production-Ready Python Implementation

The function below handles all three mapping paths — base codes, extended ranges, and invalid values — in a single vectorized pass. It is safe to call on any routes.txt DataFrame, including feeds that mix base and extended codes in the same file.

python
import logging
import pandas as pd

logger = logging.getLogger(__name__)

# GTFS base route type codes (0–7)
BASE_TYPE_MAP: dict[int, str] = {
    0: "Tram/Light Rail",
    1: "Subway/Metro",
    2: "Rail",
    3: "Bus",
    4: "Ferry",
    5: "Cable Tram",
    6: "Aerial Lift",
    7: "Funicular",
}

# Extended codes: floor-divide by 100, then look up the prefix
EXTENDED_PREFIX_MAP: dict[int, str] = {
    1:  "Rail",
    2:  "Coach/Intercity Bus",
    3:  "Suburban Bus",
    4:  "Urban Rail Bus",
    5:  "Bus",
    7:  "Bus",
    8:  "Trolleybus",
    9:  "Tram/Light Rail",
    10: "Water Transport",
    11: "Air Transport",
    12: "Ferry",
    13: "Aerial Lift",
    14: "Funicular",
    15: "Taxi/Shared",
    17: "Demand-Responsive",
}

FALLBACK_LABEL = "Other"
INVALID_LABEL  = "Unknown/Invalid"


def normalize_route_types(routes: pd.DataFrame) -> pd.DataFrame:
    """
    Add a 'standard_category' column to a routes.txt DataFrame by normalising
    the 'route_type' integer field.

    Args:
        routes: DataFrame loaded from routes.txt; must contain 'route_type'.

    Returns:
        A copy of the input DataFrame with 'raw_route_type' (original value)
        and 'standard_category' (normalised label) columns appended.

    Raises:
        KeyError: if 'route_type' column is absent.
    """
    if "route_type" not in routes.columns:
        raise KeyError(
            "'route_type' column not found. "
            "Verify that routes.txt was loaded without header mangling "
            "(check for a BOM: open with encoding='utf-8-sig')."
        )

    df = routes.copy()

    # Preserve raw value before coercion for audit trail
    df["raw_route_type"] = df["route_type"]

    # Coerce to numeric — catches quoted strings and BOM-damaged values
    df["route_type"] = pd.to_numeric(df["route_type"], errors="coerce").astype("Int64")

    # Initialise output column
    df["standard_category"] = FALLBACK_LABEL

    # --- Path 1: base codes 0–7 ---
    base_mask = df["route_type"].isin(BASE_TYPE_MAP)
    df.loc[base_mask, "standard_category"] = (
        df.loc[base_mask, "route_type"].map(BASE_TYPE_MAP)
    )

    # --- Path 2: extended codes ≥ 100 ---
    ext_mask = df["route_type"].notna() & (df["route_type"] >= 100)
    if ext_mask.any():
        prefixes = (df.loc[ext_mask, "route_type"] // 100).astype("Int64")
        mapped = prefixes.map(EXTENDED_PREFIX_MAP)
        df.loc[ext_mask, "standard_category"] = mapped.fillna(FALLBACK_LABEL)

    # --- Path 3: NaN / invalid ---
    invalid_mask = df["route_type"].isna()
    df.loc[invalid_mask, "standard_category"] = INVALID_LABEL

    # Downcast to categorical to save memory on large feeds
    df["standard_category"] = df["standard_category"].astype("category")

    # Structured log for anomalies
    other_mask = df["standard_category"].isin([FALLBACK_LABEL, INVALID_LABEL])
    if other_mask.any():
        anomaly_counts = (
            df.loc[other_mask, ["route_id", "raw_route_type", "standard_category"]]
            .groupby(["raw_route_type", "standard_category"])
            .size()
            .reset_index(name="count")
        )
        for _, row in anomaly_counts.iterrows():
            logger.warning(
                "Unmapped route_type=%r → %s (%d route(s))",
                row["raw_route_type"],
                row["standard_category"],
                row["count"],
            )

    return df

Step-by-Step Walkthrough

Preserve the raw value first. The raw_route_type column is written before any coercion. This matters for GTFS validation workflows where you need to report the original integer alongside the normalised label in audit exports.

Coerce with errors="coerce", not errors="ignore". The "ignore" mode leaves the dtype as object, which silently breaks all numeric comparisons downstream. "coerce" converts non-numeric strings to pd.NA (with Int64 dtype), which the invalid-mask step then catches explicitly.

Use Int64 (nullable integer), not int64. Standard int64 cannot represent pd.NA; it would silently coerce NaN to 0, misclassifying invalid values as Tram routes. Int64 preserves the null without requiring a sentinel value.

Base-code lookup with .isin() before .map(). Calling df["route_type"].map(BASE_TYPE_MAP) on rows that already matched the base_mask prevents any extended-code integer from accidentally falling through to the base lookup on a second pass.

Integer division for extended codes. df.loc[ext_mask, "route_type"] // 100 extracts the hundreds prefix (301 → 3, 1402 → 14) without needing a lookup for every possible integer in the extended range. The .fillna(FALLBACK_LABEL) after the prefix map catches prefixes not yet defined in EXTENDED_PREFIX_MAP — for example 6xx (Telecabin) or 16xx, which exist in some feeds but have no agreed English label.

Categorical dtype conversion. A standard_category column that holds ten or fewer string values across tens of thousands of routes wastes significant memory as object dtype. Converting to "category" typically reduces the column’s memory footprint by 60–80% — relevant when processing large feeds as described in memory-efficient processing for large feeds.

Verification and Output

After calling normalize_route_types, confirm that the mapping is working correctly and that anomaly rates are acceptable:

python
import pandas as pd

# Load routes.txt, normalize, verify
routes_raw = pd.read_csv(
    "routes.txt",
    dtype={"route_id": str, "agency_id": str, "route_type": str},
    encoding="utf-8-sig",   # strips BOM if present
)

routes = normalize_route_types(routes_raw)

# Distribution check
dist = routes["standard_category"].value_counts(dropna=False)
print(dist.to_string())

# Assert anomaly rate is below 5 %
total = len(routes)
anomalous = routes["standard_category"].isin(["Other", "Unknown/Invalid"]).sum()
anomaly_rate = anomalous / total
assert anomaly_rate < 0.05, (
    f"Anomaly rate {anomaly_rate:.1%} exceeds threshold. "
    f"Audit raw_route_type values: {routes.loc[routes['standard_category'].isin(['Other','Unknown/Invalid']), 'raw_route_type'].unique()}"
)

# Spot-check a known rail route
sample_rail = routes.loc[
    routes["standard_category"] == "Rail",
    ["route_id", "route_short_name", "raw_route_type", "standard_category"]
].head(3)
print(sample_rail.to_string(index=False))

Expected output for a clean feed:

text
Bus                  4821
Rail                  312
Subway/Metro           88
Tram/Light Rail        41
Ferry                  19
Aerial Lift             2
Other                   0
Unknown/Invalid         0

If Other or Unknown/Invalid appears with a non-zero count, inspect raw_route_type for the affected rows and decide whether to add an agency-specific override or to file a feed quality report with the operator.

Gotchas and Edge Cases

  • BOM-prefixed route_type header. Some agencies produce CSV files with a UTF-8 byte-order mark, which causes pandas to read the first column header as route_id or route_type. Opening the file with encoding="utf-8-sig" strips the BOM automatically. If you are reading from a ZIP archive, pass the open file object explicitly: pd.read_csv(zip_file.open("routes.txt"), encoding="utf-8-sig").

  • Agency-specific non-spec integers. A handful of operators use values like 99 (shuttle), 1000 (water taxi outside the 10xx range), or 800 (trolleybus where spec says 800–899 maps to Trolleybus). Before calling normalize_route_types, inject an agency-specific override dictionary if agency_id matches a known non-conforming operator.

  • Mixed base and extended codes in a single feed. Operators that run both urban buses (3) and suburban coaches (300) may use both in the same routes.txt. The three-path logic above handles this correctly, but verify that the two groups are not merged into the same standard_category bucket when you join against trips.txt for mode-specific schedule filtering.

  • route_type as the join pivot into trips.txt and stop_times.txt. Because route_id links routes.txt to trips.txt, and trip_id links trips.txt to stop_times.txt, a wrong mode label cascades to every stop event on every trip under that route. Run the distribution check before any downstream join, not after.


Frequently Asked Questions

What is the GTFS route_type field?

route_type is a required integer column in routes.txt that encodes the transport mode of a route. The GTFS specification defines base codes 0–7 for common modes and extended codes 100–1799 for sub-modal distinctions such as suburban rail (100–199) or urban bus (400–499).

How do I handle extended GTFS route type codes above 100?

Divide the integer by 100 using floor division (route_type // 100) to extract a prefix, then map the prefix to a parent category. This scales to future spec additions without requiring exhaustive conditional chains.

What should I do when route_type is missing or non-numeric?

Coerce the column with pd.to_numeric(errors='coerce') to surface NaN values, assign them the label Unknown/Invalid, and log the affected route_ids. If the share exceeds 5%, audit the source feed — this usually indicates a CSV encoding issue or an agency-specific custom value.