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.
| Component | Tool | Purpose |
|---|---|---|
| Transit data | GTFS feeds | Bus/rail routes, stops, schedules |
| Feed registry | Cal-ITP / Transitland | Find and download feeds with fallbacks |
| Structural validation | zipfile + pandas | Check file presence, parse tables |
| Geographic validation | pandas + bounding box | Coordinate sanity checks |
| Calendar validation | pandas datetime | Expired services, active date ranges |
| Downstream testing | r5py | Confirm 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 ↩