Discrete Global Grid Macros

A discrete global grid turns a coordinate into a string or an integer. That single change makes almost everything in a warehouse easier: a spatial join becomes an equality join, a partition key becomes obvious, a set of points becomes groupable without any geometry function at all, and the whole thing works on engines that have no spatial index to offer. It also introduces a specific class of error — cells have edges, features straddle them, and a naive grid join quietly loses the features that sit on a boundary.

This topic covers grid indexing as a dbt concern: which grid system to pick, how to wrap the cell functions in macros so a project stays portable, how to choose a resolution from the data rather than from a default, and how to write the join so boundary cases stay correct. It pairs with the predicate work in spatial joins and predicate tuning, which is where the geometry-first version of the same problem lives.

Prerequisites checklist

  • A grid implementation on the target engine: the h3 PostgreSQL extension, BigQuery’s S2_CELLIDFROMPOINT, Snowflake’s H3_POINT_TO_CELL, or DuckDB’s H3 community extension.
  • A canonical SRID of 4326 before indexing. Every grid system takes longitude and latitude; feeding it projected coordinates produces cell ids that are silently wrong.
  • A decision on resolution, made from measured cell occupancy rather than copied from a tutorial.
  • The macro dispatch pattern already in place, as described in cross-engine UDF portability — grid function names differ on every engine.
  • A test dataset with features on cell boundaries. Without one, the boundary bug ships.

Architecture context: where the grid key enters the DAG

Where cell indexing is applied, and what it turns the join into Staging models attach a cell id to points and a set of covering cell ids to polygons. The intermediate join then matches on cell id equality, which the warehouse can hash and prune, and applies an exact geometry test only to features whose cells lie on a boundary. The mart consumes the result. An annotation contrasts this with the geometry-only path, where every candidate pair needs an exact test. stg_pings h3_cell = point_to_cell(geom, 9) one cell per point stg_zone_cells polygon_to_cells(geom, 9) many cells per zone equality join on cell USING (h3_cell) hashable · prunable · cheap exact test boundary cells only a few per cent of rows mart geometry-only path: every candidate pair pays an exact test

The important structural point is that the grid does not replace the geometry test — it reduces how many rows need one. A join written as “match on cell, then verify with ST_Intersects only where the cell straddles a boundary” is both faster and exactly as correct as the geometry-only version. A join written as “match on cell” alone is faster and slightly wrong, which is fine for a heatmap and not fine for billing.

Choosing a grid

Property H3 S2 Geohash
Cell shape hexagons (with 12 pentagons) quadrilaterals on a cube projection rectangles in longitude/latitude
Neighbour distance uniform in all six directions varies with position in the cell varies strongly with latitude
Hierarchy resolutions 0–15, non-nesting levels 0–30, strictly nesting length 1–12, strictly nesting
Prefix truncation not possible possible on the id possible on the string
Area distortion modest modest severe toward the poles
Engine support PostGIS extension, Snowflake, DuckDB, Databricks BigQuery native everywhere, trivially

The practical decision is usually made for you by the engine. Where it is not, H3’s uniform neighbour distance is what makes it the default choice for anything involving proximity or aggregation — a hexagon’s six neighbours are all the same distance away, so “cells within one ring” means a consistent radius, which is not true for squares. S2 wins where prefix truncation matters, since a coarser cell is a prefix of a finer one, and geohash wins only where you need something every tool understands with no extension at all.

Configuration walkthrough

Wrap the functions before writing a single model against them. Grid function names differ on every engine and change between extension versions.

sql
-- macros/grid/cell_for_point.sql
{% macro cell_for_point(geom, resolution) %}
  {{ return(adapter.dispatch('cell_for_point', 'spatial')(geom, resolution)) }}
{% endmacro %}

{% macro postgres__cell_for_point(geom, resolution) %}
  h3_lat_lng_to_cell({{ geom }}::point, {{ resolution }})
{% endmacro %}

{% macro snowflake__cell_for_point(geom, resolution) %}
  h3_point_to_cell_string({{ geom }}, {{ resolution }})
{% endmacro %}

{% macro duckdb__cell_for_point(geom, resolution) %}
  h3_latlng_to_cell(st_y({{ geom }}), st_x({{ geom }}), {{ resolution }})
{% endmacro %}

{% macro bigquery__cell_for_point(geom, resolution) %}
  s2_cellidfrompoint({{ geom }}, {{ resolution }})
{% endmacro %}

Set the resolution once, as a project var, so a change is one edit and every model agrees:

yaml
vars:
  grid_resolution: 9        # H3 r9 ≈ 0.1 km² per cell
  grid_resolution_coarse: 6 # for partitioning and rollups

Then staging attaches the key:

