Partitioning Vector Data by H3 Cell
H3 gives a vector pipeline three things a lat-lon grid does not: cells of near-equal area anywhere on Earth, a hierarchy where a cell’s parent is derivable arithmetically, and neighbours that are cheap to enumerate — which matters because most vector operations need a buffer. What it does not give is uniform work per cell, because density is a property of the world rather than of the tessellation. Used with that expectation, it is an excellent partition key; used as a cure for skew, it disappoints.
When to Use This Pattern
- Work is point- or small-polygon-based — sensor readings, addresses, incidents, parcels.
- Neighbour access matters, because the operation needs a buffer or a k-ring.
- Analysis spans latitudes where a degree-based grid’s cells vary in area by a factor of two or more.
- A hierarchy is useful, so the same data can be summarised at several resolutions from one key.
Complete Working Example
Assign a cell per feature, index it, and partition on it.
from __future__ import annotations
import h3
from shapely.geometry import shape
from prefect import flow, task, get_run_logger
RESOLUTION = 7 # ~5 km² per cell; see the table below
BUFFER_RINGS = 1 # how far neighbouring work reaches
def cell_for(geometry) -> str:
"""One cell per feature, from a point guaranteed to be inside it."""
p = shape(geometry).representative_point()
return h3.latlng_to_cell(p.y, p.x, RESOLUTION)
def cells_touching(geometry) -> set[str]:
"""Every cell a polygon overlaps — for features larger than a cell."""
geo = shape(geometry).__geo_interface__
return set(h3.geo_to_cells(geo, RESOLUTION))
In PostGIS the cell becomes an indexed column, and the partition is a WHERE clause:
ALTER TABLE incidents ADD COLUMN h3_r7 text
GENERATED ALWAYS AS (h3_lat_lng_to_cell(geom::point, 7)::text) STORED;
CREATE INDEX incidents_h3_r7_idx ON incidents (h3_r7);
-- The partition's own features, plus one ring of neighbours for edge effects.
-- The buffer is read and not written, so neighbouring partitions cannot conflict.
SELECT *
FROM incidents
WHERE h3_r7 = ANY(%(cell_and_ring)s);
and the flow maps over cells rather than over a bounding box:
@task(retries=2, timeout_seconds=900, tags=["cell"])
def process_cell(cell: str) -> CellResult:
ring = h3.grid_disk(cell, BUFFER_RINGS) # the cell plus its neighbours
features = load_features(ring)
result = analyse(features)
# Write only what belongs to this cell; the ring was context, not ownership.
return result.restricted_to(cell)
@flow(name="cell-analysis")
def analyse_area(boundary_geojson: dict) -> int:
cells = sorted(h3.geo_to_cells(boundary_geojson, RESOLUTION))
get_run_logger().info("partitions: %d cells at r%d", len(cells), RESOLUTION)
states = process_cell.map(cells, return_state=True)
return sum(s.is_completed() for s in states)
Hexagons have a practical advantage over squares here that is easy to overlook: every neighbour is the same distance away. A square grid has four edge neighbours and four corner neighbours at different distances, so a buffer expressed as “one ring” means two different things depending on direction, and any distance-weighted operation has to special-case it. With hexagons a ring is a ring, which makes grid_disk(cell, k) a genuinely uniform buffer and removes a class of subtle bias from focal operations.
Parameter & Option Reference
| Resolution | Approx. cell area | Approx. edge | Typical use |
|---|---|---|---|
| 5 | 250 km² | 8 km | National summaries, coarse partitions |
| 6 | 36 km² | 3 km | Regional analysis, a workable default partition |
| 7 | 5 km² | 1.2 km | Urban-scale partitions; the example above |
| 8 | 0.7 km² | 460 m | Neighbourhood analytics |
| 9 | 0.1 km² | 174 m | Street-level aggregation |
| 10 | 0.015 km² | 66 m | Approaching the point where a cell identifies a building |
Verification & Testing
import h3
import pytest
def test_cell_assignment_is_stable() -> None:
a = cell_for(POINT_GEOJSON)
assert a == cell_for(POINT_GEOJSON)
assert h3.get_resolution(a) == RESOLUTION
def test_ring_covers_every_neighbour() -> None:
cell = h3.latlng_to_cell(51.5074, -0.1278, RESOLUTION)
ring = h3.grid_disk(cell, 1)
assert len(ring) == 7 or len(ring) == 6, "a pentagon cell has five neighbours"
def test_only_the_owning_cell_is_written(tmp_db) -> None:
result = process_cell.fn(CELL)
written = {r.h3_r7 for r in result.rows}
assert written == {CELL}, f"wrote into neighbouring cells: {written - {CELL}}"
def test_large_polygons_use_all_touching() -> None:
# A motorway spans many cells; a representative point would assign it to one.
cells = cells_touching(MOTORWAY_GEOJSON)
assert len(cells) > 20, "a linear feature was collapsed to a single cell"
def test_pentagon_cells_are_handled() -> None:
# Twelve pentagons exist at every resolution. They have five neighbours, not six.
pent = h3.get_pentagons(RESOLUTION)[0]
assert len(h3.grid_disk(pent, 1)) == 6
assert process_cell.fn(pent) is not None
The pentagon test is the H3-specific trap, and it is genuine rather than academic. Twelve cells at every resolution are pentagons rather than hexagons — an unavoidable consequence of tiling a sphere — and they have five neighbours, slightly different area, and distorted distance relationships. Code that assumes six neighbours will produce an index error or a silently short buffer at exactly twelve places on Earth, and if none of those places is in your service area you will never see it until the service area expands.
The read-wide, write-narrow arrangement in the example is worth adopting as a rule rather than as a detail of this pattern, because it is what makes spatial partitions safely concurrent. Any operation that considers neighbouring features — a density surface, a nearest-neighbour join, a clustering pass — needs data from outside its partition to produce correct results at the edge, and the obvious fix of letting each partition write wherever its analysis reaches creates overlapping writers whose output depends on execution order. Restricting every partition’s writes to its own cell keeps the results deterministic, and the only cost is reading each feature once per neighbouring cell as well as its own — about seven times over at one ring, which is why the ring size deserves to be as small as the operation genuinely needs.
That multiplication is also the reason to prefer a coarser resolution when a buffer is required. At resolution 7 with one ring, a pipeline reads roughly seven times the data it writes; at resolution 5 the same physical buffer distance is a fraction of a cell and the ring may not be needed at all. The trade is against partition count and skew, and the arithmetic is worth doing explicitly rather than settling on a resolution because it produced a convenient number of cells.
Common Pitfalls
- Expecting H3 to fix skew. Equal-area cells still contain wildly unequal amounts of data; see handling skewed partitions.
- Assuming six neighbours. Twelve pentagons per resolution have five, and the failure is positional and rare.
- Assigning large features by representative point. A motorway becomes one cell’s problem, and the cells it actually crosses see nothing.
- Treating the hierarchy as exact. Children can straddle parent boundaries, so summing child counts to a parent is approximate.
- Writing into the buffer ring. Ownership must stay with one cell, or adjacent partitions conflict and results depend on order.
- Choosing a resolution from cell size alone. Choose it so a typical cell’s work takes a few minutes; area is a means, not the goal.
Frequently Asked Questions
H3, S2 or geohash?
H3 for anything needing neighbours or equal area — its hexagons make buffers uniform. S2 where exact hierarchical containment matters, since its squares subdivide cleanly. Geohash where a string prefix must be a spatial prefix and nothing more is needed; it is the simplest and the most distorted.
What resolution should I start at?
Profile it, as in partitioning strategies for spatial workloads. As a starting point, resolution 6 or 7 gives partition counts in the hundreds to low thousands for a country-sized area, which is a comfortable fan-out.
How do I handle features larger than a cell?
Decide whether the feature belongs to the cell containing its representative point or to every cell it touches, and apply the rule everywhere. Point-based is right for ownership and aggregation; all-touching is right for spatial joins and rendering. Mixing them produces totals that disagree.
Does PostGIS support H3 natively?
Through the h3-pg extension, which provides indexing functions and cell arithmetic in SQL. Without it, compute the cell in Python on write and store it as an indexed text column — which is what the example does and is perfectly adequate.
Is the cell identifier stable across library versions?
Yes — the index is defined by the specification rather than by an implementation, so a cell computed by one version is the same cell in another, and cells computed years apart remain comparable. That stability is what makes it safe to store the identifier in a ledger and in object-storage paths, which is not true of every spatial key people reach for.
Can cells be used as the object-storage prefix?
Yes, and the hierarchy makes it work well: r4/r6/r8/cell.parquet gives cheap prefix listing at three levels and lets a lifecycle rule or a credential policy target a region. See scoping object storage credentials per tile job.
Related
- Partitioning strategies for spatial workloads — where hierarchical cells fit among the alternatives
- Handling skewed partitions in spatial workloads — what H3 does not solve
- Choosing tile sizes for raster partitioning — the raster equivalent of this decision
- Streaming large GeoPackage loads into PostGIS — where the cell column is populated
- Branching workflows based on spatial extent — routing by the same key