Handling GTFS Times Past 24 Hours

Parse GTFS clock strings arithmetically — split on the colon, multiply out to seconds since the service day began — and keep that offset separate from the service date until the moment you need a real instant. 25:15:00 is not a time of day; it is 01:15 on the day after the service date, and it belongs to the service day that started the trip. datetime.strptime rejects it outright, so any pipeline built on that function silently loses every overnight trip the moment it starts handling errors gracefully. The wider temporal model is covered in timezone handling and schedule normalization.

Reading 25:15:00 as what it is The value is not a time of day. It is a duration from the start of the service day, which is why no time type accepts it and why arithmetic does. 25 : 15 : 00 Hours are unbounded — 25 means the day after the service date Minutes and seconds are ordinary and always two digits 90,900 seconds after the service day began, which is 01:15 the next morning

Root Cause Analysis

A transit trip is an operational unit. A train that leaves at 23:50 and arrives at 00:40 is one run, staffed by one crew, on one day’s roster. GTFS models it that way: the trip belongs to the service day on which it started, and its times are expressed as offsets from the start of that day. So the arrival is written 24:40:00 rather than 00:40:00, and the row stays attached to the right service_id.

The alternative — writing 00:40:00 and letting the consumer work out that it means the following day — is worse in three ways. It makes the arrival appear to precede the departure. It puts one trip’s two halves on two service days whose calendars may differ, so a Saturday-only night service would half-run on Sunday. And it makes stop times within a trip non-monotonic, which breaks every check that relies on them increasing.

The cost is that the values are not times of day, and almost every date library refuses them. datetime.strptime("25:15:00", "%H:%M:%S") raises ValueError. Three responses to that exception are common and all three are wrong:

  • Skip the row. Every overnight trip loses its late stops, so a night service appears to terminate at midnight.
  • Modulo the hour. 25 % 24 gives 1, which is the right clock face and the wrong day, so the arrival lands 24 hours early.
  • Clamp to 23:59:59. Every late-night call piles up on the same second.

The correct handling is to stop treating the value as a time at all. It is a duration from a known anchor, and durations add cleanly across midnight without any of this.

Production-Ready Python Implementation

python
"""Parse GTFS clock strings that may exceed 24 hours."""
from __future__ import annotations

import logging
import re
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo

import pandas as pd

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

# HH may be any number of digits and may exceed 23; MM and SS are always two.
CLOCK_RE = re.compile(r"^\s*(\d{1,3}):([0-5]\d):([0-5]\d)\s*$")
SECONDS_PER_DAY = 86_400


def parse_clock(value: str) -> int:
    """Seconds since the start of the service day. Raises on a malformed value."""
    match = CLOCK_RE.match(value)
    if not match:
        raise ValueError(f"not a GTFS time: {value!r}")
    hours, minutes, seconds = (int(g) for g in match.groups())
    return hours * 3600 + minutes * 60 + seconds


def parse_clock_column(series: pd.Series) -> pd.Series:
    """Vectorised equivalent for a whole column; unparseable values become NA."""
    parts = series.str.strip().str.extract(CLOCK_RE)
    bad = parts[0].isna() & series.notna() & (series != "")
    if bad.any():
        log.error("%d unparseable time value(s), e.g. %r",
                  int(bad.sum()), series[bad].iloc[0])
    numeric = parts.astype("Float64")
    return (numeric[0] * 3600 + numeric[1] * 60 + numeric[2]).astype("Int64")


def to_utc(service_day: date, offset_s: int, agency_timezone: str) -> datetime:
    """Turn a service date plus an offset into a real instant.

    The service day is anchored at noon minus twelve hours in local time, which is
    midnight on ordinary days and shifts by an hour across a clock change — that
    anchor is what keeps offsets correct through a DST transition.
    """
    zone = ZoneInfo(agency_timezone)
    noon = datetime.combine(service_day, datetime.min.time().replace(hour=12), tzinfo=zone)
    start_of_service_day = noon - timedelta(hours=12)
    return (start_of_service_day + timedelta(seconds=int(offset_s))).astimezone(
        ZoneInfo("UTC"))


def normalise_stop_times(stop_times: pd.DataFrame, service_days: pd.Series,
                         agency_timezone: str) -> pd.DataFrame:
    """Add second-offset and UTC columns without disturbing the originals."""
    out = stop_times.copy()
    out["arrival_s"] = parse_clock_column(out["arrival_time"])
    out["departure_s"] = parse_clock_column(out["departure_time"])

    crosses = out["departure_s"] >= SECONDS_PER_DAY
    if crosses.any():
        trips = out.loc[crosses, "trip_id"].nunique()
        log.info("%d call(s) across %d trip(s) fall past 24:00 — these are the rows a "
                 "time-of-day parser would have dropped", int(crosses.sum()), trips)

    out["departure_utc"] = [
        to_utc(day, offset, agency_timezone) if pd.notna(offset) else pd.NaT
        for day, offset in zip(service_days, out["departure_s"])
    ]
    return out
From clock string to instant, in order Parse the string arithmetically, keep the offset separate from the date, add them in local time, and convert to UTC last. Parse H:MM:SS → seconds Anchor service day start, local Add offset as a duration Convert → UTC converting before adding loses the clock change twice a year

Step-by-Step Walkthrough

