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.
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.
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:
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:
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_typeheader. Some agencies produce CSV files with a UTF-8 byte-order mark, which causes pandas to read the first column header asroute_idorroute_type. Opening the file withencoding="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), or800(trolleybus where spec says800–899maps to Trolleybus). Before callingnormalize_route_types, inject an agency-specific override dictionary ifagency_idmatches 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 sameroutes.txt. The three-path logic above handles this correctly, but verify that the two groups are not merged into the samestandard_categorybucket when you join againsttrips.txtfor mode-specific schedule filtering. -
route_typeas the join pivot intotrips.txtandstop_times.txt. Becauseroute_idlinksroutes.txttotrips.txt, andtrip_idlinkstrips.txttostop_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.
Related
- Understanding GTFS Static Feed Structure — the full file hierarchy and relational model that routes.txt sits within
- GTFS Validation Rules and Common Schema Errors — how to catch invalid
route_typevalues with the MobilityData validator before normalization - Mastering stops.txt and stop_times.txt Relationships — how route mode propagates through trips into stop events
- Memory-Efficient Processing for Large Feeds — categorical dtype and chunked reading strategies when route counts exceed 50,000
- GTFS Feed Architecture & Fundamentals — parent guide to the full GTFS static data model
- transit-data.com home — all guides on Python for GTFS and public transit data automation