Reading GTFS data - team-exception-handlers/TransitSync GitHub Wiki

This page explains how the GTFS files we use are structured and how to get the information the simulator needs out of them. All examples come from the REM feed we have on hand (version 20260520).

What GTFS is

GTFS (General Transit Feed Specification) is the standard format transit agencies use to publish their schedules. A feed is a zip file of plain CSV text files. Each file is one table, the first line is the column names, and the files link to each other through ID columns, the same way tables link through foreign keys in a database.

GTFS describes the planned schedule only. It does not say where a train actually is or whether it was late. That comes from GTFS-Realtime or archived arrival data, which is a separate source.

Full reference: https://gtfs.org/documentation/schedule/reference/

Feeds we have

Feed Version Valid from Valid to Licence
REM 20260520 2026-05-19 2026-12-31 CC BY 4.0 (credit required)
STM 20260805110000_26S 2026-06-15 2026-10-25 Check the licence file in the feed

The STM feed expires on October 25. Feeds are replaced every few months, so we always record which version a result was built on (see "Where the data lives" below).

How the files connect

routes.txt        one row per line (REM A1, STM green line, ...)
   │ route_id
   ▼
trips.txt         one row per single run of a train from one end to the other
   │ trip_id                     │ service_id
   ▼                             ▼
stop_times.txt    one row per    calendar.txt / calendar_dates.txt
   │ stop_id      stop on a trip  which days that trip runs
   ▼
stops.txt         platforms, stations and entrances

To answer "when does a train leave McGill?", you go from the stop to stop_times, from there to trips to get the line and direction, and from trips to the calendar to check it runs that day.

The files we use

routes.txt

One row per line.

route_id,agency_id,route_short_name,route_long_name,route_type,...
S1,REM,A4-A1,A4 - Deux-Montagnes / A1 - Brossard,0,...

route_type says what kind of vehicle it is: 0 is tram or light rail, 1 is subway, 3 is bus. The REM is listed as 0 and the STM metro lines as 1. The STM feed also contains every bus route, which we filter out.

STM metro route IDs: 1 green, 2 orange, 4 yellow, 5 blue.

stops.txt

Platforms, stations and entrances, with coordinates.

stop_id,stop_name,stop_lat,stop_lon,zone_id,location_type,parent_station,...
STA_ZCD_BT_QUAI_01_MCG,Station McGill - Quai 1,  45.502695, -73.572374,1,0,ST_MCG_1,...
STA_ZCD_BT_QUAI_02_MCG,Station McGill - Quai 2,  45.502651, -73.572415,1,0,ST_MCG_1,...
ST_MCG_1,Station McGill,  45.502535, -73.572116,1,1,,...
ST_MCG_1_entree_centre_eaton,Entrée Centre Eaton,45.502368,-73.572284,1,2,ST_MCG_1,...

location_type tells you what the row is:

  • 0: a platform (quai), where trains actually stop. stop_times only points to these
  • 1: the station as a whole. Its platforms point to it through parent_station
  • 2: an entrance or exit

Our three REM interchanges:

Station Station ID Platform toward Brossard Platform away from Brossard
Gare Centrale ST_GCT_1 STA_ZCD_BT_QUAI_02_GCT STA_ZCD_BT_QUAI_01_GCT
McGill ST_MCG_1 STA_ZCD_BT_QUAI_02_MCG STA_ZCD_BT_QUAI_01_MCG
Édouard-Montpetit ST_EDM_1 STA_ZCD_BT_QUAI_02_EDM STA_ZCD_BT_QUAI_01_EDM

trips.txt

One row per run of a train.

