Handling calendar_dates-Only Feeds

If a GTFS archive contains no calendar.txt, do not treat it as an error and do not treat it as an empty schedule: read calendar_dates.txt alone, take every row with exception_type = 1 as a service date, and feed the result into exactly the same date-indexed service map you build for pattern-based feeds. The rest of the pipeline should never learn which shape the feed used. This is the one branch the calendar and service exception model cannot avoid, and it is far cheaper to handle at the read boundary than to discover three layers down.

What the absence of calendar.txt actually means Three readings of a missing calendar.txt and their consequences: two are wrong and one is what the specification intends. calendar.txt is not in the archive — now what? raise an error Wrong the feed is valid; the ingest dies for nothing return no service Worse every trip filtered out, silently read the exceptions Correct calendar_dates.txt supplies every date

Root Cause Analysis

The specification marks calendar.txt as conditionally required, and the condition is easy to misread. The file is required only when it is the mechanism the feed uses to describe service. A feed that states every service date explicitly in calendar_dates.txt satisfies the specification without shipping calendar.txt at all.

This is not a rare shape. Scheduling systems that hold a concrete operating calendar — as most rail operators and a good number of bus agencies do — have no weekly pattern to export. Synthesising one would mean inventing an abstraction the source system does not have and then keeping it correct across every holiday, strike day and engineering closure. Enumerating the dates is the honest export.

The damage comes from pipelines that assume the pattern shape, and it takes three recognisable forms:

  • A hard crash. Opening calendar.txt from the archive raises on a valid feed, and the whole ingest dies.
  • A silent empty schedule. Code that catches the missing member and returns an empty frame produces a feed with zero service dates, so every trip is filtered out downstream. That looks like a bug in the application rather than a mishandled feed, and it is usually found by a rider rather than by a test.
  • A half-read feed. Code that treats calendar_dates.txt only as a correction to calendar.txt reads the file, finds no base pattern to correct, and applies the exceptions to nothing.

The fix is one normalisation at the boundary: both shapes produce the same (service_id, date) frame, and nothing downstream branches on where it came from.

Production-Ready Python Implementation

python
"""Read a GTFS feed's service calendar regardless of which shape it uses."""
from __future__ import annotations

import logging
from dataclasses import dataclass
from pathlib import Path
from zipfile import ZipFile

import pandas as pd

log = logging.getLogger("gtfs.calendar.shape")

DAY_COLUMNS = ("monday", "tuesday", "wednesday", "thursday",
               "friday", "saturday", "sunday")


@dataclass(frozen=True)
class ServiceCalendar:
    active: pd.DataFrame          # columns: service_id, date
    shape: str                    # "pattern", "dates-only" or "mixed"

    @property
    def first_date(self):
        return self.active["date"].min()

    @property
    def last_date(self):
        return self.active["date"].max()


def _member(archive: ZipFile, name: str):
    """The member as a frame, or None when the feed does not carry it."""
    if name not in archive.namelist():
        return None
    with archive.open(name) as fh:
        frame = pd.read_csv(fh, dtype="string", keep_default_na=False, na_values=[""])
    return None if frame.empty else frame


def _to_dates(series: pd.Series) -> pd.Series:
    return pd.to_datetime(series, format="%Y%m%d", errors="coerce")


def read_service_calendar(feed_path: Path) -> ServiceCalendar:
    with ZipFile(feed_path) as archive:
        calendar = _member(archive, "calendar.txt")
        exceptions = _member(archive, "calendar_dates.txt")

    if calendar is None and exceptions is None:
        raise ValueError(
            f"{feed_path.name}: neither calendar.txt nor calendar_dates.txt defines "
            "any service — this feed describes no schedule at all")

    frames = []

    if calendar is not None:
        starts, ends = _to_dates(calendar["start_date"]), _to_dates(calendar["end_date"])
        usable = starts.notna() & ends.notna() & (ends >= starts)
        span = pd.date_range(starts[usable].min(), ends[usable].max(), freq="D")
        bounds = pd.DataFrame({"service_id": calendar.loc[usable, "service_id"],
                               "start": starts[usable], "end": ends[usable]})
        for weekday, column in enumerate(DAY_COLUMNS):
            days = span[span.weekday == weekday]
            runs = calendar.loc[usable & (calendar[column] == "1"), ["service_id"]]
            if days.empty or runs.empty:
                continue
            paired = runs.merge(pd.DataFrame({"date": days}), how="cross")
            paired = paired.merge(bounds, on="service_id")
            keep = (paired["date"] >= paired["start"]) & (paired["date"] <= paired["end"])
            frames.append(paired.loc[keep, ["service_id", "date"]])

    added = removed = None
    if exceptions is not None:
        ex = exceptions.assign(date=_to_dates(exceptions["date"]))
        ex = ex[ex["date"].notna()]
        added = ex.loc[ex["exception_type"] == "1", ["service_id", "date"]]
        removed = ex.loc[ex["exception_type"] == "2", ["service_id", "date"]]
        if len(added):
            frames.append(added)

    if not frames:
        raise ValueError(
            f"{feed_path.name}: the calendar files are present but define no active "
            "service date — check for a feed made only of exception_type 2 removals")

    active = pd.concat(frames, ignore_index=True).drop_duplicates(["service_id", "date"])

    if removed is not None and len(removed):
        drop = set(zip(removed["service_id"], removed["date"]))
        active = active[[(s, d) not in drop
                         for s, d in zip(active["service_id"], active["date"])]]

    if calendar is None:
        shape = "dates-only"
    elif added is not None and len(added) > len(calendar) * 20:
        shape = "mixed"
    else:
        shape = "pattern"

    active = active.sort_values(["date", "service_id"]).reset_index(drop=True)
    log.info("%s: %s feed, %d active service date pair(s), %s..%s",
             feed_path.name, shape, len(active),
             active["date"].min().date(), active["date"].max().date())
    return ServiceCalendar(active=active, shape=shape)
