H3 vs geohash cell indexing for zone lookups

A cell index turns a position into a short key that any store can look up — a string in a dictionary, a row in SQLite, a topic segment in a message. That property is why it survives on gateways even though a packed R-tree is faster in-process: the key can leave the process. This guide compares the two schemes a field deployment realistically uses, within the envelope described in spatial indexing on constrained devices and the wider Local Spatial Processing Patterns guide.

The two schemes, and what actually differs

Geohash interleaves the bits of latitude and longitude and base-32 encodes the result. Each character adds five bits, alternating between the two axes, so cells are rectangles whose aspect ratio flips with every character and whose ground size shrinks toward the poles. The encoder is thirty lines of arithmetic with no tables and no dependencies.

H3 tiles the sphere with hexagons on an icosahedral projection, indexed by a 64-bit integer that encodes resolution and a path down the hierarchy. Hexagons have one property that matters operationally: every neighbour is the same distance away, which removes the diagonal-versus-orthogonal asymmetry that makes geohash proximity logic awkward. The cost is a real library — the reference implementation is several thousand lines of C — and twelve pentagons on the globe where the neighbour count is five rather than six.

The choice on a gateway usually comes down to three questions: does the index key have to cross a process or a wire; is proximity logic (rings, neighbours, distance-bounded searches) part of the workload; and can the image carry a compiled dependency.

Neighbour geometry in a geohash grid and an H3 hex grid Left, a geohash cell with its eight neighbours: four orthogonal neighbours share an edge at one distance, four diagonal neighbours share only a corner at 1.41 times that distance, and two of the eight require a prefix change because the cell sits on a base-32 boundary. Right, an H3 cell with six neighbours, all sharing an edge at the same distance, all reachable through one library call. A note records that twelve pentagon cells worldwide have five neighbours instead of six. Eight neighbours at two distances, or six at one u33d edge neighbours at 1× · corner neighbours at 1.41× 2 of 8 need a prefix change 8a2a… six neighbours, all at the same distance 12 pentagons worldwide
Uniform neighbour distance is the operational difference. Everything a fleet does with rings, buffers and "within N cells" gets simpler on the right.

A dependency-free geohash encoder

If the deployment can live with rectangles, the whole scheme fits on one screen and adds nothing to the image:

# geohash.py — encode/decode with no dependencies, no tables beyond the alphabet.
# Pure integer and float arithmetic; safe to call from a hot loop.
_B32 = "0123456789bcdefghjkmnpqrstuvwxyz"


def encode(lat: float, lon: float, precision: int = 6) -> str:
    lat_lo, lat_hi = -90.0, 90.0
    lon_lo, lon_hi = -180.0, 180.0
    out = []
    bits = 0
    bit_count = 0
    even = True                       # even bits split longitude
    while len(out) < precision:
        if even:
            mid = (lon_lo + lon_hi) / 2
            if lon > mid:
                bits = (bits << 1) | 1
                lon_lo = mid
            else:
                bits <<= 1
                lon_hi = mid
        else:
            mid = (lat_lo + lat_hi) / 2
            if lat > mid:
                bits = (bits << 1) | 1
                lat_lo = mid
            else:
                bits <<= 1
                lat_hi = mid
        even = not even
        bit_count += 1
        if bit_count == 5:
            out.append(_B32[bits])
            bits = 0
            bit_count = 0
    return "".join(out)


def neighbours(gh: str) -> list[str]:
    """The eight surrounding cells, computed by re-encoding offset centres.
    Slower than bit manipulation and immune to the prefix-boundary bugs that
    hand-rolled neighbour tables are famous for."""
    lat, lon, dlat, dlon = decode_with_error(gh)
    out = []
    for dy in (1, 0, -1):
        for dx in (-1, 0, 1):
            if dx == 0 and dy == 0:
                continue
            out.append(encode(lat + dy * dlat * 2, lon + dx * dlon * 2, len(gh)))
    return out


def decode_with_error(gh: str):
    """Cell centre and half-extents — the inverse of encode."""
    lat_lo, lat_hi = -90.0, 90.0
    lon_lo, lon_hi = -180.0, 180.0
    even = True
    for ch in gh:
        idx = _B32.index(ch)
        for shift in (4, 3, 2, 1, 0):
            bit = (idx >> shift) & 1
            if even:
                mid = (lon_lo + lon_hi) / 2
                if bit:
                    lon_lo = mid
                else:
                    lon_hi = mid
            else:
                mid = (lat_lo + lat_hi) / 2
                if bit:
                    lat_lo = mid
                else:
                    lat_hi = mid
            even = not even
    return ((lat_lo + lat_hi) / 2, (lon_lo + lon_hi) / 2,
            (lat_hi - lat_lo) / 2, (lon_hi - lon_lo) / 2)

Computing neighbours by re-encoding offset centres rather than by manipulating the base-32 string is a deliberate trade: it costs eight encodes instead of eight increments, and it is correct across every prefix boundary and at the antimeridian, which the string-manipulation version famously is not.

Constraint validation

Constraint Expected impact How each scheme behaves
Image size A compiled dependency has to be cross-built and audited Geohash: 60 lines of Python, nothing to build. H3: a C library plus bindings, roughly 380 KB on aarch64
RAM Per-cell dictionaries scale with populated cells Identical for both; the key type is 6–12 bytes either way
CPU Encoding runs per fix Geohash: ~2.4 µs in pure Python, ~0.2 µs through the C path. H3: ~0.4 µs via the library
Correctness at boundaries Neighbour logic decides whether crossings are seen Geohash: 8 neighbours, 2 distances, prefix changes at boundaries. H3: 6 neighbours, uniform, one call
Portability of the key The key may be joined elsewhere Both are stable, documented and language-neutral; geohash is human-sortable, H3 is not
Polar behaviour Fleets operating at high latitude Geohash cells narrow toward the poles; H3 keeps near-constant area everywhere

