GTFS Validation in Python: An Article-Reported Workflow

Six instructional validation layers, with project results and implementation availability stated at their actual evidence level.

The article reports a transit-routing project spanning 7 California counties and 11 GTFS feeds, with hundreds of census tracts and thousands of grocery stores. It reports that four counties failed on the first attempt even though the feeds had downloaded and contained the required files. Those project-specific counts and outcomes are not publicly reproduced.

The article attributes the failures to an empty Caltrain frequencies table, two agency download URLs that stopped working, and OpenStreetMap memory problems in three counties. No public feed snapshot or routing log currently verifies that diagnosis, so the examples should be read as article-reported cases.

Unfortunately, a GTFS feed can pass every basic check and still be functionally useless for research. Fortunately, walking through a validation workflow can catch those problems before they reach the routing engine.[1] Let's walk through it together.

Before diving in, it helps to understand that GTFS validation operates on two levels. Structural validation checks whether files exist and fields conform to the spec: correct column names, valid data types, required files present. Content validation checks whether the data makes sense: reasonable coordinates, active service dates, non-empty route lists. A feed can be structurally perfect and still fail content validation in ways that break routing engines. The workflow below addresses both levels.

Several GTFS validation tools already exist. The MobilityData Canonical Validator[4] checks spec compliance. QGIS offers visual inspection of stop locations. Transitland provides feed discovery and archiving. The instructional Python approach here shows how content checks can be integrated into a pipeline alongside those tools.


Six Layers, Six Failure Modes

Why six layers? Each targets a different category of data quality issue. Download failures, missing files, bad coordinates, expired calendars, broken relational integrity, and multi-feed conflicts are all distinct failure modes. A feed can pass five layers and still break on the sixth. The layered approach means we catch problems at the earliest possible stage, before they cascade into harder-to-diagnose downstream failures.

Instructional validation tool stack
ComponentToolPurpose
Transit dataGTFS feedsBus/rail routes, stops, schedules
Feed registryCal-ITP / TransitlandFind and download feeds with fallbacks
Structural validationzipfile + pandasCheck file presence, parse tables
Geographic validationpandas + bounding boxCoordinate sanity checks
Calendar validationpandas datetimeExpired services, active date ranges
Downstream testingr5pyConfirm feeds actually produce routes

To make the output format concrete, consider this illustrative report: SFMTA -- 72 routes, 3,241 trips, date range 2024-01-15 to 2024-07-14, 0 coordinate outliers, 2 warnings (missing agency_url, empty frequencies.txt). These values are example values, not results reproduced from a public run. Each layer contributes one part of the report.


Step 1: Download with Fallbacks

Transit agencies publish their own GTFS feeds, but URLs can change. The article reports that two of 11 target agencies returned HTTP errors without a redirect or deprecation notice; the underlying request log is not public.

The fix: always have a fallback source. Transitland maintains a feed archive covering thousands of agencies worldwide.[2]

import requests
import zipfile
from io import BytesIO
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed

TRANSITLAND_FALLBACKS = {
    'sfmta': 'https://transit.land/api/v2/feeds/f-9q8y-sfmta/download_latest_feed_version',
    'bart': 'https://transit.land/api/v2/feeds/f-9q9-bart/download_latest_feed_version',
    'actransit': 'https://transit.land/api/v2/feeds/f-9q9-actransit/download_latest_feed_version',
}

def download_feed(feed_id, primary_url, output_dir):
    """Download GTFS feed with Transitland fallback."""
    zip_path = output_dir / f"{feed_id}_gtfs.zip"

    for url in [primary_url, TRANSITLAND_FALLBACKS.get(feed_id)]:
        if url is None:
            continue
        try:
            response = requests.get(url, timeout=60, allow_redirects=True)
            payload = BytesIO(response.content)
            if response.status_code == 200 and zipfile.is_zipfile(payload):
                zip_path.write_bytes(response.content)
                return zip_path
        except requests.RequestException:
            continue

    return None

For multiple agencies, parallel downloads save significant time:

# Illustrative batch: download 11 feeds in parallel
with ThreadPoolExecutor(max_workers=4) as executor:
    futures = {
        executor.submit(download_feed, fid, info['url'], GTFS_DIR): fid
        for fid, info in GTFS_FEEDS.items()
    }
    for future in as_completed(futures):
        result = future.result()
        if result is None:
            print(f"  Failed: {futures[future]}")

Step 2: Structural Validation

A valid GTFS feed is a zip file containing five core tables plus service-calendar information. A feed may provide calendar.txt, calendar_dates.txt, or both.[3]

