Loading GTFS into PostGIS with Python
Create typed tables with primary keys, bulk-load the raw rows with COPY (not row-by-row to_sql), then build geometry(Point, 4326) for stops from stop_lon/stop_lat and geometry(LineString, 4326) for each shape_id by aggregating shapes.txt ordered by shape_pt_sequence. Finish by adding a GiST spatial index and foreign keys so the store is both fast and referentially sound. Keep all geometry in SRID 4326 to match the GTFS spec.
Root Cause Analysis
The naive approach — GeoDataFrame.to_postgis() or DataFrame.to_sql(if_exists="replace") straight from parsed CSV — fails in production for three structural reasons, and each maps to a step in the script below.
Untyped, keyless tables. Letting pandas infer the schema yields TEXT columns throughout and no primary keys, so a stop_id join against stop_times has no index behind it and referential errors go undetected. GTFS is a relational model with well-defined keys; the load has to write those keys down explicitly. The full model and the DDL rationale live in the exporting GTFS to databases and warehouses guide.
Missing geometry. GTFS ships coordinates as loose numeric columns, not spatial objects. Without a real geometry column and a GiST index, a “stops within 400 m of this point” query degrades to a full-table Haversine scan. PostGIS wants typed Point and LineString geometry so its spatial index can prune candidates before any distance math runs.
Slow, non-atomic loads. Row-by-row inserts on a multi-million-row stop_times table can take an order of magnitude longer than a streamed COPY, and a partial failure leaves the database half-populated. Bulk-loading through a single COPY per table, inside a transaction, is both faster and atomic.
Production-Ready Python Implementation
The script below loads a full feed end to end: it creates typed tables, COPYs stops, stop_times, and shapes, builds Point and LineString geometry in SQL, then adds a GiST index and a foreign key. It uses sqlalchemy for connection management and psycopg2’s copy_expert for the bulk path.
import io
import logging
from pathlib import Path
import pandas as pd
import sqlalchemy as sa
from sqlalchemy import text
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
ENGINE = sa.create_engine(
"postgresql+psycopg2://gtfs:gtfs@localhost:5432/transit", future=True
)
DDL = """
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS stops (
stop_id TEXT PRIMARY KEY,
stop_name TEXT,
stop_lat DOUBLE PRECISION NOT NULL,
stop_lon DOUBLE PRECISION NOT NULL
);
CREATE TABLE IF NOT EXISTS trips (
trip_id TEXT PRIMARY KEY,
route_id TEXT NOT NULL,
service_id TEXT NOT NULL,
shape_id TEXT
);
CREATE TABLE IF NOT EXISTS stop_times (
trip_id TEXT NOT NULL,
stop_id TEXT NOT NULL,
stop_sequence INTEGER NOT NULL,
arrival_time TEXT,
departure_time TEXT,
PRIMARY KEY (trip_id, stop_sequence)
);
CREATE TABLE IF NOT EXISTS shapes (
shape_id TEXT NOT NULL,
shape_pt_lat DOUBLE PRECISION NOT NULL,
shape_pt_lon DOUBLE PRECISION NOT NULL,
shape_pt_sequence INTEGER NOT NULL,
PRIMARY KEY (shape_id, shape_pt_sequence)
);
CREATE TABLE IF NOT EXISTS shape_geom (
shape_id TEXT PRIMARY KEY,
geom geometry(LineString, 4326)
);
"""
# Explicit dtypes: IDs stay strings, sequences are ints, times are text.
DTYPES = {
"stops": {"stop_id": str, "stop_name": str,
"stop_lat": float, "stop_lon": float},
"trips": {"trip_id": str, "route_id": str,
"service_id": str, "shape_id": str},
"stop_times": {"trip_id": str, "stop_id": str, "stop_sequence": "Int32",
"arrival_time": str, "departure_time": str},
"shapes": {"shape_id": str, "shape_pt_lat": float, "shape_pt_lon": float,
"shape_pt_sequence": "Int32"},
}
def create_schema() -> None:
with ENGINE.begin() as conn:
for statement in filter(str.strip, DDL.split(";")):
conn.execute(text(statement))
logging.info("Schema created")
def copy_table(feed_dir: Path, table: str) -> int:
"""Load one GTFS file into its table via COPY, with explicit dtypes."""
dtypes = DTYPES[table]
frame = pd.read_csv(
feed_dir / f"{table}.txt", dtype=dtypes, usecols=list(dtypes)
)
buffer = io.StringIO()
frame.to_csv(buffer, index=False, header=False, na_rep="")
buffer.seek(0)
columns = ", ".join(frame.columns)
raw = ENGINE.raw_connection()
try:
with raw.cursor() as cur:
cur.copy_expert(
f"COPY {table} ({columns}) FROM STDIN WITH (FORMAT csv, NULL '')",
buffer,
)
raw.commit()
finally:
raw.close()
logging.info("COPY loaded %d rows into %s", len(frame), table)
return len(frame)
def build_geometry() -> None:
"""Create Point geometry for stops and LineString geometry per shape_id."""
with ENGINE.begin() as conn:
conn.execute(text(
"ALTER TABLE stops "
"ADD COLUMN IF NOT EXISTS geom geometry(Point, 4326)"
))
conn.execute(text(
"UPDATE stops "
"SET geom = ST_SetSRID(ST_MakePoint(stop_lon, stop_lat), 4326)"
))
# Aggregate shape points into a LineString, ordered by sequence
conn.execute(text("""
INSERT INTO shape_geom (shape_id, geom)
SELECT shape_id,
ST_MakeLine(
ST_SetSRID(ST_MakePoint(shape_pt_lon, shape_pt_lat), 4326)
ORDER BY shape_pt_sequence
)
FROM shapes
GROUP BY shape_id
ON CONFLICT (shape_id) DO UPDATE SET geom = EXCLUDED.geom
"""))
logging.info("Geometry built for stops and shapes")
def add_indexes_and_keys() -> None:
with ENGINE.begin() as conn:
conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_stops_geom "
"ON stops USING GIST (geom)"
))
conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_stop_times_stop_id "
"ON stop_times (stop_id)"
))
# Referential foreign keys between the relational tables
conn.execute(text("""
ALTER TABLE stop_times
ADD CONSTRAINT fk_stop_times_stop
FOREIGN KEY (stop_id) REFERENCES stops (stop_id) NOT VALID
"""))
conn.execute(text("""
ALTER TABLE stop_times
ADD CONSTRAINT fk_stop_times_trip
FOREIGN KEY (trip_id) REFERENCES trips (trip_id) NOT VALID
"""))
logging.info("GiST index and foreign keys added")
def load_feed(feed_dir: str) -> None:
feed_path = Path(feed_dir)
create_schema()
for table in ("stops", "trips", "stop_times", "shapes"):
copy_table(feed_path, table)
build_geometry()
add_indexes_and_keys()
logging.info("GTFS feed loaded into PostGIS from %s", feed_dir)
# --- Entry point ---
# load_feed("/data/gtfs/mta")
Step-by-Step Walkthrough
Typed DDL before any load. The CREATE TABLE statements pin every column’s type: IDs are TEXT to preserve leading zeros, stop_sequence and shape_pt_sequence are INTEGER, coordinates are DOUBLE PRECISION, and arrival_time/departure_time stay TEXT because GTFS allows times past 24:00:00. Loading into pre-created tables (never if_exists="replace") protects this schema.
COPY over row-by-row inserts. copy_table serializes each frame to an in-memory CSV buffer and streams it through copy_expert. The server parses the whole payload once instead of planning a statement per row, which is what makes this viable on a stop_times table with millions of rows. The na_rep="" plus NULL '' pairing ensures empty GTFS fields land as SQL NULL, not the literal string "nan".
Point geometry for stops. ST_MakePoint(stop_lon, stop_lat) builds the point with longitude first — the easting/northing convention — and ST_SetSRID(..., 4326) tags it as WGS84 so PostGIS knows the coordinate system. Getting the argument order wrong silently places every stop on the wrong continent.
LineString geometry for shapes. The critical detail is ST_MakeLine(... ORDER BY shape_pt_sequence). shapes.txt rows are not guaranteed to arrive in path order, so aggregating without the ORDER BY produces a self-intersecting tangle. Grouping by shape_id yields exactly one LineString per route shape. For consumers of that geometry — map rendering, stop-to-shape snapping — see spatial analysis and route geometry.
GiST index and foreign keys last. Building the GiST index after the geometry columns are populated is far faster than maintaining it during the load. The foreign keys are declared NOT VALID so they bind future writes immediately without an upfront full scan; you can VALIDATE CONSTRAINT during a maintenance window. Keeping the stored geometry in SRID 4326 aligns with coordinate reference systems for transit data — reproject with ST_Transform only when a query needs metric distances.
Verification and Output
After loading, confirm geometry validity and referential integrity directly in the database:
from sqlalchemy import text
with ENGINE.connect() as conn:
# Every stop must have a non-null, valid Point geometry
bad_geom = conn.execute(text(
"SELECT count(*) FROM stops "
"WHERE geom IS NULL OR NOT ST_IsValid(geom)"
)).scalar_one()
assert bad_geom == 0, f"{bad_geom} stops have missing or invalid geometry"
# SRID must be 4326 on both geometry columns
stop_srid = conn.execute(text(
"SELECT DISTINCT ST_SRID(geom) FROM stops"
)).scalar_one()
assert stop_srid == 4326, f"stops.geom SRID is {stop_srid}, expected 4326"
# No stop_times row may reference a stop_id absent from stops
orphans = conn.execute(text("""
SELECT count(*) FROM stop_times st
LEFT JOIN stops s ON st.stop_id = s.stop_id
WHERE s.stop_id IS NULL
""")).scalar_one()
assert orphans == 0, f"{orphans} stop_times reference missing stops"
# A spatial query should now use the GiST index
nearby = conn.execute(text("""
SELECT count(*) FROM stops
WHERE ST_DWithin(
geom::geography,
ST_SetSRID(ST_MakePoint(-73.9857, 40.7484), 4326)::geography,
500
)
""")).scalar_one()
print(f"Stops within 500 m of the test point: {nearby}")
print("PostGIS load verified — geometry, SRID, and foreign keys all pass")
A healthy load reports zero invalid geometries, an SRID of 4326, and zero orphaned stop_times. The ST_DWithin count confirms the spatial index answers proximity queries; casting to ::geography gives a true metre-based radius without reprojecting the stored column.
Gotchas and Edge Cases
-
Longitude/latitude argument order.
ST_MakePointtakes X (longitude) first, then Y (latitude) — the opposite of how GTFS columns are usually named and read. PassingST_MakePoint(stop_lat, stop_lon)compiles fine and produces geometry in the wrong hemisphere. Always order the arguments(stop_lon, stop_lat). -
Feeds without
shapes.txt.shapes.txtis optional in GTFS. A feed that omits it will make theshapesCOPY fail with a missing-file error. Guard the shape steps behind aPath.exists()check and skip geometry assembly when there is no shape data. -
Duplicate
(shape_id, shape_pt_sequence)rows. Some agency exports emit duplicate shape points, which violates the primary key on theshapestable and aborts the COPY. Deduplicate on(shape_id, shape_pt_sequence)in pandas before loading, keeping the first occurrence. -
Empty coordinate fields read as strings. If
stop_latorstop_loncontains blank cells and you omit thefloatdtype, pandas widens the column toobject, the COPY sends"nan", and PostgreSQL rejects it against aDOUBLE PRECISIONcolumn. Enforce the float dtype and letna_rep=""map blanks to SQLNULL.
Frequently Asked Questions
Do I need GeoAlchemy2 to load GTFS into PostGIS?
No. You can build geometry entirely in SQL with ST_MakePoint and ST_MakeLine after a plain COPY, which is the fastest path. GeoAlchemy2 is useful when you want ORM-mapped geometry columns for application code, but it is not required for a bulk load pipeline.
What SRID should GTFS geometry use in PostGIS?
Use SRID 4326 (WGS84), because that is the coordinate system GTFS mandates for stop_lat, stop_lon, and shapes. Store geometry in 4326 and reproject on demand with ST_Transform when you need metric distances, rather than converting the stored column.
Why build the GiST index after loading instead of before?
Maintaining a GiST index during a large COPY forces an index update per row and slows the load substantially. Building the index once after the geometry columns are populated is far faster and produces a denser, better-balanced tree.
Related
- Writing GTFS to Partitioned Parquet — the columnar-warehouse counterpart to this spatial-database load, driven from the same DataFrames
- Spatial Analysis and Route Geometry — what to do with the Point and LineString geometry once it is in PostGIS
- Coordinate Reference Systems for Transit Data — why the stored geometry stays in EPSG:4326 and how to reproject for metric queries
- Up: Exporting GTFS to Databases and Warehouses — the target schema and idempotent-load model behind this script
- Section: Python Parsing & Data Normalization — the full GTFS parsing and normalization pipeline · Home