Querying Iceberg Tables with the DuckDB Spatial Extension
This guide shows how to read an Apache Iceberg table from DuckDB using the iceberg extension, decode its WKB geometry column with the spatial extension, and run a spatial query end to end — including catalog and snapshot metadata configuration for both metadata-file and REST-catalog access.
Context and prerequisites
Apache Iceberg (1.4+; these examples target a table written by Spark 3.5 with the 1.9.0 runtime) stores geometry as WKB in a binary column, since core Iceberg has no dedicated geometry type. DuckDB reads Iceberg through the iceberg extension, which parses the table’s metadata to find the current snapshot’s data files, and decodes geometry through the spatial extension. Use DuckDB 1.0 or later. This page belongs to DuckDB geospatial analytics on lakehouse tables; if your data is loose GeoParquet rather than an Iceberg table, the companion recipe how to run ST_Intersects in DuckDB on GeoParquet applies instead.
Complete working solution
import duckdb
con = duckdb.connect()
# 1. Load the three extensions: iceberg (table format), spatial (geometry), httpfs (S3)
con.execute("INSTALL iceberg; LOAD iceberg;")
con.execute("INSTALL spatial; LOAD spatial;")
con.execute("INSTALL httpfs; LOAD httpfs;")
# 2. S3 credentials for the warehouse bucket
con.execute("""
CREATE OR REPLACE SECRET s3_warehouse (
TYPE S3, PROVIDER credential_chain, REGION 'us-east-1'
);
""")
# 3a. Direct metadata-file access: point iceberg_scan at the current metadata JSON.
# Iceberg stores geometry as WKB in a BLOB column named "geom_wkb" here.
con.execute("""
CREATE VIEW parcels AS
SELECT
parcel_id,
zoning,
ST_GeomFromWKB(geom_wkb) AS geom
FROM iceberg_scan(
's3://warehouse/gis/parcels/metadata/00042-abc.metadata.json',
allow_moved_paths = true
);
""")
# 4. Spatial query: total parcel area per zoning class within a bounding box.
# ST_MakeEnvelope(xmin, ymin, xmax, ymax) builds the query window (EPSG:4326 coords).
result = con.execute("""
SELECT
zoning,
round(sum(ST_Area(geom)), 2) AS total_area,
count(*) AS n
FROM parcels
WHERE ST_Intersects(
geom,
ST_MakeEnvelope(-122.42, 37.77, -122.40, 37.79)
)
GROUP BY zoning
ORDER BY total_area DESC;
""").fetchall()
for zoning, area, n in result:
print(f"{zoning:12s} area={area:12.2f} parcels={n}")
con.close()
If you run a REST catalog (Nessie, Polaris, AWS Glue via the Iceberg REST spec), attach it instead of pointing at a metadata file, then query by table name:
con.execute("""
ATTACH 's3://warehouse/gis' AS gis_cat (
TYPE ICEBERG,
ENDPOINT 'https://catalog.example.com/api/catalog'
);
""")
con.execute("""
SELECT count(*) FROM gis_cat.parcels
WHERE ST_Contains(
ST_MakeEnvelope(-122.5, 37.7, -122.3, 37.8),
ST_GeomFromWKB(geom_wkb)
);
""")
Step-by-step walkthrough
-
Load all three extensions.
icebergreads the table format,spatialsuppliesST_GeomFromWKB/ST_Area/ST_Intersects, andhttpfsfetches bytes from S3. All three must be loaded in the same connection; missingicebergyields aCatalog Error: Table Function "iceberg_scan" does not exist. -
Resolve the snapshot.
iceberg_scanwith a metadata JSON path reads that file’scurrent-snapshot-id, walks the manifest list, and produces the set of live data files. To query an older snapshot for time-travel, passsnapshot_from_idorsnapshot_from_timestampas options — useful for reproducing a past spatial aggregate. -
allow_moved_paths = true. Iceberg metadata records absolute data-file paths. If the table was copied or the warehouse prefix changed, this option lets DuckDB rebase those paths against the metadata location instead of failing on a stale absolute path. -
Decode WKB. The geometry lives as raw WKB in a
BLOBcolumn.ST_GeomFromWKB(geom_wkb)turns it into a DuckDBGEOMETRY. Do this once in the view so downstream queries operate on real geometries. -
Run the spatial predicate.
ST_MakeEnvelopebuilds an axis-aligned rectangle fromxmin, ymin, xmax, ymax, andST_Intersectsfilters parcels overlapping that window.ST_Areathen aggregates by zoning class. Because the geometry is decoded from the scanned column, the predicate runs per row after Iceberg has already pruned data files by partition.
Common errors and fixes
| Error | Cause | Fix |
|---|---|---|
Catalog Error: Table Function "iceberg_scan" does not exist |
iceberg extension not loaded |
INSTALL iceberg; LOAD iceberg; before scanning |
IO Error: No files found that match the pattern |
Stale absolute data-file paths in metadata after a move | Add allow_moved_paths = true to iceberg_scan |
Invalid Error: Could not read metadata |
Pointed at a manifest or data file, not the *.metadata.json |
Pass the current metadata JSON path (or attach a REST catalog) |
Binder Error: No function matches ST_Area(BLOB) |
Passed the raw WKB column to a spatial function | Wrap it in ST_GeomFromWKB(geom_wkb) first |
| Empty result over a known-populated area | Query envelope in a different CRS than stored geometry | Build the envelope in the table’s CRS (EPSG:4326 here) or reproject with ST_Transform |
Verification
Confirm you are reading the expected snapshot and that geometry decoded correctly. Iceberg metadata functions expose the snapshot history, and a validity check catches WKB corruption.
# Which snapshot did we resolve, and how many data files?
meta = con.execute("""
SELECT count(*) AS files
FROM iceberg_metadata('s3://warehouse/gis/parcels/metadata/00042-abc.metadata.json');
""").fetchone()
print("data files in snapshot:", meta[0])
# Every decoded geometry should be valid
invalid = con.execute("""
SELECT count(*) FROM parcels WHERE NOT ST_IsValid(geom);
""").fetchone()[0]
assert invalid == 0, f"{invalid} invalid geometries decoded from WKB"
print("all geometries valid")
The read path above shows why Iceberg complements DuckDB’s spatial engine: partition pruning happens at the manifest stage before any geometry is decoded, so the WKB-to-GEOMETRY conversion and the ST_Intersects refinement only run on files the snapshot metadata already selected. To reduce the scan even further with bbox statistics, see predicate pushdown optimization; for how the geometry bytes are laid out in the first place, see GeoParquet encoding standards, and for how Iceberg and Delta compare for spatial storage, see Iceberg vs Delta Lake for spatial data. The authoritative references are the DuckDB Iceberg extension documentation and the Apache Iceberg table specification.