PostGIS to DuckDB Spatial Migration

Moving a spatial workload off PostGIS and into DuckDB is less a data copy than a change of execution model: you trade a stateful, SRID-tagged, GiST-indexed server for an in-process columnar engine that tracks coordinate systems out-of-band and rebuilds its R-trees on demand. This guide is part of Migrating to DuckDB Spatial and walks the full end-to-end path — exporting PostGIS tables to GeoParquet, mapping the geometry model and column types, loading and indexing in DuckDB, and validating that row counts and areas survived the trip. The failure surface is specific and repeatable: SRID metadata evaporates on export, geometries that GiST happily indexed turn out invalid under ST_IsValid, and a generic geometry column carrying mixed types confuses format writers. Each of those is caught by a validation gate rather than discovered in production, and this walkthrough builds those gates as you go.

Runtime Configuration & Migration Guardrails

A migration run holds three working sets at once — the decoded source geometry, the R-tree under construction, and the validation queries that compare source to target — so configure the session for the peak, not the average. The general-analytics defaults assume a read-mostly workload and under-provision the temp directory that a large index build spills into.

INSTALL spatial;   -- one-time download of the spatial extension into the local cache
LOAD spatial;      -- per-session; required before any ST_ function or RTREE index resolves

INSTALL postgres;  -- optional: lets DuckDB ATTACH and read the live PostGIS database
LOAD postgres;     -- only needed for the over-the-wire export route below

-- Match physical cores. R-tree construction is memory-bandwidth bound, so hyperthread
-- siblings contend for the same cache lines and slow the build instead of speeding it.
SET threads = 8;

-- Ceiling for the largest working set: decoded geometry plus the in-flight index.
-- Too low → the index build spills mid-migration; too high on a shared host → OOM-kill.
SET memory_limit = '12GB';

-- Drop row-order guarantees so the bulk load and index build parallelize. Re-impose
-- order only on the final validated output with an explicit ORDER BY.
SET preserve_insertion_order = false;

-- Spill target for the index build and any wide validation join. Point at local NVMe;
-- a network mount turns a spilling RTREE build into an I/O cliff.
SET temp_directory = '/var/lib/duckdb/spill';

Before starting a production cutover, confirm these preconditions hold:

Trade-off: the over-the-wire postgres extension route is convenient but serializes every geometry through the PostgreSQL protocol one buffer at a time, which is slow for large tables and holds a connection open against the live server. The file-based GeoParquet route decouples the two systems entirely and lets you re-run the DuckDB side without re-touching PostGIS — prefer it for anything beyond a few million rows.

The Geometry Model Difference

Everything downstream depends on one structural fact: PostGIS and DuckDB store the coordinate reference system in completely different places, and the export step is where that difference bites.

In PostGIS a column is typed geometry(Point, 4326), and the SRID travels inside every geometry value — it is part of the on-disk representation, enforced by a type modifier, and readable per row with ST_SRID(geom). The server also maintains a spatial_ref_sys catalog table that resolves each SRID to a full PROJ definition. DuckDB’s GEOMETRY type is deliberately CRS-agnostic: it stores only the WKB coordinate stream, with no SRID slot. Coordinate systems are tracked externally — in a column you carry yourself, in a GeoParquet file’s crs metadata, or simply as a documented convention for the table. ST_SRID in DuckDB generally returns 0, and that is not a bug; the internal geometry representation is analyzed in the ST_Geometry versus WKB reference.

The practical consequence is that a raw WKB or plain-Parquet export drops the SRID on the floor. GeoParquet is the format that solves this: it writes the CRS into the file-level geo metadata block, so a projected layer round-trips its coordinate system even though the individual geometries no longer carry it. When you must preserve SRID through a format that has no CRS slot, carry it as an ordinary integer column and treat it as documented metadata, then reproject with ST_Transform — which in DuckDB takes an explicit source and target CRS rather than reading a stored SRID off the geometry. The mechanics are detailed in the CRS mapping and transformations reference and in the function-level translation reference.

The Migration Pipeline

The reliable shape of a PostGIS-to-DuckDB migration is a five-stage pipeline: dump the source table, land it as GeoParquet, load it into DuckDB, build the R-tree, and validate against the source. Each stage has a single responsibility and a checkable output, so a failure localizes instead of cascading.

