Problem Statement
Twelve million points from a weekly extract have to land in PostGIS, and the ORM path takes four hours. COPY takes eleven minutes. This page covers the whole shape of a bulk geometry load — the staging table, the binary writer, the promotion step and the index timing — as the high-throughput counterpart to the ordinary session-managed write path.
Why the Naive Approach Fails
Three write paths differ by an order of magnitude each, and the difference is entirely protocol overhead rather than database work.
Production-Ready Implementation
The load has four stages and only one of them is the copy itself.
from __future__ import annotations
import logging
from typing import Iterable, Iterator
import psycopg
from shapely import wkb
from shapely.geometry.base import BaseGeometry
log = logging.getLogger("bulk-load")
STAGING_DDL = """
CREATE UNLOGGED TABLE IF NOT EXISTS observations_staging (
source_id bigint,
recorded_at timestamptz,
geom geometry(Point, 4326)
)
"""
def load(dsn: str, rows: Iterable[tuple[int, str, BaseGeometry]]) -> dict:
stats = {"copied": 0, "promoted": 0, "rejected": 0}
with psycopg.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute(STAGING_DDL)
cur.execute("TRUNCATE observations_staging")
conn.commit()
# 1. stream into staging — no indexes, no constraints, unlogged
with conn.cursor() as cur, cur.copy(
"COPY observations_staging (source_id, recorded_at, geom) "
"FROM STDIN (FORMAT BINARY)"
) as copy:
copy.set_types(["int8", "timestamptz", "bytea"])
for source_id, recorded_at, geometry in rows:
copy.write_row((source_id, recorded_at, wkb.dumps(geometry, srid=4326)))
stats["copied"] += 1
conn.commit()
log.info("staged %s rows", stats["copied"])
# 2. validate set-based, so a bad row names itself
with conn.cursor() as cur:
cur.execute("""
DELETE FROM observations_staging
WHERE geom IS NULL
OR NOT ST_IsValid(geom)
OR ST_SRID(geom) <> 4326
RETURNING source_id
""")
stats["rejected"] = cur.rowcount
conn.commit()
# 3. promote in one statement
with conn.cursor() as cur:
cur.execute("""
INSERT INTO observations (source_id, recorded_at, geom)
SELECT source_id, recorded_at, geom FROM observations_staging
ON CONFLICT (source_id) DO UPDATE
SET recorded_at = EXCLUDED.recorded_at,
geom = EXCLUDED.geom
""")
stats["promoted"] = cur.rowcount
conn.commit()
return statswkb.dumps(geometry, srid=4326) produces EWKB with the SRID embedded, which PostGIS accepts directly into a typed geometry column — no ST_GeomFromText, no server-side parsing of a text representation, and no chance of the SRID being lost in transit.
The staging table being UNLOGGED removes WAL writes for the staging phase entirely, which on a twelve-million-row load is several gigabytes of I/O that serve no purpose: if the load fails, the staging contents are worthless anyway.
Configuration and Tuning Knobs
maintenance_work_mem governs the index build in stage four. A gigabyte on a machine that can spare it typically halves the build time.
max_wal_size should be generous during a bulk load. Too small and PostgreSQL checkpoints constantly, turning a sequential write into a stuttering one; raising it to several gigabytes for the load window is the single most effective server-side tuning knob.
synchronous_commit = off for the loading session is safe for a staging load — the worst case is losing recent staging rows on a crash, and the load restarts anyway.
Batch size within the copy is handled by psycopg’s buffering and rarely needs tuning. What does matter is not accumulating the source rows in a list before copying: pass a generator so memory stays flat regardless of the file size.
Verification Steps
-- did everything arrive?
SELECT count(*) FROM observations
WHERE recorded_at >= :load_window_start;
-- is the geometry what it should be?
SELECT count(*) FILTER (WHERE NOT ST_IsValid(geom)) AS invalid,
count(*) FILTER (WHERE ST_SRID(geom) <> 4326) AS wrong_srid,
min(ST_X(geom)) AS min_lon, max(ST_X(geom)) AS max_lon
FROM observations
WHERE recorded_at >= :load_window_start;
-- and did the index get built?
SELECT indexrelid::regclass, indisvalid FROM pg_index
WHERE indrelid = 'observations'::regclass;The longitude range check catches the classic swapped-coordinate bug in one line: values outside ±180 mean latitude and longitude were transposed somewhere in the writer.
Gotchas Checklist
copy.set_typesmatters for binary format. Without it psycopg has to infer types per row, which is both slower and occasionally wrong forbyteacarrying EWKB.wkb.dumpswithoutsrid=produces WKB without an SRID, which PostGIS accepts and labels SRID 0 — silently breaking every subsequent spatial predicate.- An
UNLOGGEDstaging table is emptied by a crash. That is the intent; do not use unlogged for the destination table. COPYenforces constraints. Putting the constraints on the staging table turns a set-based validation into a stream that aborts on row 4,000,001.- Do not hold the source in memory. A generator is the difference between a flat 60 MB process and one that tries to hold twelve million shapely objects.
Sizing the Batch, the Transaction, and the Server
Throughput on a bulk geometry load is decided by three settings that interact, and tuning any one of them alone produces the modest improvement that makes people conclude COPY is not much faster after all.
Batch size controls memory and retry granularity. Ten thousand rows per batch is a reasonable starting point for point data and a poor one for detailed polygons, where a single row can be several hundred kilobytes. The right rule is bytes rather than rows: accumulate until the buffer reaches a few megabytes, then flush. That keeps memory bounded regardless of geometry complexity and keeps a failed batch small enough to retry cheaply.
Transaction scope decides how much work is lost on failure and how much WAL accumulates. One transaction for the entire load gives the fastest possible run and the worst possible failure behaviour — an error at row nine million discards everything. One transaction per batch costs a commit per batch, which at a few megabytes each is negligible, and makes the load resumable. Resumability is worth more than the last few percent of speed on any load long enough to be interrupted.
Server-side settings are where the largest single gain usually hides. Raising maintenance_work_mem before creating indexes, increasing max_wal_size so the load does not trigger a checkpoint every few seconds, and loading into a table whose indexes are created afterwards rather than maintained during the insert routinely doubles throughput on their own. For a staging table that will be repopulated from source on failure, UNLOGGED removes WAL entirely and can double it again — at the cost of the table being truncated after an unclean shutdown, which is exactly the trade a staging table should be making.
Measure with the real geometries. Loading synthetic points will overstate the achievable rate by a factor that grows with how detailed the production data is.
Related Topics
- Session Management for Spatial Data — parent topic: transaction and connection boundaries
- Handling Session Timeouts During Bulk Spatial Inserts — what to do when a load runs into a timeout
- GeoAlchemy2 vs Raw WKB — When to Use Each — the representation this loader uses
- CREATE INDEX CONCURRENTLY on Large Spatial Tables — stage four in detail