This recipe supports the PostGIS vs DuckDB spatial for analytical loads comparison within the wider Orchestrating Spatial ETL Pipelines reference. Once you have chosen PostGIS as the load target, this is the write path that makes the Load stage safe to retry.
Loading GeoDataFrames into PostGIS with GeoPandas
The Problem: The One-Line Load Is Not Production Safe
gdf.to_postgis("features", engine, if_exists="append") works perfectly in a notebook and fails quietly in a pipeline. The single call handles the happy path and nothing else, and a scheduled task will eventually hit every unhandled case at once.
- Duplicate geometries on retry:
if_exists="append"re-inserts every row on a second run, so any orchestrated retry doubles the data. There is no key, so nothing dedupes it. - SRID drift: if the GeoDataFrame’s CRS is unset or mixed, PostGIS stores the geometry with SRID 0, and every later
ST_Intersectsagainst a properly-tagged table silently returns nothing. - All-or-nothing memory blowups: writing a multi-million-row GeoDataFrame in one statement buffers the entire INSERT, spiking memory on the worker and holding one long transaction open.
- Missing spatial index:
to_postgisnever creates a GiST index, so the first analytical query does a sequential scan over the whole table and everyone assumes PostGIS is slow.
The fix is a small, boring function that enforces the SRID, streams rows into a staging table in chunks, upserts into the live target on a stable key, and builds the index once. The rest of this page is that function.
Version and Environment Compatibility
| Library | Version | Note |
|---|---|---|
| GeoPandas | >=1.0 |
to_postgis chunksize and stable WKB writing; pin to avoid the 0.x SRID quirks |
| SQLAlchemy | >=2.0 |
Engine / text() API used throughout; 1.4 URL strings still parse |
| GeoAlchemy2 | >=0.15 |
Registers the geometry type so to_postgis emits a typed column |
| PostGIS | 3.4+ on PostgreSQL 15+ |
ON CONFLICT upsert and GiST on geometry; earlier versions work but tune vacuum |
pip install "geopandas>=1.0" "SQLAlchemy>=2.0" "geoalchemy2>=0.15" "psycopg[binary]>=3.1"The Staging-Table Upsert Flow
The load never writes directly to the live table. It lands rows in a transient staging table, then swaps them into the target with an atomic upsert. The diagram traces the path a single batch takes.
Staging first is what buys idempotency: the live table is only ever touched by the keyed upsert, so a retried task converges on the same rows instead of stacking duplicates.
The load_to_postgis Recipe
One copy-pasteable function. It enforces the SRID, streams into a staging table, upserts on a caller-supplied key, ensures the GiST index, and cleans up — all with logging and the transactional guarantees a scheduled task needs.
import logging
import geopandas as gpd
from sqlalchemy import Engine, text
logger = logging.getLogger(__name__)
def load_to_postgis(
gdf: gpd.GeoDataFrame,
engine: Engine,
table: str,
key: str = "feature_id",
srid: int = 4326,
geom_col: str = "geometry",
chunksize: int = 50_000,
) -> int:
"""Idempotently load a GeoDataFrame into a PostGIS table via a staging upsert.
Reprojects to a single SRID, streams rows into a transient staging table,
then upserts into the live target keyed on ``key`` so re-runs overwrite
rather than append. Ensures a GiST index on the geometry column.
Returns the number of rows written from this batch.
"""
if gdf.empty:
logger.info("Empty GeoDataFrame for %s — nothing to load.", table)
return 0
# 1. Enforce one known SRID. An unset CRS would land as SRID 0 in PostGIS.
if gdf.crs is None:
raise ValueError(
f"GeoDataFrame has no CRS; refusing to load into {table} with SRID {srid}."
)
if gdf.crs.to_epsg() != srid:
logger.info("Reprojecting %d features to EPSG:%d", len(gdf), srid)
gdf = gdf.to_crs(epsg=srid)
if key not in gdf.columns:
raise KeyError(f"Upsert key {key!r} not present in GeoDataFrame columns.")
staging = f"_stg_{table}"
cols = [c for c in gdf.columns] # preserve column order for the INSERT SELECT
col_list = ", ".join(f'"{c}"' for c in cols)
update_set = ", ".join(
f'"{c}" = EXCLUDED."{c}"' for c in cols if c != key
)
# 2. Stream the batch into a fresh staging table in chunks.
gdf.to_postgis(staging, engine, if_exists="replace", index=False, chunksize=chunksize)
with engine.begin() as conn: # single transaction: upsert + index + cleanup
# 3. Ensure the live target exists with the same shape as staging.
conn.execute(
text(f'CREATE TABLE IF NOT EXISTS "{table}" '
f'(LIKE "{staging}" INCLUDING DEFAULTS)')
)
# A stable key is what makes ON CONFLICT possible.
conn.execute(
text(f'ALTER TABLE "{table}" '
f'ADD CONSTRAINT IF NOT EXISTS "{table}_pk" PRIMARY KEY ("{key}")')
)
# 4. Upsert staged rows into the live target on the key.
result = conn.execute(
text(
f'INSERT INTO "{table}" ({col_list}) '
f'SELECT {col_list} FROM "{staging}" '
f'ON CONFLICT ("{key}") DO UPDATE SET {update_set}'
)
)
# 5. Build the GiST index once, then refresh planner statistics.
conn.execute(
text(f'CREATE INDEX IF NOT EXISTS "{table}_{geom_col}_gix" '
f'ON "{table}" USING GIST ("{geom_col}")')
)
conn.execute(text(f'ANALYZE "{table}"'))
# 6. Drop the staging table inside the same transaction.
conn.execute(text(f'DROP TABLE IF EXISTS "{staging}"'))
written = result.rowcount if result.rowcount is not None else len(gdf)
logger.info("Upserted %d features into %s (SRID %d)", written, table, srid)
return writtenKey Implementation Notes
- The CRS check refuses to guess. An unset CRS raises rather than silently defaulting, because a wrong SRID is invisible until a spatial join returns zero rows in production. The reprojection normalizes to one EPSG so every geometry in the target shares a reference system — the same discipline the load-target comparison depends on when it benchmarks
ST_Areaacross engines. - Staging plus
ON CONFLICTis the whole idempotency story. The live table is only mutated by the keyed upsert, so a second run of the same batch converges to identical state. Nothing appends blindly, which is exactly why an orchestrated retry is safe. ADD CONSTRAINT IF NOT EXISTSneeds PostgreSQL 15+. On older servers, guard the constraint creation with a catalog lookup instead; the primary key is mandatory becauseON CONFLICTrequires a unique constraint on the conflict target.- The index is built once, after the load, not per chunk. Maintaining a GiST index across a 50k-row-per-chunk stream taxes every insert; creating it after the rows land and then running
ANALYZEis both faster and leaves the planner with fresh selectivity statistics. - Everything after the staging write is one transaction. The upsert, index creation, statistics refresh, and staging drop either all commit or all roll back, so a mid-load crash never leaves a half-swapped target or an orphaned index.
chunksizebounds memory, not correctness. Larger chunks are faster but hold more rows in the INSERT buffer; 50k is a safe default for wide geometry rows, and you can raise it for narrow point tables.
Troubleshooting the Load Path
| Failure | Root Cause | Fix |
|---|---|---|
| Geometry column stored with SRID 0 | GeoDataFrame CRS was unset before writing | Call set_crs/to_crs and let the recipe’s guard reject an unset CRS |
ON CONFLICT errors: “no unique constraint matching” |
Target table has no primary key on the upsert key | Ensure the ADD CONSTRAINT step ran; the conflict target must be unique |
| Duplicate rows still appear after a retry | Load wrote straight to the target with append instead of staging |
Route every write through the staging table and the keyed upsert |
| First analytical query is a slow sequential scan | GiST index missing or planner stats stale | Confirm the CREATE INDEX ... GIST ran, then ANALYZE the table |
| Worker memory spikes during load | Single-statement insert of the full GeoDataFrame | Set a chunksize so to_postgis streams the batch |
| Load holds a long lock and blocks readers | Index maintained during the chunked insert | Build the index once after the rows land, not per chunk |
Integration Note
This function is the body of the Load task when PostGIS is the target chosen in the PostGIS vs DuckDB spatial for analytical loads comparison. Because it upserts on a stable key inside one transaction, it satisfies the exactly-once contract an orchestrator expects — call it from a task that has already passed geometry and schema gates, and a retry costs nothing but a repeated upsert. The same keyed-write discipline generalizes to any engine, which is why it mirrors the pattern in writing idempotent spatial ETL tasks in Airflow: key on an identifier, mutate atomically, and let re-runs converge.
Related
- PostGIS vs DuckDB Spatial for Analytical Loads — the comparison that decides when PostGIS is the right load target for this write path
- Writing Idempotent Spatial ETL Tasks in Airflow — the orchestration-level pattern this upsert satisfies
- Orchestrating Spatial ETL Pipelines — the top-level reference for the Load stage inside a scheduled spatial DAG