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.
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.
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.
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_traveledstays 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.
Internal links
- How to read a GTFS feed in Python — the referential checks
- GTFS explained — which keys link which tables
- How to compute transit travel times from GTFS in Python — why whole trips matter
- Fixing accessibility scores that are wrong near the study area edge — the same edge problem on streets
- stop_times.txt is too big to load comfortably — the other way to cope with size
- How to clip spatial data in Python — clipping ordinary layers
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.