sql
-- models/staging/stg_trip_pings.sql
select
    ping_id,
    trip_id,
    observed_at,
    geom,
    {{ cell_for_point('geom', var('grid_resolution')) }} as h3_cell,
    {{ cell_for_point('geom', var('grid_resolution_coarse')) }} as h3_cell_coarse
from {{ source('ops', 'trip_pings') }}

Picking a resolution from the data

Resolution is the one parameter that matters and the one most often copied from an example. Choose it by measuring occupancy: too coarse and every cell holds thousands of features so the join barely filters; too fine and the cell table for a polygon explodes.

sql
-- analyses/cell_occupancy_by_resolution.sql
{% for r in [6, 7, 8, 9, 10] %}
select
    {{ r }} as resolution,
    count(distinct {{ cell_for_point('geom', r) }}) as cells,
    round(count(*)::numeric / count(distinct {{ cell_for_point('geom', r) }}), 1) as avg_features_per_cell
from {{ ref('stg_trip_pings') }}
{% if not loop.last %}union all{% endif %}
{% endfor %}
Choosing a grid resolution between too coarse and too fine Two opposing curves plotted against resolution. Features per cell falls steeply as resolution increases, so a coarse grid barely filters anything. Cells required to cover the polygons rises steeply, so a fine grid explodes the covering table. A shaded band in the middle marks the workable range where both quantities are moderate, annotated as the resolution to pick. workable range features per cell cells to cover a polygon r6 · coarse r8 – r9 r12 · fine Pick where both curves are moderate — measured on your data, not copied from a tutorial.

A rule of thumb worth having, then discarding once you have measured: for point data, aim for tens of features per cell, not thousands and not one. For polygon coverings, aim for a few hundred cells per polygon at most; beyond that the covering table costs more than the join saves.

Why a cell covering needs an exact test along the polygon's edge A polygon overlaid on a hexagonal grid. Interior cells lie entirely inside the polygon and need no further test. Edge cells straddle the boundary: some of their area is inside and some outside, so a point in such a cell may or may not be in the polygon. Two points are marked in the same edge cell, one inside the polygon and one outside, showing why cell equality alone cannot decide the case. two points, one cell, different answers interior cells cell match is the final answer edge cells cell match is only a candidate exact test needed on the edge cells alone Classifying cells as interior or edge is what makes the grid join both fast and exactly correct.

Validation and testing

Grid keys are strings or integers, so the tests are ordinary — which is much of their appeal.

yaml
models:
  - name: stg_trip_pings
    columns:
      - name: h3_cell
        tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: "length(h3_cell) = 15"   # H3 r9 string ids

The test that actually matters compares the grid join against the geometry join on a sample, because that is the assertion that the optimisation did not change the answer:

sql
-- tests/assert_grid_join_matches_geometry_join.sql
with grid_result as (
    select p.ping_id, z.zone_id
    from {{ ref('stg_trip_pings') }} p
    join {{ ref('stg_zone_cells') }} z using (h3_cell)
    where p.ping_id in (select ping_id from {{ ref('sample_pings') }})
),
geometry_result as (
    select p.ping_id, z.zone_id
    from {{ ref('stg_trip_pings') }} p
    join {{ ref('stg_zones') }} z on st_intersects(p.geom, z.geom)
    where p.ping_id in (select ping_id from {{ ref('sample_pings') }})
)
select 'grid_only' as side, * from (select * from grid_result except select * from geometry_result) a
union all
select 'geometry_only', * from (select * from geometry_result except select * from grid_result) b

Rows in the geometry_only side are features the grid join lost — almost always boundary cases. Rows in grid_only are features it gained, which happens when a cell overlaps a zone the feature is not actually in. Both are expected in small numbers before the boundary handling is added, and both should be zero afterwards.

Building the covering, and keeping it honest

The polygon side needs a set of cells rather than one, and how that set is built decides whether the join over- or under-counts. Every grid library offers at least two coverings: one that returns cells whose centre falls inside the polygon, and one that returns every cell the polygon touches at all. The first under-covers — a thin sliver along the edge is lost — and the second over-covers, admitting cells that barely graze the shape.

sql
-- models/staging/stg_zone_cells.sql
{{ config(materialized = 'table', cluster_by = ['h3_cell']) }}

select
    z.zone_id,
    c.h3_cell,
    -- an interior cell needs no further test; an edge cell does
    st_contains(z.geom, {{ cell_boundary('c.h3_cell') }}) as is_interior_cell
from {{ ref('stg_zones') }} as z
cross join lateral {{ polygon_to_cells('z.geom', var('grid_resolution')) }} as c(h3_cell)

Carrying is_interior_cell is what makes the two-stage join possible: rows matching an interior cell are final, and only rows matching an edge cell go through ST_Intersects. On typical administrative data, interior cells account for the large majority of matches, so the exact test runs on a small fraction of the volume while the answer stays identical to a pure geometry join.