The third row deserves a caveat: the CPU figures only matter at rates above a few thousand fixes per second, which almost no gateway reaches. In practice both are free, and the row that decides the outcome is the first or the fourth.

Gotchas and edge cases

  • A cell is not a zone. Both schemes answer “which cell is this point in”, never “which zone contains it”. The cell is a bucket key; the exact predicate still has to run, exactly as in the funnel described in on-device geometry filtering.
  • One lookup is a bug. A fix near a cell edge belongs to a zone indexed in the neighbouring cell. Probing the centre cell plus its ring is mandatory in both schemes; the difference is that H3’s ring is six cells and geohash’s is eight.
  • Precision is not accuracy. A geohash 7 cell is about 153 m across; encoding a fix to precision 9 does not make the position better, it makes the bucket smaller and the neighbour probe more expensive.
  • Geohash sorts lexicographically; H3 does not. If the design relies on range scans over a sorted key store — WHERE gh BETWEEN 'u33d' AND 'u33e' — geohash gives that for free and H3 does not, because its integer ordering follows the hierarchy rather than space.
  • H3 pentagons break the uniform assumption. Twelve cells worldwide have five neighbours. Code that assumes six will index one neighbour short there, silently. The library exposes a predicate for it; call it once during ring construction rather than trusting the geometry.
  • Do not mix resolutions in one index. A store keyed on cells at two resolutions cannot answer a lookup without probing both, which doubles the work and usually indicates that two separate indexes were wanted.
Probe cost for a point query at increasing search radius Number of cells that must be probed to guarantee coverage of a search radius. For geohash, the centre plus one ring is nine cells, plus two rings is twenty-five, plus three is forty-nine. For H3, the centre plus one ring is seven cells, plus two is nineteen, plus three is thirty-seven. Because H3 rings are uniform in distance, one ring genuinely covers the cell edge length, whereas a geohash ring covers the orthogonal direction fully and the diagonal only to 71 percent, so a conservative geohash search often needs a further ring. Cells probed to guarantee a radius — and the extra ring geohash often needs 1 ring2 rings3 rings geohash H3 9 cells25 cells49 cells 71937 A geohash ring covers the orthogonal direction fully and the diagonal to only 71% — so a conservative search adds a ring the hex grid does not need. At the same effective radius the real comparison is often 25 cells against 7.
The probe count is where the hexagon earns its dependency: uniform neighbour distance means a ring means what it says.

Integrating with the reference store

Whichever scheme is chosen, the shape of the integration is the same — a dictionary from cell key to the feature ids whose envelope touches that cell, built once at load and treated as immutable:

def build_cell_index(features, encode_cell, precision):
    """cell key → feature ids. Built once; replaced wholesale on layer change."""
    index: dict[str, list[int]] = {}
    for fid, geom in enumerate(features):
        for lat, lon in geom.envelope_sample_points(precision):
            index.setdefault(encode_cell(lat, lon, precision), []).append(fid)
    return {k: tuple(sorted(set(v))) for k, v in index.items()}


def candidates(index, encode_cell, neighbour_fn, lat, lon, precision):
    """Centre cell plus its ring — never the centre cell alone."""
    keys = [encode_cell(lat, lon, precision)]
    keys.extend(neighbour_fn(keys[0]))
    seen = set()
    for k in keys:
        seen.update(index.get(k, ()))
    return seen

Feed the result into the exact predicate as usual. When the same keys are used to shard work across processes or to route messages, keep the resolution in the key’s metadata rather than inferring it from length — a consumer that guesses wrong produces an empty join with no error, which is the failure mode this whole section exists to avoid.

Picking a resolution from the zone size

Resolution is the parameter that decides whether the scheme works, and it follows from the features rather than from the query. Choose the resolution whose cell is roughly the size of the median zone: larger cells put many zones in one bucket and turn a lookup into a scan, smaller cells spread one zone across many buckets and inflate the index without improving selectivity.

For geohash, precision 5 gives cells of about 4.9 by 4.9 km, precision 6 about 1.2 km by 610 m, and precision 7 about 153 m square. For H3, resolution 7 has an average edge length near 1.2 km, resolution 8 near 460 m and resolution 9 near 174 m. A yard-scale zone set — loading bays, parking areas, buildings — lands on geohash 7 or H3 9; a regional set of administrative or operational areas lands on geohash 5 or H3 7.

Cell size against zone size for two common deployments Two zone sets with their matching resolutions. A yard deployment with a median zone diagonal of 140 metres matches geohash precision 7 at 153 metres or H3 resolution 9 at 174 metres, giving one to three zones per bucket. A regional deployment with a median zone diagonal of 3.4 kilometres matches geohash 5 at 4.9 kilometres or H3 7 at 1.2 kilometres, giving two to six zones per bucket. A mismatched pairing — regional zones at geohash 7 — spreads one zone across 480 buckets. Match the cell to the zone, not to the query yard scale · median zone 140 m geohash 7 (153 m) · H3 res 9 (174 m) 1–3 zones per bucket loading bays, buildings, parking areas regional · median zone 3.4 km geohash 5 (4.9 km) · H3 res 7 (1.2 km) 2–6 zones per bucket operational areas, service territories mismatched · regional zones at geohash 7 one zone spans 480 buckets index inflates, selectivity does not improve, and every rebuild costs proportionally more
The mismatched row is the common failure: a resolution chosen for the precision it sounds like rather than for the features it has to separate.

Where zone sizes span orders of magnitude, use two indexes at two resolutions rather than a compromise resolution, and query the one whose scale matches the question. A compromise resolution is worse than either for both.