Streaming Large GeoPackage Loads into PostGIS

A large GeoPackage load should never build a GeoDataFrame of the whole file. Read features in chunks with a cursor, write them through PostgreSQL’s binary COPY into an unlogged staging table with no indexes, build the spatial index once at the end, and swap the staging table into place in a single transaction. Memory then stays flat regardless of file size, the load runs several times faster than row-by-row inserts, and a failure at any point leaves the live table untouched.

When to Use This Pattern

  • The source is larger than a comfortable fraction of worker memory — in practice anything over about a gigabyte, since a GeoDataFrame typically occupies several times the file size.
  • The target is a table that consumers read while the load runs, so a partially-loaded state must never be visible.
  • Load time matters. The difference between per-row INSERT and chunked COPY on ten million features is hours against minutes.
  • The load is repeated, nightly or weekly, so the cost of getting it right amortises immediately.

For a small file that fits comfortably in memory, ogr2ogr -f PostgreSQL with PG_USE_COPY=YES is simpler and fast enough — chaining GDAL tasks in Prefect covers that path. This recipe is for when the file stops fitting.

Memory during the loadReading the whole GeoPackage into memory peaks at fourteen gigabytes before falling. A chunked read holds a flat four hundred megabytes for the whole load.16 GB8 GB0elapsed time through one 6 GB loadread_file() — 14 GB peak, then OOM riskchunked COPY — flat 400 MBThe flat line is what lets eight of these run on one worker; the peaked line barely allows one.
The peak matters more than the average: a worker sized for the flat profile runs eight concurrent loads, and a worker sized for the peak runs one and still occasionally dies.

Complete Working Example

The loader below reads with Fiona’s cursor rather than geopandas, so only one chunk is ever materialised, and writes with psycopg’s binary COPY.

from __future__ import annotations

from itertools import islice
from pathlib import Path
from typing import Iterable, Iterator

import fiona
import psycopg
import shapely
from shapely.geometry import shape

CHUNK = 50_000


def chunks(iterable: Iterable, size: int) -> Iterator[list]:
    it = iter(iterable)
    while batch := list(islice(it, size)):
        yield batch


def create_staging(conn: psycopg.Connection, table: str, srid: int) -> None:
    """Unlogged, index-free staging: the fastest shape a table can have for writes."""
    with conn.cursor() as cur:
        cur.execute(f"DROP TABLE IF EXISTS {table}_stage")
        cur.execute(
            f"""
            CREATE UNLOGGED TABLE {table}_stage (
                feature_id text,
                geom       geometry(MultiPolygon, {srid}),
                properties jsonb
            )
            """
        )


def stream_load(
    gpkg: Path, conn: psycopg.Connection, table: str, srid: int = 25832, layer: str = "features"
) -> int:
    """Load a GeoPackage into a staging table in bounded memory. Returns the row count."""
    create_staging(conn, table, srid)
    loaded = 0

    with fiona.open(gpkg, layer=layer) as src:
        if src.crs.to_epsg() != srid:
            # Refuse rather than transform: the chain reprojects at exactly one step,
            # and a surprise transform here would hide a contract violation upstream.
            raise ValueError(f"expected EPSG:{srid}, file declares {src.crs.to_epsg()}")

        for batch in chunks(src, CHUNK):
            with conn.cursor().copy(
                f"COPY {table}_stage (feature_id, geom, properties) FROM STDIN (FORMAT BINARY)"
            ) as copy:
                copy.set_types(["text", "geometry", "jsonb"])
                for feature in batch:
                    geom = shape(feature["geometry"])
                    if geom.geom_type == "Polygon":
                        geom = shapely.MultiPolygon([geom])   # match the column's type
                    copy.write_row((
                        feature["properties"].get("gml_id") or str(feature["id"]),
                        shapely.to_wkb(geom, include_srid=False),
                        json.dumps(dict(feature["properties"]), default=str),
                    ))
            loaded += len(batch)
            conn.commit()          # commit per chunk: bounded WAL, resumable progress

    return loaded


def finalise(conn: psycopg.Connection, table: str) -> None:
    """Index once, then swap. Both steps are far cheaper here than during the load."""
    with conn.cursor() as cur:
        cur.execute(f"CREATE INDEX ON {table}_stage USING gist (geom)")
        cur.execute(f"CREATE UNIQUE INDEX ON {table}_stage (feature_id)")
        cur.execute(f"ANALYZE {table}_stage")
        # One transaction: consumers see the old table or the new one, never neither.
        cur.execute("BEGIN")
        cur.execute(f"DROP TABLE IF EXISTS {table}_old")
        cur.execute(f"ALTER TABLE IF EXISTS {table} RENAME TO {table}_old")
        cur.execute(f"ALTER TABLE {table}_stage RENAME TO {table}")
        cur.execute(f"ALTER TABLE {table} SET LOGGED")
        cur.execute("COMMIT")

Two choices deserve defending. Building the GiST index after the load rather than before is worth several times the load duration on a large table, because every inserted row otherwise pays an index maintenance cost. And UNLOGGED during the load skips write-ahead logging entirely for data that will be discarded if the load fails — SET LOGGED at the end pays the WAL cost once, in bulk, rather than per row.

The chunk size is the one number worth tuning by measurement rather than by rule. It trades two costs against each other: a small chunk pays the per-COPY set-up more often, and a large chunk holds more geometry in memory at once. Because geometry size varies enormously — a point is 21 bytes of WKB and a detailed municipal boundary can be several megabytes — the same feature count produces wildly different memory footprints across datasets. Measure peak RSS on your largest layer, not on a representative one.

