Choosing between H3, S2 and geohash in dbt
This page decides which grid a dbt project should adopt: what each system is actually good at, which differences change results rather than aesthetics, and a short benchmark that answers the question on your own data instead of in the abstract.
When to use this approach
- You are adding grid keys for the first time and the choice will be hard to reverse — every key, partition and incremental model inherits it.
- A second team wants a different grid. Two grids in one warehouse means every cross-team join goes back through geometry, which defeats the purpose.
- Your engine supports more than one. Where only one is available the decision is made; the trade-offs still matter for knowing what you have given up. Engine coverage is summarized in discrete global grid macros.
Prerequisites
- A representative sample of your own data — the decision depends on the latitude band and the feature mix.
- At least one grid implementation installed, for the benchmark.
- Agreement on the primary workload: aggregation, proximity, partitioning, or interchange. They favour different grids.
Step-by-step instructions
1. Rule out the systems your workload cannot use
Three properties eliminate candidates faster than any benchmark.
Need prefix truncation (a coarse key derived from a fine one by string/bit ops)?
→ S2 or geohash. H3 resolutions do not nest, so a parent must be computed, not truncated.
Need uniform neighbour distance (proximity, k-rings, diffusion, routing heuristics)?
→ H3. Square and rectangular cells have edge neighbours and corner neighbours at
different distances, which biases every "nearby" calculation.
Need zero dependencies (an interchange key any tool can parse)?
→ geohash. It is a base-32 string computable in ten lines anywhere.
2. Measure area distortion at your latitudes
Geohash cells are defined in degrees, so a cell’s ground area shrinks as latitude rises — dramatically. If your data spans a wide latitude range and you intend to aggregate by cell, that distortion is a bias in every count per unit area.
-- analyses/cell_area_by_latitude.sql
with lats as (select generate_series(0, 70, 10) as lat)
select
lat,
round(st_area(st_transform(
st_setsrid(st_makeenvelope(0, lat, 0.0439, lat + 0.0219), 4326), 3035)
)::numeric / 1000000, 3) as geohash6_area_km2
from lats
lat | geohash6_area_km2
-----+-------------------
0 | 1.190
30 | 1.031
50 | 0.766
70 | 0.408
Verify against the equivalent H3 or S2 cell at a comparable resolution, whose area varies by a factor well under two across the same range rather than by a factor of three.
3. Benchmark the join you actually run
Abstract properties are settled; the remaining question is throughput on your data and engine.
-- analyses/grid_join_benchmark.sql
{% set variants = [
{'name': 'h3_r9', 'expr': "h3_cell"},
{'name': 'geohash_7', 'expr': "st_geohash(geom, 7)"}
] %}
{% for v in variants %}
select
'{{ v.name }}' as variant,
count(*) as matched_rows,
count(distinct {{ v.expr }}) as distinct_cells
from {{ ref('stg_trip_pings') }} p
join {{ ref('stg_zone_cells_' ~ v.name) }} z on {{ v.expr }} = z.cell
{% if not loop.last %}union all{% endif %}
{% endfor %}
Run each variant three times, take the median, and record the covering table’s row count alongside — a grid that joins slightly faster but needs twice the covering rows is usually the worse choice overall.
Verify that both variants return the same matched rows against a geometry-only baseline. A faster grid that loses boundary features is not faster, it is wrong.
4. Commit, and make the choice visible
# dbt_project.yml
vars:
grid_system: h3
grid_resolution: 9
grid_resolution_coarse: 6
Record the reasoning where the next person will look — in the topic’s model description rather than in a wiki that will drift:
models:
- name: stg_trip_pings
description: >
Ping-level staging. Grid: H3 r9 (var grid_system). Chosen for uniform
neighbour distance, since proximity aggregation is the primary workload
and hexagon rings approximate a radius without diagonal bias. S2 was the
runner-up; rejected because prefix truncation is not needed here and
BigQuery is not a target. Changing the grid or resolution is a breaking
change: every key, partition and incremental unique key derives from it.
Configuration reference
| Consideration | H3 | S2 | Geohash |
|---|---|---|---|
| Typical id form | 15-character hex string or int64 | int64 | base-32 string |
| Resolution scale | 0–15, ~7× area per step | 0–30, 4× area per step | length 1–12, alternating 8×/4× |
| Parent from child | function call | bit shift | string truncation |
| Ring neighbours | 6, equidistant | 8, two distances | 8, two distances |
| Area stability by latitude | good | good | poor |
| Library maturity in SQL engines | high and growing | high on BigQuery | universal, trivial |
Gotchas & edge cases
- H3 resolutions do not nest exactly. A parent cell does not contain its children’s full area, so “roll up r9 to r6 by taking the parent” is approximate. Where exact hierarchical containment is required, S2 is the honest choice.
- Geohash cells are not square. They alternate between 8:1 and 4:1 subdivisions, so a length-6 cell and a length-7 cell have very different aspect ratios — a surprise when rendering bins.
- S2 cell ids are signed 64-bit and can be negative in some encodings; string-typed columns avoid a class of client bugs.
- Two grids means no shared keys. If another team standardized already, matching them usually beats being right in isolation.
- Benchmarks on a city extent mislead for continent-scale data, because area distortion and covering size both scale with the latitude range.
FAQ
Can I store more than one grid key?
Technically yes, and occasionally it is right — a geohash for interchange alongside H3 for computation, for instance. But each key is a column that every transformation must keep correct, and two keys mean two chances for a join to use the wrong one. Add the second only when an external consumer requires it.
Does the grid choice affect correctness or only performance?
Both, in different ways. Performance is the obvious axis. Correctness enters through aggregation: counts per cell are counts per unit area only if cells have comparable areas, so a geohash heatmap spanning many latitudes is systematically biased toward the poles in a way an H3 one is not.
What resolution corresponds between the systems?
Roughly: H3 r8 ≈ S2 level 12 ≈ geohash 6 at mid-latitudes, and H3 r9 ≈ S2 level 13 ≈ geohash 7. These are approximations that drift with latitude, so calibrate on your own extent rather than treating them as conversions.
Is it worth switching an existing project?
Only for a specific, measured problem — proximity results biased by square cells, or a rollup that needs true nesting. The migration touches every key, partition and incremental model, and the tests in writing H3 index macros for dbt models exist partly to make the scale of that change visible before it starts.
Related
- Discrete Global Grid Macros — the topic overview and the boundary-correctness rules.
- Writing H3 Index Macros for dbt Models — implementing the choice.
- Running dbt Spatial Models on BigQuery GIS — the engine where S2 is native.
Up: Part of Discrete Global Grid Macros.