How to clip a GTFS feed to a study area

On this page

Problem statement

A regional feed is often far bigger than the area a study needs, and the obvious way to shrink it — keep the stops inside a boundary — produces a feed that is no longer valid. Trips that cross the boundary now reference stops that do not exist, parent stations disappear from under their platforms, and a router reading the result either crashes or quietly treats every boundary-crossing trip as ending at the edge.

Clipping TriMet's feed to a box around inner Portland keeps 983 of 6,027 stops. But 41,687 trips touch those stops, and they call at 2,373 stops outside the box. Dropping those stops leaves 1,310,500 rows of stop_times.txt pointing at nothing.

Quick answer

Decide what a clipped trip means — the whole trip or only its part inside — and then filter every table so that each remaining key still resolves:

import pandas as pd

FEED = "trimet_gtfs"
t = {n: pd.read_csv(f"{FEED}/{n}.txt", dtype=str)
     for n in ["agency", "routes", "trips", "stops", "calendar", "calendar_dates",
               "shapes", "transfers", "feed_info", "stop_times"]}

W, S, E, N = -122.70, 45.49, -122.60, 45.55          # inner Portland
lat, lon = t["stops"].stop_lat.astype(float), t["stops"].stop_lon.astype(float)
inside = set(t["stops"].stop_id[(lon >= W) & (lon <= E) & (lat >= S) & (lat <= N)])

st = t["stop_times"]
keep_trips = set(st.trip_id[st.stop_id.isin(inside)])          # any call inside
out = {"stop_times": st[st.trip_id.isin(keep_trips)]}          # whole trips
out["trips"] = t["trips"][t["trips"].trip_id.isin(keep_trips)]
used = set(out["stop_times"].stop_id)
parents = set(t["stops"].parent_station[t["stops"].stop_id.isin(used)].dropna())
out["stops"] = t["stops"][t["stops"].stop_id.isin(used | parents)]
out["routes"] = t["routes"][t["routes"].route_id.isin(out["trips"].route_id)]
out["calendar"] = t["calendar"][t["calendar"].service_id.isin(out["trips"].service_id)]
out["calendar_dates"] = t["calendar_dates"][t["calendar_dates"].service_id.isin(out["trips"].service_id)]
out["shapes"] = t["shapes"][t["shapes"].shape_id.isin(out["trips"].shape_id)]
ids = set(out["stops"].stop_id)
out["transfers"] = t["transfers"][t["transfers"].from_stop_id.isin(ids) & t["transfers"].to_stop_id.isin(ids)]
out["agency"], out["feed_info"] = t["agency"], t["feed_info"]
print({k: len(v) for k, v in out.items()})
{'stop_times': 2144613, 'trips': 41687, 'stops': 3397, 'routes': 44, 'calendar': 44, 'calendar_dates': 31, 'shapes': 682580, 'transfers': 2232, 'agency': 3, 'feed_info': 1}

Keeping whole trips is the right default for routing: a rider who boards inside the area can ride out of it, and a travel time computed on a truncated trip would stop at the boundary.

Three ways to clip a trip that crosses the study-area boundary. drop outside stops??calls point at nothingwhole tripsoutside stops kepttruncated tripstrip ends at the edgeWhole trips for routing; truncated trips only for counting service inside the area spatialworkflow.io
Three ways to clip one trip; only two of them produce a valid feed.

Select stops, keep whole trips, derive the rest

1. Select stops with the boundary

A bounding box is enough for a quick cut; a polygon with geopandas.sjoin is better for an administrative area. Select boarding points and stations alike — the station rule comes later.

2. Decide whole trips or truncated trips

Whole trips keep every call of any trip that touches the area. Truncated trips keep only the calls inside. Routing and accessibility need whole trips; a count of service inside the area can use truncated ones.

3. Filter stop_times, then derive everything from it

The stop times you keep define which trips, stops, routes, services and shapes survive. Derive each table's keep-list from the one above it, never from the boundary directly.

4. Restore parent stations

A platform kept because a trip calls there needs its parent_station, or the file refers to a missing stop.

5. Filter the secondary tables

transfers.txt, frequencies.txt, fare rules and pathways.txt all reference stops, trips or routes; keep only rows whose every reference survived.

6. Validate the result

Run the referential checks from the reading guide on the output, or the canonical GTFS validator. A clipped feed that fails validation will fail in a router later, further from the cause.

How the keep-list cascades from stops inside the boundary through every GTFS table. each table filtered by the one above itstops inside the box983the boundary, used oncestop_times of touching trips2,144,61341,687 tripsstops those trips use3,3972,373 outside the box+ parent stations60or platforms dangleroutes, services, shapes44 · 44 · 744from the tripstransfers2,232both ends must surviveOnly the first row looks at geography; the rest is key-following spatialworkflow.io
Every table's keep-list comes from the table above it, not from the boundary.

