Skip to main content

Geospatial Data in Iceberg Tables

Iceberg and Parquet are rapidly becoming the standard substrate for large-scale geospatial analytics: GeoParquet brought geometry to Parquet files, and Iceberg v3 adds native geometry/geography column types on top. Arraylake Iceberg tables work well for this data today using the same conventions GeoParquet uses internally — this page shows the pattern.

The pattern: WKB geometry + bbox covering

Arraylake tables use Iceberg format v2, which has no native geometry type (that arrives with v3 — see below). The interoperable v2 approach, identical to what a GeoParquet file does inside plain Parquet, is:

  • Store each geometry column as WKB in a binary column. Every spatial library (Shapely, GeoPandas, DuckDB spatial, Sedona, PostGIS) reads and writes WKB.
  • Record the CRS and encoding in table properties, since v2 has no spec'd home for them.
  • Add a bbox covering struct (xmin/ymin/xmax/ymax) alongside each geometry column, following GeoParquet 1.1's covering convention. Iceberg keeps min/max column statistics for nested struct fields, so predicates on the bbox fields prune manifests and data files — spatial pushdown without a spatial index.

The Arraylake web app recognizes these geo.* properties: columns declared in geo.geometry-columns render in the schema browser as geometry columns (with their geometry type, encoding, and CRS) rather than opaque binary fields.

Writing geospatial data

Using Shapely for the WKB encoding:

import pyarrow as pa
import shapely
from arraylake import Client

client = Client()
catalog = client.get_iceberg("my-org")

bbox = pa.struct(
[("xmin", pa.float64()), ("ymin", pa.float64()), ("xmax", pa.float64()), ("ymax", pa.float64())]
)
schema = pa.schema(
[
("event_id", pa.int64()),
("event_type", pa.string()),
("magnitude", pa.float64()),
("geometry", pa.binary()), # WKB Point: event location
("bbox", bbox), # covering for `geometry`
]
)

table = catalog.create_table(
"my-tables.storm_events",
schema=schema,
properties={
"comment": "Severe-weather events with GeoParquet-style WKB geometry",
"geo.encoding": "WKB",
"geo.crs": "OGC:CRS84 (lon-lat, WGS 84)",
"geo.geometry-columns": "geometry (Point)",
"geo.covering": "bbox covers geometry (GeoParquet 1.1 style)",
},
)

events = [
(1, "tornado", 3.0, shapely.Point(-97.5, 35.5)),
(2, "hail", 2.25, shapely.Point(-98.1, 37.7)),
(3, "flash flood", 0.8, shapely.Point(-95.4, 29.8)),
]
table.append(
pa.Table.from_pylist(
[
{
"event_id": eid,
"event_type": etype,
"magnitude": mag,
"geometry": shapely.to_wkb(geom),
"bbox": dict(zip(("xmin", "ymin", "xmax", "ymax"), geom.bounds)),
}
for eid, etype, mag, geom in events
],
schema=schema,
)
)

For point geometries the bbox is degenerate (xmin == xmax), which is fine — it still drives file pruning. For lines and polygons, geom.bounds gives the true bounding box.

Partition spatially-queried tables by time or region

An identity partition on an event date (or a coarse region key) composes with bbox pruning: the partition eliminates whole directories, then the bbox statistics prune the surviving files.

Spatial filtering with predicate pushdown

A bounding-box query is an ordinary Iceberg filter on the bbox fields, so it pushes down to file statistics:

table = catalog.load_table("my-tables.storm_events")

# events inside a lon/lat window (here, Tornado Alley)
window = "bbox.xmin >= -103 and bbox.xmax <= -94 and bbox.ymin >= 33 and bbox.ymax <= 39"
df = table.scan(row_filter=window).to_pandas()

# decode WKB for use with GeoPandas
import geopandas as gpd
gdf = gpd.GeoDataFrame(
df.drop(columns=["geometry"]),
geometry=shapely.from_wkb(df["geometry"]),
crs="OGC:CRS84",
)

For exact (non-rectangular) predicates, filter on the bbox first — that's the part Iceberg can prune on — then refine with Shapely or GeoPandas on the much smaller result.

Querying from DuckDB

DuckDB's spatial extension decodes the WKB columns directly, and the bbox predicate pushes down through the Iceberg scan just as in Python:

INSTALL iceberg; LOAD iceberg;
INSTALL spatial; LOAD spatial;

CREATE SECRET al (TYPE ICEBERG, TOKEN 'ema_...', ENDPOINT 'https://api.earthmover.io/iceberg');
ATTACH 'my-org' AS warehouse (TYPE iceberg, SECRET al);

SELECT event_type,
count(*) AS n,
ST_AsText(ST_GeomFromWKB(min(geometry))) AS sample_point
FROM warehouse."my-tables".storm_events
WHERE bbox.xmin >= -103 AND bbox.xmax <= -94
AND bbox.ymin >= 33 AND bbox.ymax <= 39
GROUP BY 1 ORDER BY n DESC;

The same WKB columns work in Spark (via Apache Sedona's ST_GeomFromWKB), Trino, and any engine with WKB functions — see Connecting Query Engines for catalog configuration.

Iceberg v3 and native geometry types

Iceberg format v3 defines first-class geometry and geography column types, aligned with GeoParquet and with Parquet's new GEOMETRY/GEOGRAPHY logical types. With v3, the CRS lives on the column type and geometry bounds are tracked natively in column statistics, so engines get spatial pushdown without a hand-maintained bbox.

Arraylake currently creates and accepts v2 tables while the engine ecosystem (PyIceberg included) completes v3 write support; v3 will be supported once that lands. The pattern on this page is forward-compatible: WKB is exactly the encoding engines use to transport v3 geometry values, so migrating is an ALTER TABLE-style schema evolution — add a geometry-typed column, rewrite the WKB into it, and drop the bbox struct.