Migrating a Topic to a New H3 Resolution
Carry the resolution in the envelope so every key is self-describing, keep consumer state under both schemes during the overlap, and cut over one parent cell at a time — a resolution change replaces every partition key simultaneously, which orphans all per-cell state and is a data migration rather than a configuration change.
This guide sits under Spatial Partitioning Strategies, within Core Event Fundamentals & Architecture. It is what to do once Choosing an H3 Resolution from Measured Traffic says the current resolution has aged out.
When to use this pattern
- Measured traffic says the hottest cell no longer fits a consumer, or that cardinality has grown past what the monitoring stack can carry.
- Consumers hold per-cell state — counters, last-seen positions, deduplication sets, tile version maps.
- The stream cannot be paused, which is the case that makes this hard; a stream that can be drained needs none of this.
What a resolution change actually breaks
Complete runnable implementation
import h3
from prometheus_client import Counter
MIGRATED = Counter("h3_state_migrated_total", "Parent cells folded into children")
OLD_RESOLUTION = 7
NEW_RESOLUTION = 8
def partition_key(lat: float, lon: float, resolution: int) -> bytes:
"""The key, and the resolution that produced it, together.
Encoding the resolution means a consumer reading a key can tell which
scheme it belongs to without consulting configuration that may have
changed since the event was written.
"""
return f"{resolution}:{h3.latlng_to_cell(lat, lon, resolution)}".encode()
def parse_key(key: bytes) -> tuple[int, str]:
resolution, cell = key.decode().split(":", 1)
return int(resolution), cell
class DualResolutionState:
"""Per-cell state that can answer under either scheme during the overlap."""
def __init__(self, store, cutover: set[str]) -> None:
self._store = store
# Parent cells (at OLD_RESOLUTION) already migrated. Cutting over by
# parent cell means a parent and its children are never both live.
self._cutover = cutover
def resolution_for(self, lat: float, lon: float) -> int:
parent = h3.latlng_to_cell(lat, lon, OLD_RESOLUTION)
return NEW_RESOLUTION if parent in self._cutover else OLD_RESOLUTION
async def migrate_parent(self, parent: str) -> None:
"""Fold one parent's state into its children, then mark it cut over.
Runs BEFORE any event is keyed to a child, so no consumer ever sees
an empty child. Order matters more than speed here.
"""
state = await self._store.get(f"{OLD_RESOLUTION}:{parent}")
if state is None:
self._cutover.add(parent)
return
children = h3.cell_to_children(parent, NEW_RESOLUTION)
# Counts must be SPLIT, not copied: copying a parent's 2.1M count into
# seven children invents 12.6M events. Splitting is also approximate —
# which is why a count that must be exact should be recomputed rather
# than folded, and the metric below exists to make the choice visible.
share = state.get("event_count", 0) // len(children)
for child in children:
await self._store.set(f"{NEW_RESOLUTION}:{child}", {
"event_count": share,
"last_seen": state.get("last_seen"), # copied: per-area fact
"dedup_keys": state.get("dedup_keys", []), # copied: a superset is safe
"migrated_from": parent,
})
self._cutover.add(parent)
MIGRATED.inc()
# Delete the parent only after the children are durable. The reverse
# order loses everything if the process dies between the two.
await self._store.delete(f"{OLD_RESOLUTION}:{parent}")
Copying the deduplication set into every child is deliberately over-inclusive: a child inherits keys for events that happened in its siblings, which can only cause extra suppression, never extra admission. Erring in the other direction admits duplicates, so the asymmetry decides the design.
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
| Key format | bytes |
"<resolution>:<cell>" — self-describing, so a key needs no config to read |
— |
| Cutover unit | H3 cell | A parent at OLD_RESOLUTION; never a bounding box or a country name |
— |
cell_to_children |
call | Exactly 7 children per step, except around the 12 pentagons | — |
| Count fold | strategy | Split, never copy; copying multiplies totals by the child count | split |
| Dedup fold | strategy | Copy to every child — over-suppression is safe, over-admission is not | copy |
| Delete order | — | Children durable before the parent is removed | — |
Gotchas and spatial edge cases
-
H3 identifiers are not hierarchical strings. A child does not begin with its parent’s identifier, so no prefix scan, no
LIKEquery and no key-range operation will find related cells. Every relationship needscell_to_parentorcell_to_children, which means the migration cannot be done in the database with a wildcard. -
Pentagons do not have seven children. Twelve cells per resolution are pentagonal and produce six children instead of seven. Code that hard-codes seven — to split a count, to size a batch, to assert a test — is wrong exactly twelve times per resolution, in places that are usually ocean and occasionally not.
-
Splitting a count is an estimate and should be labelled as one. Seven equal shares of a parent’s total is almost certainly not how the events were distributed. If a count feeds billing or reporting, recompute it from the source rather than folding it, and use the fold only for state that is merely an optimisation.
-
A moving asset can cross the cutover boundary mid-journey. With region-by-region rollout, a vehicle driving from a migrated region into an unmigrated one produces events keyed at resolution 8 then resolution 7. Consumers must handle both, which is what the resolution prefix is for — and it is why the prefix cannot be dropped the moment the last region cuts over.
-
Partition count and cell count are different numbers. Changing the resolution does not change how many Kafka partitions exist; it changes how keys hash into them. A finer resolution spreads load better only if the partition count is high enough to receive it, so check both together against Detecting Partition Skew in H3-Sharded Streams.
-
Events already in the log keep their old keys forever. A replay after the migration reads resolution 7 keys, so the old reader path must survive as long as the retention window and any replayable archive — the same rule as Migrating a Spatial Stream Between Schema Versions.
Verification
import h3
import pytest
PARENT = h3.latlng_to_cell(53.5400, 9.9300, 7)
@pytest.mark.asyncio
async def test_children_inherit_before_any_event_arrives(store):
"""No consumer may ever see an empty child cell."""
await store.set(f"7:{PARENT}", {"event_count": 2_100_000,
"last_seen": "2026-08-08T12:04:31Z",
"dedup_keys": ["a", "b", "c"]})
state = DualResolutionState(store, cutover=set())
await state.migrate_parent(PARENT)
children = h3.cell_to_children(PARENT, 8)
for child in children:
loaded = await store.get(f"8:{child}")
assert loaded is not None
assert loaded["dedup_keys"] == ["a", "b", "c"]
@pytest.mark.asyncio
async def test_counts_are_split_not_copied(store):
"""Copying would turn 2.1M events into 14.7M."""
await store.set(f"7:{PARENT}", {"event_count": 2_100_000})
state = DualResolutionState(store, cutover=set())
await state.migrate_parent(PARENT)
children = h3.cell_to_children(PARENT, 8)
total = sum((await store.get(f"8:{c}"))["event_count"] for c in children)
assert total <= 2_100_000
def test_pentagon_children_are_not_assumed_to_be_seven():
"""Twelve cells per resolution have six children, not seven."""
pentagon = next(c for c in h3.get_pentagons(7))
assert len(h3.cell_to_children(pentagon, 8)) == 6
@pytest.mark.asyncio
async def test_uncut_region_still_keys_at_the_old_resolution(store):
"""A vehicle crossing the boundary must produce both key shapes."""
state = DualResolutionState(store, cutover={PARENT})
assert state.resolution_for(53.5400, 9.9300) == 8 # migrated region
assert state.resolution_for(48.1372, 11.5756) == 7 # not yet
The count test uses <= rather than == because integer division loses a remainder — which is itself worth noticing, since the missing events are exactly the kind of small permanent discrepancy that shows up in a reconciliation report months later.
Related
- Spatial Partitioning Strategies — the topic this guide belongs to
- Choosing an H3 Resolution from Measured Traffic — the measurement that decides the target resolution
- Partitioning Kafka Topics by H3 Cell — how the key reaches a partition, and why partition count is a separate lever
- Detecting Partition Skew in H3-Sharded Streams — confirming the migration achieved what it was for