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.

The three shapes a holiday takes in a feed A grid over how agencies encode a holiday, what the feed then asserts, and whether the encoding is correct. The feed asserts Verdict Removal plus addition substitute service runs correct Removal alone nothing runs at all almost always a defect Baked into the pattern dates service simply stops legal, but invisible

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

python
"""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
Trips running on each day of a holiday week Trip counts across a week containing two holidays: Thanksgiving carries a substitute pattern, the day after does not and the network appears to stop. Wednesday, ordinary 4106 the baseline for comparison Thursday, holiday 1840 substituted — 45% of a normal day Friday, holiday 0 removed with no substitute; a defect Saturday, ordinary 2240 the normal weekend level a holiday at 45% is service; a holiday at zero is a missing pattern

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

python
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:

text
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.

How a holiday check decides whether to complain The check compares a date's trip count against nearby ordinary dates on the same weekday, and treats the three outcomes differently. How does this date compare with an ordinary same weekday? similar Nothing to report the exception changed little reduced but present Note it normal holiday service zero, with a removal Report as an error the substitute was never added

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_id appears 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.