How to Validate a GTFS Feed with Python
The most reliable production approach is to orchestrate the MobilityData GTFS Validator via subprocess, parse its structured JSON report, and gracefully degrade to a pandas-based schema checker when a Java runtime is unavailable. GTFS validation extends far beyond CSV parsing — it requires cross-file referential integrity checks, business-rule enforcement, and strict type validation that no pure-Python CSV reader handles completely out of the box.
Why GTFS Validation Requires Cross-File Integrity Checks
A GTFS feed is a ZIP archive containing interrelated CSV files that map to transit domains: agencies, stops, routes, trips, stop times, and service calendars. The foundational relationships are described in the GTFS Feed Architecture & Fundamentals guide. Validation failures rarely stem from malformed CSV syntax alone. They typically arise from broken foreign keys — a trip_id in stop_times.txt that has no matching record in trips.txt, invalid WGS84 coordinates, or overlapping service calendars. Catching these violations at the ingestion boundary, before the feed reaches routing engines or passenger-facing applications, is the core value proposition of any validation layer.
The diagram below shows the two-path validation architecture this guide implements: the preferred Java-backed path on the left and the Python-native fallback on the right.
Root Cause: Why Pure-Python Checks Are Insufficient
Most transit data teams initially reach for a CSV reader or pandas.read_csv to check a feed. This catches encoding errors and missing columns but misses the violations that actually break downstream systems:
Referential integrity gaps. The GTFS relational model requires stop_id values in stop_times.txt to resolve to records in stops.txt, route_id in trips.txt to resolve to routes.txt, and service_id in trips.txt to resolve to either calendar.txt or calendar_dates.txt. A pure CSV reader loads each file in isolation and cannot detect cross-file orphans. The complete FK graph is documented in GTFS Validation Rules and Common Schema Errors.
Enumeration and range constraints. Fields like route_type accept only integer values from a closed set (0–7 per GTFS Schedule Reference). location_type must be 0–4. Coordinates must satisfy WGS84 bounds — agencies that mis-order latitude and longitude produce coordinates that pass CSV parsing but geocode to the ocean. For coordinate-specific issues, see Coordinate Reference Systems for Transit Data.
Extended time format. The GTFS spec intentionally allows arrival_time and departure_time in stop_times.txt to exceed 23:59:59. A value of 25:30:00 means 1:30 AM of the next calendar day. Python’s datetime.strptime raises a ValueError on these inputs; the spec-compliant validator handles them correctly.
Business rule complexity. The MobilityData validator encodes over 120 rules derived from the specification and real-world agency behavior. Replicating all of them in pure Python is impractical. The subprocess orchestration approach in this guide delegates that logic to the authoritative Java implementation while keeping Python as the control plane.
Production-Ready Python Implementation
The script below downloads a GTFS ZIP, invokes the MobilityData validator, parses the output, and falls back to a Python-native schema check if no Java runtime is found. It uses tempfile to avoid leaving extraction artifacts, structured logging for pipeline observability, and explicit type hints throughout.
import os
import subprocess
import json
import zipfile
import logging
import tempfile
import urllib.request
import pandas as pd
from pathlib import Path
from typing import Dict, List, Any, Optional
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("gtfs_validator")
# ── Configuration ─────────────────────────────────────────────────────────────
GTFS_URL = "https://transit.agency/gtfs-static.zip"
VALIDATOR_JAR = Path(os.getenv("GTFS_VALIDATOR_JAR", "gtfs-validator.jar"))
REQUIRED_FILES: set[str] = {
"agency.txt", "stops.txt", "routes.txt",
"trips.txt", "stop_times.txt",
}
CALENDAR_FILES: set[str] = {"calendar.txt", "calendar_dates.txt"}
REQUIRED_HEADERS: dict[str, list[str]] = {
"agency.txt": ["agency_id", "agency_name", "agency_url", "agency_timezone"],
"stops.txt": ["stop_id", "stop_lat", "stop_lon"],
"routes.txt": ["route_id", "agency_id", "route_short_name", "route_type"],
"trips.txt": ["route_id", "service_id", "trip_id"],
"stop_times.txt": ["trip_id", "arrival_time", "departure_time", "stop_id", "stop_sequence"],
}
# ── Step 1: Download ───────────────────────────────────────────────────────────
def download_gtfs(url: str, dest: Path) -> Path:
"""Download a GTFS ZIP from url to dest. Returns dest on success."""
dest.parent.mkdir(parents=True, exist_ok=True)
logger.info("Downloading feed from %s", url)
urllib.request.urlretrieve(url, dest)
logger.info("Saved feed to %s (%.1f MB)", dest, dest.stat().st_size / 1_048_576)
return dest
# ── Step 2: Java validator ─────────────────────────────────────────────────────
def run_mobility_validator(
zip_path: Path,
output_dir: Path,
heap: str = "2g",
timeout_sec: int = 300,
) -> Dict[str, Any]:
"""
Invoke the official MobilityData GTFS Validator JAR and return the JSON report.
The validator exits with a non-zero code when it finds errors — that is
expected behaviour, not a script failure. FileNotFoundError signals a missing
Java runtime; we return a sentinel dict the caller uses to trigger the fallback.
"""
output_dir.mkdir(parents=True, exist_ok=True)
cmd: list[str] = [
"java", f"-Xmx{heap}", "-jar", str(VALIDATOR_JAR),
"-i", str(zip_path),
"-o", str(output_dir),
]
logger.info("Running validator: %s", " ".join(cmd))
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout_sec,
)
if result.returncode not in (0, 1):
# Exit code 1 = validation notices found (normal)
# Any other code = unexpected failure
logger.warning(
"Validator exited with code %d. stderr: %s",
result.returncode,
result.stderr[:400],
)
except subprocess.TimeoutExpired:
logger.error("Validator timed out after %d seconds.", timeout_sec)
return {"status": "timeout", "notices": []}
except FileNotFoundError:
logger.warning("Java runtime not found — switching to pandas fallback.")
return {"status": "missing_runtime", "notices": []}
report_path = output_dir / "report.json"
if not report_path.exists():
logger.error("No report.json produced. Check heap size or JAR path.")
return {"status": "no_report", "notices": []}
with report_path.open("r", encoding="utf-8") as fh:
report = json.load(fh)
report["status"] = "complete"
return report
# ── Step 3: pandas fallback ────────────────────────────────────────────────────
def fallback_schema_check(zip_path: Path) -> Dict[str, Any]:
"""
Python-native fallback: verifies required files, column headers, and the
most critical foreign key (stop_times.trip_id → trips.trip_id).
Sufficient for CI pre-checks but does not replace full spec validation.
"""
findings: List[Dict[str, Any]] = []
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(tmp)
present_files = {p.name for p in tmp.iterdir() if p.suffix == ".txt"}
# ── Required file presence ─────────────────────────────────────────────
missing = REQUIRED_FILES - present_files
if missing:
findings.append({
"severity": "ERROR",
"rule": "missing_required_files",
"detail": sorted(missing),
})
return {"status": "fallback_complete", "findings": findings}
if not (CALENDAR_FILES & present_files):
findings.append({
"severity": "ERROR",
"rule": "missing_calendar",
"detail": "Neither calendar.txt nor calendar_dates.txt present",
})
# ── Required column headers ────────────────────────────────────────────
for fname, req_cols in REQUIRED_HEADERS.items():
fpath = tmp / fname
if not fpath.exists():
continue
try:
df = pd.read_csv(
fpath,
dtype=str,
nrows=0, # header only — fast
encoding="utf-8-sig",
keep_default_na=False,
)
missing_cols = set(req_cols) - set(df.columns)
if missing_cols:
findings.append({
"severity": "ERROR",
"rule": "missing_columns",
"file": fname,
"detail": sorted(missing_cols),
})
except pd.errors.ParserError as exc:
findings.append({
"severity": "ERROR",
"rule": "parse_failure",
"file": fname,
"detail": str(exc),
})
# ── Foreign key check: stop_times.trip_id → trips.trip_id ─────────────
try:
trips_ids = pd.read_csv(
tmp / "trips.txt",
dtype=str,
usecols=["trip_id"],
encoding="utf-8-sig",
keep_default_na=False,
)["trip_id"].unique()
st_ids = pd.read_csv(
tmp / "stop_times.txt",
dtype=str,
usecols=["trip_id"],
encoding="utf-8-sig",
keep_default_na=False,
)["trip_id"].unique()
orphaned = set(st_ids) - set(trips_ids)
if orphaned:
findings.append({
"severity": "WARNING",
"rule": "orphaned_trip_ids",
"file": "stop_times.txt",
"orphan_count": len(orphaned),
"sample": sorted(orphaned)[:5],
})
except Exception as exc:
findings.append({
"severity": "WARNING",
"rule": "fk_check_skipped",
"detail": str(exc),
})
return {"status": "fallback_complete", "findings": findings}
# ── Step 4: Summarise report ───────────────────────────────────────────────────
def summarise_report(report: Dict[str, Any]) -> Dict[str, int]:
"""
Count notices by severity from a MobilityData validator JSON report.
Returns a dict with keys ERROR, WARNING, INFO, NOTE.
"""
counts: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0, "NOTE": 0}
for notice in report.get("notices", []):
severity = notice.get("severity", "INFO").upper()
if severity in counts:
counts[severity] += 1
return counts
# ── Entrypoint ─────────────────────────────────────────────────────────────────
def validate_feed(
url: str,
jar_path: Path = VALIDATOR_JAR,
heap: str = "2g",
) -> Dict[str, Any]:
zip_dest = Path("gtfs_feed.zip")
output_dir = Path("validation_output")
download_gtfs(url, zip_dest)
report = run_mobility_validator(zip_dest, output_dir, heap=heap)
if report.get("status") in ("missing_runtime", "timeout", "no_report"):
logger.info("Switching to pandas fallback validation.")
report = fallback_schema_check(zip_dest)
zip_dest.unlink(missing_ok=True)
return report
if __name__ == "__main__":
result = validate_feed(GTFS_URL)
print(json.dumps(result, indent=2, default=str))
Step-by-Step Walkthrough
Download and file management
download_gtfs writes the ZIP to a local path and logs the file size before returning. Logging the size immediately surfaces feeds that are suspiciously small (an agency CDN returning an HTML error page, for instance) before the validator wastes 300 seconds on a non-archive.
Subprocess invocation and exit-code semantics
run_mobility_validator wraps three distinct failure modes:
FileNotFoundError— thejavabinary is not onPATH. Return themissing_runtimesentinel; the caller routes to the fallback.subprocess.TimeoutExpired— the feed is unusually large or the JVM is thrashing swap. Returntimeout; do not block the pipeline indefinitely.- Non-zero exit with code ≠ 1 — the validator itself failed (corrupt JAR, out-of-memory, missing output directory). Log stderr and return
no_report.
Exit code 1 is deliberately not treated as an error. The MobilityData validator exits 1 whenever it finds validation notices, which is the normal case for any real agency feed. Treating it as a subprocess failure would mask every result.
Reading report.json
The validator writes its output to the directory you pass with -o. The primary artifact is report.json, a structured document containing a notices array. Each entry carries severity, noticeCode, and a sampleMessages list. The summarise_report function provides a quick severity histogram for pipeline gating.
The pandas fallback
When Java is absent, fallback_schema_check performs three checks in order:
- File presence — verifies all five mandatory files and at least one calendar file exist. Failing here short-circuits the rest: downstream column checks on absent files produce meaningless results.
- Column headers — reads only the first row (via
nrows=0) to check column names without loading data. This completes in under a second even for feeds wherestop_times.txthas ten million rows. stop_times.trip_id→trips.trip_idFK — the single most impactful referential violation. Loading only thetrip_idcolumn from each file keeps memory usage proportional to the number of unique trips, not the number of stop time records. For memory-efficient processing of large feeds,usecolsis non-negotiable.
The encoding="utf-8-sig" parameter throughout strips the UTF-8 BOM silently added by Windows-based publisher tools — a common source of phantom column-name mismatches.
Verification and Output
After running validate_feed, confirm the report is actionable before routing it downstream:
def assert_report_usable(report: Dict[str, Any]) -> None:
"""
Gate check: raise AssertionError if the report cannot be acted on.
Call this before writing the report to your alert/monitoring system.
"""
assert "status" in report, "Report missing 'status' field"
assert report["status"] not in ("timeout",), (
f"Validation did not complete: status={report['status']}"
)
if report["status"] == "complete":
counts = summarise_report(report)
logger.info(
"Validation complete — ERROR: %d WARNING: %d INFO: %d NOTE: %d",
counts["ERROR"], counts["WARNING"], counts["INFO"], counts["NOTE"],
)
# Gate: fail the pipeline if structural errors are present
assert counts["ERROR"] == 0, (
f"Feed failed validation with {counts['ERROR']} ERROR(s). "
"Inspect validation_output/report.json for details."
)
elif report["status"] == "fallback_complete":
error_findings = [
f for f in report.get("findings", [])
if f.get("severity") == "ERROR"
]
assert not error_findings, (
f"Fallback check found {len(error_findings)} ERROR(s): "
+ "; ".join(f.get("rule", "unknown") for f in error_findings)
)
Sample output from a clean MobilityData validator run:
{
"status": "complete",
"notices": [
{
"severity": "WARNING",
"noticeCode": "RouteShortNameTooLongNotice",
"totalNotices": 3,
"sampleMessages": [
{ "routeId": "B46", "routeShortName": "B46-SBS" }
]
}
]
}
Sample output from the fallback path with an orphaned FK:
{
"status": "fallback_complete",
"findings": [
{
"severity": "WARNING",
"rule": "orphaned_trip_ids",
"file": "stop_times.txt",
"orphan_count": 14,
"sample": ["BX12_LOCAL_0", "BX12_LOCAL_1", "M15_SBS_3"]
}
]
}
Gotchas and Edge Cases
-
Heap sizing and silent failures. If the JVM runs out of heap, the validator process terminates without writing
report.json. The script detects the missing file and returnsno_report, but the root cause is often a feed that is larger than anticipated. Scale-Xmxto at least 4 GB for metro-area feeds. Pair this with a check of the ZIP file size before invoking the JAR. -
calendar_dates.txt-only feeds. The GTFS spec permits feeds that omitcalendar.txtentirely and express all service dates viacalendar_dates.txt. The fallback’s file-presence check accounts for this by requiring at least one of the two calendar files. The Java validator handles this correctly by design. Do not hard-requirecalendar.txtin your own schema checks. Relevant context: Timezone Handling and Schedule Normalization covers how service-date semantics interact with DST boundaries. -
Nested ZIP layouts. Some agencies publish archives with a subdirectory inside the ZIP (e.g.,
gtfs/stops.txtinstead ofstops.txtat the root). The Java validator rejects these outright. The fallback’sz.extractall(tmpdir)recreates the subdirectory, so the subsequenttmp.iterdir()call finds no.txtfiles and flags every mandatory file as missing. Add a flattening step —(tmp / fname).rename(tmp / Path(fname).name)for each nested path — if you routinely receive these feeds from specific agencies. -
Mixed
stop_idtypes across files. An agency may publish numericstop_idvalues instops.txtand zero-padded string equivalents instop_times.txt(e.g.,1001versus01001). Thedtype=strdeclarations in the fallback prevent silent integer coercion, but the FK check will still report these as orphans even though a human reader would consider them matching. Log sample values and compare against known agency patterns before treating this as a hard error.
FAQ: Validating GTFS Feeds with Python
Do I need Java installed to validate a GTFS feed with Python?
The MobilityData GTFS Validator requires a Java 11+ runtime. When Java is unavailable, the pandas fallback in this guide verifies required files, column headers, and stop_times→trips referential integrity — sufficient for CI pre-checks but not for full spec compliance.
How do I parse the MobilityData validator JSON report in Python?
The validator writes report.json to the output directory you specify with the -o flag. Load it with json.load() and iterate over the notices list. Each notice has a severity field (ERROR, WARNING, INFO, NOTE) and a noticeCode that maps to a spec rule.
What heap size should I pass to the GTFS validator JVM?
Pass -Xmx2g for feeds under 500 MB. For large metropolitan feeds or multi-agency aggregates that exceed 1 GB uncompressed, use -Xmx4g. Insufficient heap causes the validator to silently exit without writing a report.
Related
- GTFS Validation Rules and Common Schema Errors — the full validation pipeline: schema checks, referential integrity, business rules, and Python patterns
- Mastering stops.txt and stop_times.txt Relationships — deep dive into the FK join logic and sequence integrity that validation must protect
- Memory-Efficient Processing for Large Feeds — chunked reading and
usecolsstrategies for feeds that would exhaust available RAM during validation - Coordinate Reference Systems for Transit Data — what to do after validation flags out-of-bounds or swapped coordinate values
Up: GTFS Validation Rules and Common Schema Errors | GTFS Feed Architecture & Fundamentals | Home