How to read a GTFS feed in Python
On this page
Problem statement
pd.read_csv will open every file in a GTFS zip without complaint, and that is the problem: the default types it chooses are wrong for this data in three ways. Identifiers become integers, times stay as text that cannot be compared numerically, and the 3.4-million-row stop_times.txt in a mid-sized feed comes back as a 384 MB frame β 803 MB on an install without pyarrow, where text falls back to Python objects β because every column was given the widest type pandas could find.
None of these raise an error. They surface later as joins that match nothing, a sort that puts 10:00:00 before 9:00:00, or a notebook that runs out of memory halfway through a city.
Quick answer
Read identifiers as strings, read only the stop_times columns you need, and convert times to integer seconds since the start of the service day:
import pandas as pd
FEED = "trimet_gtfs" # an unzipped feed; zipfile.ZipFile works too
def read_table(name, **kw):
return pd.read_csv(f"{FEED}/{name}.txt", dtype=str, **kw)
def to_secs(hhmmss):
parts = hhmmss.str.split(":", expand=True).astype("int32")
return parts[0] * 3600 + parts[1] * 60 + parts[2]
routes, trips, stops = read_table("routes"), read_table("trips"), read_table("stops")
st = pd.read_csv(f"{FEED}/stop_times.txt",
usecols=["trip_id", "arrival_time", "departure_time", "stop_id", "stop_sequence"],
dtype={"trip_id": "category", "stop_id": "category", "stop_sequence": "int16",
"arrival_time": str, "departure_time": str})
st["dep"] = to_secs(st.departure_time)
print(f"{len(st):,} stop times, {st.memory_usage(deep=True).sum() / 1e6:.0f} MB")
3,402,538 stop times, 153 MB
Integer seconds sort, subtract and compare correctly, and they handle times past 24:00, which datetime parsing cannot.
Strings for ids, selective stop times, seconds for times
1. Unzip, or read from the zip directly
pd.read_csv(zipfile.ZipFile(path).open("stops.txt")) avoids unpacking. Unzipping once is faster if you will read the feed repeatedly.
2. Read the small tables with dtype=str
routes, trips, stops, calendar and calendar_dates are small. Reading every column as text keeps identifiers intact; convert the few numeric columns you need β stop_lat, stop_lon, direction_id β explicitly afterwards.
3. Read stop_times.txt selectively
Pick the columns the question needs. Store trip and stop IDs as category, which replaces millions of repeated strings with small integer codes.
4. Convert times to seconds
Split on the colon and compute seconds. Blank times are allowed on non-timepoint stops in some feeds; fill or interpolate them before converting.
5. Build geometry for stops
gpd.points_from_xy(stops.stop_lon.astype(float), stops.stop_lat.astype(float)) with crs=4326. Stop coordinates are always WGS 84.
6. Check the feed before trusting it
Look at feed_info.txt for the date window, check that every stop_times.stop_id exists in stops.txt and every trips.service_id in the calendar. A clip or a merge done badly breaks referential integrity without breaking the files.
Defaults measured and references checked
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 20260913-20260924-0900, valid 20260913 to 20270227
Example 1 β the defaults, and what they cost
default = pd.read_csv(f"{FEED}/stop_times.txt")
print(default.dtypes.to_dict())
print(f"{default.memory_usage(deep=True).sum() / 1e6:.0f} MB with defaults")
{'trip_id': dtype('int64'), 'arrival_time': <StringDtype(na_value=nan)>, 'departure_time': <StringDtype(na_value=nan)>, 'stop_id': dtype('int64'), 'stop_sequence': dtype('int64'), 'stop_headsign': <StringDtype(na_value=nan)>, 'pickup_type': dtype('int64'), 'drop_off_type': dtype('int64'), 'shape_dist_traveled': dtype('float64'), 'timepoint': dtype('int64')}
384 MB with defaults
trip_id and stop_id became int64. They join to nothing read as text, and in feeds where IDs have leading zeros, the zeros are gone.
Example 2 β the referential checks
problems = {
"stop_times β stops": int((~st.stop_id.isin(stops.stop_id)).sum()),
"stop_times β trips": int((~st.trip_id.isin(trips.trip_id)).sum()),
"trips β routes": int((~trips.route_id.isin(routes.route_id)).sum()),
}
cal, cd = read_table("calendar"), read_table("calendar_dates")
known = set(cal.service_id) | set(cd.service_id)
problems["trips β calendar"] = int((~trips.service_id.isin(known)).sum())
print(problems)
{'stop_times β stops': 0, 'stop_times β trips': 0, 'trips β routes': 0, 'trips β calendar': 0}
All zero, as a published feed should be. Run the same checks after any clip or merge.
Example 3 β stops as a GeoDataFrame, boarding points only
import geopandas as gpd
stops_gdf = gpd.GeoDataFrame(
stops, crs=4326,
geometry=gpd.points_from_xy(stops.stop_lon.astype(float), stops.stop_lat.astype(float)))
print(stops_gdf.location_type.fillna("0").value_counts().to_dict())
boarding = stops_gdf[stops_gdf.location_type.fillna("0") == "0"]
print(len(boarding), "boarding points")
{'0': 5959, '1': 68}
5959 boarding points
The 68 rows with location_type 1 are stations that group platforms; they have no stop times of their own.
Text ids, non-datetime times and categories
Why identifiers must stay text
GTFS IDs are opaque strings. Some feeds use numbers, some use codes like W.736, and some mix them. Reading one table with inference and another as text gives an int64 column on one side of a join and an object column on the other, and pandas returns an empty merge rather than an error.
Why times are not datetimes
A GTFS time is an offset from the start of a service day, not a clock time. 25:30:00 is valid and means 01:30 the next morning on the previous day's service. pd.to_datetime(..., format="%H:%M:%S") rejects it; integer seconds keep it.
Why category is the right type for the big table
A feed with 67,510 trips repeats each trip ID about 50 times in stop_times.txt. As Python strings that is 3.4 million objects; as a category it is 67,510 strings and 3.4 million small integers. Grouping and filtering on categories is faster too.
Byte-order marks, quoted commas and blank times
- Byte-order marks at the start of a file can turn the first column name into
'\ufefftrip_id';encoding="utf-8-sig"strips it. - Quoted fields with commas appear in headsigns; pandas handles them, but some other CSV readers need telling. See the stop_times fix.
- Blank
arrival_timeis allowed on non-timepoint stops; interpolate between timepoints. frequencies.txtmeans some trips are templates repeated at a headway.- Libraries such as
gtfs_kitandpartridgewrap these steps; the pitfalls are the same underneath. - Record the feed version from
feed_info.txtwith every result.
Internal links
- GTFS explained β what each table holds
- stop_times.txt is too big to load comfortably β chunks, DuckDB and Parquet
- GTFS times after 24:00 break time parsing β the service-day clock
- How to find the trips running on a given date in GTFS β the next step
- How to map transit routes and stops from GTFS shapes β geometry from the tables
- How to speed up GeoPandas β general memory advice
FAQ
How do I read a GTFS zip in Python?
Unzip it, or open each file through zipfile, and read with pd.read_csv β with identifiers as strings and times converted to seconds.
Why do my GTFS joins return nothing?
Usually because one table's IDs were read as integers and the other's as strings.
How do I parse GTFS times?
Split on the colon and compute seconds since the start of the service day. Do not use datetime, because times can exceed 24:00.
How do I reduce memory for stop_times.txt?
Read only the columns you need and store IDs as category.
Should I use a library?
gtfs_kit and partridge are good; they apply the same ideas, so the pitfalls here still explain what they do.
What CRS are GTFS coordinates in?
WGS 84, EPSG:4326, always.