A GTFS query returns no trips, or the wrong trips, on a date
On this page
Problem statement
You filter a GTFS feed to a date and get nothing back β no active services, no trips, an empty map. Or you get a count that looks plausible and is wrong: the weekday timetable on a holiday, or two timetables at once. The code runs without error either way, which is what makes this failure expensive: the empty or wrong result flows into frequency maps and accessibility scores before anyone notices.
There are five common causes, and they can be reproduced on a clean, valid feed. On TriMet's feed for Thanksgiving 2026 the correct answer is 4,560 trips. A date outside the feed gives 0, a hyphenated date gives 0, exception dates read as integers give 632, and ignoring calendar_dates.txt gives 632. The fifth β numeric service IDs read as integers on one side of the join β gives 0 in feeds whose IDs are all digits; TriMet's contain letters, so it is immune.
Quick answer
Check, in this order: the date is inside the feed, the date is formatted YYYYMMDD, the tables were read as strings, both calendar files were applied, and exceptions are actually matching:
import pandas as pd
FEED = "trimet_gtfs"
cal = pd.read_csv(f"{FEED}/calendar.txt", dtype=str)
cd = pd.read_csv(f"{FEED}/calendar_dates.txt", dtype=str)
trips = pd.read_csv(f"{FEED}/trips.txt", dtype=str)
info = pd.read_csv(f"{FEED}/feed_info.txt", dtype=str).iloc[0]
DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
def trips_on(date):
ds = pd.Timestamp(date).strftime("%Y%m%d") # normalise the format
assert info.feed_start_date <= ds <= info.feed_end_date, f"{ds} outside feed"
dow = DAYS[pd.Timestamp(date).dayofweek]
weekly = set(cal.loc[(cal[dow] == "1") & (cal.start_date <= ds) & (cal.end_date >= ds), "service_id"])
ex = cd[cd.date == ds]
active = (weekly | set(ex.loc[ex.exception_type == "1", "service_id"])) \
- set(ex.loc[ex.exception_type == "2", "service_id"])
return trips[trips.service_id.isin(active)]
print(len(trips_on("2026-11-26")), "trips on Thanksgiving")
4560 trips on Thanksgiving
Every table is read with dtype=str, every date is normalised to YYYYMMDD before comparing, and the window check raises instead of returning an empty set.
Window, date format, strings and exceptions
1. Check the feed window
feed_info.txt gives feed_start_date and feed_end_date. If it is missing, take the smallest start_date and largest end_date in calendar.txt, and the range of calendar_dates.txt. Feeds are often published for a few months at a time; a date next quarter is outside yesterday's download.
2. Normalise the date format
GTFS dates are YYYYMMDD strings. Comparing "2026-11-26" with "20261126" as text is legal and wrong, because the hyphen sorts before every digit.
3. Read everything as strings
A service_id of 7.736 read as a float becomes 7.736, but one of 012 becomes 12, and a column of dates read as integers never equals a string date. Mixed types across tables are the most common reason a join silently returns nothing.
4. Apply calendar_dates.txt
The weekly pattern in calendar.txt is the default; exceptions add and remove services on specific dates. Skipping them gives the wrong timetable on holidays and nothing at all for services defined only by exceptions.
5. Confirm the exceptions matched
Count the exception rows for your date. Zero rows on a public holiday is a sign that the comparison is failing, not that the holiday has no special timetable.
6. Pick analysis dates that are ordinary
For typical service, choose a date with no exceptions for any service, inside the feed, and state it.
Three silent failures reproduced
The data used on this page
TriMet's GTFS feed for Portland, Oregon, downloaded from developer.trimet.org and unzipped to trimet_gtfs/. TriMet republishes it every few weeks, so counts of trips and stop times move between versions; the setup prints the version the numbers on this page came from.
import io
import os
import zipfile
import pandas as pd
import requests
if not os.path.exists("trimet_gtfs"):
feed = requests.get("https://developer.trimet.org/schedule/gtfs.zip", timeout=600)
zipfile.ZipFile(io.BytesIO(feed.content)).extractall("trimet_gtfs")
info = pd.read_csv("trimet_gtfs/feed_info.txt", dtype=str).iloc[0]
print(f"TriMet feed {info.feed_version}, valid {info.feed_start_date} to {info.feed_end_date}")
TriMet feed 20260927-20261007-0900, valid 20260927 to 20270227
Example 1 β the date format
ds_wrong = "2026-11-26"
weekly = cal[(cal.thursday == "1") & (cal.start_date <= ds_wrong) & (cal.end_date >= ds_wrong)]
print("services matched with a hyphenated date:", len(weekly))
print("'2026-11-26' >= '20260914'?", "2026-11-26" >= "20260914")
services matched with a hyphenated date: 0
'2026-11-26' >= '20260914'? False
Example 2 β integer types on one side of a join
trips_int = pd.read_csv(f"{FEED}/trips.txt") # default inference
cd_int = pd.read_csv(f"{FEED}/calendar_dates.txt")
print(trips_int.service_id.dtype, cd_int.date.dtype)
print("exception rows found for '20261126' in the inferred table:",
int((cd_int.date == "20261126").sum()))
str int64
exception rows found for '20261126' in the inferred table: 0
The dates became integers, so the string comparison finds no exception rows at all. The weekly pattern alone then gives the weekday timetable on Thanksgiving.
Example 3 β the other three results
d = "20261126"
weekly = set(cal.loc[(cal.thursday == "1") & (cal.start_date <= d) & (cal.end_date >= d), "service_id"])
print("calendar.txt only:", trips.service_id.isin(weekly).sum())
try:
trips_on("2027-03-04")
except AssertionError as e:
print("outside the window:", e)
calendar.txt only: 632
outside the window: 20270304 outside feed
Why none of these raise
Why none of these raise
Each mistake produces a valid pandas operation. A string comparison between two differently formatted dates returns a boolean; a comparison between an integer column and a string returns all False; an isin against an empty set returns an empty frame. The pipeline carries on with zero rows, or with the wrong rows, and the first visible symptom is usually a map.
Why holidays expose it
On an ordinary weekday, applying calendar.txt alone gives the right answer for most feeds, so the missing exception step goes unnoticed through testing. Holidays are when exceptions do their work, and they are the dates most often chosen for "what happens on Thanksgiving?" questions β or silently included in a month-long average.
Why the window check should raise
An empty result is indistinguishable from "no service". Raising on a date outside the feed turns a silent, plausible zero into an error at the line that caused it.
Missing feed info, merged feeds and validators
- Feeds without
feed_info.txtneed the window derived from the calendars. - Merged feeds can have overlapping service IDs; prefix them per feed.
- Seasonal service sometimes ends mid-feed; a route with no trips on a date may simply be off-season.
- Time zones do not affect dates in GTFS; all dates are local to the agency.
- Trips past midnight belong to the previous service date; see the past-midnight fix.
- Validators such as the MobilityData GTFS validator flag expired feeds.
Internal links
- GTFS service calendars explained β the rules behind the query
- How to find the trips running on a given date in GTFS β the query done properly
- How to read a GTFS feed in Python β types for every column
- GTFS times after 24:00 break time parsing β the service-day clock
- Fixing census joins that fail because leading zeros were dropped β the same type trap elsewhere
- GTFS explained β how the tables connect
FAQ
Why does my GTFS date filter return no trips?
Most often the date is outside the feed, formatted with hyphens, or compared against columns read as integers.
Why do I get the weekday timetable on a holiday?
Because calendar_dates.txt was not applied, or its dates were read as integers and never matched.
What format are GTFS dates?
YYYYMMDD strings, such as 20261126.
How do I find the feed's date range?
feed_start_date and feed_end_date in feed_info.txt, or the extremes of the calendar files.
Should an out-of-range date return an empty result?
It should raise. An empty result looks like a day without service.
How do I choose a typical weekday?
Pick one inside the feed with no exception rows for any service.