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)
Read the ring, write the cellA cell and its six neighbours are read together so that edge effects are handled. Only the central cell's results are written, so neighbouring partitions never conflict.one task, seven cells read, one cell writtenreadreadreadreadwrittenreadreadwhy a ring rather than a bounding boxsix neighbours from one callevery neighbour the same distance awayno corner cases near the polesownership stays with the centreRead-wide, write-narrow: the ring supplies context and only one cell owns the result.
Because ownership is unambiguous, two adjacent cells can run concurrently without any coordination between them.

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
The hierarchy, and why it is approximateEach cell has about seven children at the next resolution. Hexagons do not tile hierarchically exactly, so children overlap parent boundaries slightly and containment is approximate.r6 cell36 km²~7 r7 children5 km² each~49 r8 children0.7 km² each“About” seven, not exactly seven: hexagons do not subdivide into hexagons cleanly, so a childcan straddle a parent boundary. Aggregating child counts up a level is approximate.
The hierarchy is excellent for indexing and navigation and unsuitable for exact area arithmetic, which is the one thing people expect from it.

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.

Cell area against latitudeA degree-based grid's cells shrink towards the poles, halving in area by sixty degrees. H3 cells stay within a few per cent of each other everywhere.area45°70°degree gridH3, near-equal everywhereEqual area matters for density statistics; it does not make the work per cell equal, because density does not.
The green line is why H3 is preferred for anything reported per unit area, and it says nothing at all about the run’s balance.

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.

Partitioning Strategies for Spatial Workloads