Validating GTFS Feeds Before Accessibility Analysis
A GTFS feed that has expired, that covers only part of the study area, or whose analysis date falls on an atypical service day will not raise an error. It will produce an accessibility map, and the map will be wrong in a direction that is hard to notice. This guide, part of Public Transit Accessibility Modeling, sets out the checks to run before any routing.
Problem Context & Constraints
GTFS is a zip of CSV files with a specification that is widely followed and loosely enforced. Structural validity — the files parse, the identifiers resolve — is necessary and nowhere near sufficient. Four failures pass every structural validator and destroy an analysis.
Expiry. calendar.txt states a service period. Routing on a date outside it returns no trips, and most routing engines report that as “unreachable” rather than as an error. The resulting map is uniformly empty and looks like a transit desert.
Calendar exceptions. calendar_dates.txt adds and removes service on specific dates. Many agencies remove most service on public holidays and during school breaks, so an analysis date chosen for convenience can describe a service level nobody experiences on a normal weekday.
Partial coverage. A feed may cover a city and not its county, so origins in the rural portion have no service by construction. Without a coverage check this reads as a genuine finding about rural access.
Frequency collapse. A route can exist, be structurally valid, and run twice a day. Any binary “served by transit” indicator built without a frequency check will treat it identically to a ten-minute service.
Prerequisites
- The GTFS zip and the study-area boundary
python3.11,gtfs-kit10.1.1,pandas2.2.2,geopandas1.0.1- A stated analysis date, chosen deliberately rather than defaulted to today
The checks run in a fixed order because each one makes the next interpretable:
Step-by-Step Solution
# Pre-routing GTFS validation: service on the date, coverage, and frequency.
# Pinned: gtfs-kit==10.1.1, pandas==2.2.2, geopandas==1.0.1
import logging
import pandas as pd
import geopandas as gpd
import gtfs_kit as gk
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("transit.gtfs")
def assert_service_on_date(feed, date: str) -> pd.DataFrame:
"""Trips actually running on `date`. Raises rather than returning empty.
The raise is the point: an empty trip set is the failure this whole check
exists to catch, and returning it silently is what produces the empty map."""
trips = feed.get_trips(date=date)
if trips.empty:
window = (feed.calendar[["start_date", "end_date"]].agg(["min", "max"]).to_dict()
if feed.calendar is not None else "no calendar.txt")
raise ValueError(f"no trips on {date}; feed service window is {window}")
log.info("%s: %d trips across %d routes", date, len(trips), trips["route_id"].nunique())
return trips
def compare_to_typical_weekday(feed, date: str, sample_dates: list[str]) -> dict:
"""Is the analysis date typical? Compares trip counts against sampled dates.
A date with under 80% of the median trip count is a reduced-service day and
should be replaced rather than explained in a footnote."""
n = len(feed.get_trips(date=date))
others = pd.Series({d: len(feed.get_trips(date=d)) for d in sample_dates})
med = float(others.median())
ratio = n / med if med else float("nan")
log.info("trips on %s: %d, median of sample: %.0f, ratio %.2f", date, n, med, ratio)
if ratio < 0.8:
log.warning("analysis date has reduced service (%.0f%% of typical)", 100 * ratio)
return {"date": date, "trips": n, "median_sample": med, "ratio": float(ratio)}
def coverage_check(feed, study_area: gpd.GeoDataFrame, buffer_m: float = 800.0) -> dict:
"""Share of the study area within walking distance of any stop.
A low share is a real finding OR a partial feed; the two are distinguished by
checking whether another agency operates in the uncovered portion."""
stops = gk.stops.geometrize_stops(feed.stops).to_crs(study_area.crs)
served = stops.buffer(buffer_m).union_all()
area_total = float(study_area.union_all().area)
area_served = float(study_area.union_all().intersection(served).area)
share = area_served / area_total if area_total else 0.0
log.info("%.1f%% of the study area lies within %.0f m of a stop", 100 * share, buffer_m)
return {"share_within_walk": share, "n_stops": len(stops)}
def frequency_profile(feed, date: str, window=("07:00:00", "10:00:00")) -> pd.DataFrame:
"""Trips per route in the departure window, so infrequent routes are visible."""
trips = assert_service_on_date(feed, date)
st = feed.stop_times.merge(trips[["trip_id", "route_id"]], on="trip_id")
st = st[st["departure_time"].between(*window)]
prof = (st.groupby("route_id")["trip_id"].nunique()
.rename("trips_in_window").sort_values().to_frame())
thin = prof[prof["trips_in_window"] <= 2]
log.info("%d of %d routes make 2 or fewer trips in the window", len(thin), len(prof))
return prof
Running assert_service_on_date as the first line of any transit pipeline removes the most damaging failure entirely, because the error message names the feed’s actual service window and the fix is immediate.
Validation & Edge Cases
1. Sample several comparison dates from the same weekday. A single comparison can itself land on an atypical day. Take four or five same-weekday dates spread across the service period:
INFO 2026-04-14: 3,182 trips across 41 routes
INFO trips on 2026-04-14: 3182, median of sample: 3204, ratio 0.99
INFO 62.4% of the study area lies within 800 m of a stop
INFO 9 of 41 routes make 2 or fewer trips in the window
ERROR no trips on 2026-07-04; feed service window is {'start_date': {'min': 20260105}, 'end_date': {'max': 20260619}}
2. Check coverage against population, not area. Sixty-two percent of area within walking distance may be ninety-four percent of population, or the reverse. The population-weighted figure is the one that matters and the area figure alone can mislead in either direction.
3. Investigate the thin routes before excluding them. A route with two trips in the morning window may be a school service, a demand-responsive route recorded as fixed, or a genuine lifeline connection. Each calls for different handling, and dropping them all is as wrong as treating them as frequent service.
4. Re-validate on every feed refresh. Agencies publish new feeds frequently, and a refresh can change stop identifiers, split routes, or shift the service window. Treat a feed update as a new input requiring the full check.
5. Merge before validating, when several agencies are involved. Validating each feed separately misses collisions between them — duplicated stops at shared interchanges and identical route identifiers from different agencies both produce silent routing errors.
6. Check stop-to-stop consistency, not only file structure. Feeds occasionally carry stops with identical names at materially different coordinates, or a parent station whose children sit kilometres away. Both route correctly and both produce access results that cannot be reproduced on the ground. A quick pass comparing stop coordinates against the shapes of the routes serving them catches most of these.
7. Confirm the feed’s coordinate reference. GTFS specifies WGS84 and the great majority of feeds comply, but a feed produced by exporting from a projected system without reprojecting will place every stop somewhere in the Gulf of Guinea or a few hundred metres off. Assert that all stop coordinates fall inside the study area’s bounding box, expanded generously, before routing.
8. Keep the validated feed, not a link to it. Agencies overwrite their published feed in place, so a URL is not a citation. Archive the exact zip that was validated alongside the analysis, keyed by its checksum.
Area coverage and population coverage can point in opposite directions, and the second is the one that matters:
Compliance Notes
- Record the feed’s publication date, its service window and its checksum in the run signature. “GTFS from the transit authority” does not identify a dataset.
- Publish the coverage share with any transit accessibility result, since a result computed on a feed covering 62% of the study area is a statement about that 62%.
- State the analysis date and why it was chosen. A date chosen because it was typical is defensible; a date chosen because it produced a better map is not, and only the record distinguishes them.
- Keep the validation output with the analysis. The thin-route list and the coverage figure are part of the result’s interpretation, not preliminary diagnostics.
Related Topics
- Public Transit Accessibility Modeling — the parent guide, whose travel-time matrices depend on a valid feed.
- Building GTFS Travel-Time Matrices for Clinic Access — the next step once the feed is validated.
- Batch Routing Error Handling — the operational discipline for the large routing sweeps that follow.