One thing to watch for before even parsing tables: file sizes. A stops.txt under 1 KB probably means an empty or near-empty feed. A stop_times.txt over 2 GB suggests a statewide aggregation that may cause memory problems downstream. The structural check below captures these sizes alongside the file-presence validation.

def validate_structure(gtfs_path):
    """Check core GTFS files and the conditional calendar requirement."""
    required = ['agency.txt', 'routes.txt', 'trips.txt',
                'stops.txt', 'stop_times.txt']
    calendar_options = ['calendar.txt', 'calendar_dates.txt']
    optional = ['shapes.txt',
                'fare_attributes.txt', 'feed_info.txt']

    if not zipfile.is_zipfile(gtfs_path):
        return {'valid': False, 'error': 'Not a valid zip file'}

    with zipfile.ZipFile(gtfs_path) as zf:
        files = zf.namelist()
        missing = [f for f in required if f not in files]
        has_calendar = any(f in files for f in calendar_options)
        present_optional = [f for f in optional if f in files]

        # Check file sizes — empty required files are a red flag
        checked = required + calendar_options + optional
        sizes = {f: zf.getinfo(f).file_size for f in files if f in checked}

    return {
        'valid': len(missing) == 0 and has_calendar,
        'missing': missing,
        'calendar_present': [f for f in calendar_options if f in files],
        'calendar_error': None if has_calendar else 'calendar.txt or calendar_dates.txt required',
        'optional_present': present_optional,
        'file_sizes': sizes
    }

Step 3: Coordinate Validation

Stops coded at (0, 0), a common placeholder in GTFS feeds, will place transit service in the Gulf of Guinea off the coast of West Africa. The coordinate checks below seem obvious, but we include them because this pattern appears more often than it should. Even well-maintained feeds occasionally contain placeholder coordinates from data entry errors.

import pandas as pd

def validate_coordinates(gtfs_path, expected_bounds):
    """Check stop coordinates for common problems."""
    with zipfile.ZipFile(gtfs_path) as zf:
        stops = pd.read_csv(zf.open('stops.txt'))

    issues = {}

    # Missing coordinates
    missing = stops['stop_lat'].isna() | stops['stop_lon'].isna()
    if missing.sum() > 0:
        issues['missing_coords'] = int(missing.sum())

    # Both coordinates zero (common placeholder — maps to Gulf of Guinea)
    zeros = (stops['stop_lat'] == 0) & (stops['stop_lon'] == 0)
    if zeros.sum() > 0:
        issues['zero_coords'] = int(zeros.sum())

    # Out of expected bounds
    # lat_min/lat_max define the north-south range for the study area
    # lon_min/lon_max define the east-west range
    # Stops outside these bounds may indicate a wider service area or data errors
    out_of_bounds = (
        (stops['stop_lat'] < expected_bounds['lat_min']) |
        (stops['stop_lat'] > expected_bounds['lat_max']) |
        (stops['stop_lon'] < expected_bounds['lon_min']) |
        (stops['stop_lon'] > expected_bounds['lon_max'])
    )
    if out_of_bounds.sum() > 0:
        issues['out_of_bounds'] = int(out_of_bounds.sum())

    return {
        'total_stops': len(stops),
        'valid': len(issues) == 0,
        'issues': issues,
        'bounds': {
            'lat': [float(stops['stop_lat'].min()), float(stops['stop_lat'].max())],
            'lon': [float(stops['stop_lon'].min()), float(stops['stop_lon'].max())]
        }
    }

The bounding box needs to match the study area. The article reports using latitude 37.0 to 37.6 and longitude -122.2 to -121.5 for Santa Clara County, but it does not publish the corresponding run inputs. Treat those bounds as an article-reported example to check against the geography and feed vintage in use.

Geographic validity is necessary but not sufficient. A feed with perfect coordinates is useless if all its services expired last month.


Step 4: Calendar Validation

An expired service window can produce zero transit routes for the requested date. Depending on the engine and configuration, that may appear as an empty result rather than a clear validation error. The calendar check below is an instructional guard against that failure mode.

The article reports a California food-access case in which r5py failed to build San Francisco's transit network without identifying whether the feed or configuration was responsible. It describes removing feeds one at a time and noticing Caltrain alongside a functionally empty frequencies table. Because frequencies.txt is optional and scheduled service can be defined through stop_times.txt, that observation does not establish the cause of the failure. The feed snapshot, configuration, and run record are not public, so this is a diagnostic clue to investigate rather than a general routing rule. The methodological takeaway is still useful: calendar validation is necessary but not sufficient.

from datetime import datetime

