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.
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.
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.
Drop-off-only stops, short turns and midnight
pickup_type = 1stops are drop-off only; exclude them or they count as departures a rider cannot take.frequencies.txtfeeds 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.
Internal links
- GTFS explained β the tables behind the counts
- How to calculate service frequency at every stop from GTFS β the full pipeline
- GTFS service calendars explained β choosing the date
- How to measure the population served by frequent transit β where the threshold ends up
- Transit accessibility explained β how headways turn into travel time
- Accessibility measures explained β nearest, cumulative and gravity
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.