DuckDB Spatial Analytics in Python: In-Process Spatial SQL
DuckDB brings database-grade spatial SQL into the Python process itself — no server, no connection pool, just an in-process engine that reads GeoParquet, Shapefiles, and GeoJSON through its spatial extension. For analytical workloads that are too big for a comfortable GeoDataFrame but don't justify a PostGIS deployment, it is often the fastest path. This stage of Spatial Analysis & Advanced Query Techniques covers the spatial extension end to end, and sits beside the two other out-of-core engines in this section: PostGIS Integration with Python for indexed, shared, transactional storage, and Scaling with Dask-GeoPandas for distributed, partitioned processing across many cores or machines.
Architecture & Data Structures
DuckDB is a columnar, vectorized OLAP engine that runs embedded in your process, like SQLite but built for analytics. It executes queries a morsel at a time — small batches of column values pushed through the operator pipeline — so a spatial predicate runs across thousands of geometries per SIMD-friendly loop rather than row by row. The spatial extension layers geospatial capability on top of that engine:
- A first-class
GEOMETRYtype stored internally as a compact binary encoding (WKB-compatible), not text. - The full
ST_*function family — predicates, measurements, constructors, and transforms — backed by GEOS, the same library that powers Shapely Geometry Operations, so predicate semantics match what you get in GeoPandas. - Readers and writers for common geospatial formats (GeoPackage, Shapefile, GeoJSON, FlatGeobuf) through GDAL's OGR layer via
ST_Read. - Native GeoParquet support that reads WKB geometry columns directly, without going through GDAL at all.
Two properties make it fast on real data. Because it is columnar, it reads only the columns a query touches — a query selecting parcel_id and geom never pays to decode the other forty attributes in the file. And because it understands GeoParquet's per-row-group bounding-box statistics, it can skip whole chunks whose extent cannot satisfy a spatial filter, so a windowed query over a multi-gigabyte file touches only a fraction of it. For repeated in-memory lookups you can also build an explicit R-tree with CREATE INDEX ... USING RTREE (geom).
import duckdb
con = duckdb.connect() # in-memory database; pass a path to persist
con.install_extension("spatial") # one-time download into the extension cache
con.load_extension("spatial") # load into this connection
# Query a GeoParquet file directly — no load or import step
con.sql("""
SELECT count(*) AS large_footprints
FROM 'buildings.parquet'
WHERE ST_Area(ST_Transform(geom, 'EPSG:4326', 'EPSG:32632')) > 500 -- m²
""").show()
Three concrete column types turn up in real result sets, and knowing which one you are holding saves an hour of confused debugging. GEOMETRY is the extension's own type and the only thing the ST_* family accepts directly. WKB_BLOB — or a plain BLOB — is a byte column that looks like geometry but is not: every function call against it fails with a binder error until you wrap it in ST_GeomFromWKB. BOX_2D is a four-double struct used for extents, produced by ST_Extent and its aggregate form ST_Extent_Agg; comparing two BOX_2D values costs a handful of float comparisons against the hundreds of coordinate tests a full ST_Intersects may run, which is exactly the asymmetry the query patterns further down exploit.
Which type a Parquet scan binds depends on the writer and the DuckDB version, so check rather than assume. Files written by GDAL or GeoPandas store geometry as WKB with geo metadata in the footer; recent DuckDB releases recognise that metadata and hand you a GEOMETRY, while older ones — and any file whose geo metadata was stripped by a Parquet rewrite that did not preserve key-value metadata — give you a BLOB. A one-line DESCRIBE settles it before you write the rest of the query:
-- Which type did the scan actually bind?
DESCRIBE SELECT * FROM 'parcels.parquet';
-- geom GEOMETRY -> ST_Intersects(geom, ...) works as written
-- geom BLOB -> wrap every reference: ST_GeomFromWKB(geom)
The R-tree deserves the same scepticism. CREATE INDEX ... USING RTREE (geom) only works on a materialized DuckDB table — you cannot index a Parquet glob in place — and the planner only uses it when the predicate compares the indexed column against a constant geometry, such as a literal envelope. A join predicate whose right side is another column will not touch the index, which is why a two-table spatial join falls back to a nested loop no matter how many indexes you build. That constraint shapes the whole scaling story below, and it is the single biggest structural difference from a server-side GiST index; the trade-off in full is laid out in DuckDB Spatial vs PostGIS for Analytics.
Format readers divide along the same line. ST_Read routes through GDAL's OGR layer and materializes the whole layer before the rest of the plan sees a row, so a 4 GB Shapefile costs 4 GB of scan whatever the WHERE clause says — it is an ingestion path, not a query path. Native Parquet and CSV scans are the ones that prune. If a format only arrives through ST_Read, the productive move is to convert once with COPY (SELECT * FROM ST_Read('parcels.shp')) TO 'parcels.parquet' and query the columnar copy from then on, for the reasons set out in GeoParquet vs Shapefile for Storage.
Environment Configuration & Dependency Resolution
pip install "duckdb>=1.1" "geopandas>=1.0" "shapely>=2.0"
# The spatial extension is downloaded on first install_extension("spatial")
DuckDB ships as a single self-contained wheel with no system dependencies — a major reason to choose it on locked-down or Windows machines where compiling the GEOS/GDAL/PROJ stack is painful. That isolation is the opposite trade-off from GeoPandas, whose reproducibility hinges on aligning those C libraries; here the entire engine, including its bundled GDAL and PROJ, arrives in one artifact.
The spatial extension itself is fetched at runtime the first time you call install_extension("spatial"), then cached under ~/.duckdb/extensions. Pin duckdb>=1.1 so the extension's ABI matches the engine — a mismatched pair raises an Invalid Input Error on load. Three environment notes matter in production:
- Air-gapped hosts. Pre-download the matching extension binary and point the engine at the cache with
con.execute("SET extension_directory='/opt/duckdb-ext'")before loading. - Autoloading. Recent DuckDB autoloads known extensions on first use, so
ST_Read(...)may work without an explicitload_extension. Load it yourself anyway for reproducible, offline-safe scripts. - Remote reads. Add the
httpfsextension to query files on S3, R2, or any HTTPS endpoint over range requests — covered under Production Export & Integration below.
memory_limit, a cached extension binary, a spill directory for oversized joins, and the data files themselves left where they are.import duckdb
con = duckdb.connect()
for ext in ("spatial", "httpfs"):
con.install_extension(ext)
con.load_extension(ext)
con.execute("SET memory_limit='8GB'") # cap RAM; spill beyond it
con.execute("SET temp_directory='/var/tmp/duck'") # where large joins spill
Two defaults bite in containers. memory_limit defaults to roughly 80% of what the engine believes the machine has, and on most container runtimes that reading comes from the host, not the cgroup — so a pod limited to 4 GB happily plans for 25 GB and gets killed by the OOM reaper with no DuckDB error to read. threads has the same blind spot: it defaults to the host core count, so a one-CPU pod spawns thirty-two worker threads that spend their time context-switching. Set both explicitly from whatever your orchestrator actually granted:
import os
con.execute(f"SET threads={os.cpu_count()}") # replace with the cgroup quota in k8s
con.execute("SET preserve_insertion_order=false") # frees the engine to stream large scans
preserve_insertion_order=false is the cheapest memory win available on a big scan-and-write job: with ordering guaranteed, DuckDB must buffer results to reassemble the original row order, and turning it off lets the pipeline stream straight through. Only set it where row order genuinely does not matter — which for an aggregate or an ORDER BY-terminated query it never does.
Connections are the other place people get surprised. A duckdb.connect() object is not safe to share across threads; the supported pattern is one connection per process and con.cursor() per thread, each cursor carrying its own transaction state but sharing the buffer pool. Extensions load per connection, so a fresh cursor inherits the parent's loaded extensions, but a brand-new connect() in a worker process does not — pool workers each need their own load_extension("spatial"). And the module-level duckdb.sql(...) helper quietly operates on a hidden global connection, which is convenient in a notebook and a trap in a library, because settings you applied to your own connection do not apply to it.
Version pinning matters more here than in most Python packages because the extension binary is version-matched to the engine. The spatial extension distributed for DuckDB 1.1 will not load into 1.2, and the failure surfaces as Invalid Input Error rather than anything mentioning versions. In a Docker image, pin the exact DuckDB patch version and bake the extension into the image at build time rather than downloading it on first request:
# Bake the extension into the image so cold starts never hit the network
python -c "import duckdb; c=duckdb.connect(); c.install_extension('spatial'); c.install_extension('httpfs')"
Vectorized Operations & Core Workflow
The core pattern is a three-step boundary crossing: read GeoParquet, filter and reduce with spatial SQL so only the rows you need materialize, then hand the result to a GeoPandas DataFrame for plotting or downstream work. The one detail that makes the hand-off clean is to serialize geometry as WKB at the SQL boundary with ST_AsWKB and rebuild it with GeoSeries.from_wkb, setting the CRS explicitly — DuckDB does not carry a Python-side CRS object across. The full recipe lives in Querying GeoParquet with DuckDB Spatial.
import duckdb
import geopandas as gpd
con = duckdb.connect()
con.load_extension("spatial")
# Spatial filter in SQL; return geometry as WKB for a clean GeoPandas hand-off.
# ST_MakeEnvelope takes (minx, miny, maxx, maxy) in the geometry's own CRS.
df = con.sql("""
SELECT parcel_id, land_use, ST_AsWKB(geom) AS wkb
FROM 'parcels.parquet'
WHERE ST_Intersects(geom, ST_MakeEnvelope(7.6, 45.0, 7.8, 45.1))
""").df()
parcels = gpd.GeoDataFrame(
df.drop(columns="wkb"),
geometry=gpd.GeoSeries.from_wkb(df["wkb"]),
crs="EPSG:4326", # must match the CRS the parquet geometry is stored in
)
The envelope filter here is a cheap coarse pass — DuckDB uses the row-group bounding boxes to prune before it ever evaluates ST_Intersects on individual geometries. For an exact clip rather than a bounding-box hit, follow up with ST_Intersection in the same query, or express the tighter predicate directly (ST_Contains, ST_DWithin) so the engine still benefits from bbox pruning while returning geometrically correct rows.
The boundary crosses in the other direction too, and it is the half most workflows forget. Anything in your Python session that exposes an Arrow table or a pandas frame — including a GeoDataFrame whose geometry you have serialized to WKB — is queryable by variable name inside a DuckDB SQL string, with no copy and no temporary file. That makes the common "small curated layer joined against a huge file" pattern a one-liner: keep the study-area boundaries in memory where you edited them, and let DuckDB stream the large side off disk.
import duckdb
import geopandas as gpd
con = duckdb.connect()
con.load_extension("spatial")
# A small, hand-curated layer that lives in the notebook
study_areas = gpd.read_file("study_areas.gpkg").to_crs("EPSG:4326")
study_wkb = study_areas.assign(geom_wkb=study_areas.geometry.to_wkb()).drop(columns="geometry")
# Referenced by variable name; DuckDB reads it in place from the Python session
counts = con.sql("""
SELECT a.area_name, count(*) AS sensor_count
FROM study_wkb a
JOIN 'sensors.parquet' s
ON ST_Contains(ST_GeomFromWKB(a.geom_wkb), s.geom)
GROUP BY a.area_name
""").df()
For results that are themselves too large to sit in one frame, do not call .df() at all. fetch_record_batch hands back an Arrow record-batch reader that pulls from the running query in chunks, so peak memory is one batch rather than the whole result — the same streaming discipline that makes pyogrio and Arrow fast on the GeoPandas side.
reader = con.sql(
"SELECT parcel_id, ST_AsWKB(geom) AS wkb FROM 'parcels.parquet'"
).fetch_record_batch(100_000)
total_vertices = 0
for batch in reader: # one 100k-row batch resident at a time
chunk = gpd.GeoSeries.from_wkb(batch.column("wkb").to_pylist(), crs="EPSG:4326")
total_vertices += chunk.count_coordinates().sum()
print("vertices scanned:", total_vertices)
Geometry / Data Processing Details
DuckDB's spatial functions mirror PostGIS naming, so SQL ports across the two engines with little change — the same ST_Contains, ST_DWithin, ST_Union, and ST_Area calls behave identically because both sit on GEOS. Where DuckDB earns its place is large aggregations that would be memory-heavy in pure GeoPandas: counting points in polygons, dissolving by attribute, computing per-zone statistics, and spatial joins between two files that together exceed RAM. For the predicate and overlay semantics themselves, see the sibling guide on Geometric Intersections & Overlays; DuckDB is the execution engine, not a different definition of the operations.
A point-in-polygon join is the canonical case. Both inputs stream from disk, the join predicate is a spatial containment test, and only the aggregated counts return:
import duckdb
con = duckdb.connect()
con.load_extension("spatial")
# Points-in-polygons aggregation across two GeoParquet files, streamed from disk
con.sql("""
SELECT z.zone_id, count(*) AS sensor_count
FROM 'zones.parquet' z
JOIN 'sensors.parquet' s ON ST_Contains(z.geom, s.geom)
GROUP BY z.zone_id
ORDER BY sensor_count DESC
""").show()
A spatial join is a nested loop unless the planner can prune, so on large inputs give it something to prune with. Either build an R-tree on the smaller side, or add a bounding-box pre-filter that lets the planner discard non-overlapping pairs before the expensive ST_Contains:
# Dissolve parcels by land-use class, then keep only sizeable resulting blocks.
# ST_Union_Agg is the aggregate ("dissolve") form; ST_Area runs on projected CRS.
con.sql("""
WITH dissolved AS (
SELECT land_use,
ST_Union_Agg(ST_Transform(geom, 'EPSG:4326', 'EPSG:32632')) AS geom
FROM 'parcels.parquet'
GROUP BY land_use
)
SELECT land_use, ST_Area(geom) AS area_m2
FROM dissolved
WHERE ST_Area(geom) > 10000
ORDER BY area_m2 DESC
""").show()
Note the aggregate ST_Union_Agg rather than the binary ST_Union — dissolving many geometries into one per group is a reduction, and DuckDB's columnar aggregation is exactly where it beats an equivalent GeoDataFrame.dissolve() on data that no longer fits in memory.
Where the join actually stops scaling. Because the R-tree cannot serve a column-against-column predicate, JOIN ... ON ST_Contains(z.geom, s.geom) is a blockwise nested loop: every zone geometry is tested against every sensor geometry, and the cost is the product of the two row counts multiplied by the per-pair GEOS cost. Two hundred zones against ten million sensors is two billion predicate evaluations — minutes, but survivable. Two hundred thousand zones against the same ten million points is a trillion, and no amount of RAM makes that finish. The inflection is not a data size, it is a product: once left_rows × right_rows passes roughly 10⁹ you need to give the planner an equality or range condition it can hash on.
The reliable way to manufacture one is to materialize the bounding-box components as plain doubles and let the optimizer turn the four inequalities into a range join, keeping the exact predicate as a cheap residual filter over the survivors:
import duckdb
con = duckdb.connect()
con.load_extension("spatial")
# Explode each geometry's envelope into ordinary numeric columns once,
# so the four range conditions can drive the join instead of a nested loop.
con.sql("""
CREATE TABLE zone_box AS
SELECT zone_id, geom,
ST_XMin(geom) AS xmin, ST_XMax(geom) AS xmax,
ST_YMin(geom) AS ymin, ST_YMax(geom) AS ymax
FROM 'zones.parquet';
CREATE TABLE sensor_pt AS
SELECT sensor_id, geom, ST_X(geom) AS x, ST_Y(geom) AS y
FROM 'sensors.parquet';
""")
con.sql("""
SELECT z.zone_id, count(*) AS sensor_count
FROM zone_box z
JOIN sensor_pt s
ON s.x BETWEEN z.xmin AND z.xmax -- range join: planner-friendly
AND s.y BETWEEN z.ymin AND z.ymax
WHERE ST_Contains(z.geom, s.geom) -- exact test on survivors only
GROUP BY z.zone_id
""").show()
On elongated or L-shaped zones the envelope is a loose approximation and the residual filter throws away a large share of the candidates, but even a 10:1 false-positive rate leaves the exact test running on a hundredth of the pairs a nested loop would have visited. Watch for the opposite failure: if one layer's features have wildly different sizes — a national boundary sitting in the same table as city blocks — the giant envelopes match nearly everything and the range join degenerates back toward a cross product. Split the layer by feature size and run two joins in that case.
Where the aggregate stops scaling. Hash joins and sorts spill to temp_directory when they exceed memory_limit, but geometry aggregates do not: ST_Union_Agg builds the union of an entire group in memory through GEOS, and there is no spill path for a half-built polygon. Dissolving a million parcels into one landmass will exhaust RAM regardless of the memory limit you set, because the limit governs the buffer manager and not the C++ allocations inside the aggregate. The workaround is hierarchical: union within a coarse spatial key first, materialize, then union the — far fewer, far simpler — intermediate polygons. Dropping the vertex count with ST_Simplify before the union is the other lever, and often the larger one, since union cost tracks vertices rather than rows.
# Two-stage dissolve: union inside coarse grid cells, then union the cell results.
con.sql("""
WITH cell AS (
SELECT land_use,
floor(ST_X(ST_Centroid(geom)) / 0.25) AS cx,
floor(ST_Y(ST_Centroid(geom)) / 0.25) AS cy,
ST_Union_Agg(ST_Simplify(geom, 0.00002)) AS geom
FROM 'parcels.parquet'
GROUP BY land_use, cx, cy
)
SELECT land_use, ST_Union_Agg(geom) AS geom
FROM cell
GROUP BY land_use
""").show()
The same staged reduction underpins Dissolving and Aggregating Features by Attribute on the GeoPandas side; DuckDB simply lets each stage read from disk instead of RAM. When even the staged version will not fit on one machine, that is the honest signal to move the job to Scaling with Dask-GeoPandas rather than to buy a larger instance.
CRS Alignment & Projection Pipeline
DuckDB's spatial extension treats coordinates as planar numbers. It does not attach a live coordinate reference system to a GEOMETRY column the way GeoPandas does, and it will not silently reproject between systems in a join — two layers in different CRSs simply produce a join that returns nothing, because their coordinates never overlap numerically. The rule is therefore explicit: confirm every input shares one CRS before you evaluate a spatial predicate, and call ST_Transform (backed by the bundled PROJ) when they don't. Establish and document that canonical CRS upstream with Coordinate Systems with PyProj, then keep it consistent through the whole query.
import duckdb
con = duckdb.connect()
con.load_extension("spatial")
# Reproject 4326 → UTM 32N inside SQL before a metric area calculation.
# ST_Transform(geom, source, target) takes explicit EPSG authority strings.
con.sql("""
SELECT id, ST_Area(ST_Transform(geom, 'EPSG:4326', 'EPSG:32632')) AS area_m2
FROM 'fields.parquet'
""").show()
Two gotchas dominate here. First, computing ST_Area or ST_Length on raw EPSG:4326 geometry yields square (or linear) degrees, not metres — a meaningless number that grows or shrinks with latitude. Reproject to an appropriate projected CRS such as the local UTM zone first, exactly as you would in GeoPandas, and never use Web Mercator (EPSG:3857) for measurement. Second, DuckDB's ST_Transform expects axis order in the traditional longitude/latitude sense for EPSG:4326 input, so coordinates that were stored latitude-first will transform to the wrong place; normalize axis order when the data is ingested rather than mid-query. Pick the target zone from the data's centroid so the distortion stays small across the extent.
Axis order is worth pinning down explicitly rather than trusting the default. ST_Transform accepts an optional fourth argument that forces the traditional longitude-then-latitude interpretation, and passing it removes any dependence on how the bundled PROJ resolved the CRS definition's authority-declared axis order:
-- Force lon/lat interpretation regardless of the CRS definition's declared axis order
SELECT ST_Transform(geom, 'EPSG:4326', 'EPSG:32632', always_xy := true) AS geom_utm
FROM 'fields.parquet';
The symptom of getting this wrong is unmistakable once you know it: transformed coordinates land in the Gulf of Guinea, or a metre-scale grid returns values off by hundreds of kilometres, because latitude was fed in where longitude was expected. The same rule and the same failure appear in the PyProj Transformer API, and the reasoning behind both is set out in EPSG vs PROJ String vs WKT CRS Formats.
There is one accuracy difference from PyProj that surprises people migrating a pipeline. The spatial extension ships its own PROJ database inside the extension binary, and that bundle contains the CRS definitions but not the optional datum-shift grid files that PROJ downloads separately. Transformations that need a grid — NAD27 to NAD83 in North America, OSGB36 to ETRS89 in Britain, most national realizations of a shifting datum — silently fall back to a lower-accuracy Helmert approximation instead of raising. The error is metre-scale, not kilometre-scale, so it will not look wrong on a map; it will just quietly disagree with the same transformation done in PyProj with grid files installed. If your work is cadastral, survey-grade, or has to reconcile against an authority dataset, do the datum change in PyProj and use DuckDB only for the same-datum projection step.
Transform cost is worth budgeting for as well. ST_Transform is evaluated per row, and unlike the predicates it cannot be pruned away — a query that transforms before it filters pays the projection cost on every row in the file. Filter first in the source CRS, then transform the survivors:
-- Filter in the stored CRS, transform only what survives
SELECT parcel_id,
ST_Area(ST_Transform(geom, 'EPSG:4326', 'EPSG:32632', always_xy := true)) AS area_m2
FROM 'parcels.parquet'
WHERE ST_Intersects(geom, ST_MakeEnvelope(7.60, 45.00, 7.80, 45.10));
On a fifty-million-row file that reordering is routinely the difference between a query that returns in seconds and one that runs for several minutes, and it is the same principle that governs Coordinate Reference System Transformations at ingestion time: reproject the smallest set of coordinates that will answer the question.
Production Export & Integration
DuckDB is most valuable as the analytical hop between cloud-native storage and whatever consumes the result — a notebook, a PostGIS table, or a web map. The integration checklist:
- Read remote data in place. With the
httpfsextension, DuckDB queries GeoParquet on S3/R2/HTTPS over range requests, downloading only the row groups a predicate needs. That fits the Cloud-Native Geospatial Formats model directly — point a query ats3://bucket/parcels.parquetafterSET s3_regionand the credentials, and no full download ever happens. - Write GeoParquet back out.
COPY (SELECT ...) TO 'derived.parquet' (FORMAT PARQUET)persists a filtered or aggregated dataset in the same columnar format, ready to be re-read by the next job or by GeoPandas. - Bridge to PostGIS. The
postgresextension lets DuckDBATTACHa PostgreSQL database and read or write tables in the same query, so you can pre-aggregate over files in DuckDB and land the summary in PostGIS Integration with Python for shared, indexed serving. - Feed spatial joins upstream. When the join is attribute-heavy rather than geometry-heavy, the merge patterns in Spatial Joins & Merging map cleanly onto DuckDB SQL.
- Hand off to the web layer. Export filtered results as GeoJSON with
ST_AsGeoJSON, or write GeoParquet that a tiling pipeline turns into vector tiles for Web Mapping & Interactive Visualization.
One export detail catches almost everyone once. A plain COPY ... TO 'out.parquet' (FORMAT PARQUET) writes a perfectly good Parquet file, but it does not write the geo key-value metadata that makes it GeoParquet — the geometry column lands as an anonymous binary blob, and gpd.read_parquet refuses it or reads it as bytes. Route the write through the GDAL Parquet driver, which records the CRS in the footer, whenever anything other than DuckDB will read the file back; the syntax appears in DuckDB Spatial vs PostGIS for Analytics.
Credentials for object storage belong in a secret rather than in SET statements, which land in query logs and shell history. A persistent secret survives across sessions in a database file, and the CREDENTIAL_CHAIN provider picks up the same instance role or environment credentials your other AWS tooling already uses:
CREATE SECRET parcels_store (
TYPE S3,
PROVIDER CREDENTIAL_CHAIN, -- instance role, env vars, or ~/.aws/credentials
REGION 'eu-central-1'
);
SELECT count(*) FROM 's3://city-open-data/parcels/*.parquet';
For datasets you will query repeatedly by area, write them out partitioned rather than as one file. Hive-style partitioning on an administrative key puts each partition in its own directory, and DuckDB skips whole directories from the path predicate alone — before it reads a single footer, let alone a geometry.
COPY (
SELECT *, ST_Transform(geom, 'EPSG:4326', 'EPSG:32632', always_xy := true) AS geom_utm
FROM 'parcels.parquet'
) TO 'parcels_by_region'
(FORMAT PARQUET, PARTITION_BY (region_code), OVERWRITE_OR_IGNORE);
-- Later: only the matching directory is ever opened
SELECT count(*) FROM 'parcels_by_region/*/*.parquet' WHERE region_code = 'ITC1';
Partition on a column with tens to low hundreds of distinct values. Partitioning on something high-cardinality — a parcel id, a full postcode — produces thousands of tiny files, and the per-file footer reads then cost more than the scan you were trying to avoid, especially over object storage where each one is a separate round trip. The same file-size arithmetic drives partition sizing in Partitioning Strategies for Dask-GeoPandas.
Choosing DuckDB vs the alternatives. Reach for DuckDB when the work is embedded, read-mostly analytics over files and object storage on a single machine — it needs no server and no ingestion step. Move to PostGIS when many clients need shared, transactional, GiST-indexed storage they can query concurrently, and to Dask-GeoPandas when a single job must fan out across partitions on many cores or nodes.
Windows / Platform Edge Cases & Debugging
Catalog Error: Table Function with name ST_Read does not exist. The spatial extension isn't loaded; callcon.load_extension("spatial")once per session (or per connection).Invalid Input Errorloading the extension. The extension binary was built for a different DuckDB version; pinduckdb>=1.1and clear the stale cache under~/.duckdb/extensionsso the matching build downloads.- Extension download fails offline. Pre-fetch the matching binary and set
SET extension_directory='...'before loading — the default cache path assumes internet access. - Areas or distances look absurd. Geometry is in degrees; wrap it in
ST_Transformto a projected CRS (the local UTM zone, not EPSG:3857) before any metric function. - A spatial join returns nothing. The two inputs are in different CRSs, so their coordinates never overlap; reproject one side to match, or check that both parquet files actually store the CRS you assume.
- Out-of-memory on huge joins.
SET memory_limit='8GB'andSET temp_directory='/var/tmp/duck'so DuckDB spills partitions to disk instead of aborting; add an R-tree or bbox pre-filter to shrink the join. - GeoPandas hand-off loses the CRS. DuckDB does not export a Python CRS object — pass
crs="EPSG:xxxx"explicitly when constructing theGeoDataFramefrom WKB, matching the CRS the geometry is actually stored in. - Windows path errors on
ST_Read. Backslashes in a file path are read as escapes; use forward slashes or a raw string for the GDAL reader. Binder Error: No function matches ST_Intersects(BLOB, GEOMETRY). The Parquet scan bound the geometry column as a plain blob because the file'sgeometadata is missing; wrap every reference inST_GeomFromWKB(geom), or rewrite the file with a GeoParquet-aware writer.- The process is OOM-killed with no DuckDB error.
memory_limitwas inferred from host RAM rather than the container's cgroup quota; set it explicitly to about 70% of the pod limit, and setthreadsto the CPU quota too. - A
CREATE INDEX ... USING RTREEmade no difference. The index only serves predicates against a constant geometry on a materialized table; a column-to-column join predicate ignores it entirely. Add bounding-box range conditions instead. IOException: Could not set lock on file. A second process tried to open the same.duckdbfile for writing. Query the Parquet files directly from each worker, or give each worker its own database file.- Remote query hangs behind a corporate proxy.
httpfshonoursSET http_proxyandSET http_proxy_username; without them the range requests stall rather than fail fast. - Transformed coordinates are a few metres off. The bundled PROJ has no datum-shift grids, so grid-based transformations degrade to a Helmert approximation; run the datum change in PyProj when survey accuracy matters.
ST_Union_Aggdies despite a generousmemory_limit. Geometry aggregates allocate outside the buffer manager and cannot spill; simplify first, then dissolve in two stages through a coarse spatial key.
Frequently Asked Questions
Should I load the data into a DuckDB database file or query the Parquet directly?
Query the Parquet directly for anything you run a handful of times — there is no ingest step, the files stay the shared source of truth, and re-running after an upstream refresh needs no reload. Materialize into a .duckdb file only when you need something a file scan cannot give you: an R-tree index, a primary key, repeated joins over the same tables where re-parsing footers dominates, or a persistent secret. The cost of being wrong is small in one direction and large in the other, so start with the files.
Why is my spatial join slower in DuckDB than in GeoPandas?
Almost certainly because GeoPandas built an R-tree and DuckDB did not. sjoin constructs an STRtree over the right-hand frame automatically; DuckDB has no equivalent for a column-to-column predicate and runs a nested loop. Below a few hundred thousand features on each side, in-memory GeoPandas usually wins outright. DuckDB pulls ahead when at least one side is too large to hold, or when the join is preceded by a selective filter that pruning can exploit — and it needs the bounding-box range-join rewrite to stay ahead as both sides grow.
Can several processes read the same DuckDB database at once? Not a database file — it takes a single-writer lock and readers of an unlocked file are limited to read-only mode. Parquet files have no such constraint: any number of processes can scan the same GeoParquet concurrently, which is why the file-first pattern also happens to be the concurrency-friendly one. If you need genuinely concurrent readers and writers against shared state, that is the boundary where PostGIS Integration with Python becomes the right answer rather than a heavier one.
Do I still need GeoPandas if DuckDB can do the spatial SQL? Yes, for anything that is not a set-oriented query. Plotting, per-feature Python logic, integration with scikit-learn or spatial clustering algorithms, and the whole CRS object model live on the GeoPandas side. The productive division is DuckDB for the filter-join-aggregate stage that shrinks the data, GeoPandas for everything you do with the result once it fits comfortably in memory.
How do I know a spatial filter actually pruned anything?
Turn on profiling and read the row counts, not the wall clock — PRAGMA enable_profiling='query_tree' prints the rows each operator emitted, and a Parquet scan that emitted the full file's row count did no pruning at all. The usual cause is a file written without per-row-group bounding-box statistics, and the fix is to rewrite it; the diagnosis is walked through in Querying GeoParquet with DuckDB Spatial.
Is DuckDB's GEOMETRY type the same thing as PostGIS geometry?
The function names match and both sit on GEOS, so predicate and overlay semantics agree, but the type systems differ in one consequential way: PostGIS geometry carries an SRID, DuckDB's does not. There is no spatial_ref_sys table and no way to ask a column what CRS it is in, so the CRS is something you track outside the engine — in file metadata, in a naming convention, or in the code that reads the result. Every CRS bug in a DuckDB pipeline traces back to that missing field.