Calculating Service Headways from GTFS

To compute headways from a timetable feed, convert departure_time in stop_times.txt to seconds since midnight (so overnight values past 24:00:00 sort correctly), sort departures within each (route_id, stop_id, service_id) group, difference consecutive departures to get the gap between vehicles, and then aggregate those gaps into time-of-day bands to report peak versus off-peak frequency. The result is a per-route, per-band summary that reveals how often a rider actually waits — the operational reality that a raw trip count cannot express.

Root Cause Analysis

Trip counts are a poor proxy for service quality. A route with sixty trips a day could run every ten minutes during two rush peaks and not at all between them, or it could run steadily every twenty-four minutes from dawn to midnight. Riders experience the gap between vehicles — the headway — not the daily total. Deriving headways correctly from a GTFS feed is therefore the foundation of any honest frequency, capacity, or equity analysis.

The subtlety is that a headway is defined per stop, per direction of service, and per calendar. The same route can have a twelve-minute headway on a weekday and a thirty-minute headway on a Sunday, so service_id must be part of the grouping key. It can also short-turn: some trips run only the central portion of the line, so the headway at a downtown stop is tighter than at a terminal. Measuring at a single arbitrary stop distorts the picture. The grouping key that yields correct headways is (route_id, stop_id, service_id) — hold any of those constant and vary the rest, and you are differencing departures that were never meant to be compared.

The most damaging failure mode is silent mis-ordering of overnight service. GTFS deliberately allows departure_time values beyond 24:00:0025:10:00 means 1:10 AM on the next service day — so a late-night trip stays attached to the correct operating day. If you parse these as ordinary clock times they wrap to 01:10:00 and sort before the evening departures, producing negative or absurdly large headways. This is why the frequency-based versus timetable schedule model insists that time be treated as elapsed seconds from midnight, not as a wall clock. It is also why converting to UTC for schedule normalization must happen only after the raw offset arithmetic is done.

Headway derivation and time-of-day banding departure_time strings become seconds since midnight, are sorted within each route stop and service group, differenced into headways, then aggregated into peak and off-peak bands. parse to seconds > 24:00 ok sort per group route, stop, service diff() gap = headway band by hour peak / off-peak median per route

Production-Ready Python Implementation

The script parses departure_time into seconds without a clock-time parser, joins route_id and service_id from trips.txt, differences departures within each (route_id, stop_id, service_id) group, assigns each departure to a time-of-day band, and returns the median headway per route and band. It uses pandas with explicit dtypes throughout.

python
import logging
from pathlib import Path

import numpy as np
import pandas as pd

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

# Peak bands (inclusive start, exclusive end) in hours-since-midnight.
TIME_BANDS = [
    ("early", 0, 6),
    ("am_peak", 6, 9),
    ("midday", 9, 15),
    ("pm_peak", 15, 19),
    ("evening", 19, 24),
    ("owl", 24, 30),   # overnight service on the same operating day
]


def gtfs_time_to_seconds(series: pd.Series) -> pd.Series:
    """Convert HH:MM:SS strings to seconds since midnight, allowing HH >= 24."""
    parts = series.str.strip().str.split(":", expand=True)
    hours = pd.to_numeric(parts[0], errors="coerce")
    minutes = pd.to_numeric(parts[1], errors="coerce")
    seconds = pd.to_numeric(parts[2], errors="coerce")
    return hours * 3600 + minutes * 60 + seconds


def assign_band(seconds: pd.Series) -> pd.Series:
    """Map a seconds-since-midnight series to its time-of-day band label."""
    hours = seconds / 3600.0
    labels = pd.Series(pd.NA, index=seconds.index, dtype="object")
    for name, start, end in TIME_BANDS:
        labels = labels.mask((hours >= start) & (hours < end), name)
    return labels


def compute_headways(feed_dir: str) -> pd.DataFrame:
    """Return median headway (minutes) per route_id, service_id, and time band."""
    feed_path = Path(feed_dir)

    stop_times = pd.read_csv(
        feed_path / "stop_times.txt",
        dtype={"trip_id": str, "stop_id": str, "departure_time": str, "stop_sequence": "Int64"},
        usecols=["trip_id", "stop_id", "departure_time", "stop_sequence"],
    )
    trips = pd.read_csv(
        feed_path / "trips.txt",
        dtype={"trip_id": str, "route_id": str, "service_id": str},
        usecols=["trip_id", "route_id", "service_id"],
    )

    # Drop rows with no scheduled departure (timepoint-only rows leave it blank).
    stop_times = stop_times[stop_times["departure_time"].notna()].copy()
    stop_times["dep_secs"] = gtfs_time_to_seconds(stop_times["departure_time"])
    stop_times = stop_times[stop_times["dep_secs"].notna()]

    events = stop_times.merge(trips, on="trip_id", how="left")

    # Sort so consecutive departures within a group are adjacent, then diff.
    events = events.sort_values(["route_id", "stop_id", "service_id", "dep_secs"])
    events["headway_secs"] = (
        events
        .groupby(["route_id", "stop_id", "service_id"], sort=False)["dep_secs"]
        .diff()
    )

    # First departure in each group has no preceding vehicle -> NaN, drop it.
    headways = events[events["headway_secs"].notna()].copy()
    headways["band"] = assign_band(headways["dep_secs"])

    summary = (
        headways
        .groupby(["route_id", "service_id", "band"], sort=False)["headway_secs"]
        .median()
        .div(60.0)
        .round(1)
        .reset_index(name="median_headway_min")
    )

    logging.info(
        "Computed headways for %d route/service/band combinations",
        len(summary),
    )
    out_path = feed_path / "route_headways.csv"
    summary.to_csv(out_path, index=False)
    logging.info("Headway summary written to %s", out_path)
    return summary


