GTFS explained: the tables behind every transit feed
On this page
Problem statement
A GTFS feed is a zip of plain CSV files, which makes it look like the easiest data on this site to open. It is easy to open. It is not easy to read correctly, because the meaning of any one row depends on rows in three or four other files: a departure time in stop_times.txt means nothing until trips.txt tells you which service it belongs to, and calendar.txt and calendar_dates.txt tell you which dates that service runs.
The scale surprises people too. TriMet's feed for Portland, Oregon β a mid-sized network β has 6,027 stops and 81 routes, and 3,403,393 rows in stop_times.txt. That one file is 227 MB of text and takes 384 MB of memory when pandas 3 reads it with default types, or 803 MB where pyarrow is not installed and text columns become Python objects.
Quick answer
GTFS describes a timetable as a small relational database of linked CSV tables, and a handful of them carry almost everything:
import pandas as pd
FEED = "trimet_gtfs"
tables = {name: pd.read_csv(f"{FEED}/{name}.txt", dtype=str)
for name in ["agency", "routes", "trips", "stops", "calendar",
"calendar_dates", "shapes"]}
for name, df in tables.items():
print(f"{name:15} {len(df):>9,} rows")
agency 3 rows
routes 81 rows
trips 67,515 rows
stops 6,027 rows
calendar 47 rows
calendar_dates 34 rows
shapes 1,093,299 rows
stop_times.txt is left out on purpose: it is the one file that needs care to load, and it is the subject of its own guide. Read every other file with dtype=str, because stop and trip identifiers are text that often looks like numbers.
A feed as a database: dates, services, then trips
1. Read the feed as a database, not as files
A route is not a timetable. routes.txt lists lines ("FX2 Division"); trips.txt lists individual journeys along a line, each with a service_id and usually a shape_id; stop_times.txt lists every stop call of every trip. Anything you want to count β departures, frequency, travel time β lives at the stop_times level and needs the other tables to be interpreted.
2. Find out which dates the feed covers
feed_info.txt, when present, gives feed_start_date and feed_end_date. The TriMet feed used here runs from 27 September 2026 to 27 February 2027. A query for a date outside that window returns nothing, and returns it silently.
3. Resolve service to dates before counting anything
Each trip points at a service_id. calendar.txt says which weekdays that service runs between two dates; calendar_dates.txt adds or removes single dates. You cannot say how many trips run on a Tuesday without applying both. The calendar guide covers the rules.
4. Treat times as seconds since the start of the service day
departure_time is HH:MM:SS measured from the start of the service day, and it can exceed 24:00 β TriMet's last departure is at 27:10:00. Convert to integer seconds; do not parse to clock times.
5. Know what the optional files add
shapes.txt gives the path a vehicle actually drives; without it a route can only be drawn stop to stop. transfers.txt names stop pairs where a transfer is allowed or timed. frequencies.txt, where used, replaces explicit trips with a headway.
6. Keep identifiers as strings
Stop IDs, trip IDs and service IDs are keys. Read as integers they lose leading zeros in some feeds and fail to join to tables read as strings in the same script.
Following one trip through TriMet's tables
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 kinds of service the feed contains
route_types = {"0": "tram / light rail", "1": "subway", "2": "rail",
"3": "bus", "6": "aerial lift"}
counts = tables["routes"].route_type.map(route_types).value_counts()
print(counts.to_string())
print(tables["agency"].agency_name.tolist())
route_type
bus 71
tram / light rail 8
rail 1
aerial lift 1
['Portland Streetcar', 'Portland Aerial Tram', 'TriMet']
One feed, three agencies β the streetcar and the aerial tram are separate operators. agency_id on each route says which operator runs it, which matters when a question is about one operator's network.
Example 2 β the feed window
info = pd.read_csv(f"{FEED}/feed_info.txt", dtype=str)
print(info[["feed_publisher_name", "feed_start_date", "feed_end_date"]].to_string(index=False))
feed_publisher_name feed_start_date feed_end_date
TriMet 20260927 20270227
Example 3 β follow one trip through the joins
trips, routes, stops = tables["trips"], tables["routes"], tables["stops"]
st = pd.read_csv(f"{FEED}/stop_times.txt", dtype=str, nrows=200_000)
trip = st.trip_id.iloc[0]
calls = (st[st.trip_id == trip]
.merge(stops[["stop_id", "stop_name"]], on="stop_id"))
meta = trips[trips.trip_id == trip].merge(routes, on="route_id")
print(meta[["route_long_name", "service_id", "shape_id"]].to_string(index=False))
print(calls[["stop_sequence", "departure_time", "stop_name"]].to_string(index=False))
route_long_name service_id shape_id
Portland Aerial Tram 7.736 643275
stop_sequence departure_time stop_name
1 05:30:00 South Waterfront Lower Tram Terminal
2 05:34:00 Marquam Hill Upper Tram Terminal
Two stop calls, four minutes apart: the aerial tram. The trip carries a service_id that says when it runs and a shape_id that says where it goes, and neither is in stop_times.txt.
Why timetables are stored this way
Why a timetable is stored this way
A network's timetable is highly repetitive: the same trip runs on hundreds of dates, and the same stops are served by thousands of trips. GTFS stores each fact once β the dates in the calendar, the path in the shape, the stop location in stops.txt β and links them by key. That keeps the feed small enough to publish daily, and it moves the cost to the reader, who has to reassemble the joins.
Why stop_times.txt is so large
It has one row per trip per stop. 67,515 trips averaging about 50 stops each is 3.4 million rows, and every other file is tiny by comparison. Almost every performance problem with GTFS is a problem with this one file.
Why routes are the wrong unit for most analysis
A route is a label for a family of trips that may vary by direction, time of day and date: short-turns, express patterns and branch variants all share a route_id. Counting routes at a stop says little about service. Counting departures from stop_times on a specific date says what a rider actually gets.
Frequencies, parent stations and multiple agencies
frequencies.txtreplaces lists of trips with a headway over a time window. A feed that uses it has fewerstop_timesrows than its service implies.- Parent stations (
location_type = 1) group platforms; they are not boarding points and have no stop times. pickup_type = 1marks a stop call where boarding is not allowed. It still has a departure time.- Multiple agencies can share a feed. Filter by
agency_idwhen a question is about one operator. - Feeds change weekly. Record the
feed_versionand download date with any result. - GTFS-Flex and fares v2 add further files; the core model above does not change.
Internal links
- GTFS service calendars explained β resolving service IDs to dates
- How to read a GTFS feed in Python β types, times and the joins in code
- stop_times.txt is too big to load comfortably β the one file that needs care
- GTFS times after 24:00 break time parsing β the service-day clock
- How to map transit routes and stops from GTFS shapes β drawing the network
- Street networks as graphs explained β the walking half of transit analysis
- How to read and write GeoPackage files in Python β storing the results
FAQ
What is GTFS?
The General Transit Feed Specification: a zip of CSV files describing a transit network's routes, stops, trips and timetable, published by most agencies worldwide.
Which file holds the timetable?
stop_times.txt, with one row per trip per stop. It needs trips.txt and the calendar files to say which dates each row applies to.
Why read everything as strings?
Identifiers are keys, not numbers. Reading them as integers can drop leading zeros and breaks joins with tables read as text.
Do I need shapes.txt?
Only to draw routes along the streets. Without it you can draw only straight lines between stops.
How big is a typical feed?
A mid-sized city is tens of megabytes zipped. TriMet's is 43 MB zipped, with 3.4 million stop-time rows.
Why does my trip count differ from the agency's?
Probably because you counted trips without resolving the service calendar to a specific date.