How to calculate service frequency at every stop from GTFS

On this page

Problem statement

A frequency map is the most requested product built from GTFS, and the steps look simple: count departures at each stop in a time window, turn the count into a headway, colour the stops. Each step has a trap. The date must be resolved through the calendar, drop-off-only calls must be excluded, departures must be split by route and direction, and the window must be stated β€” otherwise the map describes a timetable nobody rides.

On TriMet's Tuesday timetable, 5,729 stops have a boardable departure between 07:00 and 09:00. Measured properly β€” the best single route in one direction, with a guard against stops served by two buses a minute apart β€” 1,919 of them have a headway of 15 minutes or better.

Quick answer

Filter to one service day and window, compute the median gap per stop, route and direction, and keep the best per stop:

import numpy as np
import pandas as pd

FEED = "trimet_gtfs"
DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
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)

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]

def stop_headways(date, start_h, end_h):
    day = trips[trips.service_id.isin(services_on(date))]
    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[(st.dep >= start_h * 3600) & (st.dep < end_h * 3600)]
    st = st.merge(day[["trip_id", "route_id", "direction_id"]], on="trip_id")

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

    per_rd = (st.groupby(["stop_id", "route_id", "direction_id"]).dep
              .agg(departures="size", median_gap=med_gap).reset_index())
    # two buses a minute apart are not a one-minute service
    per_rd["headway"] = np.maximum(per_rd.median_gap, (end_h - start_h) * 60 / per_rd.departures)
    best = per_rd.sort_values("headway").drop_duplicates("stop_id")
    return best.rename(columns={"route_id": "best_route"}), st

best, peak = stop_headways("2027-01-26", 7, 9)
print(len(best), "stops;", int((best.headway <= 15).sum()), "with a route every 15 min or better")
5729 stops; 1919 with a route every 15 min or better
Map of Portland’s transit stops coloured by the best morning-peak frequency at each: frequent stops, every 15 minutes or better, form a dense grid across the east side and lines radiating from downtown; the suburbs are mostly served every 15 to 30 minutes or less often. Map of Portland’s transit stops coloured by the best morning-peak frequency at each: frequent stops, every 15 minutes or better, form a dense grid across the east side and lines radiating from downtown; the suburbs are mostly served every 15 to 30 minutes or less often.
Every stop coloured by its best route’s headway, 07:00–09:00 on a Tuesday. On the feed of 27 September 2026, for Tuesday 26 January 2027, 1,919 of 5,729 stops have a route every 15 minutes or better. The frequent grid covers the inner east side and the corridors out of downtown; outside it, most stops wait 15–30 minutes or longer.

The np.maximum line is a guard. A median gap describes a pattern only when there are enough departures to form one: two buses two minutes apart have a median gap of 2.0, and without the guard they put a stop in the "10 minutes or better" band. Taking the larger of the median gap and window Γ· departures stops that. A stop with one departure gets NaN, which is also correct β€” one bus in two hours has no headway.

From a GTFS feed to a stop frequency map. six steps to a frequency map1resolve the service datecalendar + exceptions2drop pickup_type = 1nobody can board3choose the window07:00–09:004split by route and directionchanges the map most5max(median gap, window Γ· departures)guards 2-bus stops6best per stop, then bandskeep the route idStep four decides whether a loop stop looks like a 4-minute or a 15-minute service spatialworkflow.io
Six steps, and the fourth β€” splitting by route and direction β€” is the one that changes the map most.

Resolve the date, drop alighting-only calls, take median gaps

1. Resolve the date

Use a date with no calendar exceptions, as in the active-service guide.

2. Drop calls where nobody can board

pickup_type = 1 means no pickup. TriMet's Gateway Transit Center has a "drop off only" stop with 68 morning departures that no rider can take.

3. Choose and state the window

Peak and base service differ. Compute each window separately and label the output.

4. Split by route and direction

Loop and terminal stops are served both ways, and trunk stops by routes that diverge. The headway a rider can use is per route and direction.

5. Take the median gap, keep the best

The median resists one irregular gap. The best route-direction per stop is the service a rider there can rely on; keep the route ID so the map can say which.

6. Join to stop locations and classify

Join to stops.txt, build points, and classify into bands whose edges are the thresholds you will state in the legend.

TriMet stops by best single-route headway, 07:00–09:00 on Tuesday 26 January 2027. stops by headway band, morning peak≀ 10 min3710–15 min1,88215–30 min1,53330–60 min1,596> 60 min449one departure232010002000Frequent service is almost entirely in the 10–15 band; under 10 minutes is rare spatialworkflow.io
The distribution a frequency map is built on, for one date and one window.

Frequency bands for every stop

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 β€” bands for the map, and what the guard removes

bands = pd.cut(best.headway, [0, 10, 15, 30, 60, np.inf],
               labels=["<=10", "10-15", "15-30", "30-60", ">60"])