def validate_calendar(gtfs_path):
    """Summarize a regular or exception-based GTFS service window."""
    with zipfile.ZipFile(gtfs_path) as zf:
        files = set(zf.namelist())
        if 'calendar.txt' in files:
            calendar = pd.read_csv(zf.open('calendar.txt'))
            start = pd.to_datetime(calendar['start_date'], format='%Y%m%d')
            end = pd.to_datetime(calendar['end_date'], format='%Y%m%d')
            source = 'calendar.txt'
            total_services = len(calendar)
        elif 'calendar_dates.txt' in files:
            dates = pd.read_csv(zf.open('calendar_dates.txt'))
            additions = dates.loc[dates['exception_type'] == 1]
            parsed = pd.to_datetime(additions['date'], format='%Y%m%d')
            start = end = parsed
            source = 'calendar_dates.txt'
            total_services = additions['service_id'].nunique()
        else:
            return {'valid': False, 'error': 'No service calendar file'}

    today = pd.Timestamp(datetime.now().date())
    return {
        'valid': True,
        'calendar_source': source,
        'total_services': total_services,
        'earliest_start': start.min().strftime('%Y-%m-%d'),
        'latest_end': end.max().strftime('%Y-%m-%d'),
        'feed_expired': end.max() < today
    }

Some agencies use calendar_dates.txt for exception-based scheduling instead of calendar.txt. The validator therefore accepts either source and records which one defines the service window. The routing departure date still needs to fall on an active service date; this window check alone does not evaluate weekday flags or removed-service exceptions.


Step 5: Content Validation

The article associates the reported Caltrain problem with this layer: all required files were present, coordinates and calendar passed the preceding checks, and the optional frequencies table was described as functionally empty. An empty optional table only means the feed supplies no frequency-based service or headway information. Scheduled trips can still be defined in stop_times.txt. No public feed snapshot or routing configuration is available to establish what caused the reported failure.

What does "functionally empty" look like? An empty frequencies.txt contains only the header row:

trip_id,start_time,end_time,headway_secs

A populated one would have entries like:

trip_id,start_time,end_time,headway_secs
CT-LOCAL-1,06:00:00,09:00:00,1200
CT-LOCAL-1,09:00:00,15:00:00,1800
CT-LOCAL-1,15:00:00,19:00:00,1200

That first entry indicates 20-minute headways (1200 seconds) during the morning peak. Without these rows, the feed provides no frequency-based headway information. That is not necessarily a routing failure: scheduled trips may still be fully specified in stop_times.txt.

The article also reports that, while configuring downloads for 11 agencies, County Connection and Tri Delta Transit returned HTTP errors without redirects or deprecation pages. It says this left Contra Costa with BART but omitted two of three target agencies. Because the request log and feed inventory are not public, those details are article-reported. The general risk remains: a missing local feed can understate transit options, while an archive fallback may not contain the newest feed.

def validate_content(gtfs_path):
    """Check relational integrity between GTFS tables."""
    with zipfile.ZipFile(gtfs_path) as zf:
        routes = pd.read_csv(zf.open('routes.txt'))
        trips = pd.read_csv(zf.open('trips.txt'))
        stop_times = pd.read_csv(zf.open('stop_times.txt'), low_memory=False)

    issues = {}

    # Routes with no trips
    routes_with_trips = set(trips['route_id'].unique())
    orphan_routes = set(routes['route_id']) - routes_with_trips
    if orphan_routes:
        issues['orphan_routes'] = len(orphan_routes)

    # Trips with no stop_times
    trips_with_times = set(stop_times['trip_id'].unique())
    orphan_trips = set(trips['trip_id']) - trips_with_times
    if orphan_trips:
        issues['orphan_trips'] = len(orphan_trips)

    # Check route_type values in the core GTFS Schedule specification
    valid_route_types = {0, 1, 2, 3, 4, 5, 6, 7, 11, 12}
    invalid_types = routes[~routes['route_type'].isin(valid_route_types)]
    if len(invalid_types) > 0:
        issues['invalid_route_types'] = len(invalid_types)

    return {
        'routes': len(routes),
        'trips': len(trips),
        'stop_times': len(stop_times),
        'valid': len(issues) == 0,
        'issues': issues
    }

Structural presence is not the same as relational integrity. A routes table with 50 entries means nothing if those routes connect to zero trips. A frequencies table with correct column headers means nothing if the referenced trips have no stop times.


Step 6: Multi-Agency Sanity Check

When combining feeds from multiple agencies (for example, SFMTA + BART for San Francisco), conflicts can emerge that no single-feed validation catches.

The article reports that four of seven counties failed the first routing run: San Francisco, Sacramento, Orange, and San Diego. It also refers to a retry script named 41b_calculate_transit_failed_counties.py, but that file is not present in the linked public repository or available locally in the reviewed project materials. The smoke test below is an instructional example of how to surface integration failures earlier.

