DuckDB Spatial vs PostGIS for Analytics
The choice between DuckDB and PostGIS is not a benchmark question — it is a question of who owns the data, because one engine lives inside your Python process and reads files it does not control, while the other is a server that owns durable state on behalf of many clients. This guide is for anyone sizing an analytical spatial stack and unsure which side a workload belongs on; it sits under DuckDB Spatial Analytics in Spatial Analysis & Advanced Query Techniques, and assumes you have already met the server-side workflow in PostGIS Integration with Python.
Why This Approach / What Goes Wrong
DuckDB is an embedded columnar engine: it starts when your script starts, holds no daemon, and treats a GeoParquet file — local or on object storage — as a table it can scan without importing anything. PostGIS is PostgreSQL with a spatial type system bolted into a row-store that was designed around durability, multi-version concurrency control, and a write-ahead log. Every practical difference between them falls out of those two sentences. DuckDB is fast because it never has to make a scan safe against a concurrent writer. PostGIS is safe because it never stops paying for that guarantee.
The two failure modes are mirror images. The first is standing up a database for a question you will ask once: provisioning Postgres, running ogr2ogr for two hours to load a 200-million-row export, building a GiST index for another hour, and then issuing a single aggregate that DuckDB would have answered directly off the Parquet files in minutes with no ingest at all. The second is the reverse — treating DuckDB as an application backend. A DuckDB database file is held by one process at a time; the moment a second worker tries to open it, or a web tier needs a connection pool, or someone needs a transaction they can roll back, the embedded model stops being a shortcut and becomes an outage.
Cost of arrival is the number most teams under-weight. DuckDB's arrival cost is one pip install and zero seconds of loading: the first query reads the Parquet directly. PostGIS charges you a server, a load, an index build, and an ANALYZE before the first useful query runs — and charges it again every time the upstream export changes. That cost is worth paying exactly when more than one consumer will benefit from it.
Prerequisites
duckdb>=1.1— the version whose bundledspatialextension ships theRTREEindex typegeopandas>=1.0— the container both engines hand results back toshapely>=2.0— decodes WKB on the Python side of either boundarysqlalchemy>=2.0,psycopg[binary]>=3.1,geoalchemy2>=0.14— the PostGIS connection stack- PostgreSQL 15+ with PostGIS 3.4+, and GDAL 3.6+ if you want
ogr2ogrto read Parquet
pip install "duckdb>=1.1" "geopandas>=1.0" "shapely>=2.0" \
"sqlalchemy>=2.0" "psycopg[binary]>=3.1" "geoalchemy2>=0.14"
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=gis --name gisdb postgis/postgis:16-3.4
Step-by-Step Implementation
The honest way to choose is to run the same question through both engines and watch where the time goes. The steps below do that on one dataset — a folder of GeoParquet parcel exports — and end at the pattern most production teams settle on.
1. Price the cost of arriving at a first answer.
DuckDB needs no load step, so the first query is also the whole setup. Time it end to end, including extension load, so the comparison is fair.
import time
import duckdb
con = duckdb.connect("analysis.duckdb") # a file, so tables and R-trees persist
con.install_extension("spatial")
con.load_extension("spatial")
started = time.perf_counter()
matched = con.sql("""
SELECT count(*)
FROM 'parcels/*.parquet'
WHERE ST_Intersects(geom, ST_MakeEnvelope(7.60, 45.00, 7.80, 45.10))
""").fetchone()[0]
print(f"{matched:,} parcels in {time.perf_counter() - started:.1f}s (ingest: none)")
# 418,203 parcels in 6.4s (ingest: none)
The PostGIS equivalent cannot start with the query. It starts with a load, and on hundreds of millions of rows the loader matters: GeoDataFrame.to_postgis sends rows through Python and is the slowest option by an order of magnitude, so drive the bulk path with ogr2ogr and build the index afterwards rather than maintaining it during the load.
# Bulk load first, index second — maintaining a GiST index during ingest is far slower
ogr2ogr -f PostgreSQL PG:"dbname=gisdb user=postgres password=gis host=localhost" \
parcels/ -nln parcels -lco GEOMETRY_NAME=geom -lco SPATIAL_INDEX=NONE \
-lco FID=parcel_pk -a_srs EPSG:4326 -progress
psql "postgresql://postgres:gis@localhost/gisdb" -c \
"CREATE INDEX parcels_gix ON parcels USING GIST (geom); ANALYZE parcels;"
2. Compare what each engine's index actually accelerates.
This is the difference that surprises people. PostGIS's GiST index is a durable structure maintained on every write, consulted by the planner for both single-window lookups and table-to-table joins. DuckDB's R-tree is an in-database index over a materialised table, and it accelerates predicates against a constant geometry — it does not turn a spatial join between two tables into an indexed join. On raw Parquet scans there is no R-tree at all; the pruning comes from row-group bounding-box statistics in the file footer, which is a different mechanism with different limits, covered in Querying GeoParquet with DuckDB Spatial.
# An R-tree needs a real table — you cannot index a Parquet glob in place
con.execute("""
CREATE OR REPLACE TABLE parcels AS
SELECT parcel_id, land_use, geom FROM 'parcels/*.parquet'
""")
con.execute("CREATE INDEX parcels_rtree ON parcels USING RTREE (geom)")
con.sql("""
EXPLAIN SELECT count(*) FROM parcels
WHERE ST_Within(geom, ST_MakeEnvelope(7.60, 45.00, 7.80, 45.10))
""").show()
# The plan contains RTREE_INDEX_SCAN — the constant window is what makes it usable
The PostGIS side reads the same way, with one detail that has no DuckDB counterpart: ST_MakeEnvelope takes an SRID as its fifth argument, and it must match the column's SRID or the query errors instead of quietly returning nothing.
EXPLAIN ANALYZE
SELECT count(*) FROM parcels
WHERE ST_Intersects(geom, ST_MakeEnvelope(7.60, 45.00, 7.80, 45.10, 4326));
-- Index Scan using parcels_gix on parcels (actual rows=418203 ...)
-- Index Cond: (geom && '0103...'::geometry)
3. Test the concurrency assumption before you design around it.
DuckDB takes a file lock. One process may hold a database file read-write; while it does, no other process can open it at all, not even read-only. Inside a single process you can open many connections and DuckDB will run them across its thread pool, but that is parallelism, not multi-tenancy.
import duckdb
writer = duckdb.connect("analysis.duckdb") # this process holds the lock
reader = duckdb.connect("analysis.duckdb", read_only=True) # from a *second* process:
# duckdb.IOException: Could not set lock on file "analysis.duckdb":
# Conflicting lock is held in <pid> ...
PostgreSQL's answer is a connection pool and MVCC: readers never block writers, writers never block readers, and a rolled-back edit leaves no trace. That is the property a web map tier or an editing application needs, and it is the one property DuckDB deliberately does not offer.
from sqlalchemy import create_engine
engine = create_engine(
"postgresql+psycopg://postgres:gis@localhost:5432/gisdb",
pool_size=10, max_overflow=20, pool_pre_ping=True,
)
4. Run the join that decides most arguments.
Joining two very large layers is where the columnar engine earns its place. Give DuckDB a memory ceiling and a spill directory and it will hash- and nested-loop its way through inputs far larger than RAM, on one machine, with no load step.
con.execute("SET memory_limit='16GB'")
con.execute("SET temp_directory='/var/tmp/duckdb'") # large joins spill here
zone_counts = con.sql("""
SELECT z.zone_id, count(*) AS ping_count
FROM 'zones.parquet' z
JOIN 'gps_pings/*.parquet' p ON ST_Contains(z.geom, p.geom)
GROUP BY z.zone_id
ORDER BY ping_count DESC
""").df()
print(zone_counts.head())
PostGIS runs the same statement — the function names are identical because both sit on GEOS — but only after both layers are resident and indexed, and its planner will lean on the GiST index for the inner side rather than scanning it. That makes PostGIS strong when one side is small and selective, and awkward when the job is a single full pass over two exports you will delete tomorrow.
How the result reaches Python differs just as much as how it is computed. DuckDB shares an address space with the interpreter, so .df() and .arrow() hand back columnar buffers with no serialisation across a socket, and the traffic runs both ways: a pandas or GeoPandas object already in scope can be queried by name without being copied into the engine first. A PostGIS result, by contrast, is encoded by the server, pushed through a socket, and decoded row by row by the driver before gpd.read_postgis rebuilds geometry from WKB — a fixed per-row cost that is irrelevant for a thousand rows and dominant for fifty million.
import pandas as pd
# A local DataFrame is queryable by name — no copy, no load step
sensor_sites = pd.DataFrame({"site_id": [11, 12], "lon": [7.68, 7.71], "lat": [45.06, 45.07]})
con.sql("""
SELECT s.site_id, count(*) AS parcels_within_500m
FROM sensor_sites s
JOIN parcels p
ON ST_DWithin(ST_Transform(ST_Point(s.lon, s.lat), 'EPSG:4326', 'EPSG:32632',
always_xy := true),
ST_Transform(p.geom, 'EPSG:4326', 'EPSG:32632', always_xy := true),
500)
GROUP BY s.site_id
""").show()
5. Check function coverage before you port a query.
DuckDB's ST_* family mirrors PostGIS naming closely enough that most analytical SQL moves across unchanged, but the tail is thinner. The geography type for spheroidal measurement, PostGIS Raster, the topology schema, pgRouting, and anything that depends on triggers or constraints simply do not exist in DuckDB. Rather than trusting a list, interrogate the catalogue in the version you actually have installed.
available = con.sql("""
SELECT DISTINCT function_name
FROM duckdb_functions()
WHERE function_name ILIKE 'st_%'
ORDER BY function_name
""").df()["function_name"].tolist()
for wanted in ("ST_DWithin", "ST_Union_Agg", "ST_Subdivide", "ST_ClusterDBSCAN"):
print(f"{wanted:18s} {'present' if wanted.lower() in [f.lower() for f in available] else 'MISSING'}")
Where a function is missing, the pragmatic move is usually not to switch engines but to split the work: aggregate in DuckDB, then run the specialised step in PostGIS or in Python. Density-based grouping, for instance, is better handled by the tooling in Spatial Clustering Algorithms than by forcing it into whichever engine you started in.
6. Adopt the hybrid deliberately, not by accident.
The pattern that survives contact with production is PostGIS as the system of record and DuckDB as the analysis surface over exports from it. DuckDB's postgres extension makes the export a single query: postgres_query ships raw SQL to the server, so PostGIS functions run server-side and only WKB comes back over the wire.
con.install_extension("postgres")
con.load_extension("postgres")
con.execute("ATTACH 'dbname=gisdb user=postgres password=gis host=localhost' "
"AS pg (TYPE POSTGRES, READ_ONLY)")
con.execute("""
CREATE OR REPLACE TABLE parcels_snapshot AS
SELECT parcel_id, land_use, ST_GeomFromWKB(wkb) AS geom
FROM postgres_query('pg', $$
SELECT parcel_id, land_use, ST_AsBinary(geom) AS wkb
FROM parcels
WHERE updated_at >= now() - interval '1 day'
$$)
""")
# Write real GeoParquet — the GDAL writer records the CRS in the file metadata
con.execute("""
COPY parcels_snapshot TO 'parcels_snapshot.parquet'
(FORMAT GDAL, DRIVER 'Parquet', SRS 'EPSG:4326')
""")
Keep every metric calculation in a projected CRS on whichever side you run it. DuckDB's ST_Transform takes explicit source and target authority strings and follows the authority's declared axis order, so pass always_xy := true when your EPSG:4326 data is stored longitude-first — the same trap PyProj sets, described in Coordinate Systems with PyProj. Use the local UTM zone, never Web Mercator, for area or distance.
con.sql("""
SELECT land_use,
sum(ST_Area(ST_Transform(geom, 'EPSG:4326', 'EPSG:32632',
always_xy := true))) AS area_m2
FROM parcels_snapshot
GROUP BY land_use
ORDER BY area_m2 DESC
""").show()
Verification
The hybrid is only trustworthy if both engines agree on the same predicate over the same rows. Because DuckDB's spatial extension and PostGIS both evaluate predicates through GEOS, an identical window over identical geometry must return an identical count — any discrepancy means the snapshot is stale, the SRID assumption is wrong, or the export dropped rows.
from sqlalchemy import create_engine, text
engine = create_engine("postgresql+psycopg://postgres:gis@localhost:5432/gisdb")
window = (7.60, 45.00, 7.80, 45.10)
with engine.connect() as pg:
srids = pg.execute(text("SELECT DISTINCT ST_SRID(geom) FROM parcels")).scalars().all()
assert srids == [4326], f"Mixed or unexpected SRIDs in PostGIS: {srids}"
pg_count = pg.execute(
text("SELECT count(*) FROM parcels "
"WHERE ST_Intersects(geom, ST_MakeEnvelope(:a, :b, :c, :d, 4326))"),
dict(zip("abcd", window)),
).scalar()
duck_count = con.execute(
"SELECT count(*) FROM parcels_snapshot "
"WHERE ST_Intersects(geom, ST_MakeEnvelope(?, ?, ?, ?))", list(window)
).fetchone()[0]
print(f"PostGIS: {pg_count:,} DuckDB: {duck_count:,}")
# PostGIS: 418,203 DuckDB: 418,203
assert duck_count == pg_count, "Snapshot drifted from the system of record"
Edge Cases & Debugging
duckdb.IOException: Could not set lock on file. Another process already holds the database. Close it, or open the second process withread_only=Trueafter the writer releases — and if you genuinely need simultaneous writers, that workload belongs in PostGIS.- A cross-engine join silently returns zero rows. PostGIS refuses mixed-SRID operations with an error; DuckDB has no SRID at all and will happily compare degrees against metres. Check
ST_SRIDon the PostGIS side and assert the CRS you assumed when exporting. Unsupported type GEOMETRYwhen writing Parquet. PlainFORMAT PARQUETcannot serialise theGEOMETRYtype. Either cast withST_AsWKB(geom)or use(FORMAT GDAL, DRIVER 'Parquet', SRS '...')as in step 6, which also records the CRS.- PostGIS still shows
Seq Scanafter a bulk load. The planner has no statistics yet; runANALYZE parcels;after every large ingest, and confirm the index exists —ogr2ogrwithSPATIAL_INDEX=NONEdeliberately did not create one. to_postgistakes hours. It round-trips rows through Python. Useogr2ogr, orCOPY ... FROM STDIN (FORMAT BINARY), for anything past a few million rows, and see connecting GeoPandas to PostGIS with SQLAlchemy for the chunked write settings.- The R-tree is ignored on a Parquet query. Indexes only exist on materialised tables; a query over
'parcels/*.parquet'can only use row-group statistics. Materialise first if the same window will be queried repeatedly.
Frequently Asked Questions
Can DuckDB replace PostGIS as a web map backend? Not as the storage layer. A tile or feature API needs many simultaneous connections and, usually, writes — and a DuckDB file admits one read-write process at a time. What DuckDB does well in that architecture is precompute: run the heavy aggregation over GeoParquet, write the result as a small table or a tile pipeline input, and let PostGIS or a static tile store serve it.
Is DuckDB actually faster than PostGIS? For full-table analytical passes — count, aggregate, dissolve, join two large exports — usually yes, often by a wide margin, because the columnar layout reads only the columns you asked for and never pays MVCC overhead. For a small, highly selective indexed lookup returning a handful of rows, a warm PostGIS with a GiST index is competitive and sometimes faster. The larger factor is almost always whether the ingest step is counted in the comparison.
Does the CRS survive a move between the two engines?
Only if you carry it deliberately. PostGIS stores an SRID with every geometry and enforces it; DuckDB's GEOMETRY is bare coordinates with no CRS attached, and WKB carries none either. Record the CRS when you export (the GDAL Parquet writer stores it in the file metadata), and pass it explicitly when rebuilding a GeoDataFrame — the hand-off mechanics are in Querying GeoParquet with DuckDB Spatial.
Can I query PostGIS from DuckDB without exporting anything?
Yes — ATTACH ... (TYPE POSTGRES) plus postgres_query runs your SQL on the server and streams the result back, which is ideal for incremental snapshots. It is not a substitute for an export when the analysis scans hundreds of millions of rows, because every row still crosses the network; in that case materialise once to GeoParquet and let DuckDB scan locally.