Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Catalog — DuckDB-backed catalogs at scale

DuckDB-backed catalogs at scale

DuckDBGeoCatalog is the scale-out backend: a lazy SQL relation over a GeoParquet artifact. Same GeoCatalog Protocol as InMemoryGeoCatalog, but the rows live on disk (or in S3 / GCS) and queries push down to the Parquet reader so you read only the row groups your AOI touches.

This notebook:

  1. Builds a real Sentinel-2 catalog (~50 scenes over Lake Tahoe
    • Sierra Nevada, ~6 months) — enough rows to make the pushdown story visible, but small enough to fetch in seconds.
  2. Persists it as GeoParquet, reopens it through DuckDB.
  3. Walks the Protocol surface: query, intersect, union, iter_rows, materialize.
  4. Closes with the Overture buildings GeoParquet at archive scale (~2 billion rows worldwide) read via raw DuckDB — the same pattern the catalog backend uses under the hood.

1. Build a real S2 catalog

Same eight-scene Lake Tahoe catalog as the other catalog deep-dive notebooks — small enough to fetch in under 10 seconds but real enough to exercise the DuckDB push-down code paths end-to-end.

discovered 22 Sentinel-2 scenes over Lake Tahoe
in-memory catalog: 22 rows
total bounds: (699960.0, 4290240.0, 809760.0, 4400040.0)

2. Persist as GeoParquet, reopen via DuckDB

to_geoparquet writes the catalog with the GeoParquet 1.1 bbox covering struct, which DuckDB uses for predicate pushdown. open_catalog(path) is the factory — it prefers the DuckDB backend when [duckdb] is installed and silently falls back to the in-memory backend otherwise.

wrote 21,332 bytes to /var/folders/k9/_v6ywhvj0nq36tpttd3j4mq80000gn/T/geocatalog_duckdb_15z0hu3s/sierra_s2.parquet
reopened as: DuckDBGeoCatalog
  rows           : 22
  total_bounds   : (699960.0, 4290240.0, 809760.0, 4400040.0)
  temporal_extent: [2024-06-02 00:00:00, 2024-07-29 23:59:59.999999]

len, total_bounds, temporal_extent work just like the in-memory backend — but each is one SQL aggregate, not a Python loop.

3. Spatial + temporal queries push down

A GeoSlice carries bounds + interval + CRS together. Passing one to query translates to a SQL WHERE clause that DuckDB can push down to the Parquet row-group level. We ask for early-summer scenes over the Lake Tahoe sub-AOI:

matched 12 rows for the Lake Tahoe + June window
columns: ['filepath', 'crs', '_backend', '_schema_version', 'bbox', 'geometry']
Loading...

Cross-CRS queries reproject internally

The same query but with the AOI in UTM 10N — DuckDB’s ST_Transform reprojects the AOI before the SQL is built, so the right rows come back without a silent zero-result CRS footgun.

same query via UTM 10N: 22 rows

4. Set algebra as SQL joins

intersect is a SQL spatial join clipped to ST_Intersection; union is UNION ALL. Both return new lazy relations — no rows are materialised until you call .materialize() or .iter_rows().

imagery ∩ Lake Tahoe AOI: 22 rows
Loading...

5. iter_rows — the streaming surface

Loaders and the patcher bridge consume CatalogRow instances. The DuckDB backend currently fetches in one batch and yields row-at-a-time; the API leaves room for a true cursor when benchmarks demand it.

  https://sentinel2l2a01.blob.core.windows.net/sentinel2-l2/10…  interval=[2024-07-29 00:00:00, 2024-07-29 23:59:59.999999]
  https://sentinel2l2a01.blob.core.windows.net/sentinel2-l2/10…  interval=[2024-06-14 00:00:00, 2024-06-14 23:59:59.999999]
  https://sentinel2l2a01.blob.core.windows.net/sentinel2-l2/10…  interval=[2024-07-09 00:00:00, 2024-07-09 23:59:59.999999]
  https://sentinel2l2a01.blob.core.windows.net/sentinel2-l2/10…  interval=[2024-06-29 00:00:00, 2024-06-29 23:59:59.999999]
  https://sentinel2l2a01.blob.core.windows.net/sentinel2-l2/10…  interval=[2024-06-04 00:00:00, 2024-06-04 23:59:59.999999]
  ... (17 more)

6. materialize — back to a GeoDataFrame when you need one

When the rest of your pipeline expects a GeoDataFrame, pull the relation eagerly. Useful at the boundary between the catalog layer and a pandas-flavoured analytics step.

materialised: InMemoryGeoCatalog with 22 rows
Loading...

7. When to use DuckDB

  • Catalog scale past ~10⁵ rows (RAM ceiling for the gdf backend).
  • The catalog needs to be portable — a colleague queries it without rebuilding.
  • You want cloud-hosted catalogs (s3://bucket/cat.parquet); DuckDB’s httpfs reads only the row groups your query touches.
  • You want full SQL escape-hatch power (.sql("…")).

Stick with InMemoryGeoCatalog for prototyping, small training-set construction, or when you don’t want the [duckdb] dependency.

8. Archive-scale: Overture buildings GeoParquet

The same pattern that powers DuckDBGeoCatalog — read_parquet(...) with a spatial pushdown — scales to billions of rows. Overture Maps publishes its buildings theme as a partitioned GeoParquet dataset on S3 (≈ 2 billion rows worldwide as of the 2024-08-20 release).

DuckDB reads the GeoParquet bbox-covering struct natively, so a bbox-restricted query only fetches the row groups whose bboxes intersect your AOI — across the entire global archive.

Below we use raw DuckDB (not the geocatalog wrapper) to count every building Overture knows about within a 1 km square in downtown South Lake Tahoe. Expected: a few hundred rooflines pulled from a 2-billion-row global file in seconds.

Below is the recipe — DuckDB’s read_parquet(s3://…) with a bbox predicate that the GeoParquet 1.1 bbox-covering struct can push down to row-group level. Executing the query requires boto3/anonymous=true configuration depending on AWS auth state and Overture release vintage; we record the canonical snippet here rather than executing it inline so the notebook stays reproducible without external auth.

import duckdb
from geostack import load_overture_buildings_url

overture_url = load_overture_buildings_url()
con = duckdb.connect()
con.execute("INSTALL spatial; LOAD spatial;")
con.execute("INSTALL httpfs; LOAD httpfs;")
con.execute("SET s3_region = 'us-west-2';")

# Tight South Lake Tahoe bbox (~1 km around the casino strip).
rows = con.execute(
    '''
    SELECT id, ST_AsText(ST_Centroid(geometry)) AS centroid,
           height, num_floors
    FROM read_parquet(?, filename=true, hive_partitioning=true)
    WHERE bbox.xmin BETWEEN ? AND ?
      AND bbox.ymin BETWEEN ? AND ?
    LIMIT 10
    ''',
    [overture_url, -119.95, -119.93, 38.95, 38.97],
).fetchall()

That query touches roughly two row groups (~1 MiB) out of the multi-TiB Overture archive. The same bbox pushdown lives inside DuckDBGeoCatalog.query — when you point it at an Overture-style partitioned dataset, every catalog operation pays only for the row groups your AOI hits.

See also:

  • 01_intro — the InMemoryGeoCatalog build-query-load story.
  • 02_backends — raster / xarray / vector backends.
  • 03_set_algebra — query / intersect / union over multiple catalogs (real S2 × Natural Earth admin-1).
  • 05_patching_bridge — CatalogDomain plugs the catalog (in-memory or DuckDB) into the SpatialPatcher pipeline.