Transit frequency and headways explained

On this page

Problem statement

"How often does the bus come?" sounds like one number. In GTFS it is at least three: the count of departures at a stop, the gap between consecutive departures on one route in one direction, and the time a rider actually waits. They agree on a simple, regular line and disagree everywhere else β€” at stops served by several routes, at loop stops served in both directions, and on routes whose timetable is irregular.

The disagreement is large enough to change a map. On TriMet's Tuesday timetable, counting departures between 07:00 and 09:00 and dividing into 120 minutes marks 2,159 stops as "every 15 minutes or better". Measuring the headway of the best single route in one direction marks 1,919 β€” and at a 10-minute threshold the two methods give 403 and 37.

Quick answer

Measure headway per route and direction, not per stop, and derive the rider's wait from the gaps rather than assuming half the headway:

import numpy as np

def headway_stats(departure_secs):
    """departure_secs: departures of ONE route in ONE direction at one stop."""
    d = np.sort(np.asarray(departure_secs))
    gaps = np.diff(d) / 60
    return {
        "departures": len(d),
        "median_headway_min": float(np.median(gaps)),
        # a rider arriving at random waits E[H^2] / (2 E[H])
        "expected_wait_min": float((gaps ** 2).sum() / (2 * gaps.sum())),
    }

Half the headway is the expected wait only when the gaps are all equal. Irregular gaps make it longer, because a random arrival is more likely to fall in a long gap than a short one.

Departures at TriMet stop 4522 between 07:00 and 08:00, all together and split by route and direction. stop 4522, 07:00–08:00, Tuesday 26 January 2027all departuresroute 87, direction 0route 87, direction 1route 74, direction 0route 74, direction 107:0007:1507:3007:4508:00Counted together: a bus every 4 minutes. Going one way on one route: every 15 to 20. spatialworkflow.io
Three numbers from the same departures; only one describes the service a rider can use.

Fix date and window, split by route, take the median gap

1. Fix the date and the time window

Frequency depends on both. Use a date with no calendar exceptions and a window that matches the question β€” a two-hour morning peak, the midday base, or the evening. A daily average blends peak and late-night service into a number nobody experiences.

2. Split by route and direction

A stop at the end of a line or on a loop is served in both directions. Counting all departures there counts buses going opposite ways as if they were one service. TriMet's stop 4522 has 28 morning-peak departures β€” one every 4.3 minutes on average β€” but they are two routes in two directions each, and the best single option is route 87 every 14 to 17 minutes.

3. Use the median gap, and keep the maximum

The median gap describes the regular pattern. The maximum gap describes the worst case, which matters for anyone who cannot choose when they travel.

4. Decide whether combined routes count

Two routes that share a corridor and a destination do give a rider a combined frequency. Two routes that diverge after the stop do not. The GTFS feed cannot tell you which; the network map can.

5. Derive the wait from the gaps

For a rider who arrives without looking at the timetable, the expected wait is the sum of squared gaps over twice the sum of gaps. On perfectly regular service that equals half the headway. Across TriMet's stops with at least eight morning-peak departures, the median ratio of expected wait to half the mean gap is 1.03, but for one stop in ten it is 1.38 or more.

6. State the threshold with the map

"Frequent" means different things in different cities: 15 minutes is common, 10 is increasingly used, 12 appears in some standards. Put the threshold, the window and the date in the legend.

TriMet stops classed as frequent in the morning peak under two definitions. stops classed as frequent, 07:00–09:00≀ 10 min, all routes403≀ 10 min, one route37≀ 15 min, all routes2,159≀ 15 min, one route1,919010002000The stricter the threshold, the more the all-routes count flatters the network spatialworkflow.io
At a 10-minute threshold the all-routes count finds more than five times as many frequent stops as the single-route headway.

One stop, three answers about frequency

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 β€” one stop, three answers

import pandas as pd

FEED = "trimet_gtfs"
cal = pd.read_csv(f"{FEED}/calendar.txt", dtype=str)
cd = pd.read_csv(f"{FEED}/calendar_dates.txt", dtype=str)
trips = pd.read_csv(f"{FEED}/trips.txt", dtype=str)
DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]

def services_on(date):
    d = pd.Timestamp(date); ds, dow = d.strftime("%Y%m%d"), DAYS[d.dayofweek]
    weekly = set(cal.loc[(cal[dow] == "1") & (cal.start_date <= ds) & (cal.end_date >= ds), "service_id"])
    ex = cd[cd.date == ds]
    return (weekly | set(ex.loc[ex.exception_type == "1", "service_id"])) \
        - set(ex.loc[ex.exception_type == "2", "service_id"])

def to_secs(s):
    p = s.str.split(":", expand=True).astype(int)
    return p[0] * 3600 + p[1] * 60 + p[2]

day = trips[trips.service_id.isin(services_on("2027-01-26"))]
st = pd.read_csv(f"{FEED}/stop_times.txt",
                 usecols=["trip_id", "departure_time", "stop_id", "pickup_type"],
                 dtype={"trip_id": str, "stop_id": str})
st = st[st.trip_id.isin(day.trip_id) & (st.pickup_type != 1)]
st["dep"] = to_secs(st.departure_time)
st = st.merge(day[["trip_id", "route_id", "direction_id"]], on="trip_id")
peak = st[(st.dep >= 7 * 3600) & (st.dep < 9 * 3600)]

