The exact scenario this page addresses: you have two large PostGIS geometry tables — say, delivery zones and order locations — and a naive fetchall() join crashes your container with an OOM kill before the first row reaches your application. This page is a companion to the Spatial Joins topic, which covers the broader join patterns available in PostGIS. Here you will get a single, copy-paste-ready solution: server-side cursor streaming with explicit SRID handling, GiST index validation, and a verification step you can run immediately after.


Batch spatial join data-flow Diagram showing Python opening a transaction, declaring a server-side cursor in PostgreSQL, which executes ST_Intersects with GiST index assistance, and streams WKB rows back to Python in fixed-size batches. Python Process conn.autocommit = False cursor(name= "join_cur") fetchmany(5000) → loop until [] DECLARE CURSOR WKB rows PostgreSQL / PostGIS SET LOCAL work_mem='512MB' ST_Intersects( s.geom, t.geom) scroll pos held in server memory until COMMIT bitmap lookup GiST Indexes source_table(geom) SRID 4326 target_table(geom) SRID 4326 candidate row ids

Why the Naive Approach Fails

The typical first attempt calls cursor.fetchall() after a ST_Intersects join:

python
# DO NOT do this in production
import psycopg2

conn = psycopg2.connect(DSN)
cur = conn.cursor()
cur.execute("""
    SELECT s.id, s.geom, t.attr_a
    FROM source_table s
    JOIN target_table t ON ST_Intersects(s.geom, t.geom)
""")
rows = cur.fetchall()   # materialises the entire result set in Python heap

This fails for two related reasons. First, fetchall() instructs psycopg2 to load every matching row into the Python process’s heap before your for loop starts. A 500 k-row join across two geometry tables — even with WKB bytes rather than parsed GEOS objects — can consume 3–8 GB of RAM. Second, if you then pass each row into shapely.wkb.loads(), you instantiate a full GEOS object for every row simultaneously. On a containerised workload with a 512 MB memory limit, the process is killed before it processes a single record.

The fix is not to reduce the batch size on the Python side after fetchall() — the damage is already done by the time Python sees the data. The fix is to keep the result set on the PostgreSQL server and pull it through a named cursor in bounded chunks.

Production-Ready Implementation

The batch loop that keeps a long join restartable A cycle of four steps. Take the next id window using keyset pagination. Run the join for that window inside PostgreSQL. Write the results back in a bounded update. Commit, record the watermark, and repeat. A crash resumes at the last committed watermark instead of starting over. One transaction per batch, one watermark per commit 1 · next window id > :watermark LIMIT 25,000 2 · join in SQL LATERAL + && geometry never leaves PG 3 · write back UPDATE … FROM bounded row count 4 · COMMIT store the new watermark repeat until the window comes back empty Batch size is a lock-duration decision, not a throughput one: 25,000 rows keeps each transaction under a second, so autovacuum can still work and a deploy can still restart the process without losing an hour of progress. Make the write idempotent and a retry costs nothing.

The complete solution below uses psycopg2 server-side cursors, SET LOCAL work_mem, explicit SRID validation, and WKB streaming. Replace DSN, source_table, and target_table with your connection string and table names.

python
import psycopg2
import psycopg2.extras
import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

DSN = "dbname=gis_db user=app_user password=secret host=10.0.0.5 port=5432"
BATCH_SIZE = 5_000   # rows per fetchmany() call; tune between 2000–10000


def _check_indexes(cur: psycopg2.extensions.cursor) -> None:
    """Abort early if GiST indexes are missing — sequential scans will OOM."""
    cur.execute("""
        SELECT tablename, indexname
        FROM pg_indexes
        WHERE indexdef ILIKE '%using gist%'
          AND tablename IN ('source_table', 'target_table')
          AND schemaname = 'public'
        ORDER BY tablename
    """)
    indexed_tables = {row[0] for row in cur.fetchall()}
    missing = {"source_table", "target_table"} - indexed_tables
    if missing:
        raise RuntimeError(
            f"GiST index missing on: {missing}. "
            "Run: CREATE INDEX CONCURRENTLY ON <table> USING gist (geom);"
        )


