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.
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
"""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))
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
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:
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)
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 synthesisingagency_idduring 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/Phoenixdoes not shift, so a feed spanning it andAmerica/Denverhas a relative offset that changes twice a year. Resolving per agency handles it; a fixed offset stored anywhere does not. stop_timezoneon 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.
ZoneInforaises at load rather than at conversion, which is the right place — but it means a slim image without thetzdatapackage 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.
Related
- Converting Local Transit Times to UTC in Python — the single-timezone version of the same conversion
- Handling Daylight Saving Time in GTFS Schedules — the transitions a multi-zone feed has several of
- Up: Timezone Handling and Schedule Normalization — the temporal model this extends
- Section: GTFS Feed Architecture & Fundamentals · Home