Iceberg Tables with GeoParquet for a Spatial Lakehouse
Iceberg does not make GeoParquet faster; it makes it transactional. The space-filling-curve sort, the covering bbox columns, and the row-group statistics that make spatial queries cheap are all properties of the files, and they are unchanged. What changes is that the set of files becomes a table with atomic commits, snapshot isolation, time travel, and schema evolution — which is exactly the machinery a hand-rolled manifest approximates. This page extends the Trino and Presto GeoParquet connector.
Quick Reference
| Concern | Handled by | Notes | Primary Use Case |
|---|---|---|---|
| Spatial skipping | GeoParquet | Covering bbox + Hilbert sort | Every spatial query |
| Column pruning | GeoParquet | Unchanged by the table format | Wide attribute schemas |
| Atomic commit | Iceberg | Replaces a manual pointer swap | Multi-writer pipelines |
| Snapshot isolation | Iceberg | Readers see a consistent file list | Long analytical queries |
| Partition pruning | Iceberg | On a materialised spatial key | Region-scoped queries |
| Schema evolution | Iceberg | Rename and add without rewriting | Long-lived tables |
| Small-file compaction | You, on a schedule | Neither format does it automatically | Incremental pipelines |
What Belongs at Which Layer
The clearest way to reason about a spatial lakehouse is to keep the two layers separate in your head, because almost every confusion comes from expecting one to do the other’s job.
The file layer is GeoParquet, and it owns everything spatial. Rows sorted on a Hilbert curve so that spatially close features are physically close; covering bbox columns so a row group can be ruled out from the footer; row groups sized so skipping has useful granularity. Take Iceberg away and every one of these still works.
The table layer is Iceberg, and it owns everything transactional. A snapshot is an immutable list of data files with their statistics; a commit atomically replaces one snapshot with another; a reader pins a snapshot for the life of its query. Take the geometry away and every one of these still works.
The one place they meet is partitioning. Iceberg prunes on partition values, and it can only prune on columns that exist. Materialising a spatial key — a Hilbert bucket, an H3 cell, a grid identifier — as a real column turns Iceberg’s ordinary partition pruning into spatial pruning, without teaching it anything about geometry.
Creating the Table
# Requires: pyiceberg>=0.7, pyarrow>=16, geopandas>=1.0 (Python 3.10+)
from __future__ import annotations
import numpy as np
import geopandas as gpd
import pyarrow as pa
from pyiceberg.catalog import load_catalog
from pyiceberg.exceptions import NoSuchTableError
from pyiceberg.partitioning import PartitionField, PartitionSpec
from pyiceberg.transforms import IdentityTransform
HILBERT_ORDER = 12 # 4096 × 4096 grid over the CRS extent
def add_spatial_key(frame: gpd.GeoDataFrame, *, buckets: int = 4096) -> gpd.GeoDataFrame:
"""Materialise the spatial partition key Iceberg will prune on.
Iceberg cannot derive this itself — it has no notion of geometry — so the
key has to be an ordinary column before the table is created.
"""
if frame.crs is None:
raise ValueError("a CRS is required before deriving a spatial key")
distance = frame.geometry.hilbert_distance(level=HILBERT_ORDER)
out = frame.copy()
# Bucketing the Hilbert index keeps the partition count bounded while
# preserving locality: adjacent buckets are adjacent in space.
out["hilbert_bucket"] = (distance // max(1, (4 ** HILBERT_ORDER) // buckets)).astype("int32")
return out.sort_values("hilbert_bucket").reset_index(drop=True)
def create_table(catalog_name: str, identifier: str, frame: gpd.GeoDataFrame):
"""Create an Iceberg table over spatially laid-out GeoParquet."""
catalog = load_catalog(catalog_name)
prepared = add_spatial_key(frame)
table_arrow = pa.Table.from_pandas(
prepared.drop(columns=prepared.geometry.name), preserve_index=False
).append_column(
"geometry", pa.array(prepared.geometry.to_wkb(), type=pa.binary())
)
try:
catalog.drop_table(identifier)
except NoSuchTableError:
pass
spec = PartitionSpec(
PartitionField(
source_id=table_arrow.schema.get_field_index("hilbert_bucket") + 1,
field_id=1000,
transform=IdentityTransform(),
name="hilbert_bucket",
)
)
table = catalog.create_table(identifier, schema=table_arrow.schema, partition_spec=spec)
table.append(table_arrow)
return table
def commit_incremental(table, replacement: pa.Table, buckets: set[int]) -> None:
"""Replace whole spatial partitions in one transaction.
This is the operation a hand-rolled manifest and pointer swap approximates;
here the format guarantees readers never see a partial state.
"""
if not buckets:
raise ValueError("no partitions to replace")
predicate = " OR ".join(f"hilbert_bucket = {b}" for b in sorted(buckets))
with table.transaction() as txn:
txn.overwrite(replacement, overwrite_filter=predicate)
Validation
Confirm two things: that partition pruning fires on the spatial key, and that row-group skipping still fires inside the surviving files. Both must work; either alone leaves most of the benefit on the table.
-- Trino: partition pruning at the table layer, row-group skipping at the file layer
EXPLAIN ANALYZE
SELECT count(*) FROM iceberg.gis.parcels
WHERE hilbert_bucket BETWEEN 1840 AND 1856
AND bbox.xmin <= -2.44 AND bbox.xmax >= -2.46
AND bbox.ymin <= 51.46 AND bbox.ymax >= 51.44;
-- Look for both:
-- "input: 17 files" ← Iceberg pruned 4,283 of 4,300 partitions
-- "physical input: 41 MB" ← Parquet skipped row groups inside those 17 files
Expected results: file count reduced by two to three orders of magnitude by partition pruning, and physical bytes read a small fraction of the surviving files’ total size. If files are pruned but physical input approaches their full size, the covering columns or the sort are missing and the file layer is doing nothing.
Edge Cases and Caveats
Expecting the table format to sort the data. Iceberg has a write-order concept, but it will not compute a Hilbert index or write covering columns. If the spatial layout work stops when the table format is adopted, queries get slower — the most common disappointment in a spatial lakehouse migration.
Partition counts chosen from geography rather than from file size. A partition per grid cell over a country produces thousands of partitions holding a few megabytes each, which is the small-file problem with extra metadata. Bucket the spatial key so each partition holds a sensible number of properly-sized files.
Snapshots retained forever. Time travel is only useful with retained snapshots, and retained snapshots pin the data files they reference, so storage grows without bound. Schedule snapshot expiry and orphan-file cleanup, and treat them as part of the pipeline exactly as incremental conversion treats its retention job.
Assuming every engine reads the table identically. Iceberg support varies across Trino, Spark, and DuckDB in which predicates get pushed down and which metadata is used. Verify the plan on each engine you actually query with, rather than on the one used for development.
Frequently Asked Questions
Does Iceberg understand spatial data?
No. It manages a list of data files, their schemas, their partition values, and their column statistics, and it treats a geometry column as an opaque binary column like any other. Every spatial optimisation still comes from the GeoParquet underneath — the space-filling-curve sort, the covering bbox columns, the row-group statistics. What Iceberg adds is transactional metadata over those files, which is orthogonal to whether the data is spatial.
What does a table format add over a manifest file I write myself?
Concurrency control, snapshot isolation, and schema evolution that you would otherwise implement and maintain. A hand-rolled manifest with an atomic pointer swap covers a single-writer pipeline well. The moment two processes write the same table, or readers need a consistent view during a rewrite, or a column needs renaming without rewriting the data, you are reimplementing a table format — usually less completely.
How do I get spatial partition pruning in Iceberg?
Materialise a spatial key as an ordinary column — a Hilbert curve bucket, an H3 cell, or a grid identifier — and declare it as the partition field. Iceberg then prunes on that column like any other, and because the key is derived from geometry, pruning on it is spatial pruning. Hidden partitioning means queries filter on the geometry-derived value without having to name the partition column, so readers never write partition predicates by hand.
Related
- Trino and Presto GeoParquet Connector — parent guide: the engine that queries these tables
- Incremental Conversion and Change Data Capture — the hand-rolled manifest a table format replaces
- Partition Overwrite vs Append for Spatial Tables — the write modes Iceberg makes transactional
- GeoParquet bbox Covering Columns Explained — the file-layer work the table format does not do for you
← Back to Trino and Presto GeoParquet Connector