One code path for both feed shapes Whichever files are present, the reader emits the same service map, so nothing downstream ever branches on which shape the feed used. Detect which files exist Collect pattern frames, if any Append exception additions Emit one service map the feed's shape is reported, never acted on

Step-by-Step Walkthrough

_member returns None, not an empty frame. The distinction carries information: a missing calendar.txt selects the dates-only path, whereas a present but empty one is a defect worth knowing about. Collapsing both to an empty frame throws that signal away, so the helper treats an empty file as absent and the caller reports which shape it decided on.

Both shapes append into the same frames list. The pattern expansion contributes one frame per weekday; the exception layer contributes the additions. Concatenating them means a dates-only feed simply has no pattern frames — no branch, no special case, and no second code path to keep in step with the first.

Removals are applied after the concatenation, unconditionally. A dates-only feed can still carry exception_type = 2 rows, and applying removals to the combined set handles both shapes with one piece of code.

The shape field is reported, not acted on. Nothing downstream branches on it. It exists so the ingest log records which shape arrived, which is exactly what you want when an agency quietly switches between them — a change that alters nothing about the schedule but everything about which of your code paths is exercised.

Both empty-feed conditions raise, with different messages. Neither file present is one error; both present but yielding no active date is another, and a feed built entirely of removals is what produces the second. That is always a publishing mistake, and naming it saves an hour of reading someone else’s CSV.

Verification and Output

python
def verify(cal: ServiceCalendar, feed_path: Path) -> None:
    with ZipFile(feed_path) as archive, archive.open("trips.txt") as fh:
        trips = pd.read_csv(fh, dtype="string", usecols=["trip_id", "service_id"])

    assert len(cal.active), "no active service dates"
    assert not cal.active.duplicated(["service_id", "date"]).any(), "duplicate pairs"

    stranded = set(trips["service_id"]) - set(cal.active["service_id"])
    assert not stranded, (
        f"{len(stranded)} service pattern(s) referenced by trips never run, "
        f"e.g. {sorted(stranded)[:3]}")

    span_days = (cal.last_date - cal.first_date).days + 1
    density = len(set(cal.active["date"])) / span_days
    log.info("service on %.0f%% of the days in the feed's window", density * 100)

The density figure is the quickest way to see the shape of a feed in a log without opening the archive. A healthy feed of either shape covers essentially every day in its window, so density sits at or near 1.0. What varies — and what matters — is the width of the window:

text
INFO gtfs.calendar.shape: rail_current.zip: dates-only feed, 5124 active service date pair(s), 2026-08-01..2026-09-14
INFO gtfs.calendar.shape: service on 100% of the days in the feed's window

Forty-five days of coverage is short for a rail feed, and it is the sort of thing that deserves an alert rather than a log line — see detecting service gaps and feed expiry.

Rows needed to describe two years of service How many calendar rows each feed shape holds for the same 40 service patterns across a two-year window. Dates-only feed 29200 one row per service per date Pattern feed plus holiday exceptions 226 40 patterns, 186 exceptions Pattern feed, no exceptions 40 one row per pattern both describe the same service; only one of them fits on a screen

Gotchas and Edge Cases

  • The archive contains calendar.txt with a header row and nothing else. Legal, and equivalent to omitting it. Treating it as absent is the reading that keeps the feed working.
  • exception_type compared as the wrong type. Reading the whole file with dtype="string" means the comparison is against "1" and "2", not 1 and 2. Mixing the two is a silent no-match that empties the exception layer, and it is one of the quietest ways to lose an entire holiday calendar.
  • Dates-only feeds are much larger. Enumerating two years of daily service for forty patterns is roughly 29,000 rows where the pattern form needs forty. Still small in absolute terms, but seven hundred times larger, which is worth remembering when the same code runs across a multi-agency batch.
  • A feed that switches shape between publications. Not an error, but exactly the moment a latent assumption elsewhere in the pipeline surfaces. The shape field makes it visible instead of letting it be absorbed silently.

Frequently Asked Questions

Why would an agency omit calendar.txt?

Because their scheduling system already works in concrete dates. Emitting one exception_type 1 row per service date is a faithful export of what that system holds, and it removes the risk of a weekly pattern drifting out of step with the dates it is meant to describe. It is fully valid GTFS.

How do I tell a calendar_dates-only feed from a broken one?

A calendar_dates-only feed has no calendar.txt but a well-populated calendar_dates.txt made almost entirely of exception_type 1 rows. A broken feed has neither file, or has a calendar_dates.txt consisting only of exception_type 2 removals — removals with nothing to remove from describe no service at all.

Do exception_type 2 rows mean anything in a calendar_dates-only feed?

Rarely, but they are legal and they do occur: an agency may enumerate a block of dates and then remove a few. Apply removals after additions exactly as you would for a pattern-based feed and the two shapes converge on the same answer.

Does this change how trips.txt is joined?

Not at all. That is the point of normalising both shapes into one service-date map — everything downstream joins on service_id and never has to know which shape the feed used.