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.

TriMet Tuesday service day from its first departure at 02:55 to its last at 27:10. one service day, Tuesday 26 Januaryfirst departure02:55:45morning peak08:00evening23:50:00past midnight24:30:00 = 00:30 Wedlast departure27:10:00 = 03:10 Wed64,812 stop times in the feed are at or after 24:00 — 1.9% of the file spatialworkflow.io
One service day, from 02:55 to 27:10: more than 24 hours, overlapping the next day's first trips.

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.

Symptoms of GTFS times mishandled as clock times. one cause, four symptomssymptomfixValueError on "24:00:44"split on ":" and compute secondsnegative trip durationsnever wrap hours modulo 24late trips sort before 05:00sort on seconds, not stringslate trips missing after 23:00filter on seconds ≥ 82,800Wrapping hours gave 1,067 TriMet trips a negative duration spatialworkflow.io
Four symptoms, one cause: a service-day offset treated as a clock time.

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.

A late-night trip as service-day seconds against hours wrapped modulo 24. a trip from 23:50 to 00:20wrapped modulo 24first stop 23:50last stop 00:20✕duration −23 h 30 min✕sorts before the morning peakservice-day secondsfirst stop 85,800 slast stop 87,600 s (24:20)✓duration 30 min✓sorts at the end of the daySeconds keep the order a rider experiences; wrapping keeps only the ability to parse spatialworkflow.io
Wrapping keeps the values parseable and destroys their order.

Blank, single-digit and very late times

  • Blank times on non-timepoint stops need interpolation before conversion.
  • Single-digit hours such as 5:30:00 are 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.

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.