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.

GTFS columns that need non-default types in pandas, what the default does, and what to use. four columns, four silent failurescolumnpandas defaultuse insteadstop_id, trip_idint64, zeros loststr / categoryservice_idmixed or intstrdeparture_timetext, sorts wrongint secondsstop_lat, stop_lonfloat64float64 (fine)datetime parsing is not an option: GTFS times run past 24:00None of the defaults raises an error; each surfaces later as a bad join or sort spatialworkflow.io
Four columns account for almost every silent problem in reading a feed.

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.

Memory used by stop_times.txt read with pandas defaults and with selected columns and category types. stop_times.txt in memory, 3,402,538 rowsdefaults, no pyarrow803 MBdefaults, pandas 3384 MB5 columns, category153 MB0450 MB900 MBpandas 3.0.6; the no-pyarrow figure is the same read with text as Python objects spatialworkflow.io
The same rows in 40% of the memory, by choosing columns and types rather than accepting the defaults.

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.

Checks to run after reading a GTFS feed. before trusting the readdate window from feed_info.txtis your date inside it?identifiers are stringson both sides of every joinevery foreign key resolvesstops, trips, routes, servicesblank times handlednon-timepoint stopsstations separated from platformslocation_typea successful read_csvproves none of the aboveEach check takes seconds, and each one catches a failure that does not raise spatialworkflow.io
Five checks that take seconds and catch the problems a successful read hides.

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_time is allowed on non-timepoint stops; interpolate between timepoints.
  • frequencies.txt means some trips are templates repeated at a headway.
  • Libraries such as gtfs_kit and partridge wrap these steps; the pitfalls are the same underneath.
  • Record the feed version from feed_info.txt with every result.

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.