GTFS times after 24:00 break time parsing
On this page
Problem statement
You parse departure_time from a GTFS stop_times.txt and the parser fails on a value like 24:00:44 or 25:17:00. Or it does not fail, because you wrapped hours past 23 back to the start of the day, and now late-night trips have negative durations, sort before the morning rush, and vanish from any "after 23:00" filter.
These times are not errors in the feed. GTFS measures times from the start of the service day, and a trip that leaves at half past midnight under the previous day's timetable is written 24:30:00. In TriMet’s feed, 64,812 stop times — 1.9% of the file — are at or after 24:00, and the last departure is at 27:10:00.
Quick answer
Convert GTFS times to integer seconds since the start of the service day. Never parse them as clock times:
import pandas as pd
st = pd.read_csv("trimet_gtfs/stop_times.txt",
usecols=["trip_id", "stop_sequence", "arrival_time", "departure_time"],
dtype={"trip_id": str})
def to_secs(hhmmss):
p = hhmmss.str.split(":", expand=True).astype("int32")
return p[0] * 3600 + p[1] * 60 + p[2]
st["dep"] = to_secs(st.departure_time)
late = st.dep >= 24 * 3600
print(f"{int(late.sum()):,} stop times at or after 24:00 ({late.mean():.1%}); "
f"latest {st.departure_time[st.dep.idxmax()]}")
64,812 stop times at or after 24:00 (1.9%); latest 27:10:00
Seconds sort, subtract and filter correctly across midnight. When a real timestamp is needed, add them to noon minus twelve hours on the service date, in the agency's time zone.
Stop using clock parsing, keep seconds and the service date
1. Stop parsing with %H:%M:%S
datetime.strptime and pd.to_datetime(format="%H:%M:%S") accept hours 0–23 only. They raise on the first late-night row, which may be hundreds of thousands of rows in.
2. Do not wrap hours with modulo 24
hours % 24 makes the values parse and makes them wrong: a trip from 23:50 to 00:20 now runs backwards for 23.5 hours.
3. Convert to seconds
Split on the colon and compute h * 3600 + m * 60 + s. It is fast, vectorised and correct for any hour value.
4. Keep the service date alongside
A time without its service date is ambiguous. Carry service_date from the calendar step into every table of stop times.
5. Build timestamps only when you need them
Add the seconds to noon minus twelve hours on the service date, localised to agency_timezone. That is how the specification defines them, and it is correct on daylight-saving change days.
6. Decide which day late trips belong to
For a service-day question, keep them where they are. For a clock-date question, include the previous service day's trips past 24:00, shifted back 24 hours.
Reproducing the error and the modulo damage
The data used on this page
TriMet's GTFS feed for Portland, Oregon, downloaded from developer.trimet.org and unzipped to trimet_gtfs/. TriMet republishes it every few weeks, so counts of trips and stop times move between versions; the setup prints the version the numbers on this page came from.
import io
import os
import zipfile
import pandas as pd
import requests
if not os.path.exists("trimet_gtfs"):
feed = requests.get("https://developer.trimet.org/schedule/gtfs.zip", timeout=600)
zipfile.ZipFile(io.BytesIO(feed.content)).extractall("trimet_gtfs")
info = pd.read_csv("trimet_gtfs/feed_info.txt", dtype=str).iloc[0]
print(f"TriMet feed {info.feed_version}, valid {info.feed_start_date} to {info.feed_end_date}")
TriMet feed 20260927-20261007-0900, valid 20260927 to 20270227
Example 1 — the error, reproduced
try:
pd.to_datetime(st.departure_time, format="%H:%M:%S")
except ValueError as e:
print(str(e).splitlines()[0])
time data "24:00:44" doesn't match format "%H:%M:%S". You might want to try:
Example 2 — what modulo 24 does to trip durations
wrapped = to_secs(st.departure_time.str.replace(
r"^(\d+)", lambda m: f"{int(m.group(1)) % 24:02d}", regex=True))
ends = st.assign(wrapped=wrapped).sort_values(["trip_id", "stop_sequence"]).groupby("trip_id").agg(
start=("dep", "min"), end=("dep", "max"),
wstart=("wrapped", "first"), wend=("wrapped", "last"))
bad = (ends.wend < ends.wstart).sum()
print(f"trips with a negative duration after wrapping: {bad:,} of {len(ends):,}")
print(f"longest real trip: {(ends.end - ends.start).max() / 60:.0f} min")
trips with a negative duration after wrapping: 1,067 of 67,515
longest real trip: 121 min
Example 3 — correct timestamps for a service date
from zoneinfo import ZoneInfo
tz = ZoneInfo("America/Los_Angeles") # agency.txt → agency_timezone
def gtfs_to_timestamp(service_date, secs):
noon = pd.Timestamp(f"{service_date} 12:00").tz_localize(tz)
return noon - pd.Timedelta(hours=12) + pd.to_timedelta(secs, unit="s")
for t in ["23:50:00", "24:30:00", "27:10:00"]:
print(t, "->", gtfs_to_timestamp("2027-01-26", to_secs(pd.Series([t]))[0]))
23:50:00 -> 2027-01-26 23:50:00-08:00
24:30:00 -> 2027-01-27 00:30:00-08:00
27:10:00 -> 2027-01-27 03:10:00-08:00
Why GTFS allows hours past 23
Why GTFS allows hours past 23
A trip belongs to exactly one service day, and its calendar decides whether it runs. If a Friday-night bus leaving at 00:30 were written as 00:30:00, it would sit at the start of Friday's timetable, sixteen hours before the evening it actually belongs to, and it would run under Friday's calendar on the wrong night. Writing it as 24:30:00 keeps it at the end of Friday's service, under Friday's calendar, where every rider expects it.
Why modulo 24 is worse than an error
An error stops the pipeline at the cause. Wrapping hours lets the pipeline run and breaks every computation that depends on order or difference: trip durations, headways across midnight, travel times after 23:00. The results are wrong only for late-night service, which is the part of the timetable least likely to be checked.
Why the anchor is noon
On the day clocks change, local midnight is 23 or 25 hours before the next one. GTFS sidesteps this by measuring from noon minus twelve hours, which is midnight on every ordinary day and the correct offset on the two change days.
Blank, single-digit and very late times
- Blank times on non-timepoint stops need interpolation before conversion.
- Single-digit hours such as
5:30:00are valid in some feeds; splitting on the colon handles them. - Times over 48:00 are legal but rare, used for multi-day trips.
- Frequencies.txt start and end times follow the same rule.
- Realtime predictions are POSIX timestamps and must be compared with anchored schedule times.
- Sorting by the original strings is wrong even before midnight:
"10:00:00" < "9:00:00"as text.
Internal links
- GTFS service calendars explained — why late trips stay on their service day
- How to find the trips running on a given date in GTFS — clock dates against service days
- How to read a GTFS feed in Python — types for every column
- How to read GTFS Realtime vehicle positions and delays in Python — anchoring schedules for delays
- GTFS explained — the tables involved
- My time column sorts or joins wrongly — time zones in general
FAQ
Why does GTFS have times like 25:30:00?
Because times are measured from the start of the service day. A trip after midnight under the previous day's timetable is written past 24:00.
How do I parse GTFS times in pandas?
Split on the colon and compute seconds. Do not use to_datetime with %H:%M:%S.
Can I just use hours modulo 24?
No. It makes late trips sort first and gives them negative durations.
How do I turn GTFS times into timestamps?
Add the seconds to noon minus twelve hours on the service date, in the agency time zone.
Which date does a 25:00 trip run on?
It runs at 01:00 on the calendar day after its service date, under the service date's calendar.
How many stop times are after midnight?
It varies; in TriMet's feed, 1.9% of all stop times.