Handling Daylight Saving Time in GTFS Schedules

Treat every departure_time and arrival_time in stop_times.txt as a naive local time anchored to the agency’s declared IANA timezone, then convert to UTC only after pairing with the correct service_date. Python’s zoneinfo module resolves DST offsets automatically when you supply the right date; the fold attribute on datetime disambiguates the two clock readings that occur during a fall-back transition, and a UTC round-trip check catches the non-existent times that fall inside a spring-forward gap.


Why DST Creates Ambiguity in GTFS Feeds

The GTFS specification stores schedule times as HH:MM:SS strings with no UTC offset, no DST flag, and no ISO 8601 suffix. This design keeps feed files compact and avoids encoding timezone transitions inside the spec itself, but it shifts the full normalization burden onto every consumer.

For most of the year this is invisible — a 06:00:00 departure in America/Chicago is unambiguously 11:00 UTC in winter or 10:00 UTC in summer. The difficulty arises twice a year:

  • Spring forward (gap): Clocks jump from 02:00 to 03:00, so any stop_times.txt value between 02:00:00 and 02:59:59 on the transition date does not correspond to a real instant in time. Feeding one of these values to a naive datetime.strptime call silently produces a wrong UTC timestamp.
  • Fall back (fold): Clocks roll back from 02:00 to 01:00, so times between 01:00:00 and 01:59:59 occur twice. A naive parser can silently pick the wrong occurrence and shift an evening trip an hour later in your database.

Proper handling requires pairing each time value with its service_date from calendar.txt or calendar_dates.txt. Without the exact calendar date you cannot look up which DST rules apply. This date-time coupling is the foundation of timezone handling and schedule normalization and must be resolved before matching real-time vehicle positions, computing headways, or generating isochrones.

The diagram below shows the two transition scenarios and where each failure can occur in a naive pipeline:

DST Transition Scenarios in GTFS Time Parsing Two side-by-side timelines: spring-forward shows a gap between 02:00 and 03:00 where GTFS times are invalid; fall-back shows two clock readings for the same local hour, requiring fold disambiguation. Spring Forward (Gap) e.g. America/New_York — second Sunday in March local time 01:00 02:00 03:00 GAP times don't exist clocks jump 01:00 forward Risk: naive parse returns wrong UTC (+1 h error) Fall Back (Fold) e.g. America/New_York — first Sunday in November time → 00:00 01:00 02:00 FOLD occurs twice fold=0 (DST) fold=1 (ST) clocks roll back 01:00 Risk: wrong fold selection shifts trip by 1 h silently

Production-Ready Python Implementation

The function below handles GTFS time parsing, overnight day offsets, spring-forward detection, and fall-back disambiguation using Python’s standard library (3.9+). It returns timezone-aware UTC datetime objects suitable for database storage or alignment with GTFS-RT feeds.

python
import datetime
import re
import logging
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from typing import Optional

logger = logging.getLogger(__name__)