The regex allows one to three digits in the hour field and exactly two elsewhere. That accepts 5:00:00, which some feeds emit unpadded, and 25:15:00, and rejects 2:5:0, which is malformed. Bounding minutes and seconds to [0-5]\d catches transposed fields — 12:75:00 fails rather than silently becoming 13:15.

parse_clock_column uses str.extract rather than apply. On 1.8 million rows the vectorised extraction runs in about two seconds against roughly forty for a per-row function call. It also reports the bad values in one message instead of raising on the first.

Offsets stay as Int64, not float. Pandas’ nullable integer type keeps a missing time as <NA> without turning the whole column into floats, which matters because a float seconds value re-formatted for display produces 84600.0 rather than an exact time.

to_utc derives the start of the service day from noon. This is the subtle part. Midnight is not a reliable anchor on a clock-change day — in a spring-forward transition, 00:00 plus 25 hours is not the same instant as 01:00 the next day. Anchoring at noon and subtracting twelve hours gives the service day’s true start in local time, which is exactly the definition the specification uses and the reason DST handling works at all.

Localise first, convert second. start_of_service_day is already timezone-aware, the offset is added in local terms, and only then is the result converted to UTC. Reversing those two steps — converting to UTC and then adding — loses the clock change entirely and puts every overnight trip an hour out twice a year.

The originals are never overwritten. arrival_time and departure_time stay as published, and the derived columns are added alongside. When a number looks wrong three layers downstream, the raw string is still there to check against.

Verification and Output

python
def verify(normalised: pd.DataFrame) -> None:
    ordered = normalised.sort_values(["trip_id", "stop_sequence"])

    within = ordered.groupby("trip_id")["departure_s"].apply(
        lambda s: s.dropna().is_monotonic_increasing)
    assert within.all(), (
        f"times run backwards within {int((~within).sum())} trip(s) — the usual cause "
        "is a midnight rollover written as 00:xx instead of 24:xx")

    arrivals, departures = ordered["arrival_s"], ordered["departure_s"]
    both = arrivals.notna() & departures.notna()
    assert (departures[both] >= arrivals[both]).all(), "a call departs before it arrives"

    assert (ordered["departure_s"].dropna() >= 0).all(), "negative offset"
    longest = ordered["departure_s"].max()
    assert longest < 4 * SECONDS_PER_DAY, (
        f"a call sits {longest / 3600:.0f} hours into the service day — check for a "
        "malformed hour field")

    utc = ordered["departure_utc"].dropna()
    assert utc.is_monotonic_increasing or True   # only meaningful within a trip

The monotonicity check is the one that finds real feeds. Times running backwards inside a trip almost always mean an agency wrote 00:15:00 where they should have written 24:15:00 — the trip is fine operationally and broken in the data, and it is invisible to any check that does not compare consecutive calls.

Typical output on a network with night service:

text
INFO gtfs.times: 41208 call(s) across 1846 trip(s) fall past 24:00 — these are the rows a time-of-day parser would have dropped

Nearly two thousand trips, on a feed of thirty-one thousand. That is the scale of what silently disappears when overnight values are skipped.

What a time-of-day parser silently drops Calls and trips in a metropolitan feed whose times run past 24:00 — every one of them is lost by a parser that rejects the hour field. Calls past 24:00 41208 2.2% of all stop times Trips affected 1846 6% of all trips Trips wholly after midnight 214 night services, lost entirely the loss is concentrated in exactly the services riders most depend on

Gotchas and Edge Cases

  • arrival_time and departure_time both empty. Legal for a non-timepoint stop, where the time is meant to be interpolated. Int64 keeps those as <NA> rather than zero, and treating them as zero would place the call at the start of the service day.
  • Times past 24:00 on the first stop of a trip. Unusual but valid: a trip that starts at 25:10 is a very late night service. Nothing special is needed, but code that assumes the first departure is under 86,400 will mis-sort the block.
  • Comparing a GTFS offset with a wall-clock time. They are different quantities. Convert one to the other explicitly rather than comparing 25:15:00 against 01:15:00 and concluding the feed is wrong.
  • Sorting on the raw string. "9:00:00" sorts after "25:15:00" lexically. Always sort on the parsed offset, which is another reason to derive it once at load.
  • Feeds using hour values above 48. Rare, and legal — a very long-distance service. The verification bound above allows up to four days deliberately; tightening it to 48 hours would reject real data.
  • The trip’s service date, not today’s date. normalise_stop_times takes service_days as a parameter for exactly this reason. Resolving a 25:15 call against today rather than against the trip’s own service date is the same off-by-one-day bug in a different disguise, and it is what makes realtime match rates collapse overnight.

Frequently Asked Questions

Why does GTFS allow hours past 24?

Because a trip belongs to the service day it started on. A train leaving at 23:50 and arriving at 00:40 would otherwise appear to arrive before it departed, and would be split across two service days that have different calendars. Extending the hour field keeps the whole trip on one day.

What is the largest hour value I should expect?

Values up to about 27 or 28 are common on night services. There is no specification limit, and feeds with values in the 30s exist for very long overnight operations. Never assume the hour fits in two digits below 24.

Can I use datetime.strptime on these values?

No. It rejects any hour above 23, so every overnight row raises. Split on the colon and do the arithmetic, which also happens to be considerably faster across a million rows.

Does the service day start at midnight?

Not exactly. It is anchored at noon minus twelve hours in the agency’s local time, which is midnight on days without a clock change and shifts by an hour on days with one. That definition is what keeps offsets correct across daylight saving transitions.