def batch_spatial_join(sink_callback) -> int:
    """
    Stream ST_Intersects(source_table.geom, target_table.geom) results to
    sink_callback(rows) in BATCH_SIZE chunks.  Both geometry columns must be
    SRID 4326; add ST_Transform() if they differ.

    Returns total row count processed.
    """
    conn = psycopg2.connect(DSN)
    conn.autocommit = False   # server-side cursors require a live transaction

    try:
        with conn.cursor() as setup:
            # Allow PostGIS hash/sort operations to run in RAM, not temp files
            setup.execute("SET LOCAL work_mem = '512MB'")
            _check_indexes(setup)

        # Passing name= triggers DECLARE CURSOR on first execute()
        cur = conn.cursor(
            name="spatial_join_cursor",
            cursor_factory=psycopg2.extras.RealDictCursor,
            withhold=False,   # cursor is closed automatically on COMMIT
        )
        cur.itersize = BATCH_SIZE   # hint psycopg2's internal buffer

        cur.execute("""
            SELECT
                s.id                          AS source_id,
                s.geom                        AS source_geom,   -- hex WKB, SRID 4326
                t.id                          AS target_id,
                t.attr_a,
                t.attr_b
            FROM source_table  s
            JOIN target_table  t
              ON ST_Intersects(
                     ST_SetSRID(s.geom, 4326),
                     ST_SetSRID(t.geom, 4326)
                 )
            WHERE s.processed = FALSE
            ORDER BY s.id     -- stable order; remove if not needed
        """)

        processed = 0
        while True:
            rows = cur.fetchmany(BATCH_SIZE)
            if not rows:
                break

            # Pass raw dicts to caller; geometries are hex-WKB strings.
            # Only call shapely.wkb.loads(bytes.fromhex(row['source_geom']))
            # when topology operations are genuinely required.
            sink_callback(rows)

            processed += len(rows)
            logging.info("Processed %d rows total", processed)

        conn.commit()
        cur.close()
        return processed

    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()


if __name__ == "__main__":
    total = batch_spatial_join(lambda rows: None)   # replace lambda with real sink
    logging.info("Done. Total rows: %d", total)

Key points in the implementation:

  • conn.autocommit = False is required. DECLARE CURSOR is transaction-scoped in PostgreSQL; without an open transaction psycopg2 raises can't use a named cursor outside of transactions.
  • cur.itersize = BATCH_SIZE sets the number of rows psycopg2 requests from the server in each internal network round-trip, matching fetchmany(BATCH_SIZE) to avoid under-fetching.
  • ST_SetSRID(..., 4326) guards against silent predicate failures when rows lack an SRID. If your geometries are already consistently SRID 4326 you may omit this, but the guard costs nothing and prevents a common failure mode.
  • withhold=False means the cursor is discarded at COMMIT, which is the correct lifecycle for a read-only streaming job. Do not set withhold=True unless you need the cursor to survive beyond the transaction — it keeps server resources allocated.

Configuration and Tuning Knobs

Choosing a batch size Two curves against batch size from one thousand to five hundred thousand rows. Throughput rises steeply then flattens after about twenty thousand rows. Transaction duration rises linearly throughout and crosses the one-second lock guideline at around fifty thousand rows. The usable window lies between the two. Bigger batches stop paying long before they stop hurting usable window rows per second — flat after ~20k transaction duration 1k 10k 50k 150k 500k Pick the smallest batch on the flat part of the throughput curve. Past it you are only lengthening transactions, holding back autovacuum, and making each retry more expensive.
GUC / setting Recommended value Effect
work_mem (SET LOCAL) 256 MB – 512 MB Allows PostGIS to sort and hash intermediate geometry candidates in RAM rather than spilling to pg_temp. Use SET LOCAL to scope it to this transaction only.
BATCH_SIZE / itersize 2 000 – 10 000 rows Controls Python heap footprint. Larger batches reduce network round-trips; smaller batches give finer progress granularity. 5 000 is a safe default.
random_page_cost 1.1 (SSD) / 4.0 (HDD) If the planner chooses a sequential scan instead of the GiST index, lower random_page_cost so index seeks appear cheaper. Set at session level with SET random_page_cost = 1.1.
effective_cache_size 75% of total RAM Tells the planner how much OS page cache is available. Too-low values make index scans appear more expensive than they are.
statement_timeout 0 (disabled for batch) Batch jobs can run for minutes or hours; ensure statement_timeout is not set at the role level or the cursor declaration will time out mid-stream.

