Handling Multi-Timezone GTFS Feeds

In a multi-timezone GTFS feed, every schedule time is expressed in the timezone of the agency that operates the trip — resolved through the route’s agency_id — and never in the timezone of the stop. stop_timezone exists for displaying a local time to a rider standing at that stop; using it to interpret arrival_time shifts every cross-border call by the offset between the two zones, which is the single most damaging mistake available in this area. The general temporal model is covered in timezone handling and schedule normalization.

The chain that resolves a trip's timezone A trip resolves through its route to an agency, and the agency's timezone is the one every schedule time in that trip is expressed in. trip trips.txt route route_id agency agency_id timezone agency_timezone there is no feed-level timezone, and there never was

Root Cause Analysis

Most pipelines are written against a single-agency, single-timezone feed and quietly hard-code the assumption. AGENCY_TZ = ZoneInfo("America/New_York") appears near the top of a module, and everything works until the feed is merged with a neighbouring operator, or the agency’s service crosses a state line, or the same code is pointed at a different city.

GTFS is explicit about where the timezone lives. agency.txt carries agency_timezone, one per agency, and it is required. routes.txt carries agency_id, which is conditionally required — optional only when the feed has exactly one agency. So the resolution chain for any trip is: trip → route → agency → timezone. There is no feed-level timezone, and there never was.

stops.txt may also carry stop_timezone, and this is where the real trouble starts, because the field looks like it should govern the times at that stop and does not. The specification is unambiguous: times in stop_times.txt are always in the timezone of the agency operating the trip, regardless of stop_timezone. The field exists so an application can tell a rider at a stop what the local wall-clock time is — useful on a service that crosses a boundary, and irrelevant to the arithmetic.

Applying stop_timezone to schedule interpretation produces a specific, recognisable failure: a long-distance trip whose times jump backwards at the boundary. A coach leaving Chicago at 14:00 Central and arriving in Indianapolis at 17:30 has its arrival written as 17:30:00 in Central; interpreting it as Eastern makes the journey appear to take four and a half hours instead of three and a half, and a trip crossing westward appears to arrive before it left.

Production-Ready Python Implementation

python
"""Resolve GTFS times correctly across a multi-timezone feed."""
from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

import pandas as pd

log = logging.getLogger("gtfs.timezone")


@dataclass(frozen=True)
class TimezoneMap:
    by_agency: dict[str, str]             # agency_id -> IANA name
    by_route: dict[str, str]              # route_id -> IANA name
    by_trip: dict[str, str]               # trip_id -> IANA name
    stop_display: dict[str, str]          # stop_id -> stop_timezone, DISPLAY ONLY

    @property
    def zones(self) -> set[str]:
        return set(self.by_agency.values())

    def for_trip(self, trip_id: str) -> str:
        try:
            return self.by_trip[trip_id]
        except KeyError:
            raise KeyError(
                f"trip {trip_id} resolves to no agency timezone; check that its route "
                "carries an agency_id and that the agency exists") from None