print(bands.value_counts(sort=False).to_dict(), "| no headway:", int(best.headway.isna().sum()))
raw = best.median_gap <= 10
print("<= 10 min by raw median gap:", int(raw.sum()), "| after the guard:", int((best.headway <= 10).sum()))
{'<=10': 37, '10-15': 1882, '15-30': 1533, '30-60': 1596, '>60': 449} | no headway: 232
<= 10 min by raw median gap: 134 | after the guard: 37

Example 2 β€” the drop-off-only trap

st_all = pd.read_csv(f"{FEED}/stop_times.txt", usecols=["trip_id", "stop_id", "pickup_type",
                     "departure_time"], dtype={"trip_id": str, "stop_id": str})
day = trips[trips.service_id.isin(services_on("2027-01-26"))]
st_all = st_all[st_all.trip_id.isin(day.trip_id)]
st_all["dep"] = to_secs(st_all.departure_time)
w = st_all[(st_all.dep >= 7 * 3600) & (st_all.dep < 9 * 3600)]
stops = pd.read_csv(f"{FEED}/stops.txt", dtype=str)
g = w[w.stop_id == "8194"]
print(stops.set_index("stop_id").stop_name["8194"], "| departures:", len(g),
      "| boardable:", int((g.pickup_type != 1).sum()))
Gateway TC (drop off only stop) | departures: 68 | boardable: 0

Example 3 β€” to a GeoPackage, with the parameters in the file

import geopandas as gpd

out = best.merge(stops[["stop_id", "stop_name", "stop_lat", "stop_lon"]], on="stop_id")
out = gpd.GeoDataFrame(out, crs=4326, geometry=gpd.points_from_xy(
    out.stop_lon.astype(float), out.stop_lat.astype(float)))
out["band"] = pd.cut(out.headway, [0, 10, 15, 30, 60, np.inf],
                     labels=["<=10", "10-15", "15-30", "30-60", ">60"]).astype(str)
out["service_date"], out["window"] = "2027-01-26", "07:00-09:00"
out.drop(columns=["stop_lat", "stop_lon"]).to_file(
    "trimet_frequency_20270126_0700-0900.gpkg", layer="stops")
print(out[["stop_name", "best_route", "departures", "median_gap", "headway", "band"]]
      .sort_values(["headway", "stop_name"]).head(4).to_string(index=False))   # ties break by name
                           stop_name best_route  departures  median_gap  headway band
    Marquam Hill Upper Tram Terminal        208          24         5.0      5.0 <=10
South Waterfront Lower Tram Terminal        208          24         5.0      5.0 <=10
                SE Baseline St & 2nd         57          16         3.0      7.5 <=10
         6400 Block NE Killingsworth         72          12        10.0     10.0 <=10

Best route per stop, not all departures

Why "best route per stop" and not "all departures"

A rider at a stop needs a vehicle going their way. Summing departures across routes and directions answers a different question β€” how busy the stop is β€” and makes terminals and loop stops look far more frequent than they are. The best single route-direction is conservative: a stop on two 20-minute routes that share a corridor offers better than 20 minutes to some riders, and the map will not show it. That is the right direction to be wrong in.

Why the median gap

The mean gap is pulled up by one long gap at the edge of the window and down by a pair of buses a minute apart. The median describes the regular pattern, which is what a frequency band claims to show.

Why the parameters go in the file name

A frequency map is always for a date and a window, and without them it cannot be reproduced or compared. Writing them into the layer's attributes and the file name keeps them attached when the file travels without its notebook.

Deciding whether to combine routes at a stop. combine the routes at this stop?Do the routes go to the same place?shared trunkcombine themone corridorthey divergebest single routethe safe defaultloop or terminalsplit by directionalwaysThe feed cannot tell you which case applies; the network map can spatialworkflow.io
Whether to combine routes is a network question, not a data question; the feed cannot answer it.

Window edges, parent stations and night service

  • Window edges clip gaps; widen the window by one headway and trim, if precision matters.
  • frequencies.txt feeds give headways directly; use them instead of counting.
  • Parent stations carry no departures; aggregate platforms to the station if the map is at station level.
  • Short-turns share route IDs; the headway past the turn-back point is longer.
  • Night service needs the previous service day's trips past 24:00.
  • Cache the day's stop times as Parquet if you compute several windows.

FAQ

How do I calculate bus frequency per stop from GTFS?

Filter stop times to one service date and window, drop drop-off-only calls, compute the median gap per route and direction, and keep the best per stop.

Should I count all departures at a stop?

Not for a frequency map. That counts both directions and diverging routes as one service.

What time window should I use?

The one that matches the question β€” typically 07:00–09:00 for peak and 10:00–14:00 for base β€” reported separately.

What does a NaN headway mean?

Fewer than two departures in the window. It should be shown as "infrequent", not dropped.

What about drop-off-only stops?

Exclude calls with pickup_type = 1; no rider can board them.

How do I make the result reproducible?

Store the service date, window and feed version in the output.