How to Move Data Between DuckDB and GeoPandas
Problem statement
DuckDB is the better engine for the heavy part โ the join, the aggregate, the scan over millions of rows. GeoPandas is the better environment for everything after that: plotting, .explore(), contextily, the shapely API, and the rest of the Python geospatial ecosystem.
So most real workflows use both, and the boundary between them needs to be cheap and lossless. Two things routinely go wrong:
- The geometry arrives as bytes. A DuckDB
GEOMETRYcolumn comes into pandas as an opaque object, not as a GeoSeries. - The CRS is lost. DuckDB tracks CRS on the column type and drops it as soon as anything is computed, so a GeoDataFrame built from a query result has
crs=Noneunless you set it.
Both are one line to fix, and both are silent when you do not.
Quick answer
Convert to WKB in SQL, load it in GeoPandas, and set the CRS explicitly:
import duckdb
import geopandas as gpd
from shapely import from_wkb
con = duckdb.connect()
con.execute("install spatial; load spatial;")
frame = con.execute("""
select name, admin, st_aswkb(geom) as geometry
from st_read('provinces.shp')
where admin = 'United States of America'
""").df()
gdf = gpd.GeoDataFrame(
frame,
geometry=from_wkb(frame["geometry"]),
crs="EPSG:4326", # DuckDB will not tell you this โ you must
)
Going the other way is the mirror image:
con.execute("create table areas as select * from gdf_with_wkb")
with the geometry converted to WKB in pandas first.
Step-by-step solution
1. Use WKB as the interchange format
Well-Known Binary is compact, exact and understood by both sides. ST_AsWKB in DuckDB produces it; shapely.from_wkb reads it, vectorised over a whole column.
The alternatives are worse: WKT is text and larger, GeoJSON is text and larger still, and passing DuckDB's internal geometry type into pandas gives you something that is not a shapely object.
2. Set the CRS yourself, every time
DuckDB attaches the source CRS to the column type when reading a file:
select typeof(geom) from st_read('provinces.shp') limit 1;
-- GEOMETRY('EPSG:4326')
but that annotation does not travel through ST_AsWKB, and ST_Transform strips it from its own result. So the CRS is knowledge you hold, not something the pipeline carries.
Write it down as a constant next to the query. A GeoDataFrame with crs=None will silently refuse to reproject and silently mis-join.
3. Prefer Arrow for large results
.df() builds a pandas DataFrame; .arrow() returns an Arrow table with no intermediate copy. For results of millions of rows the difference is measurable, and GeoPandas reads from Arrow directly:
table = con.execute(sql).arrow()
frame = table.to_pandas()
Compare the transfer costs for a million two-column rows: JSON is 31.77 MB, Arrow with zstd 11.53 MB, raw float32 8.0 MB. The same ordering applies inside a process โ Arrow moves columns, pandas rebuilds them.
4. Go the other way with a registered DataFrame
DuckDB can query a pandas DataFrame in the local scope by name, which makes pushing data in trivial:
places = gdf.to_wkb() # geometry column becomes bytes
result = con.execute("""
select country, count(*) from places group by 1
""").df()
For geometry, convert first: gdf.to_wkb() returns a DataFrame whose geometry column holds WKB, which DuckDB reads with ST_GeomFromWKB.
5. Keep the split at the right place
The division that works:
- DuckDB โ reading files, filtering, joining, aggregating, anything over a million rows.
- GeoPandas โ the result, once it is small enough to hold comfortably: plotting, interactive maps, shapely operations on a few thousand features, export to formats GDAL writes well.
Measured, the heavy end justifies itself: 13,464,017 points joined to 4,596 polygons took 61.8 s and 305 MB in DuckDB against 117.5 s and 4,739 MB in GeoPandas. The light end justifies itself too: on 7,342 points GeoPandas was faster, 0.104 s against 0.168 s.
6. Do not round-trip more than once
Each crossing costs a serialisation. A pipeline that alternates โ query, convert, operate, convert back, query โ spends most of its time in WKB.
Decide where the boundary is, do all the heavy work on one side, cross once.
Code examples
Example 1 โ the two conversions, as functions
import duckdb
import geopandas as gpd
import pandas as pd
from shapely import from_wkb
def duckdb_to_geodataframe(con, sql, crs, geometry_column="geom"):
"""Run a query and return a GeoDataFrame with the CRS you specify."""
frame = con.execute(sql).df()
if geometry_column not in frame.columns:
raise KeyError(
f"no column {geometry_column!r} in the result; "
f"select st_aswkb({geometry_column}) as {geometry_column}")
geometry = from_wkb(frame[geometry_column])
frame = frame.drop(columns=[geometry_column])
return gpd.GeoDataFrame(frame, geometry=geometry, crs=crs)
def geodataframe_to_duckdb(con, gdf, table_name, geometry_column="geometry"):
"""Register a GeoDataFrame as a DuckDB table with a real geometry column."""
staged = gdf.to_wkb() # geometry โ bytes
con.register("_staging", staged)
con.execute(f"""
create or replace table {table_name} as
select * exclude {geometry_column},
st_geomfromwkb({geometry_column}) as geom
from _staging
""")
con.unregister("_staging")
rows = con.execute(f"select count(*) from {table_name}").fetchone()[0]
print(f"{table_name}: {rows:,} rows (CRS {gdf.crs} โ record it, "
f"DuckDB will not keep it)")
Example 2 โ the heavy-then-light pattern
def heavy_then_light(points_parquet, polygons_path, crs="EPSG:4326", top=20):
"""DuckDB does the millions of rows; GeoPandas gets the summary."""
con = duckdb.connect()
con.execute("install spatial; load spatial;")
con.execute("set enable_progress_bar = false")
sql = f"""
select a.name,
count(*) as points,
st_aswkb(any_value(a.geom)) as geom
from read_parquet('{points_parquet}') p
join st_read('{polygons_path}') a
on st_intersects(st_point(p.lon, p.lat), a.geom)
group by a.name
order by points desc
limit {top}
"""
gdf = duckdb_to_geodataframe(con, sql, crs=crs)
print(f"{len(gdf)} areas crossed the boundary into GeoPandas")
return gdf
any_value(a.geom) keeps one geometry per group without a second join. The whole point of the shape is that a 13-million-row input becomes a 20-row GeoDataFrame before anything is materialised in Python.
Example 3 โ a round-trip test that catches silent loss
def assert_roundtrip(con, gdf, crs=None):
"""Geometry and CRS should survive a trip through DuckDB unchanged."""
crs = crs or gdf.crs
geodataframe_to_duckdb(con, gdf, "_roundtrip")
back = duckdb_to_geodataframe(
con, "select * exclude geom, st_aswkb(geom) as geom from _roundtrip", crs=crs)
assert len(back) == len(gdf), f"row count changed: {len(gdf)} โ {len(back)}"
assert back.crs == gdf.crs, f"CRS changed: {gdf.crs} โ {back.crs}"
same = gdf.geometry.reset_index(drop=True).geom_equals(
back.geometry.reset_index(drop=True))
assert same.all(), f"{(~same).sum()} geometries differ after the round trip"
print(f"round trip clean: {len(gdf):,} features, CRS {crs}")
Run this once when you set the boundary up. WKB is lossless, so a failure means the CRS assumption is wrong or a geometry was invalid to begin with.
Explanation
Why WKB and not the native type
DuckDB's GEOMETRY is an internal representation optimised for its own operations. When it crosses into pandas it becomes an object pandas cannot interpret, and shapely cannot read it either.
WKB is the lingua franca: a documented binary encoding that both sides implement natively and vectorised. shapely.from_wkb on a whole column is a single C-level loop, which is why the conversion is fast enough not to think about.
Why the CRS has to be an explicit constant
DuckDB's CRS tracking is a type annotation, and annotations are lost by most operations โ including the ST_AsWKB that gets the data out. GeoPandas needs a real CRS object to reproject, to join safely and to plot with a basemap.
The consequence is that the CRS must live in your code, next to the query, as a constant. That is not as bad as it sounds: a pipeline should know what CRS it works in, and writing it down is a documentation improvement as much as a technical necessity.
Why the direction of the split matters
Both engines can do everything. The reason to put the heavy work in DuckDB is memory: it streams, and GeoPandas materialises.
Measured on the global join, GeoPandas peaked at 4,739 MB against DuckDB's 305 MB โ a 15.5ร difference on identical output. Below the memory ceiling that is a nicety; at the ceiling it is the difference between an answer and a crash.
And the reverse holds at small sizes: on 7,342 points against 4,596 polygons, GeoPandas' in-memory index finished in 0.104 s against DuckDB's 0.168 s, because the query planning and structure building dominate.
Why one crossing is the target
Serialising a million geometries to WKB and back is not free, and a pipeline that alternates between the two libraries does it repeatedly.
The design that avoids it: express the whole heavy stage as one query โ joins, filters, aggregates and all โ and cross once with a result small enough that the conversion cost is irrelevant. That is also the stage where SQL is at its most readable, because the whole transformation is in one place.
Edge cases or notes
st_aswkb()in the select list, or the geometry arrives unusable.crs=is not optional. A GeoDataFrame withcrs=Nonewill not reproject and will join wrongly.gdf.to_wkb()returns a plain DataFrame with bytes in the geometry column, ready forcon.register..arrow()avoids a copy and is worth it above about a million rows.- Empty and null geometries survive WKB but need handling on both sides.
- Mixed geometry types are fine in DuckDB and awkward in some GeoPandas exports.
- Register temporary frames and unregister them, or the connection holds references.
- Do not put a WKB column in a Parquet file and call it GeoParquet โ that needs the
geometadata key, which DuckDB's native writer does add.
Internal links
- DuckDB spatial explained: a spatial database that is just a file โ when to use which engine
- How to run a spatial join in DuckDB โ the heavy stage
- CRS in DuckDB: why ST_Transform moves your data to the wrong place โ why the CRS is not carried
- How to benchmark DuckDB against GeoPandas honestly โ deciding where the boundary is
- WKT, WKB and GeoJSON explained โ the interchange formats
- What is a GeoDataFrame โ the destination
- Spatial SQL versus GeoPandas โ the wider comparison
- How to read shapefiles, GeoJSON and GeoParquet in DuckDB โ getting data in
FAQ
How do I get a GeoDataFrame out of a DuckDB query?
Select st_aswkb(geom), read the result with .df(), and build the GeoDataFrame with shapely.from_wkb and an explicit crs=.
Why is my geometry column full of bytes?
Because WKB is bytes. Convert it with shapely.from_wkb, which is vectorised over the whole column.
Why does my GeoDataFrame have no CRS?
DuckDB tracks CRS on the column type and drops it in most operations, including ST_AsWKB. Set crs= explicitly from a constant in your code.
Should I use .df() or .arrow()?
.arrow() above about a million rows โ it avoids an intermediate copy. Below that the difference is not worth the extra line.
How do I push a GeoDataFrame into DuckDB?
gdf.to_wkb(), register the resulting DataFrame, then st_geomfromwkb() in a create table as select.
Where should the boundary between the two be?
After the heavy stage. DuckDB streams a 13.5-million-point join in 305 MB where GeoPandas needs 4,739 MB; cross once, with a result small enough that the conversion does not matter.