def build_timezone_map(agency: pd.DataFrame, routes: pd.DataFrame,
                       trips: pd.DataFrame, stops: pd.DataFrame) -> TimezoneMap:
    if "agency_timezone" not in agency.columns:
        raise ValueError("agency.txt has no agency_timezone; every time in this feed "
                         "is uninterpretable")

    by_agency: dict[str, str] = {}
    for row in agency.itertuples(index=False):
        zone = str(row.agency_timezone).strip()
        try:
            ZoneInfo(zone)
        except (ZoneInfoNotFoundError, ValueError):
            raise ValueError(f"agency_timezone {zone!r} is not an IANA zone name") from None
        # agency_id is optional in a single-agency feed; key that case on "".
        agency_id = str(getattr(row, "agency_id", "") or "")
        by_agency[agency_id] = zone

    if len(by_agency) == 1 and "" not in by_agency:
        # A single named agency: routes may still omit agency_id entirely.
        by_agency[""] = next(iter(by_agency.values()))

    by_route: dict[str, str] = {}
    for row in routes.itertuples(index=False):
        agency_id = str(getattr(row, "agency_id", "") or "")
        zone = by_agency.get(agency_id) or by_agency.get("")
        if zone is None:
            log.error("route %s names agency %r, which agency.txt does not define",
                      row.route_id, agency_id)
            continue
        by_route[str(row.route_id)] = zone

    by_trip = {str(r.trip_id): by_route[str(r.route_id)]
               for r in trips.itertuples(index=False)
               if str(r.route_id) in by_route}

    # Captured for DISPLAY only. It never interprets a schedule time.
    stop_display = {}
    if "stop_timezone" in stops.columns:
        stop_display = {str(r.stop_id): str(r.stop_timezone)
                        for r in stops.itertuples(index=False)
                        if pd.notna(r.stop_timezone) and str(r.stop_timezone) != ""}

    mapping = TimezoneMap(by_agency=by_agency, by_route=by_route, by_trip=by_trip,
                          stop_display=stop_display)
    if len(mapping.zones) > 1:
        log.info("feed spans %d timezone(s): %s — schedule times resolve per agency",
                 len(mapping.zones), sorted(mapping.zones))
    if stop_display:
        crossing = {s for s, z in stop_display.items()
                    if z not in mapping.zones}
        log.info("%d stop(s) carry stop_timezone; %d differ from every agency zone "
                 "(display only)", len(stop_display), len(crossing))
    return mapping


def trip_times_to_utc(trip_id: str, service_day: date, offsets_s: list[int],
                      mapping: TimezoneMap) -> list[datetime]:
    """Convert one trip's second-offsets to UTC using its OPERATING agency's zone."""
    zone = ZoneInfo(mapping.for_trip(trip_id))
    noon = datetime.combine(service_day, datetime.min.time().replace(hour=12), tzinfo=zone)
    start_of_day = noon - timedelta(hours=12)
    return [(start_of_day + timedelta(seconds=int(s))).astimezone(ZoneInfo("UTC"))
            for s in offsets_s]


def local_display_time(instant: datetime, stop_id: str, trip_id: str,
                       mapping: TimezoneMap) -> datetime:
    """What the clock on the wall at that stop reads — the ONLY use of stop_timezone."""
    zone = mapping.stop_display.get(stop_id) or mapping.for_trip(trip_id)
    return instant.astimezone(ZoneInfo(zone))
Which timezone field governs what A grid separating the two timezone fields by what each is allowed to be used for. agency_timezone stop_timezone Interprets arrival_time yes, always never Displays a local clock as a fallback yes Required by the spec yes no Scope one agency one stop

Step-by-Step Walkthrough

The resolution chain is materialised as three dictionaries. Trip to zone is what every conversion needs, and deriving it once through route and agency means the hot path is a single lookup. Keeping the intermediate maps also makes a diagnostic possible: when a trip fails to resolve, you can see whether the route or the agency was the missing link.

The empty string keys the unnamed agency. agency_id is optional in a single-agency feed, so both agency.txt and routes.txt may omit it. Using "" as the key for that case lets by_agency.get(agency_id) or by_agency.get("") handle every combination without branching on how many agencies exist.

Every agency_timezone is validated against the IANA database at load. An unknown zone name is a feed defect that must surface immediately, because the alternative is a ZoneInfoNotFoundError raised deep inside a conversion loop halfway through an ingest.

stop_display is named for what it is. The field is captured, and the only function that reads it is local_display_time. That separation is the entire point of the module: there is exactly one call site where stop_timezone may legitimately be used, and it is not in the schedule path.

trip_times_to_utc takes offsets, not clock strings. The parsing of times past 24:00 happens upstream, so this function deals only in seconds since the service day began — which is what makes a cross-midnight, cross-timezone trip convert correctly in one expression.

The service day is anchored at noon minus twelve hours. Same reasoning as everywhere else in this section: midnight is not a stable anchor on a clock-change day, and a multi-timezone feed has several clock-change days because different zones transition on different dates.

Verification and Output