route_id,service_id,trip_id,direction_id,shape_id,trip_headsign,...
S3,RDS-32B220.xml,RDS-32B220.xml_0001,0,S30017,A3 - Anse-à-l'Orme,1
  • service_id links to the calendar (which days this trip runs)
  • direction_id is 0 or 1. Its meaning is set by each agency. For the REM, 0 is away from Brossard (headsigns Deux-Montagnes, Anse-à-l'Orme, Bois-Franc) and 1 is toward Brossard. For the STM, directions.txt gives the name of each direction per line (for example route 1, direction 0 is Est)
  • trip_headsign is the destination shown on the train

stop_times.txt

The largest file. One row per stop on each trip, in order.

trip_id,arrival_time,departure_time,stop_id,stop_sequence,...
RDS-32B220.xml_0001,05:29:15,05:30:00,STA_ZCD_BT_QUAI_01_RIV,1,...
RDS-32B220.xml_0001,05:32:02,05:32:30,STA_ZCD_BT_QUAI_01_DUQ,2,...
  • The difference between departure_time and arrival_time is the scheduled dwell, the time the train sits at the platform with its doors open. At our three REM stations it is fixed at 28 to 45 seconds depending on station and direction
  • The difference between two consecutive departures from the same platform is the headway, the time between trains. For the REM on weekdays it is 210 seconds at peak and 450 seconds otherwise

calendar.txt and calendar_dates.txt

Which days each service_id runs.

service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date
RDS-32B220.xml,1,1,1,1,1,0,0,20260519,20261224      <- weekdays
RDS-16B160.xml,0,0,0,0,0,1,0,20260523,20261231      <- Saturdays
RDS-16B156.xml,0,0,0,0,0,0,1,20260524,20261227      <- Sundays

calendar_dates.txt lists exceptions to that pattern. exception_type 1 means the service is added on that date, 2 means it is removed.

service_id,date,exception_type
RDS-32B220.xml,20261012,2     <- no weekday service on Oct 12 (Thanksgiving)
RDS-16B156.xml,20261012,1     <- Sunday service runs instead

frequencies.txt (if present)

Some feeds describe service as "a train every N seconds between two times" instead of listing every trip. Each row gives a trip_id, a start_time, an end_time and a headway_secs. The REM feed does not have this file. Check whether the STM feed uses it for the metro, since that changes how metro headways are read.

shapes.txt

The path a line follows on the map, as a list of points. Only needed for drawing the network (US-3.1), not for the simulation itself.

Reading times

  • Times are local Montreal time, written HH:MM:SS
  • Times can go past 24:00:00. A train leaving at 00:30 after a late evening is written 24:30:00 because it belongs to the previous day's service. The REM feed goes up to 26:16:39. Do not parse these with a normal time type. Convert them to seconds after midnight instead: 24:30:00 becomes 88200
  • The database stores both the original text and the seconds value (arrival_s, departure_s)

Finding which service runs on a given date

  1. Find the day of the week and check calendar.txt for every service_id with a 1 in that column, where the date is between start_date and end_date
  2. Remove any service_id that has that date with exception_type 2 in calendar_dates.txt
  3. Add any service_id that has that date with exception_type 1

Skipping steps 2 and 3 is the most common mistake. On a holiday it gives you weekday service when the real schedule is Sunday service.

Worked example: weekday REM departures from McGill toward Brossard

select st.departure_time, t.trip_headsign
from gtfs.stop_times st
join gtfs.trips t using (feed_id, trip_id)
where st.feed_id = 'rem_20260520'
  and st.stop_id = 'STA_ZCD_BT_QUAI_02_MCG'
  and t.service_id = 'RDS-32B220.xml'
  and st.departure_s between 8*3600 and 9*3600
order by st.departure_s;

The first rows are departures at 08:00:18, 08:03:48 and 08:07:18. Those 210 seconds between departures are the AM peak headway.

REM and STM differences

REM STM
Feed contents REM only Metro and all buses
route_type 0 1 for metro, 3 for bus
Stop ID style STA_ZCD_BT_QUAI_01_MCG Different format, check stops.txt for the metro platforms
Directions Only in direction_id and headsign Named in directions.txt
Service IDs One per day type Many, one per garage and day type (bus garages make up most of them)

Common mistakes

  • Reading IDs as numbers. Always read every column as text first. Pandas will otherwise drop leading zeros or turn IDs into floats
  • Extra spaces. Some REM coordinates have leading spaces ( 45.502695). Strip every value
  • French characters. Files are UTF-8. Opening them in Excel without choosing UTF-8 turns "Édouard" into "Édouard"
  • Platform vs station. stop_times points to platforms (location_type 0), never to the station row
  • Duplicate files. When files get uploaded twice under different names, check they are identical before loading
  • Ignoring the calendar. A trip_id on its own says nothing about which days it runs

Where the data lives

  • Original zips are in Supabase Storage, bucket gtfs-raw, under rem/<feed_version>/ and stm/<feed_version>/. These are never edited
  • Parsed tables are in the Supabase database under the gtfs schema. Each row has a feed_id (for example rem_20260520) so several feed versions can be stored side by side. gtfs.feeds lists every version loaded, with its dates and download date
  • Only rail routes are loaded. STM buses are dropped by the loader to keep the database small
  • Values the simulator uses (headways per time band, dwells, platform IDs) are extracted once and committed to the repo as config. The simulator never reads GTFS at runtime

Licence and credit

The REM feed is published under CC BY 4.0. Anything we publish that uses it (the app, the report) needs a credit line:

Schedule data from the Réseau express métropolitain (REM), https://rem.info, used under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Data has been processed and modified for this project.