Chunk size against duration and memoryAt five thousand features the load is slow from per-copy overhead. Between twenty thousand and one hundred thousand the duration is flat. At five hundred thousand memory rises sharply for no speed gain.chunkdurationpeak memory5 00031 min90 MB20 00017 min210 MB50 00016 min400 MB500 00016 min3.6 GB — all cost, no benefit
The duration curve flattens long before the memory curve does, which is why the sensible choice sits at the left edge of the flat region rather than in its middle.

Parameter & Option Reference

Parameter Type Default Spatial notes
CHUNK int 50000 Balances per-COPY overhead against peak memory. Lower it for geometries with many vertices — a chunk of 50 000 complex polygons is far larger than 50 000 points.
FORMAT BINARY Avoids the text encode/decode round trip for WKB. Meaningfully faster than the text format for geometry columns.
UNLOGGED staging yes Skips WAL for throwaway data. Lost on a crash, which is correct for a table rebuilt from a file.
index timing after load One bulk GiST build instead of per-row maintenance. Often the single largest saving here.
commit cadence per chunk Bounds WAL and transaction age. Also gives a natural resume point if the load is restarted.
geometry type promoted MultiPolygon Promote in the loader so a mixed source cannot fail halfway through a chunk.
SRID check fail, not transform The chain reprojects at one step; a silent transform here would mask a broken contract.

Verification & Testing

The properties to assert are that memory stays flat and that a failure never exposes a partial table.

import resource


def test_memory_stays_bounded(large_gpkg, conn) -> None:
    before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    stream_load(large_gpkg, conn, table="parcels")
    after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    # A 6 GB file must not push peak RSS up by more than a few hundred megabytes.
    assert (after - before) < 800_000, "peak memory grew with the file — not streaming"


def test_failed_load_leaves_live_table_intact(large_gpkg, conn, monkeypatch) -> None:
    baseline = row_count(conn, "parcels")
    monkeypatch.setattr("mymodule.finalise", lambda *a, **k: 1 / 0)
    with pytest.raises(ZeroDivisionError):
        run_load(large_gpkg, conn, table="parcels")
    # The staging table may be half-built; the live table must be untouched.
    assert row_count(conn, "parcels") == baseline

After a real load, three SQL checks confirm the outcome — count, projection and index presence. The last one is easy to lose in a refactor and expensive to discover later, because the table works perfectly until someone runs a spatial query on it:

SELECT count(*) AS features, ST_SRID(geom) AS srid FROM parcels GROUP BY 2;

SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'parcels';

-- Cheap sanity on the geometry itself: anything outside the CRS's valid range
-- means the contract upstream was wrong, whatever the SRID column says.
SELECT min(ST_XMin(geom)), max(ST_XMax(geom)),
       min(ST_YMin(geom)), max(ST_YMax(geom))
FROM   parcels;
Load, index, swapChunks are copied into an unlogged staging table, then a single GiST index is built, then a transaction renames the live table aside and the staging table into place.STAGING — invisible to consumers throughoutchunk 1chunk 2chunk 3chunk nbuild GiST index onceSWAP — one transactionparcels → parcels_oldparcels_stage → parcelsSET LOGGEDA reader in flight sees the old table or the new one. There is no instant at which it sees a partial load.
Keeping parcels_old for a cycle costs disk and buys a rollback that takes one rename — worth it for any table someone reads in production.

Common Pitfalls

  • Committing once at the end. A single transaction over ten million rows holds an enormous amount of WAL, blocks autovacuum for the duration, and makes a failure at 95% cost the whole load. Commit per chunk.
  • Building the index first. Creating the GiST index on an empty table feels tidy and makes every inserted row pay maintenance. On a large load this is frequently the difference between twenty minutes and two hours.
  • Loading directly into the live table. Even with a transaction, a long TRUNCATE-then-load holds locks that block readers. The staging-and-rename pattern keeps the live table readable at full speed until the instant of the swap.
  • Letting the loader transform the CRS. It is one line and it hides a broken contract upstream. Fail instead, and fix the step that produced the wrong projection — the reasoning in building ETL chains for vector data.
  • Assuming a uniform geometry type. A single Polygon in a file of MultiPolygons fails a typed column mid-chunk, rolling back fifty thousand rows. Promote in the loader, where it costs one branch.

Frequently Asked Questions

What chunk size should I use?

Start at 50 000 features and adjust by memory rather than by throughput. The right size depends on vertex count, not feature count: 50 000 points is a few megabytes, and 50 000 detailed coastline polygons can be a gigabyte. If peak memory is uncomfortable, halve the chunk; the per-COPY overhead is small enough that even 10 000 performs well.

Can I parallelise the load across workers?

Yes, into the same staging table, provided each worker handles a distinct slice of the file and no worker builds the index. COPY from several connections into one unlogged table scales well. Coordinate the finalise step so it runs exactly once, after all workers report done — usually as a separate task with a dependency on all of them.

What about `ogr2ogr` — is it not simpler?

It is simpler and it is a perfectly good choice up to a few gigabytes with PG_USE_COPY=YES. What you give up is control: you cannot easily promote geometry types conditionally, commit on your own cadence, or defer the index. When those matter, the Python loader is worth the extra thirty lines.

How does this interact with idempotency?

The rename is the commit point, which makes the whole load naturally idempotent — running it twice produces the same table. What is not idempotent is the parcels_old chain if you run it twice in quick succession, since the second run’s “old” is the first run’s “new”. Guard with an idempotency key on the delivery so an unchanged source is skipped before any of this starts.

Building ETL Chains for Vector Data