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.

GTFS to PostGIS load stages Four stages left to right: create typed tables, COPY bulk load of raw rows, build Point geometry for stops and LineString geometry for shapes, then add GiST index and foreign keys. Typed tables PK + dtypes COPY load stream CSV buffer Build geometry Point · stops LineString · shapes Index + FK GiST · references

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.

python
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:

python
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_MakePoint takes X (longitude) first, then Y (latitude) — the opposite of how GTFS columns are usually named and read. Passing ST_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.txt is optional in GTFS. A feed that omits it will make the shapes COPY fail with a missing-file error. Guard the shape steps behind a Path.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 the shapes table 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_lat or stop_lon contains blank cells and you omit the float dtype, pandas widens the column to object, the COPY sends "nan", and PostgreSQL rejects it against a DOUBLE PRECISION column. Enforce the float dtype and let na_rep="" map blanks to SQL NULL.

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.