What a naive clip breaks

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 — what the naive clip breaks

naive_stops = t["stops"][t["stops"].stop_id.isin(inside)]
dangling = st[st.trip_id.isin(keep_trips) & ~st.stop_id.isin(naive_stops.stop_id)]
print(f"stops inside: {len(inside)} | trips touching: {len(keep_trips):,} | "
      f"stop_times pointing at dropped stops: {len(dangling):,}")
print("stops outside the box that whole trips still need:", len(used - inside))
stops inside: 983 | trips touching: 41,687 | stop_times pointing at dropped stops: 1,310,500
stops outside the box that whole trips still need: 2373

Example 2 — the truncated alternative

trunc = st[st.trip_id.isin(keep_trips) & st.stop_id.isin(inside)]
calls = trunc.groupby("trip_id").size()
print(f"truncated stop_times: {len(trunc):,} | trips left with one call: {int((calls < 2).sum())}")
truncated stop_times: 834,113 | trips left with one call: 0

A trip with a single call left inside the area cannot carry anyone anywhere and should be dropped; here there are none, but on a tighter boundary there usually are.

Example 3 — write and re-check

import zipfile, os

with zipfile.ZipFile("trimet_inner.zip", "w", zipfile.ZIP_DEFLATED) as z:
    for name, df in out.items():
        z.writestr(f"{name}.txt", df.to_csv(index=False))

checks = {
    "stop_times → stops": int((~out["stop_times"].stop_id.isin(out["stops"].stop_id)).sum()),
    "stops → parent": int((~out["stops"].parent_station.dropna().isin(out["stops"].stop_id)).sum()),
    "trips → services": int((~out["trips"].service_id.isin(
        set(out["calendar"].service_id) | set(out["calendar_dates"].service_id))).sum()),
}
print(checks, f"| {os.path.getsize('trimet_inner.zip') / 1e6:.1f} MB")
{'stop_times → stops': 0, 'stops → parent': 0, 'trips → services': 0} | 27.1 MB

Why whole trips are usually right

Why the naive clip is invalid

stop_times.txt refers to stops by ID. Removing a stop without removing every stop time that references it leaves the feed internally inconsistent, and GTFS consumers are entitled to assume a published feed is consistent. Some fail loudly; others drop the offending trips, and a router that drops boundary-crossing trips will report the edge of the study area as unreachable from the centre.

Why whole trips are usually right

Transit trips are the edges of a time-dependent graph. Cutting them at the boundary is like cutting roads at the edge of a street network: travel times near the edge become artificially long, because every journey that briefly leaves the area is lost. Whole trips keep those journeys and cost only the extra stops outside.

Why the feed barely shrinks

Clipping by stops saves less than it seems when trips are long. The whole-trip clip here kept 2,144,613 of 3,403,393 stop times — 63% of the file for 16% of the stops — because most routes pass through the centre. If size is the goal, clip by date as well: a single service week is a fraction of a six-month feed.

stop_times rows in the full TriMet feed, a whole-trip clip and a truncated clip to inner Portland. stop_times.txt rowsfull feed3,403,393whole-trip clip2,144,613 (63%)truncated clip834,113 (25%)02M4M16% of the stops, 63% of the timetable: most routes run through the centre spatialworkflow.io
Most trips pass through the centre, so a central clip keeps most of the timetable.

Clipping by date, shapes and validation

  • Clip by date too. Filtering to the services active in one week often shrinks a feed more than any spatial clip.
  • Shapes outside the area are kept with whole trips; truncate them only for drawing.
  • shape_dist_traveled stays valid on whole trips and becomes misleading on truncated ones.
  • Fares v2 tables reference areas and networks; filter or drop them deliberately.
  • Blocks (block_id) link consecutive trips; truncation can break in-seat transfers.
  • Validate with the MobilityData GTFS validator before publishing a clipped feed.

FAQ

How do I clip a GTFS feed to an area?

Keep the trips that call inside the area, then derive stops, routes, services, shapes and transfers from what those trips use.

Should I keep whole trips or cut them at the boundary?

Whole trips for routing and accessibility; truncated trips only for counting service inside the area.

Why is my clipped feed invalid?

Probably because stops were removed while stop times still reference them, or platforms were kept without their parent stations.

Why did my feed barely shrink?

Because most routes pass through the area and whole trips keep their outside stops. Clip by date as well.

Do I need to keep shapes?

Keep the shapes of the trips you keep, or the routes cannot be drawn along the streets.

How do I check the result?

Run referential checks, or the MobilityData GTFS validator.