End-to-end PostGIS to DuckDB Spatial migration pipeline A left-to-right pipeline of five stages. Stage one, a PostGIS source table typed geometry Point 4326 with a GiST index. Stage two, a DUMP or COPY step using ogr2ogr, the postgres extension, or ST_AsWKB. Stage three, a GeoParquet file that carries the CRS in its file-level geo metadata. Stage four, a DuckDB load into a native GEOMETRY column followed by an R-tree build using CREATE INDEX USING RTREE. Stage five, a VALIDATE step that compares row counts and total ST_Area between source and target. An annotation below the middle notes that the SRID leaves the per-geometry slot and is preserved only in the GeoParquet file metadata. PostGIS table geometry(Point,4326) GiST index DUMP / COPY ogr2ogr · postgres ST_AsWKB GeoParquet CRS in file metadata DuckDB load GEOMETRY column + RTREE build VALIDATE row counts total ST_Area DUMP → LAND AS GEOPARQUET → LOAD → INDEX → VALIDATE The SRID leaves the per-geometry slot — it survives only in the GeoParquet file metadata.

Stage 1–2: Exporting PostGIS tables

There are three practical export routes, and the right one depends on table size and whether the CRS must survive automatically.

The cleanest route for most tables is ogr2ogr, which reads PostGIS directly and writes GeoParquet with the CRS embedded:

# GDAL reads the PostGIS table and writes GeoParquet with the CRS in file metadata.
# -lco GEOMETRY_ENCODING=WKB keeps the geometry as standard WKB the DuckDB reader
# resolves natively; -nlt PROMOTE_TO_MULTI normalizes a mixed single/multi column.
ogr2ogr -f Parquet parcels.parquet \
  PG:"host=db user=gis dbname=cadastre" \
  -sql "SELECT gid, parcel_ref, land_use, geom FROM parcels" \
  -lco GEOMETRY_ENCODING=WKB -nlt PROMOTE_TO_MULTI

When GDAL is not available, dump WKB straight from PostgreSQL. ST_AsEWKB preserves the SRID as an extended-WKB prefix, but most readers expect plain WKB, so pair a plain ST_AsBinary/ST_AsWKB export with an explicit SRID column you carry yourself:

-- Run inside psql on the PostGIS side. Emit plain WKB plus an explicit srid column
-- so the coordinate system is recoverable in DuckDB even without GeoParquet metadata.
COPY (
  SELECT gid,
         parcel_ref,
         ST_SRID(geom)      AS srid,      -- carried as a plain integer column
         ST_AsBinary(geom)  AS geom_wkb   -- standard WKB, no SRID prefix
  FROM parcels
) TO STDOUT WITH (FORMAT parquet);

The third route skips files entirely: attach the live database with DuckDB’s postgres extension and pull rows across the wire, converting WKB on arrival. This is the fastest to set up and the slowest to run at volume, because every geometry crosses the PostgreSQL protocol serially.

-- Read directly from the running PostGIS server. The PostGIS ST_AsWKB runs server-side;
-- DuckDB reconstructs the native GEOMETRY with ST_GeomFromWKB on arrival.
ATTACH 'host=db user=gis dbname=cadastre' AS pg (TYPE postgres, READ_ONLY);

CREATE TABLE parcels AS
SELECT gid,
       parcel_ref,
       land_use,
       ST_GeomFromWKB(geom_wkb) AS geom
FROM postgres_query('pg',
  'SELECT gid, parcel_ref, land_use, ST_AsBinary(geom) AS geom_wkb FROM parcels');

Stage 3–4: Loading and indexing in DuckDB

GeoParquet written by GDAL carries a WKB geometry column plus the geo metadata block. DuckDB with the spatial extension loaded recognizes that block and exposes the column as native GEOMETRY; when reading plain Parquet, reconstruct it explicitly with ST_GeomFromWKB. Persist into a real table so the R-tree has a stable column to attach to — an index cannot be built over a view or a raw file scan.

-- Load GeoParquet into a persisted GEOMETRY table. Reading via ST_Read uses the
-- GDAL layer; read_parquet is faster and preferred when the geo metadata is present.
CREATE TABLE parcels AS
SELECT gid, parcel_ref, land_use, geom
FROM read_parquet('parcels.parquet');

-- If the source was plain WKB Parquet, decode the blob into native geometry instead:
-- SELECT gid, parcel_ref, srid, ST_GeomFromWKB(geom_wkb) AS geom FROM read_parquet(...);

-- Build the R-tree AFTER the bulk load, never before — a per-row-inserted index is
-- both slower to build and worse-packed than one built over the settled table.
CREATE INDEX idx_parcels_geom ON parcels USING RTREE (geom);

Building the index after the load is not a stylistic choice — DuckDB’s R-tree is bulk-loaded far more efficiently over a table that already holds all its rows, and the rebuild-after-load discipline is the whole subject of the GiST-to-R-tree migration guide and the deeper R-tree rebuild strategies reference. The GeoParquet reader’s own performance characteristics, including how it prunes row groups by bounding box, are covered under GeoParquet parsing.

