Designing a GTFS Schema for Postgres
Give every table a feed_version column, make the primary key composite on (feed_version, natural_key), store schedule times as integer seconds since the service day began rather than as a time, defer foreign key checks to the end of the load transaction, and build indexes after the data is in. Then load into a staging schema and swap it atomically, so no reader ever queries a half-loaded feed. Each of those is a direct consequence of something GTFS does that a naive relational mapping does not survive. The loading mechanics are covered in exporting GTFS to databases and warehouses.
Root Cause Analysis
GTFS looks relational, and mapping it directly into Postgres produces a schema that works for exactly one feed version and then breaks.
Natural keys are not stable. trip_id is unique within a feed. It is not unique across publications, and agencies reuse identifiers for different trips between them. The moment you want two versions in the database at once — which any reload that does not take the service down requires — trip_id alone cannot be the primary key.
Times do not fit a time type. arrival_time runs past 24:00:00, and Postgres time rejects 25:15:00 outright. interval accepts it and compares correctly but indexes poorly and invites accidental arithmetic against timestamps. An integer count of seconds accepts every value, sorts correctly across midnight, and indexes as cheaply as any integer.
Immediate foreign keys make the load slow and ordered. stop_times references trips and stops; trips references routes and calendar. Enforcing those as each table loads forces a strict order and prevents parallel loading, and every row insert pays a lookup. Deferring the constraints to commit gets the identical guarantee at a fraction of the cost.
Indexes built before the load are maintained during it. Every inserted row updates every index. On 1.8 million stop_times rows that roughly doubles the load time for no benefit, because nothing queries the table until the load finishes.
And the operational trap underneath all of them: truncating the live tables and reloading means that for however long the load takes, readers see an empty or partial schedule. On a 1.8 million row feed that is minutes of a public application reporting no service.
Production-Ready Python Implementation
-- gtfs_schema.sql — one schema per load, swapped in atomically.
CREATE SCHEMA IF NOT EXISTS gtfs_staging;
SET search_path TO gtfs_staging;
-- Every table carries the feed version, so two publications can coexist and a
-- reload never has to destroy the version that is currently being served.
CREATE TABLE feed_version (
feed_version text PRIMARY KEY, -- the content checksum of the archive
fetched_at timestamptz NOT NULL,
feed_start_date date,
feed_end_date date,
agency_timezone text NOT NULL
);
CREATE TABLE routes (
feed_version text NOT NULL REFERENCES feed_version ON DELETE CASCADE,
route_id text NOT NULL,
agency_id text,
route_short_name text,
route_long_name text,
route_type smallint NOT NULL,
route_color char(6), -- hex; text, so 00FF00 survives
PRIMARY KEY (feed_version, route_id)
);
CREATE TABLE calendar_service (
feed_version text NOT NULL REFERENCES feed_version ON DELETE CASCADE,
service_id text NOT NULL,
service_date date NOT NULL, -- the EXPANDED calendar, one row per date
PRIMARY KEY (feed_version, service_id, service_date)
);
CREATE TABLE trips (
feed_version text NOT NULL REFERENCES feed_version ON DELETE CASCADE,
trip_id text NOT NULL,
route_id text NOT NULL,
service_id text NOT NULL,
shape_id text,
block_id text,
direction_id smallint,
trip_headsign text,
PRIMARY KEY (feed_version, trip_id),
CONSTRAINT trips_route_fk FOREIGN KEY (feed_version, route_id)
REFERENCES routes DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE stops (
feed_version text NOT NULL REFERENCES feed_version ON DELETE CASCADE,
stop_id text NOT NULL,
stop_name text,
stop_code text,
location_type smallint NOT NULL DEFAULT 0,
parent_station text,
zone_id text,
geom geometry(Point, 4326), -- as published; project at query time
PRIMARY KEY (feed_version, stop_id)
);
CREATE TABLE stop_times (
feed_version text NOT NULL REFERENCES feed_version ON DELETE CASCADE,
trip_id text NOT NULL,
stop_sequence integer NOT NULL,
stop_id text NOT NULL,
-- Seconds since the service day began. GTFS values exceed 24 hours, which
-- the time type rejects outright; integers also index and sort correctly
-- across midnight, which is the whole point.
arrival_s integer,
departure_s integer,
pickup_type smallint NOT NULL DEFAULT 0,
drop_off_type smallint NOT NULL DEFAULT 0,
shape_dist_traveled double precision,
timepoint smallint,
PRIMARY KEY (feed_version, trip_id, stop_sequence),
CONSTRAINT stop_times_trip_fk FOREIGN KEY (feed_version, trip_id)
REFERENCES trips DEFERRABLE INITIALLY DEFERRED,
CONSTRAINT stop_times_stop_fk FOREIGN KEY (feed_version, stop_id)
REFERENCES stops DEFERRABLE INITIALLY DEFERRED,
CONSTRAINT stop_times_ordered CHECK (
arrival_s IS NULL OR departure_s IS NULL OR departure_s >= arrival_s)
);
"""Create the schema, load into it, index it, and swap it in atomically."""
from __future__ import annotations
import logging
from pathlib import Path
import psycopg
log = logging.getLogger("gtfs.postgres")
# Built AFTER the data is in: maintaining them during a bulk COPY roughly
# doubles the load time and buys nothing, because nothing queries mid-load.
POST_LOAD_INDEXES = (
# The query every departure board runs.
"CREATE INDEX stop_times_by_stop ON stop_times (feed_version, stop_id, departure_s)",
# Trip reconstruction, in stop order.
"CREATE INDEX stop_times_by_trip ON stop_times (feed_version, trip_id, stop_sequence)",
# 'What runs today' — the calendar lookup behind almost every query.
"CREATE INDEX calendar_by_date ON calendar_service (feed_version, service_date)",
"CREATE INDEX trips_by_route ON trips (feed_version, route_id)",
"CREATE INDEX trips_by_service ON trips (feed_version, service_id)",
"CREATE INDEX stops_geom ON stops USING gist (geom)",
"CREATE INDEX stops_by_parent ON stops (feed_version, parent_station)"
" WHERE parent_station IS NOT NULL",
)
def load_feed(dsn: str, schema_sql: Path, copy_tables: dict[str, str],
feed_version: str) -> None:
"""copy_tables: table name -> a CSV buffer path, already normalised."""
with psycopg.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute("DROP SCHEMA IF EXISTS gtfs_staging CASCADE")
cur.execute(schema_sql.read_text())
conn.commit()
with conn.cursor() as cur:
# Deferred constraints mean the tables may load in ANY order, so this
# loop can become a thread pool without a dependency graph.
cur.execute("SET CONSTRAINTS ALL DEFERRED")
for table, csv_path in copy_tables.items():
with open(csv_path, "rb") as fh, cur.copy(
f"COPY gtfs_staging.{table} FROM STDIN WITH (FORMAT csv, HEADER)"
) as copy:
while chunk := fh.read(1 << 20):
copy.write(chunk)
log.info("copied %s", table)
conn.commit() # every deferred foreign key is checked here
with conn.cursor() as cur:
for statement in POST_LOAD_INDEXES:
cur.execute(statement.replace(" ON ", " ON gtfs_staging.", 1))
cur.execute("ANALYZE")
conn.commit()
log.info("staging schema ready for feed_version %s", feed_version)
def promote(dsn: str, keep_previous: bool = True) -> None:
"""Swap staging into place in ONE transaction; readers never see a partial feed."""
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute("DROP SCHEMA IF EXISTS gtfs_previous CASCADE")
cur.execute("ALTER SCHEMA gtfs RENAME TO gtfs_previous")
cur.execute("ALTER SCHEMA gtfs_staging RENAME TO gtfs")
if not keep_previous:
cur.execute("DROP SCHEMA gtfs_previous CASCADE")
conn.commit()
log.info("promoted staging to live%s",
"; previous kept for rollback" if keep_previous else "")
Step-by-Step Walkthrough
feed_version is the archive’s content checksum. The same digest used for feed version control, which means the database row and the archive on disk are provably the same feed. A fetch timestamp would not be — two fetches of an unchanged feed would create two versions.
Every primary key leads with feed_version. That is also the leading column of every index, which is exactly right: no query ever spans versions, so the planner prunes to one version before doing anything else.
calendar_service stores the expanded calendar. One row per (service_id, service_date) rather than the weekly pattern and its exceptions. The expansion is done once at load, and “what runs today” becomes an indexed lookup instead of arithmetic in SQL.
geom stays in EPSG:4326. Storing the published coordinates keeps the table faithful to the feed; queries that need metres project on the way out, or the table gains a second generated column in a local CRS. Storing only a projected geometry loses information the feed actually contained.
Deferred foreign keys are what allow parallel loading. With SET CONSTRAINTS ALL DEFERRED, stop_times can be copied before trips exists. Every reference is checked at COMMIT, so the guarantee is identical and the load can use a thread per table.
Indexes are created after the copy, and ANALYZE follows. Without the ANALYZE, the planner has no statistics for a freshly built table and will choose sequential scans over the indexes just created.
promote renames schemas rather than moving data. A rename is a catalogue update, so the swap is effectively instantaneous and fully transactional. Keeping gtfs_previous makes a rollback another two renames rather than a reload.
Verification and Output
VERIFY_SQL = """
-- Every trip has calls, and every call belongs to a trip.
SELECT 'trips with no stop_times' AS check, count(*) AS n
FROM gtfs_staging.trips t
WHERE NOT EXISTS (SELECT 1 FROM gtfs_staging.stop_times st
WHERE st.feed_version = t.feed_version AND st.trip_id = t.trip_id)
UNION ALL
-- Every trip's service resolves to at least one real date.
SELECT 'trips whose service never runs', count(*)
FROM gtfs_staging.trips t
WHERE NOT EXISTS (SELECT 1 FROM gtfs_staging.calendar_service c
WHERE c.feed_version = t.feed_version AND c.service_id = t.service_id)
UNION ALL
-- Times must not run backwards within a trip.
SELECT 'trips with non-monotonic times', count(*) FROM (
SELECT trip_id FROM (
SELECT trip_id, departure_s,
lag(departure_s) OVER (PARTITION BY feed_version, trip_id
ORDER BY stop_sequence) AS previous
FROM gtfs_staging.stop_times) s
WHERE previous IS NOT NULL AND departure_s < previous
GROUP BY trip_id) bad
UNION ALL
SELECT 'stops with no geometry', count(*)
FROM gtfs_staging.stops WHERE geom IS NULL AND location_type IN (0, 4);
"""
def verify(dsn: str) -> None:
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute(VERIFY_SQL)
for check, n in cur.fetchall():
log.info("%-34s %d", check, n)
assert n == 0, f"{check}: {n}"
A clean load:
INFO gtfs.postgres: copied routes
INFO gtfs.postgres: copied stops
INFO gtfs.postgres: copied trips
INFO gtfs.postgres: copied calendar_service
INFO gtfs.postgres: copied stop_times
INFO gtfs.postgres: staging schema ready for feed_version 9f2a1c...
INFO gtfs.postgres: trips with no stop_times 0
INFO gtfs.postgres: trips whose service never runs 0
INFO gtfs.postgres: trips with non-monotonic times 0
INFO gtfs.postgres: stops with no geometry 0
INFO gtfs.postgres: promoted staging to live; previous kept for rollback
The non-monotonic check is worth keeping in SQL rather than in Python: a window function over 1.8 million rows runs in a couple of seconds in the database and does not require the table to leave it.
Gotchas and Edge Cases
- Disk during promotion. Keeping the previous version doubles the storage for the largest table in the feed. On a 1.8 million row
stop_timesthat is manageable; across forty agencies it is not, andkeep_previous=Falsewith a separate archived export is the better trade. ON DELETE CASCADEfromfeed_version. Dropping a version removes every row belonging to it in one statement. Convenient, and dangerous — a mistaken delete of the current version takes the whole schedule with it. Restrict who may delete fromfeed_version.- Partial index on
parent_station. Most stops have none, so a full index is mostly nulls. TheWHERE parent_station IS NOT NULLclause makes it a fraction of the size and still serves every hierarchy query. - Times that need the timezone.
arrival_sis an offset, not an instant, so any query returning a wall-clock time must joinfeed_version.agency_timezoneand add the offset to the service date. Storing atimestamptzinstead would bake in a conversion that a multi-timezone feed makes wrong. ANALYZEon very large tables. It is fast, and skipping it is the most common reason a freshly loaded schema is inexplicably slow for its first hour.
Frequently Asked Questions
Why can't trip_id be the primary key?
Because it is only unique within one feed version, and an agency reuses identifiers across publications for different trips. Holding two versions at once — which any zero-downtime reload requires — means the key must include the version.
Why store times as integers rather than Postgres time?
Because GTFS times exceed 24:00:00 and the time type rejects them. An integer count of seconds since the service day started stores every value, compares correctly across midnight, and is faster to index.
Should foreign keys be enforced?
Yes, but deferred. Enforcing them immediately forces the load into dependency order and makes bulk COPY far slower. Deferring to the end of the transaction gets the same guarantee and lets the tables load in any order, in parallel.
How do I reload without downtime?
Load into a staging schema, validate there, then swap the search_path or rename schemas inside one transaction. Truncating and reloading the live tables leaves readers seeing an empty or half-loaded feed for the duration.
Related
- Loading GTFS into PostGIS with Python — the COPY mechanics this schema is built for
- Writing GTFS to Partitioned Parquet — the analytical alternative to a relational store
- Up: Exporting GTFS to Databases and Warehouses — choosing a store for the question you will ask
- Section: Python Parsing & Data Normalization · Home