stop = peak[peak.stop_id == "4522"]
print("all departures:", len(stop), "->", round(120 / len(stop), 1), "min 'headway'")
for (route, direction), g in stop.groupby(["route_id", "direction_id"]):
    s = headway_stats(g.dep)
    print(f"route {route} dir {direction}: {s['departures']} deps, "
          f"median {s['median_headway_min']:.0f} min, wait {s['expected_wait_min']:.1f} min")
all departures: 28 -> 4.3 min 'headway'
route 74 dir 0: 6 deps, median 20 min, wait 9.8 min
route 74 dir 1: 6 deps, median 20 min, wait 10.1 min
route 87 dir 0: 8 deps, median 15 min, wait 7.6 min
route 87 dir 1: 8 deps, median 15 min, wait 7.5 min

The stop looks like a four-minute service and is really a fifteen-minute one.

Example 2 β€” a regular frequent line

fx2 = peak[(peak.stop_id == "1435")]
for (route, direction), g in fx2.groupby(["route_id", "direction_id"]):
    gaps = (np.diff(np.sort(g.dep.values)) / 60).round(1)
    print(route, direction, gaps.tolist(), headway_stats(g.dep))
10 0 [64.2] {'departures': 2, 'median_headway_min': 64.25, 'expected_wait_min': 32.125}
2 0 [12.0, 12.0, 12.2, 12.0, 14.0, 12.0, 12.0, 12.0, 12.0] {'departures': 10, 'median_headway_min': 12.0, 'expected_wait_min': 6.139950609817558}

Route 2 β€” the FX2 on Division β€” runs every 12 minutes at this stop, and on a line this regular the expected wait is almost exactly half the headway: 6.1 minutes. Route 10 also calls at this stop, twice in the whole window, which is why splitting by route matters even at a stop on a frequent line.

Example 3 β€” how many stops are "frequent" under each definition

def med_gap(x):
    x = np.sort(x.values)
    return np.median(np.diff(x)) / 60 if len(x) > 1 else np.nan

def headway(x, window_min=120):
    # the larger of the median gap and window / departures, so that two
    # buses a minute apart do not count as a one-minute service
    return max(med_gap(x), window_min / len(x)) if len(x) > 1 else np.nan

any_route = peak.groupby("stop_id").dep.apply(headway)
best_single = (peak.groupby(["stop_id", "route_id", "direction_id"]).dep
               .apply(headway).groupby("stop_id").min())
for thr in (10, 15):
    print(f"<= {thr} min: all routes {int((any_route <= thr).sum()):5,}   "
          f"best single route {int((best_single <= thr).sum()):5,}")
<= 10 min: all routes   403   best single route    37
<= 15 min: all routes 2,159   best single route 1,919

Departures are not frequency

Why departures are not frequency

A departure count answers "how many vehicles call here", which is the operator's view. A rider needs a vehicle going their way, so the relevant count is per direction and, unless routes share a destination, per route. Loop stops, terminals and stops on a shared trunk are where the two diverge most, and they tend to be the busiest stops on a map.

Why the expected wait uses squared gaps

A rider arriving at a random moment is more likely to land in a long gap simply because long gaps occupy more of the clock. Weighting each gap by its own length gives E[HΒ²] / (2 E[H]). With equal gaps this is H / 2; with a 5-minute gap followed by a 25-minute gap, the mean headway is 15 but the expected wait is (25 + 625) / (2 Γ— 30) = 10.8 minutes, not 7.5.

Why the threshold should be stated

Frequency maps are used to argue for or against service changes, zoning and housing. A map that says "frequent" without saying 10 or 15 minutes, which window and which date cannot be checked, and the choice moves thousands of people in or out of the frequent-service area.

Regular 15-minute gaps against alternating 5 and 25 minute gaps, and the expected wait on each. regular: 15, 15, 15, 1515151515expected wait 7.5 minbunched: 5, 25, 5, 25most arrivals land hereexpected wait 10.8 minBoth average 15 minutes and four departures an hour; the wait is E[HΒ²] / 2E[H] spatialworkflow.io
Same average headway, same departures per hour, and a longer wait on the irregular line.

Drop-off-only stops, short turns and midnight

  • pickup_type = 1 stops are drop-off only; exclude them or they count as departures a rider cannot take.
  • frequencies.txt feeds give headways directly for a window; expand them or read them, but do not ignore them.
  • Short turns share a route ID with full trips. A rider going past the turn-back point gets a lower frequency.
  • Scheduled is not delivered. Bunching makes real waits longer than any timetable measure.
  • Window edges clip the first and last gaps. Take departures a little either side and trim.
  • Midnight needs the service-day clock, not clock time.

FAQ

What is a headway?

The time between consecutive departures of the same service in the same direction at a stop.

Is expected wait half the headway?

Only when the gaps are equal. Irregular gaps make the expected wait longer: E[HΒ²] / (2 E[H]).

Should I combine routes at a stop?

Only where they go to the same place. Routes that diverge after the stop do not add frequency for most riders.

What threshold defines frequent service?

There is no standard. Fifteen minutes is common and ten is increasingly used; always state it.

Why does my frequency map look too good?

Probably because it counts all departures at a stop, including both directions and diverging routes.

Which time window should I use?

The one that matches the question β€” typically the morning peak and the midday base, reported separately.