Before indexing, confirm the geometry column actually landed as native GEOMETRY rather than a BLOB or VARCHAR — a mislabelled column is the most common reason a later CREATE INDEX ... USING RTREE fails outright. A one-line schema check catches it immediately:

-- The geom column must report GEOMETRY; a BLOB means the geo metadata was not
-- recognised and you still need an explicit ST_GeomFromWKB decode step.
SELECT column_name, data_type FROM duckdb_columns()
WHERE table_name = 'parcels' AND column_name = 'geom';

If the type reads BLOB, the reader treated the file as plain Parquet — re-run the load through the explicit ST_GeomFromWKB decode shown above, or read it via ST_Read('parcels.parquet'), which routes through the GDAL layer and always materializes native geometry. Either way, the index step is blocked until the column is a true GEOMETRY.

Schema & Type Mapping

Most non-spatial columns map without surprise, but a handful of PostGIS-specific types have no DuckDB counterpart and force a decision at migration time.

PostGIS type DuckDB type Migration note
geometry(Point,4326) GEOMETRY SRID not stored; carry it externally or in GeoParquet metadata
geography GEOMETRY No geodesic type; distances become planar unless you ST_Transform first
geometry (generic) GEOMETRY Mixed types allowed, but format writers may need a single type — see Edge Cases
box2d / box3d BOX_2D 2D box maps directly; the Z range of box3d is dropped
raster No raster type; migrate rasters as external files, not table columns
serial / bigserial INTEGER / BIGINT Sequence generation is not carried; assign keys explicitly
numeric(p,s) DECIMAL(p,s) Exact for p ≤ 38; wider precision falls back to DOUBLE
timestamptz TIMESTAMPTZ Stored as microseconds UTC; session TimeZone governs display
jsonb JSON Text-backed in DuckDB; no binary jsonb operators
uuid / bytea UUID / BLOB Direct

The two rows that reliably cause incidents are geography and raster. A PostGIS geography column computes distances and areas on the ellipsoid; DuckDB has no geodesic type, so an ST_Area or ST_Distance that returned metres on a geography column will return degrees on the migrated planar geometry unless you project to a metric CRS first. Rasters have no home in the relational model at all and should be migrated as external files referenced by path, not pulled into a column.

Execution Plan Validation

After loading and indexing, confirm the R-tree is actually reachable by the planner before trusting any spatial query’s performance. The check is the same two-stage predicate the point-in-polygon optimization guide relies on: a bounding-box && in the join, exact topology in the WHERE.

-- The R-tree serves the && pre-filter only if the indexed column stays bare.
EXPLAIN ANALYZE
SELECT p.gid, r.region_name
FROM parcels p
JOIN regions r ON r.geom && p.geom
WHERE ST_Intersects(r.geom, p.geom);

Read the plan and assert three things:

  • An index scan, not a nested loop. A RTREE_INDEX_SCAN (or an index-driven join node) confirms the && predicate reached the R-tree. A NESTED_LOOP_JOIN over the full relation means the index was not used — verify the indexed column is bare on one side of && and that the index registered in duckdb_indexes().
  • Estimated versus actual rows within ~30%. Large drift at the join node signals stale statistics or a non-selective bounding-box stage, often a symptom of the CRS mismatch described above.
  • No implicit geometry cast. A CAST(geom AS VARCHAR) or a per-row ST_GeomFromText in the plan means a WKT literal leaked into the hot path; the deeper mechanics of index reachability are in the spatial indexing internals reference.

Trade-offs: What You Gain and Lose

A migration is a deliberate exchange. Naming the losses up front prevents a mid-cutover surprise that stalls the project.

Capability PostGIS DuckDB Spatial Migration impact
Server concurrency Many concurrent writers, MVCC Single-writer, multi-reader in-process Move write coordination into your application or a queue
Triggers & constraints BEFORE/AFTER triggers, spatial CHECKs None Re-implement geometry validation in the load pipeline
Topology extension postgis_topology (nodes, edges, faces) Not available Precompute topology upstream or keep it in PostGIS
Geography type Ellipsoidal geography Planar GEOMETRY only Project to a metric CRS for accurate area/distance
SRID persistence Stored per geometry Tracked externally Carry CRS in GeoParquet metadata or a column
Analytical scan speed Row-store, per-tuple Vectorized columnar, SIMD kernels Large aggregations and scans get dramatically faster
Deployment footprint A managed server process A single embedded library No server to operate, patch, or scale

What you gain is the reason to migrate at all: vectorized columnar execution turns full-table spatial aggregations and joins from minutes into seconds, and the whole engine ships as an embedded library with no server to run. The relative crossover point — the data size and query shape at which DuckDB overtakes PostGIS — is quantified in the performance crossover benchmarks. What you lose is everything that depends on a long-running stateful server: concurrent writers, triggers, and the topology extension. If your workload is transactional, keep PostGIS; if it is analytical, the exchange is heavily in DuckDB’s favour.