def parse_gtfs_time_to_utc(
    time_str: str,
    service_date: datetime.date,
    agency_tz: str,
    *,
    strict_dst: bool = False,
) -> Optional[datetime.datetime]:
    """
    Convert a GTFS HH:MM:SS string to a timezone-aware UTC datetime.

    Args:
        time_str:     Raw GTFS time, e.g. '26:15:00' for 02:15 the next day.
        service_date: The calendar date on which this trip operates (from
                      calendar.txt / calendar_dates.txt).
        agency_tz:    IANA timezone identifier from agency.txt, e.g.
                      'America/Chicago'.
        strict_dst:   If True, return None for times that fall in a
                      spring-forward gap; if False, normalise forward.

    Returns:
        A UTC datetime, or None if the input cannot be resolved.
    """
    if not time_str or not re.match(r"^\d{1,3}:\d{2}:\d{2}$", time_str):
        logger.warning("Invalid GTFS time format: %r", time_str)
        return None

    try:
        tz = ZoneInfo(agency_tz)
    except ZoneInfoNotFoundError:
        logger.error("Unknown IANA timezone identifier: %r", agency_tz)
        return None

    hours, minutes, seconds = (int(p) for p in time_str.split(":"))

    # GTFS extended hours: 25:00:00 means 01:00:00 the day after service_date.
    day_offset: int = hours // 24
    local_hour: int = hours % 24

    # Build the naive local datetime on the adjusted calendar date.
    effective_date = service_date + datetime.timedelta(days=day_offset)
    naive_dt = datetime.datetime(
        year=effective_date.year,
        month=effective_date.month,
        day=effective_date.day,
        hour=local_hour,
        minute=minutes,
        second=seconds,
    )

    # Attach the IANA timezone.
    # fold=0: first occurrence — the pre-transition (DST) reading during fall-back.
    # fold=1: second occurrence — the post-transition (standard) reading.
    # GTFS schedulers typically publish in the pre-transition frame, so fold=0
    # is the correct default for timetable-based feeds.
    local_dt = naive_dt.replace(tzinfo=tz, fold=0)

    # Detect spring-forward gaps via a UTC round-trip.
    # A non-existent local time will map to a UTC instant whose local
    # representation differs from the original naive datetime.
    utc_dt = local_dt.astimezone(datetime.timezone.utc)
    round_tripped = utc_dt.astimezone(tz).replace(tzinfo=None)

    if round_tripped != naive_dt:
        if strict_dst:
            logger.warning(
                "Non-existent local time in DST spring-forward gap: %s %s",
                naive_dt,
                agency_tz,
            )
            return None
        # Lenient mode: advance to the post-gap equivalent (fold=1 maps to
        # the moment clocks resume after the spring-forward skip).
        local_dt = naive_dt.replace(tzinfo=tz, fold=1)
        utc_dt = local_dt.astimezone(datetime.timezone.utc)
        logger.debug(
            "DST gap normalised: %s %s → %s UTC", naive_dt, agency_tz, utc_dt
        )

    return utc_dt

Step-by-Step Walkthrough

1. Validate the time string

The regex ^\d{1,3}:\d{2}:\d{2}$ accepts both standard (08:30:00) and extended-hour (26:15:00) GTFS values while rejecting malformed strings early. GTFS feeds in the wild sometimes contain empty strings or Excel-mangled formats such as 8:30 — catching these before datetime construction prevents ValueError exceptions deep in the conversion pipeline.

2. Parse the IANA timezone

ZoneInfo(agency_tz) loads offset rules from the system IANA database (or from the tzdata package on Windows). If the identifier is unknown — a common problem when an agency publishes a non-standard string like "US/Eastern" instead of "America/New_York" — the function logs an error and returns None rather than silently applying the wrong offset.

3. Resolve overnight day offset

