Modeling Holiday Service in GTFS
GTFS has no concept of a holiday. A holiday is expressed as a pair of ordinary rows in calendar_dates.txt — an exception_type = 2 removing the weekday pattern from that date, and an exception_type = 1 adding a substitute pattern in its place — and the only thing linking them is that they share a date. When an agency emits the removal and forgets the addition, the feed asserts that the entire network stops running, every schema check still passes, and the defect reaches riders. Detecting that omission is the most valuable holiday-specific check a pipeline can run, and it sits directly on the service map built in calendar and service exception modeling.
Root Cause Analysis
Holidays are hard in GTFS for a structural reason: the format models service as patterns and exceptions, and a holiday is neither. It is a substitution — run this pattern instead of that one — and the specification provides no way to say so. What it provides is two independent rows that a publisher is expected to emit together.
Three encodings appear in real feeds.
Substitution is the correct shape and the most common. Two rows on the same date: the weekday service_id removed, a Sunday or holiday-specific service_id added. The date’s service level drops to whatever the substitute carries, and everything downstream works without knowing a holiday occurred.
Bare removal is the defect. One row: the weekday pattern removed, nothing added. The feed now says no vehicle moves that day. Schema validators pass it because both the row and the file are valid. Service-gap detection catches it only if the removal covers every pattern; a partial removal — the bus network stops, the rail network does not — leaves no gap at all and goes entirely unnoticed.
Baked-in pattern is the third shape, and it is legal but fragile. Rather than using exceptions, the agency defines a service_id whose weekday flags happen to exclude the holiday, usually by giving it a start_date/end_date range that stops before the holiday and another that resumes after. Nothing is wrong with it, but it means the holiday is invisible in calendar_dates.txt, so a check that only reads exceptions will not see the holiday at all.
The practical consequence is that a holiday check cannot work by looking for holidays. It has to work by looking at the service level on each date and asking whether any date’s level is implausible given its neighbours.
Production-Ready Python Implementation
"""Classify calendar exceptions by date and flag holidays with no substitute."""
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, timedelta
import pandas as pd
log = logging.getLogger("gtfs.calendar.holiday")
# A substituted date whose trip count falls this far below a normal same-weekday
# date is worth a human look, even when a substitute pattern was supplied.
IMPLAUSIBLE_RATIO = 0.15
@dataclass(frozen=True)
class ExceptionDay:
day: date
removed: frozenset[str]
added: frozenset[str]
trips_today: int
trips_typical: int
@property
def kind(self) -> str:
if self.removed and self.added:
return "substitution"
if self.removed:
return "bare-removal"
return "addition"
@property
def ratio(self) -> float:
return self.trips_today / self.trips_typical if self.trips_typical else 0.0
def _trips_per_service(trips: pd.DataFrame) -> dict[str, int]:
counts = trips.groupby("service_id").size()
return {str(k): int(v) for k, v in counts.items()}
def _typical_for_weekday(active: pd.DataFrame, per_service: dict[str, int],
target: date, exception_days: set[date]) -> int:
"""Median trip count for the four nearest ordinary dates on the same weekday."""
by_day: dict[date, int] = defaultdict(int)
for sid, stamp in zip(active["service_id"], active["date"]):
by_day[stamp.date()] += per_service.get(str(sid), 0)
candidates = []
for offset in (-28, -21, -14, -7, 7, 14, 21, 28):
other = target + timedelta(days=offset)
if other in exception_days or other not in by_day:
continue
candidates.append(by_day[other])
if len(candidates) == 4:
break
if not candidates:
return 0
candidates.sort()
return candidates[len(candidates) // 2]
def classify_exception_days(active: pd.DataFrame, exceptions: pd.DataFrame,
trips: pd.DataFrame) -> list[ExceptionDay]:
per_service = _trips_per_service(trips)
by_day: dict[date, int] = defaultdict(int)
for sid, stamp in zip(active["service_id"], active["date"]):
by_day[stamp.date()] += per_service.get(str(sid), 0)
grouped: dict[date, dict[str, set[str]]] = defaultdict(
lambda: {"removed": set(), "added": set()})
for sid, stamp, kind in zip(exceptions["service_id"], exceptions["date"],
exceptions["exception_type"]):
bucket = "removed" if str(kind) == "2" else "added"
grouped[stamp.date()][bucket].add(str(sid))
exception_days = set(grouped)
out: list[ExceptionDay] = []
for day in sorted(grouped):
out.append(ExceptionDay(
day=day,
removed=frozenset(grouped[day]["removed"]),
added=frozenset(grouped[day]["added"]),
trips_today=by_day.get(day, 0),
trips_typical=_typical_for_weekday(active, per_service, day, exception_days),
))
return out
def report(days: list[ExceptionDay]) -> list[ExceptionDay]:
"""Log every exception day; return the ones a human should look at."""
suspect = []
for d in days:
if d.kind == "bare-removal":
log.error("%s: %d pattern(s) removed with no substitute — the feed claims "
"%d trip(s) run, against a typical %d",
d.day, len(d.removed), d.trips_today, d.trips_typical)
suspect.append(d)
elif d.kind == "substitution" and d.ratio < IMPLAUSIBLE_RATIO:
log.warning("%s: substituted, but only %d trip(s) against a typical %d "
"(%.0f%%) — check the substitute pattern",
d.day, d.trips_today, d.trips_typical, d.ratio * 100)
suspect.append(d)
else:
log.info("%s: %s, %d trip(s) against a typical %d (%.0f%%)",
d.day, d.kind, d.trips_today, d.trips_typical, d.ratio * 100)
return suspect
Step-by-Step Walkthrough
Everything is counted in trips, not in service patterns. A date on which three patterns were removed and one added is not necessarily reduced service — the one added pattern might carry more trips than the three removed. Counting service_id values would report that date as a 3-for-1 cut; counting trips reports what actually happens. _trips_per_service builds the lookup once, and every subsequent figure is a sum over it.
_typical_for_weekday compares like with like. A holiday falling on a Thursday has to be judged against other Thursdays, not against the weekly average — a Thursday is not comparable to a Sunday. The function walks outward in seven-day steps, skips any date that is itself an exception day, and takes the median of the four nearest ordinary matches. The median rather than the mean, because one seasonal outlier in the sample should not move the baseline.
Exception days are excluded from their own baseline. Without that exclusion, a run of consecutive holidays — the week between Christmas and New Year, for example — would use one holiday as the yardstick for the next, and the whole week would look normal.
kind is derived, not stored. Whether a date is a substitution or a bare removal follows entirely from which buckets are non-empty, so there is nothing to keep in step. The three cases are exhaustive: removals only, additions only, or both.
Two different severities. A bare removal is an error, because the feed is almost certainly wrong. A substitution with an implausibly low ratio is a warning, because it might be entirely correct — some agencies really do run a skeleton service on New Year’s Day. The check raises the question; it does not answer it.
The ratio threshold is a constant with a comment. 15% of a typical day is low enough that a genuine holiday service — normally 40–70% of a weekday — will not trip it, but high enough to catch a substitute pattern that only carries a handful of trips because the agency populated the wrong service_id.
Verification and Output
def verify(days: list[ExceptionDay], exceptions: pd.DataFrame) -> None:
assert len({d.day for d in days}) == len(days), "duplicate exception day"
seen = sum(len(d.removed) + len(d.added) for d in days)
unique = len(exceptions.drop_duplicates(["service_id", "date", "exception_type"]))
assert seen == unique, f"classified {seen} exceptions, file holds {unique}"
for d in days:
assert not (d.removed & d.added), (
f"{d.day}: {sorted(d.removed & d.added)} both added and removed")
The last assertion catches a real publishing bug: the same service_id removed and added on the same date. It is contradictory, it appears in the wild, and it resolves differently depending on which order a consumer applies the two rows in — which is exactly why the expansion applies removals last.
Output on a feed with one correctly modelled holiday and one bare removal:
INFO gtfs.calendar.holiday: 2026-11-26: substitution, 1840 trip(s) against a typical 4106 (45%)
ERROR gtfs.calendar.holiday: 2026-12-25: 3 pattern(s) removed with no substitute — the feed claims 0 trip(s) run, against a typical 4106
INFO gtfs.calendar.holiday: 2026-12-26: substitution, 2240 trip(s) against a typical 4106 (55%)
Thanksgiving and Boxing Day are modelled properly. Christmas Day is not: three weekday patterns were removed and nothing was put back, so the feed asserts the network does not run. Whether that is true is a question for the agency — but it is now a question, rather than a silent zero.
Gotchas and Edge Cases
- An agency that genuinely runs nothing. Some smaller operators really do shut down completely on Christmas Day, and the bare removal is then correct. The check cannot distinguish this from the mistake, and should not try; a one-line per-agency allowlist of known-closed dates is the right place to record it.
- Holidays baked into pattern date ranges. These produce no exception rows at all, so this check sees nothing. Only the trip-count comparison finds them, which is an argument for running the ratio test across every date rather than only across exception days once a feed is known to use that shape.
- Moving holidays. Easter, Thanksgiving and the various observed-on-Monday rules move year to year. Any hard-coded holiday list rots; the comparison against a typical same-weekday date does not, which is why the check is built on service levels rather than on a calendar.
- A substitute pattern that also serves ordinary Sundays. Perfectly normal, and it means the same
service_idappears both in the weekly pattern and as an exception addition. Nothing here treats that as a conflict, but a check that assumed exception-added patterns were holiday-specific would. - Multi-day closures. A week of engineering work produces seven bare removals in a row. Reporting each separately is noisy; grouping consecutive suspect days before alerting keeps the signal readable, in the same way that gap detection reports intervals rather than days.
Frequently Asked Questions
Does GTFS have a holiday concept?
No. There is no holiday flag, no holiday calendar and no way to say ‘this date runs Sunday service’. A holiday is expressed as two ordinary calendar_dates.txt rows: an exception_type 2 removing the weekday pattern and an exception_type 1 adding the substitute one. The relationship between them exists only in the publisher’s intent.
What happens if the agency removes the weekday service and forgets the substitute?
The feed asserts that no service runs that day at all. Every schema check passes, because both files are valid; the network simply disappears for a day. This is the single most common holiday defect and it is invisible to validators.
Is a holiday with reduced service a substitution or something else?
A substitution. The agency removes the weekday service_id and adds whichever service_id carries the reduced level — often the Sunday pattern, sometimes a holiday-specific one. What matters for checking is that something was added, not which pattern it was.
Should I maintain my own holiday calendar to cross-check?
It helps, but treat it as advisory rather than authoritative. Agencies legitimately run normal service on holidays your calendar lists, and legitimately run holiday service on days it does not — a major sporting event, for example. Use it to raise questions, never to overrule the feed.
Related
- Detecting Service Gaps and Feed Expiry — the network-wide view of the same failure
- Expanding GTFS Calendar to Service Dates in Python — building the service map these counts are taken from
- Up: Calendar and Service Exception Modeling — the exception model a holiday is expressed in
- Section: GTFS Feed Architecture & Fundamentals · Home