Partitioning Geospatial Tables with H3 and dbt

This page shows you how to turn an Uber H3 hexagonal cell index into the physical partition and cluster key for a large geometry table in dbt, so the warehouse prunes whole regions of data before any ST_* predicate runs.

When to use this approach

A grid partition key earns its keep once a table is large enough that unqualified geometry scans dominate query cost. Reach for H3 cell partitioning when:

  • Your table has passed tens of millions of rows and queries are spatially local. Dashboards and joins that touch one metro area should never scan a continent. H3 partitioning is the physical-layout half of the broader scaling discipline in handling large geospatial datasets; pair it with incremental materialization for large geometry tables to bound rebuild cost too.
  • You want an equal-area, uniform partition key. Partitioning by country or admin region produces wildly skewed partitions — one dense city cell can dwarf a rural nation. H3 hexagons are near-uniform in area at a fixed resolution, so partitions stay balanced.
  • Your join is a proximity or containment test. A shared cell key lets you replace part of an exact predicate with a cheap equality pre-filter, the two-phase pattern detailed in optimizing proximity joins. If you only need bounding-box incremental filters, incremental spatial models with bounding-box filters may be enough on its own.

If your data is globally uniform point density and your engine already clusters natively (BigQuery, Snowflake), a Z-order or QuadKey clustering key can be simpler; H3 wins when you also need the cell as a join and aggregation key, not just storage locality.

Prerequisites

  • dbt Core >= 1.7 for current partition_by / cluster_by config semantics and on_schema_change handling.
  • An H3 binding for your engine: the h3 PostgreSQL extension (h3-pg) alongside PostGIS >= 3.3, or the DuckDB h3 community extension alongside the DuckDB spatial extension for CI. BigQuery exposes jslibs.h3.* UDFs; Snowflake has H3 built in (H3_LATLNG_TO_CELL).
  • A canonical SRID already enforced in staging. H3 assignment reads lon/lat, so geometries must be normalized to EPSG:4326 before the cell is computed — see spatial reference system management.
  • Warehouse grants to CREATE tables and indexes on the target schema.
  • A chosen resolution wired through a project var so dense and sparse marts can share one transformation:
yaml
# dbt_project.yml
vars:
  canonical_srid: "{{ env_var('DBT_SPATIAL_CANONICAL_SRID', '4326') }}"
  h3_resolution: 8   # ~0.46 km^2 hexagons; raise for denser data, lower for sparser

Step-by-step instructions

The flow below is what you are building: the cell index is computed once in staging, becomes the physical partition/cluster key in the intermediate layer, and is supplied as a literal set at query time so the planner discards non-matching partitions before touching geometry.

Assigning an H3 cell in staging, partitioning by it, then pruning a query by cell A left-to-right flow in three stages. Stage one, staging, computes h3_latlng_to_cell from the normalized EPSG 4326 geometry and stamps an h3_index column. Stage two, the intermediate incremental model, uses that column as the partition_by field and cluster_by key so rows that share a cell are physically co-located. Stage three, a consuming query, filters WHERE h3_index IN a small literal set covering the area of interest, and the planner prunes every partition outside that set before evaluating the exact ST_Intersects predicate. A caption notes that pruning happens before any geometry is read. The cell key is computed once, then reused as storage layout and as a query prune key 1 · Staging normalize CRS, then stamp the cell h3_latlng_to_cell( ST_Y(geom), ST_X(geom), res) AS h3_index one string column per row 2 · Intermediate cell becomes the physical key partition_by = h3_index cluster_by = [h3_index, ts] same-cell rows co-located on disk 3 · Query prune partitions, then test geometry WHERE h3_index IN (…) AND ST_Intersects(…) non-matching cells never scanned Partition prune at read time A query for one metro touches 3 of 40 cells — the planner skips the other 37 partitions before any geometry is read. scanned (matched cells) pruned (37 cells skipped)

1. Assign the H3 cell in staging

Compute the cell exactly once, immediately after CRS normalization, and stamp it as a plain string column. Deriving it downstream re-runs the lon/lat math on every model and invites drift in resolution.