The cost to watch is the size of the covering table itself. One zone at a fine resolution can produce tens of thousands of cells, and a country’s worth of zones can produce a covering larger than the geometry it came from. Two mitigations are usually enough: build the covering at a coarser resolution than the point index and accept a larger share of edge cells, or compact the covering into mixed resolutions, which every mature H3 and S2 implementation supports — a single coarse cell replaces its seven or four children when all of them are present.

Advanced patterns

Use a coarse cell as a partition key and a fine cell as a join key. Partitioning by r6 and joining on r9 gives pruning and selectivity from the same family of keys; the partitioning side is developed in partitioning geospatial tables with H3 and dbt.

Store the cell, not the geometry, in aggregate marts. A heatmap mart keyed by cell with a count is tiny, indexes trivially, and renders directly — the cell boundary can be reconstructed on demand for display.

Use k-ring expansion for proximity questions. “Everything within roughly 500 m” becomes “cells within k rings of this cell”, which is an IN list against an indexed column rather than a distance computation.

Keep the geometry column. The grid is an accelerator, not a replacement; the moment someone needs an exact area or a precise boundary, the geometry has to be there. Dropping it to save space is a decision people regret at the next requirement.

Watch for the pentagons. H3 has twelve pentagonal cells where the icosahedron’s vertices land. They break the “six neighbours” assumption, and code that hard-codes six will misbehave over them — rare in practice, since they sit mostly in ocean, but worth a comment in any macro that walks neighbours.

Where grid keys stop helping

It is worth naming the cases where the whole approach is the wrong tool, because a grid key is easy to add and hard to remove once models depend on it. Long linear features are the clearest example: a motorway or a river crosses hundreds of cells at any useful resolution, so its covering is large, the join fans out across it, and the deduplication afterwards costs more than a geometry predicate would have. Nearest-neighbour work is the second: k-ring expansion finds candidates but says nothing about ordering, so a query for the five closest depots still needs a distance calculation, and on PostGIS the KNN operator does that better on its own.

The third case is subtler. Where the analysis is about the shapes themselves — overlap area, shared boundary length, containment depth — the grid can only ever be a prefilter, because those quantities are properties of the geometry and cells cannot approximate them. Adding cell keys to such a pipeline buys a faster candidate set and nothing else, which is worth having only if the candidate set was the bottleneck.

Troubleshooting

Symptom Root cause Fix
Grid join returns fewer rows than the geometry join Boundary features whose cell is not in the polygon covering Add the exact test on boundary cells, or use a covering that includes partially-overlapping cells
Grid join returns more rows Covering includes cells that only partly overlap the polygon Verify with ST_Intersects on those cells, or use a containment-only covering for approximate work
Cell ids differ between engines for the same point Different grid systems or resolutions, or projected coordinates fed to the indexer Transform to 4326 before indexing; assert resolution in a test
The covering table is enormous Resolution too fine for large polygons Use a coarser covering plus an exact test, or compact the covering to mixed resolutions
Aggregations look blocky at high zoom Cell resolution coarser than the display scale Add a finer band for detailed views, as with zoom bands in serving
Neighbour logic breaks in a few places H3 pentagons Handle a five-neighbour case explicitly rather than assuming six

FAQ

Does a grid join replace a spatial index?

On engines that have one, no — it complements it, and the geometry predicate with a GiST index is usually simpler. On engines without one, it is the closest available substitute, which is why grid keys matter far more on BigQuery, Snowflake and Redshift than on PostGIS. The comparison is in warehouse-native GIS adapters.

Is it safe to join on cell id alone?

Only when an approximate answer is acceptable — a heatmap, a coarse rollup, a first-pass candidate set. For anything a person will reconcile against another number, follow the cell match with an exact geometry test on the boundary cells. The cost of that second step is small because it applies to a fraction of the rows.

How do I handle features that span many cells?

Polygons get a covering — a set of cell ids — and the join fans out across it, which needs the same deduplication as subdivision does in the geometry world. Lines are the awkward case: a long line crosses many cells, and a covering can be large. Where lines dominate, geometry predicates usually remain the better tool.

Which resolution should I store if I am not sure yet?

Store two: one fine enough for the finest join you expect, and one coarse for partitioning. They cost a few bytes per row and adding a resolution later means rewriting the table. What you should not do is store five “just in case” — each one is a column that must stay correct through every transformation.

Do grid keys work with incremental models?

Well, and better than geometry does. A cell id is a stable, hashable value, so it makes a good component of a unique key and a good partition column, and an incremental model can select “cells touched by the new slice” cheaply. That is a large part of why grid indexing shows up in high-volume pipelines.

Up: Part of Advanced Spatial Macros & UDF Patterns.

Explore this section