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.
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 % 24gives 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
"""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
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
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:
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.
Gotchas and Edge Cases
arrival_timeanddeparture_timeboth empty. Legal for a non-timepoint stop, where the time is meant to be interpolated.Int64keeps 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_timestakesservice_daysas 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.
Related
- Converting Local Transit Times to UTC in Python — the full conversion pipeline this parsing feeds
- Handling Daylight Saving Time in GTFS Schedules — why the service day is anchored at noon
- Up: Mastering stops.txt and stop_times.txt Relationships — the table these values live in
- Section: GTFS Feed Architecture & Fundamentals · Home