PostGIS vs DuckDB + GeoParquet for zoning overlay storage
You are standing up the storage layer for a system that tracks zoning overlays against a parcel fabric, and the choice of engine will decide whether a nightly impact run finishes in ninety seconds or forty minutes, whether two analysts can query the same district without blocking each other, and how much a per-release snapshot costs to keep. The two credible answers pull in opposite directions: a transactional PostGIS instance that treats overlays as live, mutable rows, or a columnar DuckDB engine reading immutable GeoParquet files from object storage. Picking the wrong one is not a catastrophe — both store polygons correctly — but it quietly taxes every query and every deploy afterward. This guide frames the decision the way you would actually make it inside the broader municipal data structures topic of the municipal zoning data architecture and compliance frameworks area: what workload each engine wins, the symptoms that should push you off one, how to migrate, and how to verify you actually gained something.
Diagnosis: which workload are you actually running? jump to heading
Before comparing features, characterize the workload, because the engines optimize for different shapes and the “right” answer is entirely a function of yours. Ask three questions and answer them with numbers, not intuition.
First, read-to-write ratio. Are overlays ingested once per release and then read thousands of times (analytical), or does an editor mutate individual parcel-district assignments through the day (transactional)? A zoning-overlay store that receives a fresh county extract every quarter and otherwise only serves impact queries is 99.9% read. An editorial parcel database with staff correcting boundaries is write-heavy in bursts.
Second, concurrency model. Do you need many writers holding row locks with ACID guarantees, or many independent readers scanning the same immutable snapshot? These are not the same problem, and an engine tuned for one is mediocre at the other.
Third, scan shape. Do queries touch a handful of parcels by primary key and spatial index (point lookups, ST_Intersects against one overlay), or do they aggregate every parcel in a jurisdiction (sum affected acreage across an entire overlay change)? Index-friendly point work favors a B-tree/GiST world; full-column aggregation favors columnar scans.
A quick way to expose the shape is to instrument a representative query on a copy of the data and read the plan. In PostGIS you time it directly:
EXPLAIN (ANALYZE, BUFFERS)
SELECT p.parcel_id, ST_Area(ST_Intersection(p.geom, o.geom)) AS overlap_m2
FROM parcels p
JOIN zoning_overlays o
ON ST_Intersects(p.geom, o.geom) -- GiST index candidate
WHERE o.overlay_code = 'FLD-AE' -- one flood overlay
AND o.valid_to IS NULL; -- current version only
If the plan shows an Index Scan using parcels_geom_gist with a tight row estimate, PostGIS is doing what it is good at. If it shows a Seq Scan over millions of rows because the query aggregates the whole layer, you are paying transactional overhead for an analytical job — a signal worth taking seriously.
The decision matrix jump to heading
Score both engines against the axes that actually move a zoning-overlay workload. Ratings are relative, not absolute — DuckDB “weak” transactional writes still commit correctly, they just are not the point.
| Axis | PostGIS (PostgreSQL) | DuckDB + GeoParquet |
|---|---|---|
| Transactional writes | Strong — full ACID, UPDATE/DELETE on live rows, constraints |
Weak — files are immutable; “writes” mean rewriting a partition |
| Concurrent readers | Good, but each connection costs a backend process and shared buffers | Excellent — every reader opens the same Parquet independently, no locks |
| Spatial index maturity | Mature — GiST/SP-GiST, ST_* predicate pushdown, decades hardened |
Emerging — spatial extension, R-tree via bbox, no persistent index yet |
| Analytical scan speed | Moderate — row store, index or seq scan, per-row function overhead | Fast — columnar, vectorized, reads only the projected columns |
| Cost | Always-on server (CPU, RAM, storage, backups) | Compute-on-read; storage is cheap object bytes, engine is embedded |
| Snapshot / versioning | Manual — table copies, valid_from/valid_to, or logical dumps |
Native feel — a new file/partition per release is the snapshot |
| Ops burden | Higher — tuning, VACUUM, connection pool, HA, upgrades |
Lower — no server; ship files, embed the engine in the job |
Two rows deserve emphasis for zoning work specifically. Spatial index maturity is where PostGIS still decisively wins: a GiST index over geometry supports fast ST_Intersects, ST_DWithin, and nearest-neighbor operators that a per-parcel editorial UI leans on constantly. Deep tuning of those indexes is its own topic under spatial database indexing and performance. Snapshot/versioning is where DuckDB plus GeoParquet feels native: a quarterly overlay release is simply a new immutable file, so “the overlays as of Q1” is a path, not a temporal join.
The two spatial queries, side by side jump to heading
The same overlap-area question expressed in each engine shows the ergonomic difference. PostGIS runs the query above against indexed live tables. DuckDB runs the equivalent directly over GeoParquet files, no load step:
import duckdb
con = duckdb.connect()
con.execute("INSTALL spatial; LOAD spatial;")
# Query immutable snapshots straight from object storage — no import.
result = con.execute("""
SELECT p.parcel_id,
ST_Area(ST_Intersection(p.geom, o.geom)) AS overlap_m2
FROM read_parquet('s3://zoning/parcels/2026q2/*.parquet') p
JOIN read_parquet('s3://zoning/overlays/2026q2/*.parquet') o
ON ST_Intersects(p.geom, o.geom) -- bbox pre-filter, then exact
WHERE o.overlay_code = 'FLD-AE'
""").fetchdf()
print(result.head()) # returns a pandas DataFrame, ready for reporting
The DuckDB version scans only the geom and overlay_code columns it references and never touches a server; the PostGIS version rides a persistent GiST index and can update a single parcel in place. Neither is “better” — they answer the same question for different operational lives.
When to pick which — and when to use both jump to heading
Pick PostGIS when the store is the transactional system of record. If staff edit parcel-to-district assignments, if you need foreign keys and check constraints enforcing that every overlay references a valid parcel, if concurrent editors must not clobber each other, or if a UI issues thousands of small indexed spatial lookups per minute, PostGIS is the correct default. Bi-temporal valid_from/valid_to columns and exclusion constraints make it a strong system of record for the mutable core of your municipal data structures.
Pick DuckDB + GeoParquet when the store is a read-mostly analytical snapshot. If overlays land as periodic county extracts and are then only queried — nightly impact runs, ad-hoc “how many parcels does this new overlay touch” analyses, dashboards — the columnar engine reading immutable files is faster, cheaper, and dramatically simpler to operate. There is no server to keep alive, snapshots are free, and every analyst gets an isolated reader. Converting incoming extracts into partitioned GeoParquet is exactly the job covered under geospatial format conversion.
Use both when your system of record and your analytics layer have different shapes — which, for most production zoning platforms, they do. Keep the authoritative, mutable overlays and parcels in PostGIS, then export a versioned GeoParquet snapshot per release into object storage and point DuckDB at it for heavy analytical scans. Transactional integrity lives in Postgres; cheap, isolated, versioned analytics live in Parquet. The export is a clean seam, and it means a runaway analytical query can never lock the editorial database.
Migrating from one to the other jump to heading
If timing your workload pushed you off your first choice, migrate deliberately.
PostGIS to GeoParquet (analytical offload). Export current-version overlays to partitioned Parquet on each release. Keep the SRID explicit so the snapshot is self-describing:
import geopandas as gpd
from sqlalchemy import create_engine
engine = create_engine("postgresql+psycopg://gis@db/zoning")
# Pull only current-version rows; project once, write immutable snapshot.
gdf = gpd.read_postgis(
"SELECT parcel_id, overlay_code, geom FROM zoning_overlays "
"WHERE valid_to IS NULL",
engine, geom_col="geom",
)
assert gdf.crs is not None, "refuse to write a snapshot with no CRS"
gdf.to_parquet("overlays_2026q2.parquet", index=False) # embeds CRS in metadata
GeoParquet to PostGIS (promoting analytics to a live store). When an analytical dataset needs editing and constraints, load it and build the index explicitly — never rely on it existing:
gdf = gpd.read_parquet("overlays_2026q2.parquet")
gdf.to_postgis("zoning_overlays", engine, if_exists="append", index=False)
# GiST index is not automatic on to_postgis — create it or every query seq-scans.
with engine.begin() as cx:
cx.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS overlays_geom_gist "
"ON zoning_overlays USING GIST (geom)"
)
The single most common migration bug is losing the CRS: GeoParquet stores it in file metadata and PostGIS stores it as an SRID, and a silent mismatch lands parcels in the wrong hemisphere. Assert the CRS on both sides of the move.
Verification: prove the engine change paid off jump to heading
Do not trust a switch on faith — measure it on the real query.
- Compare wall-clock on the representative query. Run the nightly impact aggregation on both engines against the same data and record the median of five runs. A columnar offload that does not beat the indexed PostGIS query on a full-scan aggregate means the workload was point-lookup shaped after all.
- Read the PostGIS plan.
EXPLAIN (ANALYZE, BUFFERS)should show anIndex Scan(notSeq Scan) on spatial-predicate queries, and theBUFFERSline should not reveal massive heap reads. ASeq ScanonST_Intersectsmeans the GiST index is missing or the planner mis-estimated — the diagnostic that belongs to indexing and performance work. - Check DuckDB projection pushdown. Run
EXPLAINon the DuckDB query and confirm it reads only the referenced columns and prunes partitions byoverlay_code; a full-file scan means partitioning or filter pushdown is not engaging. - Round-trip a checksum. After any migration, count rows and sum
ST_Areaof the geometry on both sides. Equal counts and areas within floating tolerance prove nothing was dropped or reprojected in transit.
Failure recovery jump to heading
- Analytical query is locking the editorial DB. This is the classic symptom that you are running an analytical scan on a transactional engine. Move the heavy read to a GeoParquet snapshot in DuckDB so it can never contend for locks on the live tables again.
- DuckDB spatial join is slow on huge extracts. The
spatialextension has no persistent index, so a naive cross-region join degrades. Partition the Parquet by jurisdiction and filter on the partition key first so the bbox pre-filter has less to scan; if you truly need a persistent spatial index and constant point lookups, that workload wanted PostGIS. - Snapshot CRS drift. If a released snapshot renders offset, a reprojection slipped in during export. Recover by re-exporting from the PostGIS system of record with the SRID asserted, and quarantine the bad file rather than patching it in place.
- Corrupt or partial Parquet write. Because snapshots are immutable, recovery is trivial: discard the partial file and re-export. Never append into a snapshot other readers may already be reading — write to a new path and swap the pointer atomically.
Frequently asked questions jump to heading
Is DuckDB a replacement for PostGIS for zoning data?
No — they solve different problems. PostGIS is a transactional system of record with mature spatial indexing and ACID writes; DuckDB plus GeoParquet is a read-mostly analytical engine over immutable files. Most production platforms keep the mutable overlays in PostGIS and offload heavy analytical scans to DuckDB snapshots, rather than choosing one exclusively.
Can DuckDB use a spatial index like PostGIS GiST?
Not a persistent one yet. The DuckDB spatial extension applies a bounding-box pre-filter on ST_Intersects-style joins, which helps, but there is no on-disk R-tree you build and reuse across sessions. For constant indexed point lookups and nearest-neighbor queries, PostGIS GiST is still the stronger choice; DuckDB shines on full-column vectorized scans instead.
How do I version zoning overlays with each approach?
In DuckDB plus GeoParquet, a version is simply a new immutable file or partition per release, so querying “overlays as of Q1” is a path selection. In PostGIS you version in-table with bi-temporal valid_from and valid_to columns or by copying tables. The Parquet approach makes snapshots essentially free; the PostGIS approach keeps them queryable alongside live edits.
Why does my ST_Intersects query seq-scan in PostGIS?
Almost always a missing or unused GiST index. Confirm a GIST index exists on the geometry column, run EXPLAIN ANALYZE to see whether the planner chooses it, and check that both geometries share an SRID — a mismatch prevents index use. Tuning this is covered under spatial database indexing and performance.
Does GeoParquet preserve CRS and field types on conversion?
Yes, when written correctly — GeoParquet stores the CRS in file-level metadata and preserves column types. The failure mode is losing it during export or a mismatched load, so assert the CRS is present on both write and read. Lossless format conversion, including CRS preservation, is its own topic in the geospatial format conversion guide.
Related jump to heading
- Parent topic: Municipal Data Structures
- Section overview: Municipal Zoning Data Architecture & Compliance Frameworks
- Spatial Database Indexing & Performance — tuning the GiST indexes that make PostGIS win point lookups
- Geospatial Format Conversion — converting municipal extracts into partitioned GeoParquet snapshots