Edge Cases & Anti-Patterns

SRID silently lost on export (wrong answers, no error). A plain WKB or plain-Parquet dump strips the SRID, and DuckDB’s GEOMETRY has no slot to hold it — the geometry is simply untagged. Nothing errors, so a later reprojection that guesses the source CRS produces wrong coordinates. The fix is to record the source CRS captured from PostGIS as documented table metadata, then reproject with an explicit source and target rather than a stored SRID:

-- ST_Transform needs both CRS ends spelled out because DuckDB stores no per-geometry
-- SRID. Feed it the CRS you captured from PostGIS before the export.
CREATE TABLE parcels_m AS
SELECT gid, ST_Transform(geom, 'EPSG:4326', 'EPSG:3857') AS geom FROM parcels;

Invalid geometry that survived GiST (poisons downstream operators). PostGIS’s GiST index does not validate geometry — self-intersecting polygons and unclosed rings index and query fine, then break ST_Union, ST_Intersection, or an area sum after migration. Quarantine and repair before indexing the target:

-- Isolate offenders first, then repair deterministically before building the R-tree.
SELECT gid, ST_IsValidReason(geom) FROM parcels WHERE NOT ST_IsValid(geom);
UPDATE parcels SET geom = ST_MakeValid(geom) WHERE NOT ST_IsValid(geom);

Mixed geometry types in one column (format writer rejects the export). A generic PostGIS geometry column can hold points, lines, and polygons together. DuckDB tolerates the mix, but a strict GeoParquet or FlatGeobuf writer may demand a single geometry type per column. Normalize on the way out — -nlt PROMOTE_TO_MULTI in ogr2ogr, or split by ST_GeometryType into typed tables — and the FlatGeobuf and GeoPackage ingestion guide covers the writer-side constraints in full.

Rebuilding the index before the load settles (slow, badly packed tree). Creating the R-tree on an empty table and then bulk-inserting produces a fragmented tree with poor node fanout. Always load first, index second.

Migration Regression Analysis

A migration is not done when the load finishes; it is done when a harness proves the target matches the source. Compare invariants that are cheap on both sides and sensitive to loss — row counts catch dropped rows, and a summed area or bounding box catches silent geometry corruption or an SRID-induced unit shift. This harness fits the orchestration patterns in Python and DuckDB integration workflows.

import duckdb
import psycopg2

TABLES = ["parcels", "regions", "roads"]
AREA_TOL = 1e-6  # relative tolerance; unit-consistent CRS assumed on both sides

pg = psycopg2.connect("host=db user=gis dbname=cadastre")
duck = duckdb.connect("cadastre.duckdb")
duck.execute("LOAD spatial;")

def pg_scalar(sql):
    with pg.cursor() as cur:
        cur.execute(sql)
        return cur.fetchone()[0]

failures = []
for t in TABLES:
    src_rows = pg_scalar(f"SELECT count(*) FROM {t}")
    dst_rows = duck.execute(f"SELECT count(*) FROM {t}").fetchone()[0]
    if src_rows != dst_rows:
        failures.append(f"{t}: row count {src_rows} -> {dst_rows}")

    # Total area is invariant across a lossless, unit-preserving migration.
    src_area = pg_scalar(f"SELECT COALESCE(SUM(ST_Area(geom)), 0) FROM {t}")
    dst_area = duck.execute(
        f"SELECT COALESCE(SUM(ST_Area(geom)), 0) FROM {t}"
    ).fetchone()[0]
    denom = max(abs(src_area), 1e-9)
    if abs(src_area - dst_area) / denom > AREA_TOL:
        failures.append(f"{t}: area drift {src_area:.4f} -> {dst_area:.4f}")

assert not failures, "Migration regressions:\n" + "\n".join(failures)
print("All tables reconciled: row counts and areas match within tolerance.")

Track these signals on every cutover run:

Signal Threshold Likely cause
Row-count delta any non-zero Filtered NULL geometry, or a failed row-group read
Relative area drift > 1e-6 SRID/unit mismatch, or ST_MakeValid reshaped a polygon
CRS absent from GeoParquet metadata any table where a CRS was expected SRID lost on export; document the CRS externally and ST_Transform explicitly
Invalid-geometry count > source count A repair introduced new invalidity — inspect ST_IsValidReason

An area check flags the two highest-frequency migration defects at once: rows silently dropped (the sum falls) and a coordinate-unit shift from a lost SRID (the sum moves by orders of magnitude). Once both invariants hold across every table, the cutover is safe to promote.

See also

Up: Migrating to DuckDB Spatial