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.

The core GTFS tables and the keys that join them. a timetable stored as linked tablesroute_idtrip_idstop_idservice_idshape_idroutes81 linestrips67,515 journeysstop_times3,403,393 stop callsstops6,027calendar+ calendar_datesshapes1.09M pointsOnly stop_times carries times; only the calendar says which dates they apply to spatialworkflow.io
Every question about a feed is a join along these keys, and the timetable itself sits in the widest table.

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.

Common transit questions and the GTFS files each one needs. which files a question touchesquestionfiles neededtrips running on 29 Septrips, calendar, calendar_datesdepartures per hour at a stop+ stop_timesdraw a route on streetsroutes, trips, shapestravel time A to Ball of the above + stopswhere a transfer is allowedtransfers, stopsevery row above except the last also needs the calendar resolved to a dateThe calendar is in almost every question, and it is the step most often skipped spatialworkflow.io
Almost no question is answered by one file; most need four.

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.

Row counts in the TriMet GTFS feed, from three agencies to 3.4 million stop times. rows per file, TriMet feed of 27 September 2026stop_times.txt3,403,393227 MB of textshapes.txt1,093,29944 MBtrips.txt67,5152.6 MBstops.txt6,0271.1 MBroutes.txt818 KBcalendar + dates47 + 342 KB, decides everythingFive orders of magnitude between the smallest file that matters and the largest spatialworkflow.io
Row counts span five orders of magnitude, and all the difficulty is in the biggest table.

Frequencies, parent stations and multiple agencies

  • frequencies.txt replaces lists of trips with a headway over a time window. A feed that uses it has fewer stop_times rows than its service implies.
  • Parent stations (location_type = 1) group platforms; they are not boarding points and have no stop times.
  • pickup_type = 1 marks a stop call where boarding is not allowed. It still has a departure time.
  • Multiple agencies can share a feed. Filter by agency_id when a question is about one operator.
  • Feeds change weekly. Record the feed_version and download date with any result.
  • GTFS-Flex and fares v2 add further files; the core model above does not change.

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.