Querying GeoParquet with DuckDB Spatial
This guide runs spatial SQL straight against a GeoParquet file — local or on object storage — and returns a GeoDataFrame, all without a database server or a full in-memory load. It is for analysts whose data has outgrown a comfortable GeoDataFrame but who don't want to stand up PostGIS. It sits under DuckDB Spatial Analytics in Spatial Analysis & Advanced Query Techniques.
Why This Approach / What Goes Wrong
GeoParquet stores geometry as well-known binary (WKB) in a columnar layout, with per-row-group bounding-box statistics written into the file footer. DuckDB exploits both facts at once. Because the format is columnar, a query that touches only building_id and geom never pays to decode the other attributes in the file. And because DuckDB reads the row-group bbox statistics, it can skip whole chunks whose extent cannot satisfy a spatial predicate — so a windowed query over a multi-gigabyte file reads only a fraction of the bytes on disk. This is predicate pushdown and row-group pruning working together, and it is why an in-process engine can out-run a naive full-file read into GeoPandas.
The naive alternative — gpd.read_parquet() — loads every row and every column into memory before you filter, which defeats the whole point of a columnar format on a large file. The failure modes when you move to DuckDB are almost all mechanical:
- Forgetting the
spatialextension. EveryST_*function is undefined until you load it, and the extension does not persist across fresh connections. - Computing metric quantities on degrees. GeoParquet is very often stored in EPSG:4326, where
ST_AreaandST_Lengthreturn numbers in square- and linear-degrees — meaningless as areas. Transform to an appropriate projected CRS first. - Losing the CRS at the boundary. DuckDB's result set is a plain columnar table; the CRS lives in the file metadata, not in the WKB. If you don't set it explicitly when you rebuild the
GeoDataFrame, downstream reprojections and Shapely geometry operations run against an unknown datum.
Pass geometry across the boundary as WKB and set the CRS by hand, and the hand-off is clean and lossless.
There is a fourth failure mode that costs more than the other three combined, because it produces correct results and simply refuses to be fast: the file may have nothing to prune with. Row-group skipping works on ordinary Parquet column statistics — the min and max recorded for each column in each row group — and the minimum and maximum of a WKB byte string are meaningless as a spatial extent. Nothing about ST_Intersects can be pushed into a raw geometry column on its own. Pruning only happens when the file also carries per-row-group extent information: either a GeoParquet 1.1 bbox covering column (a struct of xmin, ymin, xmax, ymax whose numeric statistics are usable), or an equivalent set of plain numeric columns you added yourself. Files written by older tooling, or by a Parquet round-trip that dropped the extra columns, have neither — and a spatial filter over them reads every byte in the file while looking exactly like a query that should be selective. Step 7 shows how to tell the two cases apart in ten seconds, and step 8 how to fix a file that lacks the machinery.
Prerequisites
duckdb>=0.10— ships thespatialextension binary for installgeopandas>=0.14— target container for the resultshapely>=2.0— backsGeoSeries.from_wkbdecoding- A GeoParquet file (a
.parquetwith ageometry/geomcolumn andgeometadata)
pip install "duckdb>=0.10" "geopandas>=0.14" "shapely>=2.0"
Version differences change what this guide's queries do, so it is worth knowing which side of them you are on. DuckDB 0.10 will read GeoParquet, but it binds the geometry column as a plain BLOB and every ST_* reference needs an explicit ST_GeomFromWKB(geom) wrapper; from 1.1 onward the reader recognises the geo footer metadata and hands you a real GEOMETRY, so the wrapper becomes unnecessary and the examples below work verbatim. ST_Hilbert, used in step 8, arrived with the 1.1-era extension. On the Python side, GeoPandas 1.0 moved GeoParquet I/O onto pyogrio and made GeoSeries.from_wkb significantly faster on large arrays, which matters once the result set is in the millions of rows. If you can choose, duckdb>=1.1 with geopandas>=1.0 is the pairing that needs the fewest workarounds:
pip install "duckdb>=1.1" "geopandas>=1.0" "shapely>=2.0" "pyarrow>=14"
Step-by-Step Implementation
1. Connect and load the spatial extension.
DuckDB opens an in-memory database by default; pass a path to connect() if you want the connection to persist cached extensions and temp tables.
import duckdb
con = duckdb.connect() # in-memory; pass "analytics.duckdb" to persist
con.install_extension("spatial")
con.load_extension("spatial") # required in every fresh session
2. Inspect the file's schema and geometry column name.
Different writers name the geometry column geometry, geom, or wkb_geometry. Check before you write the query rather than guessing.
schema = con.sql("DESCRIBE SELECT * FROM 'buildings.parquet'").df()
print(schema[["column_name", "column_type"]].to_string(index=False))
# column_name column_type
# building_id BIGINT
# area_m2 DOUBLE
# geom GEOMETRY
3. Run a windowed spatial query, returning geometry as WKB.
ST_MakeEnvelope builds the query window from lon/lat corners, and ST_Intersects is the predicate DuckDB can push down against the row-group bounding boxes. Encoding the result geometry with ST_AsWKB keeps it in the one format GeoPandas can decode without ambiguity.
query = """
SELECT building_id, area_m2, ST_AsWKB(geom) AS wkb
FROM 'buildings.parquet'
WHERE ST_Intersects(
geom,
ST_MakeEnvelope(7.60, 45.00, 7.80, 45.10) -- lon/lat window, EPSG:4326 (Turin)
)
AND area_m2 > 120
"""
result_df = con.sql(query).df()
4. Rebuild a GeoDataFrame, setting the CRS explicitly.
Decode the WKB column into a GeoSeries, drop the raw bytes, and stamp the CRS the file was authored in. The CRS is not carried in the WKB, so this line is mandatory, not optional.
GeoDataFrame, never in between — the crs= argument in step 4 is the only thing that reconnects the two ends.import geopandas as gpd
buildings = gpd.GeoDataFrame(
result_df.drop(columns="wkb"),
geometry=gpd.GeoSeries.from_wkb(result_df["wkb"]),
crs="EPSG:4326", # the CRS the GeoParquet was written in
)
5. Compute metric quantities in a projected CRS, not degrees.
If you need real areas or distances, reproject inside SQL with ST_Transform before measuring. For the Turin window, UTM zone 32N (EPSG:32632) is the correct metric CRS — never use Web Mercator for area or length. Note that ST_Transform in DuckDB takes source and target CRS as arguments and, like PyProj, expects lon/lat axis order for EPSG:4326 input here.
metric_sql = """
SELECT
building_id,
ST_Area(ST_Transform(geom, 'EPSG:4326', 'EPSG:32632')) AS area_m2_true
FROM 'buildings.parquet'
WHERE ST_Intersects(geom, ST_MakeEnvelope(7.60, 45.00, 7.80, 45.10))
"""
areas = con.sql(metric_sql).df()
print(areas["area_m2_true"].describe()) # values in square metres, not degrees
6. (Optional) Query remote GeoParquet over HTTP range requests.
The httpfs extension lets DuckDB read a remote file with range requests, pulling only the row groups the predicate needs. Keep the filter spatial so pruning still applies over the network.
con.install_extension("httpfs")
con.load_extension("httpfs")
remote_count = con.sql(
"SELECT count(*) FROM 'https://data.example.com/parcels.parquet' "
"WHERE ST_Intersects(geom, ST_MakeEnvelope(7.60, 45.00, 7.80, 45.10))"
).fetchone()[0]
print("Matching parcels:", remote_count)
7. Prove that pruning actually happened.
Wall-clock time is a poor test — a fast disk hides a full scan of a 2 GB file. Read the row counts instead. Profiling prints how many rows each operator emitted, and a PARQUET_SCAN node that emitted the file's entire row count did no pruning at all, no matter how selective the WHERE clause looks.
con.execute("PRAGMA enable_profiling='query_tree'")
con.sql("""
SELECT count(*) FROM 'buildings.parquet'
WHERE ST_Intersects(geom, ST_MakeEnvelope(7.60, 45.00, 7.80, 45.10))
""").fetchall()
con.execute("PRAGMA disable_profiling")
# PARQUET_SCAN ... Rows: 41,208 <- pruned: only matching row groups decoded
# PARQUET_SCAN ... Rows: 8,914,330 <- NOT pruned: whole file scanned, then filtered
The metadata function tells you the same thing without running the query, and it is the faster diagnosis when you are deciding whether a file is worth querying remotely at all. If parquet_metadata reports one enormous row group, or parquet_schema shows no bbox column, there is nothing to skip:
rg = con.sql("""
SELECT row_group_id, row_group_num_rows
FROM parquet_metadata('buildings.parquet')
GROUP BY 1, 2 ORDER BY 1 LIMIT 5
""").df()
print(rg)
# One row group of 8.9M rows means row-group pruning can never help.
# Aim for roughly 100k–1M rows per group on a spatially sorted file.
cols = con.sql("SELECT name FROM parquet_schema('buildings.parquet')").df()
print("bbox covering column present:", cols["name"].str.startswith("bbox").any())
8. Make a slow file prunable: sort spatially, then add a bbox column.
Pruning is only as good as the clustering of the data. If the rows arrived in database insertion order, every row group spans the whole study area, every bounding box overlaps every query window, and nothing can be skipped even with perfect statistics. Sorting by a space-filling curve fixes that: ST_Hilbert maps each geometry's centre to a single integer such that points close in space land close in the ordering, so consecutive rows — and therefore row groups — end up spatially compact. Write out the bbox components alongside it so the numeric statistics exist for the planner to use.
import duckdb
con = duckdb.connect()
con.load_extension("spatial")
bounds = con.sql(
"SELECT ST_Extent_Agg(geom) FROM 'buildings.parquet'"
).fetchone()[0]
con.execute("""
COPY (
SELECT *,
ST_XMin(geom) AS bbox_xmin, ST_YMin(geom) AS bbox_ymin,
ST_XMax(geom) AS bbox_xmax, ST_YMax(geom) AS bbox_ymax
FROM 'buildings.parquet'
ORDER BY ST_Hilbert(geom, ?::BOX_2D)
) TO 'buildings_sorted.parquet'
(FORMAT GDAL, DRIVER 'Parquet', SRS 'EPSG:4326', ROW_GROUP_SIZE 200000)
""", [bounds])
Query the sorted copy with the numeric columns in the predicate first and the exact test second. The four inequalities are ordinary column comparisons, so the planner prunes row groups on them, and ST_Intersects then runs only on what survived:
fast = con.sql("""
SELECT building_id, ST_AsWKB(geom) AS wkb
FROM 'buildings_sorted.parquet'
WHERE bbox_xmin <= 7.80 AND bbox_xmax >= 7.60
AND bbox_ymin <= 45.10 AND bbox_ymax >= 45.00
AND ST_Intersects(geom, ST_MakeEnvelope(7.60, 45.00, 7.80, 45.10))
""").df()
print("rows:", len(fast))
On a nationwide building footprint file the difference is not marginal. An unsorted 9-million-row export answers a city-sized window by decoding all nine million geometries; the Hilbert-sorted copy touches four or five row groups and returns in a second or two. The one-time sort costs a few minutes and is worth it for any file you will query more than twice — the same reasoning that justifies pre-tiling in Generating PMTiles from GeoParquet.
Verification
Confirm the result decoded to real geometries with the expected CRS, and that the spatial filter actually narrowed the set rather than silently returning everything.
print("Rows returned:", len(buildings)) # Rows returned: 4127
print("CRS:", buildings.crs.to_epsg()) # CRS: 4326
print("Geom type:", buildings.geometry.geom_type.unique()) # ['Polygon']
assert buildings.geometry.notnull().all()
# All features fall inside the requested window
minx, miny, maxx, maxy = buildings.total_bounds
assert 7.60 <= minx and maxx <= 7.80, "Filter window not respected"
Edge Cases & Debugging
spatialfunctions undefined. Load the extension in every session:con.load_extension("spatial"). It does not carry over from a previous process or connection.- CRS is
Noneafter conversion. Passcrs="EPSG:4326"(or the file's real CRS) when building theGeoDataFrame; the WKB carries coordinates but no datum. - Areas in the millions or billions — or suspiciously tiny. You measured on degrees. Wrap geometry in
ST_Transform(geom, 'EPSG:4326', 'EPSG:32632')(the right UTM zone for your data) beforeST_AreaorST_Length, as in step 5. - Slow despite a spatial filter. The file lacks row-group bbox statistics (older writers omit them), so nothing can be pruned; rewrite it with a recent GeoParquet writer to restore skipping.
- Remote read fails or downloads the whole file. Load
httpfsand confirm the host serves HTTP range requests; without ranges, DuckDB falls back to a full fetch. - Geometry column named differently. It may be
geometryorwkb_geometry, notgeom; check theDESCRIBEoutput from step 2 and adjust the SQL. Binder Error: No function matches ST_Intersects(BLOB, ...). An older DuckDB, or a file whosegeometadata was stripped, bound the column as bytes; wrap each reference inST_GeomFromWKB(geom)or upgrade toduckdb>=1.1.- One giant row group.
parquet_metadatashows a single group covering the whole file, so skipping is impossible by construction; rewrite withROW_GROUP_SIZE 200000as in step 8. - Statistics exist but nothing is skipped. The rows are not spatially clustered, so every group's extent covers the study area; sort by
ST_Hilbertbefore writing. gpd.read_parquetrejects a file DuckDB wrote. A plain(FORMAT PARQUET)copy omits thegeometadata that marks it as GeoParquet; re-export through(FORMAT GDAL, DRIVER 'Parquet', SRS '...').- Empty result over a window you can see data in. The envelope was built in the wrong axis order or the wrong CRS —
ST_MakeEnvelopetakes(minx, miny, maxx, maxy)in the stored CRS, so a lon/lat envelope against a UTM file matches nothing. - Memory climbs until the kernel intervenes.
.df()materializes the whole result; stream it withfetch_record_batch(100_000)and build theGeoDataFrameper chunk instead. - Remote queries are slow but the file is sorted. Each row group is a separate HTTPS round trip; raise the group size so fewer, larger ranges are requested, and confirm the endpoint is not redirecting each request.
Frequently Asked Questions
Should I query GeoParquet with DuckDB or just use gpd.read_parquet with a filter?
gpd.read_parquet accepts filters that prune on ordinary attribute columns, so if your selection is land_use == 'residential' and the file fits in RAM, GeoPandas is simpler and there is no reason to add an engine. Reach for DuckDB when the selection is spatial, when the file is larger than memory, when it lives on object storage, or when the result you actually want is an aggregate — in that last case DuckDB returns a few hundred rows where GeoPandas would have to materialize millions first.
Does DuckDB read the geometry faster than GeoPandas? Per geometry, no — both end up decoding WKB, and GeoPandas 1.0 with pyogrio is competitive at that. The advantage is entirely in how many geometries get decoded. A well-sorted file with row-group statistics lets DuckDB decode one percent of the file; GeoPandas decodes everything it reads. That is why step 8 matters more than any engine choice: on a badly laid-out file, DuckDB's advantage largely disappears.
Why does the CRS have to be set by hand every single time?
Because WKB is a coordinate encoding, not a spatial reference. The CRS lives in the GeoParquet footer, DuckDB's GEOMETRY type has no field to carry it, and the result set that reaches Python is a plain columnar table. Read the file's declared CRS once with gpd.read_parquet(path, columns=[]).crs or from the geo metadata and store it in a constant, rather than hard-coding EPSG:4326 and hoping — the second habit is what produces silently unprojected frames.
Can I write the query result straight back to GeoParquet without touching Python?
Yes, and you should when the result is large. COPY (SELECT ...) TO 'out.parquet' (FORMAT GDAL, DRIVER 'Parquet', SRS 'EPSG:4326') keeps the whole round trip inside the engine, never materializes a frame, and produces a file GeoPandas can read directly. Only pull the result into Python when Python is going to do something with it.
Is it safe to run this against a file another job is writing? No. Parquet has no in-place update path, so a writer that is rewriting a file mid-query gives DuckDB a truncated or inconsistent footer, usually surfacing as a decode error rather than wrong answers. Write to a new path and swap, or use a partitioned directory where each write lands a new partition — the same discipline the Cloud-Native Geospatial Formats workflow expects.