sql
-- models/staging/stg_events_h3.sql
{{ config(materialized='view') }}

WITH normalized AS (
    SELECT
        event_id,
        event_timestamp,
        ST_Transform(geom, {{ var('canonical_srid') }}) AS geom
    FROM {{ source('gis', 'events_raw') }}
    WHERE geom IS NOT NULL AND ST_IsValid(geom)
)

SELECT
    event_id,
    event_timestamp,
    geom,
    -- lon/lat order: H3 takes (lat, lng)
    h3_latlng_to_cell(ST_Y(geom), ST_X(geom), {{ var('h3_resolution') }}) AS h3_index
FROM normalized

Verify every row got a cell and the resolution is uniform:

sql
SELECT
    count(*) FILTER (WHERE h3_index IS NULL) AS missing_cell,
    count(DISTINCT h3_get_resolution(h3_index)) AS distinct_resolutions
FROM {{ ref('stg_events_h3') }};
-- expect missing_cell = 0 and distinct_resolutions = 1

2. Partition and cluster the incremental model by the cell

Make h3_index the partition key and lead the cluster_by columns with it so co-located rows share a physical block. On PostGIS, where there is no native partition_by, use the cell to build a GiST index and (optionally) declarative table partitions in a post_hook; on BigQuery and Snowflake the config maps to native clustering.

sql
-- models/intermediate/int_events_partitioned.sql
{{ config(
    materialized='incremental',
    unique_key='event_id',
    partition_by={"field": "h3_index", "data_type": "string"},
    cluster_by=["h3_index", "event_timestamp"],
    post_hook="CREATE INDEX IF NOT EXISTS {{ this.name }}_geom_gist ON {{ this }} USING GIST (geom)"
) }}

SELECT
    event_id,
    event_timestamp,
    h3_index,
    geom
FROM {{ ref('stg_events_h3') }}
{% if is_incremental() %}
  WHERE event_timestamp > (SELECT MAX(event_timestamp) FROM {{ this }})
{% endif %}

Verify rows sharing a cell are physically close (small block range per cell means good clustering):

sql
-- PostGIS: ctid page spread per cell should be tight after CLUSTER/ANALYZE
SELECT h3_index, count(*) AS rows, count(DISTINCT (ctid::text::point)[0]) AS pages
FROM {{ ref('int_events_partitioned') }}
GROUP BY h3_index ORDER BY rows DESC LIMIT 5;

3. Derive the candidate cell set for a query

Never scan the whole table and filter geometry. Instead, resolve the area of interest to a small set of cells first — the exact cell of a point plus its ring of neighbours — and pass that set as the partition filter.

sql
-- resolve an area of interest (a point + 1-ring) to a cell set
WITH aoi AS (
    SELECT h3_grid_disk(
        h3_latlng_to_cell(:lat, :lng, {{ var('h3_resolution') }}),
        1
    ) AS cells
)
SELECT e.event_id, e.geom
FROM {{ ref('int_events_partitioned') }} e, aoi
WHERE e.h3_index = ANY(aoi.cells)      -- partition prune happens here
  AND ST_DWithin(e.geom::geography, ST_MakePoint(:lng, :lat)::geography, 2000);

Verify the planner prunes partitions instead of scanning them all:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM int_events_partitioned WHERE h3_index = ANY(ARRAY['8828308281fffff']);
-- expect the scan to touch only the matching partition/blocks, not the full relation

4. Aggregate to the cell for coarse marts

For dashboards that do not need individual geometries, roll rows up to their cell. The aggregate is dramatically smaller, needs no geometry at read time, and doubles as a privacy control — the same generalization used in masking precise coordinates for privacy.

sql
-- models/marts/mart_events_by_cell.sql
{{ config(materialized='table', cluster_by=['h3_index']) }}

SELECT
    h3_index,
    h3_cell_to_boundary_wkt(h3_index) AS cell_boundary,
    count(*)                          AS event_count
FROM {{ ref('int_events_partitioned') }}
GROUP BY h3_index

Verify the aggregate is complete and no cell was lost:

