Skip to main content

Derive NTD service data from your GTFS archive

An NTD submission has two halves. One is counted: passengers, miles, hours, peak vehicles, and it comes from your APC, farebox and CAD systems. That half is the monthly ridership pack. The other half describes the service you scheduled, and for most agencies the schedule of record is already published as a GTFS archive.

This reads the second half off that archive, so the numbers you report about your own service and the numbers a rider's app shows come from the same file instead of from a spreadsheet maintained beside it.

This files nothing, and it is not a compliance product

There is no NTD submission here. Nothing is uploaded, transmitted or filed. What follows produces figures and the evidence behind them, and a person still enters them wherever they are entered.

It also does not tell you what your reporter type owes. Requirements differ by reporter type and change between report years, and the current NTD reporting policy manual is the only authority on which figures are asked for and how each is defined. Read it first, then work out which of these tables answers it.

What has to be true

The archive has to be the schedule of record rather than a derivative of it. Plenty of agencies build their GTFS by exporting from a scheduling system and then hand-editing something before publishing. If yours does, the published archive and the schedule you actually operate have drifted by however much that edit was, and every figure below inherits the drift. Settle that before anyone reports off it. The usual fix is to correct the export.

The archive also has to cover the period you are reporting on. An archive is a snapshot of current service, so last quarter's service is in last quarter's archive, which you may or may not have kept. If you do not keep dated copies of what you publish, start doing so. Nothing in this product recovers an archive that was thrown away.

GTFS describes trips and service days, but some of the definitions are yours. Whether a particular trip counts as revenue service, and whether a school-day tripper is a separate pattern or the same route, are agency decisions.

Before you start

  • The archive, queryable as a Static GTFS data source.
  • The current manual for your reporter type, open beside you.
  • Your route and mode coding, as the agency files it.

The steps

1. Check the archive before you report off it

Check the archive before deriving anything from it, since problems in the archive are not visible in the figures. The validator service has a route that checks a static archive on its own merits, and running it takes one call:

curl -s -F gtfs=https://transit.example.gov/gtfs/feed.zip \
http://<validator-host>/validate-static

See Check the archive before you rely on it for what comes back and how to read it. Run it against the dated archive for the period you are reporting on rather than whatever is at the URL today.

2. Find out which tables this archive actually has

{"resource": "list"}

Row counts are the first sanity check here. If the trips count is a third of what you expected, something is wrong with the archive before any query is involved.

Look at the calendar files in particular. GTFS lets an agency describe service days in calendar, in calendar_dates, or in both, and some feeds carry only calendar_dates with one row per operating day. Which of those this archive uses decides how the service-day question in step 4 has to be asked.

3. Read the tables that answer service questions

QuestionTableWhat to read
How many routes are operatedroutesOne row per route. route_type is the mode as GTFS codes it, which is not your NTD mode coding
How many trips are scheduledtripsOne row per trip. Each names its route_id and its service_id
What a trip's span and stop count arestop_timesOne row per stop per trip, with scheduled arrival and departure
Which days a pattern operatescalendar, calendar_datesThe weekly pattern and its date range, plus the exceptions

Each is a read against the archive:

{"table": "trips", "columns": ["trip_id", "route_id", "service_id", "direction_id"]}

Project the columns you need rather than reading whole tables. stop_times is where the row cap bites: without a projection and a filter, the read comes back short.

4. Compose the counts in a results query

The archive reads are the inputs, and the arithmetic is SQL over their cached results:

SELECT r.route_id,
r.route_short_name,
count(DISTINCT t.trip_id) AS trips_on_this_pattern
FROM cached_query_13 r
JOIN cached_query_12 t ON t.route_id = r.route_id
JOIN cached_query_14 c ON c.service_id = t.service_id
WHERE c.monday = 1
GROUP BY r.route_id, r.route_short_name
ORDER BY trips_on_this_pattern DESC

Two assumptions in that query are worth spelling out:

  • The comparison is c.monday = 1, unquoted. The connector types each column from its values, so a flag column of zeroes and ones arrives as a number, while anything named *_id is always text so it keeps its leading zeroes and still joins. See Query a static GTFS archive.
  • One weekday does not stand in for the week. A pattern flagged for Monday may be superseded on a specific Monday by calendar_dates, and an agency running reduced service on a holiday shows that only there. Any figure covering a period rather than a typical day has to read the exceptions.

5. Put the definitions next to the numbers

Every figure here rests on a decision the file did not make: which routes counted, whether a deviated trip is its own route, how a holiday was handled. Write each of those into the query's description, the same discipline the ridership pack uses, so that "where did 214 come from" can be answered with a link.

How you know it worked

Reproduce a period you have already filed. Run the queries against that period's archive and compare to what you submitted, line by line. Differences are usually a definition surfacing rather than a failure, and each one is worth a written cause: a route coded differently, a school tripper counted one way in the spreadsheet and another way here, an archive published a week after service changed.

Do this reconciliation once at the start rather than checking individual figures as they come up later.

What takes it off the air

What happenedWhat you see
The published archive drifted from the operated scheduleFigures that are internally consistent but wrong. Nothing here detects it; it only shows up against the scheduling system
You did not keep the period's archiveThe reads run against current service, which answers a different question than the one you asked
The feed describes service days only in calendar_datesA query joining calendar returns nothing, or a small unrepresentative slice
A stop_times read hit the row capA trip-span figure computed over part of the file. Compare against the row count from step 2

What this does not do

It does not count passengers, miles or hours. Those come from systems that measure them, and they are covered in the ridership pack, including the caution that FTA approval of automatic passenger counters is a matter for the equipment and its validation programs rather than for how the numbers are later assembled.

It also does not know your reporter type, your mode coding, or what this year's manual asks for. It reads the schedule you published and leaves every question of what is required to the manual and to you.