import r5py

def smoke_test_network(osm_path, gtfs_paths):
    """Try building an r5py network to catch integration issues."""
    try:
        network = r5py.TransportNetwork(
            osm_pbf=osm_path,
            gtfs=gtfs_paths
        )
        return {'status': 'success', 'feeds_loaded': len(gtfs_paths)}
    except Exception as e:
        return {'status': 'failed', 'error': str(e)}

This is a blunt instrument: it tells us whether r5py can build a network, not whether the results will be correct. But it catches the category of failure that cost us the most time: feeds that look valid individually but break when combined.

OSM file size is another possible failure point. The article reports memory-related crashes in three counties when using a statewide extract, but it does not publish the logs needed to reproduce that count. A smaller regional extract can reduce memory pressure, though that remains outside this GTFS validation workflow.


Debugging in Practice

The article organizes its teaching workflow around three reported failures. The underlying feeds, logs, and scripts are not public:

  • Caltrain's empty frequencies table -- diagnosed through a painstaking process of removing feeds one at a time. The frequencies example and debugging narrative appear in Step 4 and Step 5.
  • Disappearing agency URLs -- County Connection and Tri Delta Transit endpoints stopped responding without warning. The fallback strategy and its tradeoffs are discussed in Step 5.
  • Four-county retry script -- when over half the counties failed routing, per-county configuration became necessary. The context for this appears in Step 6.

Those debugging sessions clarified when this validation overhead is worth the investment versus when simpler checks suffice.


When to Use This Approach

GTFS validation is overhead, and not every project needs all six layers. The investment tends to pay off when bad feeds can cascade into downstream failures that are harder to diagnose than to prevent.

Good fit:

  • Multi-agency studies where one bad feed can poison the whole analysis
  • Longitudinal studies where feeds may expire between data collection and analysis
  • Any pipeline feeding into r5py, OpenTripPlanner, or similar routing engines

Less suitable:

  • Real-time transit applications (use GTFS-realtime validation instead)
  • Single-agency analysis with a known-good feed
  • Quick exploratory work where a few missing stops are tolerable

Limitations

These checks are practical safeguards, not a comprehensive test suite. Several constraints are worth noting.

  • Pattern-based, not exhaustive. These checks catch problems we actually encountered. Other feeds will have other problems.
  • Point-in-time. Feeds update regularly. A feed that validates today may expire next month.
  • Engine-specific tolerance. r5py, OpenTripPlanner, and Valhalla each handle GTFS quirks differently. A feed that crashes r5py may work fine in OTP, and vice versa.
  • Requires geographic knowledge. Bounding box checks only work if we know where the stops should be.
  • Does not cover GTFS-realtime. Real-time feeds (vehicle positions, trip updates, service alerts) use a different specification and require different validation tools.

Public-Material Status

No complete validation or routing implementation is currently public. The linked foodsecurity_mobility repository contained only a one-line README when reviewed for this release.

The article refers to scripts/30_validate_gtfs_feed.py, scripts/40_download_all_gtfs_feeds.py, and scripts/41b_calculate_transit_failed_counties.py, but none of those files is present in the linked repository or the reviewed local materials. The code blocks on this page remain instructional excerpts. Reproducing the reported project would require the feed inventory and vintages, routing inputs, environment specification, complete scripts, run commands, and saved validation outputs.


References and Notes

[1] The article reports that this validation workflow was developed for a food-access study measuring transit travel times to grocery stores across 7 California counties. The project package is not publicly reproduced. See the transit routing tutorial for the instructional routing methodology.

[2] Transitland is a community-edited open data platform maintained by Interline Technologies. Feed archive: transit.land. For California specifically, Cal-ITP (California Integrated Travel Project) aggregates GTFS feeds from 96% of the state's transit agencies: data.ca.gov.

[3] GTFS (General Transit Feed Specification) is maintained by MobilityData. Over 10,000 transit agencies in 100+ countries publish GTFS feeds. Specification: gtfs.org. Repository: github.com/google/transit

[4] MobilityData. "Canonical GTFS Schedule Validator." github.com/MobilityData/gtfs-validator

Frequently asked questions

Why validate a GTFS feed before routing?

Trips without stop_times, expired service windows, or out-of-bounds coordinates can cause a routing engine to omit affected service. The checks on this page are instructional; its project-specific failure counts are article-reported and not publicly reproduced.

What are common GTFS problems to check for?

The excerpts check for missing or both-zero stop coordinates, stops outside a study-area bounding box, an expired overall service window, routes without trips, trips without stop_times, and invalid route_type values. They do not implement shape-reference validation or exact date-level service validation, and the page does not provide a complete public implementation.