if __name__ == "__main__":
    report = compute_headways("/data/gtfs/metro")
    print(report.to_string(index=False))

Step-by-Step Walkthrough

Time parsing that respects the spec. gtfs_time_to_seconds splits departure_time on the colon and multiplies out the components, so 25:10:00 becomes 90600 seconds rather than wrapping to 1:10 AM. Keeping departures as monotonically increasing seconds is what makes the subsequent diff() meaningful across the midnight boundary.

The grouping key is a triple. Differencing happens within each (route_id, stop_id, service_id) group. Route keeps unrelated lines apart, stop respects short-turning so a downtown headway is not averaged with a terminal headway, and service separates the weekday timetable from the weekend one. Omitting any of the three produces headways computed across departures that never followed one another in reality.

diff() yields the gap to the previous vehicle. After sorting, groupby().diff() on dep_secs returns, for each departure, the seconds since the previous departure in the same group. The first departure in each group has no predecessor, so its value is NaN and is dropped — you cannot have a headway before the first bus.

Banding assigns each departure to a period. assign_band maps the departure’s own time to a labelled window, including an owl band from 24:00 to 30:00 for overnight service that belongs to the same operating day. Median rather than mean is used for the summary because a single long gap around a service break would otherwise inflate the average and misrepresent the typical wait.

Blank departure times are handled. Some stop_times.txt rows are timepoint-only and leave departure_time empty; those rows are filtered before parsing so they neither crash the split nor create phantom departures.

Verification and Output

Sanity-check that the computed headways are physically plausible before publishing them.

python
import pandas as pd
from pathlib import Path

report = pd.read_csv(
    Path("/data/gtfs/metro") / "route_headways.csv",
    dtype={"route_id": str, "service_id": str, "band": str},
)

# No negative headways: a negative gap means departures were mis-sorted
assert (report["median_headway_min"] > 0).all(), "Negative headway detected — check time parsing"

# Peak headways should not exceed off-peak on a typical urban route
pivot = report.pivot_table(
    index="route_id", columns="band", values="median_headway_min", aggfunc="min"
)
if {"am_peak", "midday"}.issubset(pivot.columns):
    worse_at_peak = pivot[pivot["am_peak"] > pivot["midday"] + 5]
    print(f"{len(worse_at_peak)} routes run notably less often at AM peak than midday — review")

print(report.sort_values(["route_id", "band"]).to_string(index=False))

The output CSV has one row per route_id, service_id, and time band, giving the median wait a rider faces in minutes. A well-formed urban feed shows tighter (smaller) headways in am_peak and pm_peak than in midday or evening. Any negative value points straight back to an overnight time that was parsed as a clock time rather than as elapsed seconds — the single most common defect in headway pipelines.

Gotchas and Edge Cases

  • Frequency-based routes carry no timetable to difference. Trips defined in frequencies.txt state their headway directly as headway_secs; their stop_times.txt rows are a template, not real departures. Read the headway from frequencies.txt for those routes and only apply the differencing method to fully enumerated timetable trips — see converting frequencies.txt to exact departure times for how to expand them if you need a unified view.

  • Directional collapse. GTFS encodes direction with direction_id on the trip, and outbound and inbound service at the same physical stop can interleave. If your stop_id values are shared across directions, add direction_id to the grouping key, or you will halve the apparent headway by merging two opposing streams of departures.

  • Duplicate trips inflate frequency. A feed with duplicated trips reports departures that do not exist, compressing headways below reality. Run the duplicate-trip detector before trusting any frequency figure.

  • Sparse routes and the single-departure case. A route with only one daily departure at a stop yields no headway at all, and one with two yields a single gap that the median treats as representative. Report the departure count alongside the median so consumers can distinguish a genuine ten-minute service from a two-trip coincidence.

Frequently Asked Questions

What is a headway in GTFS terms?

A headway is the time gap between consecutive departures of a route at a given stop. In a timetable feed it is computed from stop_times.txt by differencing sorted departure_time values; in a frequency feed it is stated directly in frequencies.txt as headway_secs.

How do I handle GTFS times greater than 24:00:00?

Parse departure_time as seconds since midnight by multiplying the hour, minute, and second components rather than using a clock-time parser. A value like 25:10:00 becomes 90600 seconds, which keeps overnight departures in correct chronological order for differencing.

Should I measure headways at every stop or one representative stop?

Compute per stop, then summarise at a representative timepoint such as the first or a high-ridership stop. Headways can differ along a route where trips short-turn, so a single first-stop measure understates frequency on the busiest central segment.