GTFS service calendars explained: calendar, calendar_dates and the service day
On this page
Problem statement
Every trip in a GTFS feed carries a service_id, and the only way to know whether that trip runs on a given date is to resolve the service ID through two files that follow different rules. calendar.txt describes a weekly pattern between a start and an end date. calendar_dates.txt lists single-date exceptions that add or remove service. Apply one without the other and the trip count for a date is wrong β sometimes slightly, sometimes by a factor of two.
On TriMet's feed, Christmas Day 2026 shows the problem at full size. Reading only calendar.txt gives 10,704 trips, the weekday timetable plus the holiday timetable stacked on top of each other. Applying the exceptions gives the 4,560 trips that actually run.
Quick answer
A service runs on a date if the weekly pattern includes it and no exception removes it, or if an exception adds it:
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)
DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
def services_on(date):
d = pd.Timestamp(date)
ds, dow = d.strftime("%Y%m%d"), DAYS[d.dayofweek]
weekly = set(cal.loc[(cal[dow] == "1") & (cal.start_date <= ds) &
(cal.end_date >= ds), "service_id"])
ex = cd[cd.date == ds]
added = set(ex.loc[ex.exception_type == "1", "service_id"])
removed = set(ex.loc[ex.exception_type == "2", "service_id"])
return (weekly | added) - removed
exception_type 1 adds the date, 2 removes it. Comparing dates as YYYYMMDD strings is safe because that format sorts correctly as text.
Calendars as defaults, exceptions as facts
1. Treat calendar.txt as a default, not a fact
A row says "Mondays to Fridays between 14 September and 27 November". It says nothing about holidays inside that window. Those are in calendar_dates.txt, and a feed can have dozens of them.
2. Expect exceptions that remove a whole timetable
On Thanksgiving, TriMet's two remaining weekday services are removed and two holiday services are added. A calendar-only count for 26 November 2026 finds 632 trips; the real figure is 4,560.
3. Expect services defined only by exceptions
Some feeds leave calendar.txt out entirely and list every operating date in calendar_dates.txt. Others, like TriMet's, include placeholder rows with every weekday set to 0 and a one-day date range, then add the date through an exception. Code that only reads the weekly pattern finds no service for these at all.
4. Check the feed window first
A date outside every service's range returns an empty set, not an error. 2 March 2027 is outside the TriMet feed, and the function above correctly returns nothing β which a pipeline will happily turn into a map with no transit on it.
5. Remember the service day is not the calendar day
A trip that departs at 25:10:00 belongs to the service day on which it started. It runs in the early hours of the next calendar date but is governed by the previous date's calendar. The past-midnight guide covers the arithmetic.
6. Pick analysis dates deliberately
A "typical weekday" should be a date with no exceptions for any service. Choose it by checking calendar_dates.txt, not by picking a Tuesday and hoping.
Trips per date with and without exceptions
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 β trips per date, with and without the exceptions
trips = pd.read_csv(f"{FEED}/trips.txt", dtype=str)
def weekly_only(date):
d = pd.Timestamp(date)
ds, dow = d.strftime("%Y%m%d"), DAYS[d.dayofweek]
return set(cal.loc[(cal[dow] == "1") & (cal.start_date <= ds) &
(cal.end_date >= ds), "service_id"])
for date in ["2026-09-29", "2026-10-04", "2026-11-26", "2026-11-27",
"2026-12-25", "2027-03-02"]:
full = trips.service_id.isin(services_on(date)).sum()
naive = trips.service_id.isin(weekly_only(date)).sum()
print(f"{date} {pd.Timestamp(date).day_name():9} "
f"with exceptions {full:6,} calendar.txt only {naive:6,}")
2026-09-29 Tuesday with exceptions 6,158 calendar.txt only 6,158
2026-10-04 Sunday with exceptions 4,560 calendar.txt only 4,560
2026-11-26 Thursday with exceptions 4,560 calendar.txt only 632
2026-11-27 Friday with exceptions 6,124 calendar.txt only 632
2026-12-25 Friday with exceptions 4,560 calendar.txt only 10,704
2027-03-02 Tuesday with exceptions 0 calendar.txt only 0
Thanksgiving undercounts by a factor of seven and Christmas overcounts by more than two, with the same shortcut.
Example 2 β what the exceptions do on a date
for date in ["2026-11-26", "2026-12-25"]:
ds = pd.Timestamp(date).strftime("%Y%m%d")
ex = cd[cd.date == ds]
print(date, "added:", sorted(ex.loc[ex.exception_type == "1", "service_id"]),
"removed:", sorted(ex.loc[ex.exception_type == "2", "service_id"]))
2026-11-26 added: ['X.737', 'f.737'] removed: ['7.737', 'D.737']
2026-12-25 added: [] removed: ['7.735', 'A.735', 'D.735', 'Q.735', 'W.735']
Christmas uses a third mechanism: the holiday services X.735 and f.735 are in calendar.txt as Friday-only rows with a one-week date range, and the exceptions remove the five weekday services instead of adding anything.
Example 3 β find clean dates for a "typical" week
start, end = pd.Timestamp("2026-09-14"), pd.Timestamp("2026-11-20")
exception_dates = set(cd.date)
clean = [d.strftime("%Y-%m-%d") for d in pd.date_range(start, end)
if d.strftime("%Y%m%d") not in exception_dates and d.dayofweek == 1]
print(len(clean), "clean Tuesdays, first three:", clean[:3])
9 clean Tuesdays, first three: ['2026-09-15', '2026-09-22', '2026-09-29']
Two files and an asymmetric error
Why there are two files
A weekly pattern is the compact way to describe most service; exceptions are the compact way to describe holidays and one-off events. A feed that listed every date would be larger and harder to maintain. The cost is that the reader must combine them, and a reader who stops at the weekly pattern gets a plausible answer that is wrong on exactly the dates people plan around.
Why the error is not symmetrical
Removing a timetable and adding another can both happen on one date, through either file. The calendar-only shortcut can undercount (when the holiday timetable is added by exception) or overcount (when the holiday timetable is in calendar.txt and the weekday one is removed by exception). No single correction repairs both.
Why the service day matters for dates
GTFS times run past 24:00 so that one trip belongs to one service day. A bus leaving at 00:40 on Saturday morning under Friday's timetable is written as 24:40:00 on Friday's service. If you compute "trips on Saturday" by clock date, you double-count or miss these late trips at every date boundary.
Optional calendars, merged feeds and seasonal breaks
calendar.txtis optional. Feeds that use onlycalendar_dates.txtare valid.- Duplicated service IDs across feeds collide when two feeds are merged. Prefix them.
- Seasonal breaks are sometimes a new service ID rather than an exception. TriMet's weekday services change ID at the end of November.
- Time zone comes from
agency_timezone, and all dates are local. - Validate with a known date. A published holiday timetable is a quick sanity check.
- Keep the date in every output. A trip count without the date it applies to is not reproducible.
Internal links
- GTFS explained β the tables and how they join
- How to find the trips running on a given date in GTFS β the resolution in a pipeline
- A GTFS query returns no trips, or the wrong trips, on a date β diagnosing the failure
- GTFS times after 24:00 break time parsing β the service-day clock
- How to calculate service frequency at every stop from GTFS β where the date matters next
- My time column sorts or joins wrongly β local time and time zones
FAQ
What is the difference between calendar.txt and calendar_dates.txt?
calendar.txt gives a weekly pattern between two dates. calendar_dates.txt adds or removes service on individual dates. Both must be applied.
What does exception_type mean?
1 adds service on that date; 2 removes it.
Can a feed have no calendar.txt?
Yes. Some list every operating date in calendar_dates.txt instead.
Why does my holiday have double the trips?
Because the holiday timetable is in calendar.txt and the weekday one is removed by an exception you did not apply.
Which date should I use for a typical weekday?
One with no exceptions for any service, inside the feed window.
Are late-night trips on the next day's calendar?
No. A trip at 25:10:00 belongs to the service day it started on.