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.

How a GTFS service ID resolves to a date. is service S running on date D?weekday flagsetcalendar.txtD insidestart–endcalendar.txtadd type-1datescalendar_dates.txtdrop type-2datescalendar_dates.txtactiveservicesβ†’ trips on DStopping after step two is the shortcut that works on ordinary days only spatialworkflow.io
Two files, one rule: the weekly pattern, plus additions, minus removals.

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 counted from calendar.txt alone against trips after exceptions, for four dates in the TriMet feed. trips on the date, TriMetTue 29 Sep β€” real6,158calendar only6,158 (same)Thanksgiving β€” real4,560calendar only632 (7Γ— too few)Christmas β€” real4,560calendar only10,704 (2.3Γ— too many)0600012kOne shortcut, wrong in both directions on the two holidays spatialworkflow.io
On an ordinary Tuesday the shortcut is exact; on the dates people actually ask about it is not.

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.

Three ways a feed encodes a holiday and what a calendar-only reader gets for each. the same holiday, three encodingsexceptions onlyrow + removalsplaceholder + addsholiday timetableadded by type 1in calendar.txtadded by type 1weekday timetableremoved by type 2removed by type 2removed by type 2calendar-only readerβœ•weekday onlyβœ•both, doubledβœ•weekday onlyTriMet uses the third for Thanksgiving and the second for Christmas spatialworkflow.io
The same holiday can be encoded three ways, and a reader that skips exceptions gets each one wrong differently.

Optional calendars, merged feeds and seasonal breaks

  • calendar.txt is optional. Feeds that use only calendar_dates.txt are 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.

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.