GTFS allows departure_time values up to 47:59:59 to represent trips that finish well after midnight. Integer division (hours // 24) extracts how many calendar days to add to service_date, while the modulo (hours % 24) gives the actual clock hour. This is applied to service_date before building the naive datetime, so the IANA database lookup happens on the correct calendar date — critical for transitions that occur overnight.

4. Attach the timezone with fold

naive_dt.replace(tzinfo=tz, fold=0) tells Python to interpret the naive datetime in the given IANA zone. The fold parameter carries no effect for times that are unambiguous — Python ignores it outside of the overlap window — so it is safe to always pass fold=0 as the default. Only during the one-hour fall-back window does it change the resulting UTC value.

5. Detect spring-forward gaps

The round-trip check (naive_dt → UTC → local → naive) exploits the fact that non-existent local times still produce a valid UTC instant, but converting that UTC instant back to local time yields a different value. For example, 02:30 America/New_York on the spring-forward date converts to 07:30 UTC, which in turn converts back to 03:30 local — the mismatch (03:30 ≠ 02:30) flags the gap. In lenient mode, fold=1 maps to the first valid moment after the transition.

Verification and Output

After deploying the parser, run this validation block against known transition dates to confirm correct behaviour:

python
import datetime

# Test cases: (time_str, service_date, agency_tz, expected_utc_hour)
CASES = [
    # Standard time: UTC-5
    ("08:00:00", datetime.date(2024, 1, 15), "America/New_York", 13),
    # Daylight time: UTC-4
    ("08:00:00", datetime.date(2024, 7, 15), "America/New_York", 12),
    # Overnight trip: 25:00 on 2024-06-12 = 01:00 on 2024-06-13 local (UTC-4)
    ("25:00:00", datetime.date(2024, 6, 12), "America/New_York", 5),
    # Fall-back ambiguous (fold=0 → DST reading → UTC-4)
    ("01:30:00", datetime.date(2024, 11, 3), "America/New_York", 5),
    # Spring-forward gap → lenient mode normalises to 03:30 local (UTC-4 → UTC 07:30)
    ("02:30:00", datetime.date(2024, 3, 10), "America/New_York", 7),
]

for time_str, sdate, tz, expected_hour in CASES:
    result = parse_gtfs_time_to_utc(time_str, sdate, tz, strict_dst=False)
    assert result is not None, f"Unexpectedly None for {time_str} on {sdate}"
    assert result.hour == expected_hour, (
        f"{time_str} on {sdate} {tz}: "
        f"expected UTC hour {expected_hour}, got {result.hour} ({result})"
    )
    print(f"PASS  {time_str} on {sdate}{result.isoformat()}Z")

A successful run prints five PASS lines. For feeds you do not control, add a count check after bulk processing:

python
import pandas as pd

stop_times_df = pd.read_csv(
    "stop_times.txt",
    dtype={"trip_id": str, "departure_time": str, "arrival_time": str},
)

# Parse and count failures
stop_times_df["departure_utc"] = stop_times_df.apply(
    lambda row: parse_gtfs_time_to_utc(
        row["departure_time"], service_date, agency_tz
    ),
    axis=1,
)

null_count = stop_times_df["departure_utc"].isna().sum()
total = len(stop_times_df)
print(f"Parsed {total - null_count}/{total} departure times successfully.")
assert null_count / total < 0.001, (
    f"More than 0.1% of departure times failed to parse ({null_count}); "
    "check agency_timezone and feed quality."
)

Gotchas and Edge Cases

  • Outdated system tzdata: The IANA Time Zone Database is updated several times per year as countries change their DST rules. Outdated tzdata on a container base image will silently apply wrong offsets for future or recently-changed transitions. Pin tzdata in your requirements.txt and rebuild images promptly after IANA database releases.

  • Multi-timezone agencies: Some large transit authorities declare a single agency_timezone in agency.txt but operate routes that cross timezone boundaries. stops.txt carries an optional stop_timezone field that overrides the agency default at the stop level. For multi-timezone feeds, apply stop_timezone resolution before calling this function rather than passing the agency timezone blindly.

  • Legacy feeds with non-IANA identifiers: Windows-style identifiers (Eastern Standard Time) or abbreviated strings (EST) are not valid IANA keys and will cause ZoneInfoNotFoundError. Maintain a mapping table of common aliases to their canonical IANA equivalents and apply it during feed validation before normalization.

  • Strict mode during ingestion vs. lenient in routing: Run strict_dst=True when first ingesting a feed to surface malformed agency schedules. Switch to strict_dst=False in your live routing engine so that legacy trips with gap times are gracefully advanced rather than silently dropped — a missing trip is worse than a one-minute shift in a gap scenario.


Frequently Asked Questions

Does GTFS store timezone offsets in stop_times.txt?

No. GTFS stores departure_time and arrival_time as plain HH:MM:SS strings with no UTC offset and no DST flag. The consumer must pair each time with its service_date and the agency_timezone declared in agency.txt, then resolve the correct UTC offset through an IANA timezone database lookup. The string alone is never enough to identify a real instant.

What happens to a trip scheduled at 02:30 during a spring-forward transition?

The local time 02:30 does not exist on the transition date — clocks jump straight from 02:00 to 03:00. A robust parser detects this with a UTC round-trip check and either normalises the value forward to 03:30 (lenient mode, strict_dst=False) or returns None and logs a warning (strict mode, strict_dst=True). A naive datetime.strptime call produces a silently wrong UTC timestamp instead.

How does Python's fold attribute resolve fall-back ambiguity?

During a fall-back transition a local time such as 01:30 occurs twice — once in daylight time and once in standard time. datetime.fold=0 selects the first occurrence (DST) and fold=1 selects the second (standard). GTFS schedulers typically publish timetable times in the pre-transition (DST) frame, so fold=0 is the safer default for timetable-based feeds.

Should I store GTFS times as UTC or keep them local?

Keep the original HH:MM:SS string for display and timetable reconstruction, and store a derived timezone-aware UTC column for joins, headway maths, and alignment with GTFS-RT. Storing only UTC loses the agency’s intended wall-clock presentation; storing only local strings forces every downstream query to repeat the DST resolution. Persist both, with the UTC value computed once at ingestion.


Up: Timezone Handling and Schedule Normalization | GTFS Feed Architecture Fundamentals | Home