python
def verify(mapping: TimezoneMap, trips: pd.DataFrame) -> None:
    unresolved = [str(r.trip_id) for r in trips.itertuples(index=False)
                  if str(r.trip_id) not in mapping.by_trip]
    assert not unresolved, (
        f"{len(unresolved)} trip(s) resolve to no timezone, e.g. {unresolved[:3]}")

    for zone in mapping.zones:
        ZoneInfo(zone)      # raises if the container has no tz database


def verify_trip_conversion(trip_id: str, service_day: date, offsets_s: list[int],
                           mapping: TimezoneMap) -> None:
    instants = trip_times_to_utc(trip_id, service_day, offsets_s, mapping)
    assert all(a <= b for a, b in zip(instants, instants[1:])), (
        f"trip {trip_id} runs backwards in UTC — the usual cause is interpreting a "
        "time in the stop's timezone rather than the operating agency's")
    span = (instants[-1] - instants[0]).total_seconds()
    assert span == offsets_s[-1] - offsets_s[0], (
        "the UTC span does not match the offset span; a DST transition was applied "
        "twice or a per-stop zone leaked into the conversion")

The second assertion is the one that catches the stop_timezone mistake directly. A trip’s duration in UTC must equal the difference between its first and last offsets — every time, on every day, in every zone. Applying a per-stop zone breaks that identity by exactly the inter-zone offset, and the assertion names the cause.

A single-timezone feed logs nothing. A cross-border one:

text
INFO gtfs.timezone: feed spans 2 timezone(s): ['America/Chicago', 'America/New_York'] — schedule times resolve per agency
INFO gtfs.timezone: 84 stop(s) carry stop_timezone; 31 differ from every agency zone (display only)
A trip that crosses a timezone boundary A coach running from Chicago to Indianapolis: every time is written in the operating agency's zone, including the arrival on the far side of the boundary. Chicago 14:00 CT boundary written in CT Indianapolis 17:30 CT reading the arrival as Eastern makes the journey an hour longer the whole trip resolves in one zone — the operating agency's

Gotchas and Edge Cases

  • Merged feeds where one agency lost its agency_id. The "" fallback then maps that agency’s routes to whichever agency happens to hold the empty key. In a merged feed that is a silent cross-agency timezone assignment, which is a strong argument for synthesising agency_id during the merge rather than after.
  • Zones with different transition dates. Europe and North America change clocks on different weekends, so a feed spanning both has two DST edges roughly a fortnight apart. Nothing here breaks, but any test fixture pinned to one transition date will not exercise the other.
  • Arizona, and other non-observing zones. America/Phoenix does not shift, so a feed spanning it and America/Denver has a relative offset that changes twice a year. Resolving per agency handles it; a fixed offset stored anywhere does not.
  • stop_timezone on a stop within the agency’s own zone. Common, redundant and harmless. Only the stops whose zone differs from every agency zone are interesting, which is why the log reports that count separately.
  • Missing tzdata in a container. ZoneInfo raises at load rather than at conversion, which is the right place — but it means a slim image without the tzdata package fails the whole ingest. Pin it explicitly.

Frequently Asked Questions

Does stop_timezone change how a stop's arrival_time is interpreted?

No, and this is the rule that surprises people. Schedule times are always expressed in the timezone of the agency operating the trip. stop_timezone describes the local time at that stop for display purposes; using it to interpret arrival_time will shift every cross-border call by the offset between the two zones.

What if a feed has several agencies in different timezones?

That is exactly what agency_id is for. Resolve the trip’s route to its agency and use that agency’s timezone. A feed-wide timezone constant is wrong the moment the second agency appears.

Can one trip cross a timezone boundary?

Yes, and it happens on long-distance rail and coach services. The whole trip’s times still resolve in the operating agency’s timezone, so nothing special is needed for the arithmetic — only for presenting a local arrival time to a rider at the far end.

What happens if agency_timezone is missing?

Every time in the feed becomes uninterpretable. It is a required field, and its absence should reject the feed rather than default to UTC, which would silently shift every schedule by the local offset.