For GiST index tuning specific to high-volume spatial joins, see the advanced GiST indexing optimisation section, particularly composite indexes for geometry and timestamp columns when your join also filters on a time range.

Verification Steps

After running the batch job, confirm correctness with two checks.

Row-count assertion — compare the batch total against a direct COUNT(*) of the join:

python
import psycopg2

def verify_join_count(expected: int) -> None:
    conn = psycopg2.connect(DSN)
    with conn.cursor() as cur:
        cur.execute("""
            SELECT COUNT(*)
            FROM source_table  s
            JOIN target_table  t
              ON ST_Intersects(
                     ST_SetSRID(s.geom, 4326),
                     ST_SetSRID(t.geom, 4326)
                 )
            WHERE s.processed = FALSE
        """)
        actual = cur.fetchone()[0]
    conn.close()
    assert actual == expected, f"Row count mismatch: expected {expected}, got {actual}"
    print(f"Verification passed: {actual} rows")

EXPLAIN check — run this before the batch job to confirm the planner uses an Index Scan, not a sequential scan:

sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT s.id, t.attr_a
FROM source_table  s
JOIN target_table  t
  ON ST_Intersects(ST_SetSRID(s.geom, 4326), ST_SetSRID(t.geom, 4326))
WHERE s.processed = FALSE
LIMIT 100;

Look for Index Scan using <index_name> on target_table in the output. If you see Seq Scan instead, the GiST index is either missing or the planner’s cost estimates are stale — run ANALYZE source_table; ANALYZE target_table; and re-check. For a detailed guide to reading spatial EXPLAIN output, see reading EXPLAIN ANALYZE output for spatial joins.

Gotchas Checklist

  • Autocommit blocks the cursor. Setting conn.autocommit = True after connecting is the most common reason DECLARE CURSOR fails. Always set conn.autocommit = False before the first execute() on a named cursor. There is no way to convert an existing autocommit connection mid-stream without reconnecting.

  • SRID mismatches return zero rows, not an error. If source_table.geom is SRID 4326 and target_table.geom is SRID 3857, ST_Intersects evaluates coordinates in different units and returns false for every pair. Verify with SELECT DISTINCT ST_SRID(geom) FROM source_table before running the batch. Use ST_Transform to reproject one side, and ensure the target index covers the transformed SRID.

  • Mid-stream COMMIT destroys the cursor. PostgreSQL closes all WITHOUT HOLD cursors at COMMIT. If you need to mark rows as processed after each batch (e.g., UPDATE source_table SET processed = TRUE WHERE id = ANY(%s)), do not use a server-side cursor for the outer loop. Switch to primary-key pagination: WHERE id > %s ORDER BY id LIMIT %s.

  • Large work_mem multiplied by concurrency. SET LOCAL work_mem = '512MB' is per-sort-operation, and a single query can run multiple sorts. If you run four batch workers in parallel, peak RAM from work_mem alone can reach several gigabytes. Start with 128 MB and increase only after profiling pg_stat_activity for temp_files.

  • Index bloat after bulk inserts. GiST indexes on geometry columns accumulate dead pages during high-throughput inserts. If your batch runs after a bulk load, run VACUUM ANALYZE source_table first. Without fresh statistics the planner may underestimate selectivity and choose a sequential scan even with a healthy index.


FAQ: Server-side cursors and spatial joins

Why does a named psycopg2 cursor require an open transaction?

PostgreSQL’s DECLARE CURSOR is transaction-scoped. psycopg2 issues DECLARE on the first execute() call of a named cursor, and the cursor’s scroll position is held in server memory until the transaction ends. With autocommit=True there is no transaction to bind to, so PostgreSQL raises cannot DECLARE a cursor outside a transaction.

Can I commit mid-stream without closing the server-side cursor?

No. A COMMIT closes every open cursor in the transaction (unless declared WITH HOLD). If you need incremental commits to mark rows as processed, switch to primary-key pagination (WHERE id > last_id ORDER BY id LIMIT batch_size) rather than a server-side cursor.

Do I need to call ST_Transform if my tables use different SRIDs?

Yes. ST_Intersects(a.geom, b.geom) silently returns false — not an error — when the SRIDs differ. Wrap one side in ST_Transform(b.geom, 4326) and ensure the index column matches the SRID you query against, or PostGIS will fall back to a sequential scan because the index stores a different SRID than the function receives.