sql
SELECT
    (SELECT count(DISTINCT h3_index) FROM {{ ref('int_events_partitioned') }}) AS source_cells,
    (SELECT count(*) FROM {{ ref('mart_events_by_cell') }})                    AS mart_cells;
-- the two counts must match

Configuration reference

Parameter Accepted values Default Spatial notes
h3_resolution integer 015 8 Res 8 ≈ 0.46 km² per hexagon. Higher = smaller cells, more partitions, finer prune; lower = fewer, coarser partitions
partition_by.field a stamped cell column h3_index Must be materialized upstream, not computed inline, or the engine cannot prune
partition_by.data_type string string H3 indexes are 64-bit; store as string for portability, or bigint if every engine agrees
cluster_by [cell, time, …] [h3_index, event_timestamp] Lead with the cell so spatial locality dominates block layout
canonical_srid any EPSG code 4326 H3 assignment needs lon/lat; assign the cell only after transforming to 4326
grid alternative H3, S2, QuadKey H3 S2 gives quadrilateral cells and 30 levels; QuadKey suits web-map tile pyramids. Pick one and store it as the key

Gotchas & edge cases

  • Resolution is a permanent physical decision. Changing h3_resolution re-keys every row and forces a full rebuild — the cell strings differ entirely between resolutions. Treat a resolution change as a schema event and record it under versioning spatial schemas in dbt.
  • Lat/lng argument order. h3_latlng_to_cell takes latitude first. Passing ST_X (longitude) before ST_Y silently places every point in the wrong hemisphere and destroys locality without erroring.
  • Polygons need a covering, not a single cell. A point maps to one cell, but a polygon spans many. Assign polygons with h3_polygon_to_cells (a covering set) and either explode rows per cell or key on the centroid cell — pick deliberately, because the two answer different questions.
  • Boundary features fall in a neighbour cell. A point near a cell edge whose true match sits across the border is missed if you query only the exact cell. Always expand the area of interest with h3_grid_disk(cell, 1) before filtering, then let the exact predicate decide.
  • S2 and QuadKey are not interchangeable keys. If a downstream system already keys on S2 tokens or QuadKeys, do not re-key to H3 for its own sake; a mismatched grid forces a re-cover on every join. Standardize one grid across the platform.
  • Too-fine a resolution explodes partition count. Thousands of tiny partitions add metadata and planning overhead that can outweigh the prune. Tune resolution to roughly one partition per query working set, not per row.

FAQ

What H3 resolution should I pick for partitioning?

Choose the resolution whose cell size roughly matches a typical query’s working set, not the finest available. Resolution 7–9 suits most city-to-regional analytics: res 8 hexagons are about 0.46 km² and give balanced partitions for urban point data. Too fine and you get thousands of tiny partitions whose planning overhead cancels the prune; too coarse and each partition still holds most of the table. Store the value in a var so dense and sparse datasets can share the transformation with different cell sizes.

How is H3 different from S2 or QuadKey for a partition key?

All three are hierarchical grids that turn a coordinate into a sortable cell id, but the cells differ. H3 uses near-equal-area hexagons, which keep partition sizes balanced and give every cell six equidistant neighbours — convenient for ring expansion. S2 uses quadrilateral cells over a sphere-projected cube with 30 levels and is strong for range scans on the cell token. QuadKey mirrors web-map tile pyramids and is natural when your consumers are slippy maps. Any of them works; pick one grid and use it everywhere so joins never re-cover.

Does PostGIS support partition_by like BigQuery does?

Not natively in a single config. On BigQuery and Snowflake the dbt partition_by and cluster_by config maps to native clustering. On PostGIS you get the same locality by clustering the physical table on the cell key and building a GiST index in a post-hook, and optionally by using declarative table partitioning keyed on h3_index ranges. The model SQL is identical; only the storage mechanics differ per adapter.

Do I still need a GiST index if I partition by H3?

Yes. The cell key prunes which partitions are read, but inside a matched partition you still evaluate exact predicates like ST_Intersects or ST_DWithin, and those want a spatial index. The two work together: the cell equality is phase one, the indexed geometry test is phase two. Dropping the geometry index leaves you scanning every row in the matched cells sequentially.

Up: Part of Handling Large Geospatial Datasets.