All sections

H3 To Exact Polygon

Rendering an H3 cell set as its true GeoJSON boundary, with no radius approximation and no area lost or gained

exactstableh36 min read
Source geometry
h3_cell_set

Purpose

Some execution targets accept an arbitrary polygon rather than a circle or a platform-native id: a DSP with a polygon-upload geofence product, an internal reporting join, a map render, or an audit artifact that must show exactly what an H3 cell set covers. This page converts an H3 cell set to its true boundary geometry — no radius is fitted, no area is added or removed. It is the reference every circle-approximation page in this section is measured against.

Source geometry and destination geometry

Source geometry is an h3_cell_set: one or more H3 cell ids at a stated resolution. The destination is not one of the catalogued geometry types in this knowledge base — it is a GeoJSON Polygon or MultiPolygon Feature, built by dissolving the shared edges of adjacent cells into a single outer ring (with inner rings for any enclosed holes). Because that output is the exact cell footprint rather than an approximating shape, it does not carry a separate destination-geometry id; it is described in prose here rather than tagged.

Exactness class

This conversion is exact: the resulting polygon's boundary coincides with the true edges of the input cells to within floating-point precision. Unlike every other conversion in this section, there is no coverage/overreach trade-off to report — coverage_ratio = 1, overreach_ratio = 0, underreach_ratio = 0, jaccard = 1, by construction, provided the output is not simplified afterward.

Containment rule and boundary behavior

The rule is definitional: the output polygon contains exactly the union of the input cells' true spherical boundaries, no more and no less. Two mechanics make this correct rather than approximate:

Ring dissolve
Shared edges between adjacent cells in the input set cancel out; only edges on the outer perimeter (or around an interior hole left by a missing cell) remain in the output rings.
Ring closure
Every output ring must repeat its first coordinate as its last coordinate. h3-js's cellsToMultiPolygon already emits closed rings; hand-built rings from cellToBoundary do not, and must be closed before use in GeoJSON consumers or turf.

Two coordinate-order pitfalls sit directly on this containment rule, not adjacent to it — get them wrong and the "exact" polygon is silently mangled:

Coordinate order: h3-js vs GeoJSON

cellToBoundary returns vertices as [lat, lng] pairs — h3-js's native order. GeoJSON, and every downstream consumer of this polygon (turf, Mapbox, a DSP's polygon-upload endpoint), expects [lng, lat]. Swapping the pair order rather than transposing it produces a polygon that is a mirror image across the equator/prime-meridian axis, not a shifted one — it will look plausible on a map at low zoom and be wrong everywhere. Always call cellsToMultiPolygon(cells, true) (the isGeoJson flag) or transpose explicitly; never assume order.

Resolution behavior

Resolution changes the density of the boundary approximation of the true curved cell edges, not the containment rule. H3 cell edges are great-circle arcs, not planar lines; a GeoJSON ring is a sequence of straight (rhumb, effectively planar-interpolated) segments between vertices. At coarse resolutions (res 4–6) a single edge can span tens of kilometers, and the chord between its two vertices deviates from the true great-circle arc by a measurable amount — this is real, not a rendering artifact, and matters for any downstream intersection test at those resolutions. At fine resolutions (res 9+) edges are short enough that the chord-arc deviation is sub-meter and usually ignorable. If a caller needs the arc itself rather than the chord, densify each edge with great-circle samples before emitting the ring (see densifyRingGeodesic in the inscribed/circumscribed circle pages) — this trades exactness-of-vertex-count for exactness-of-shape.

One R9 cell as a closed GeoJSON ring: 6 vertices (gold) around the center (cyan).
Rendered from the tested conversion code · One R9 cell as a closed GeoJSON ring: 6 vertices (gold) around the center (cyan).

Units and CRS

Output is EPSG:4326, [lng, lat] decimal degrees, closed rings. cellArea(cell, "m2") and any turf area computation on the output ring are consistent with each other because both assume a spherical model rather than an ellipsoidal one; treat area comparisons as spherical m², not survey-grade WGS84 m².

Algorithm

import { cellToBoundary, cellsToMultiPolygon } from "h3-js";
import type { Feature, MultiPolygon } from "geojson";

// Single cell -> closed GeoJSON ring, [lng,lat], via explicit transpose.
function cellToRing(cell: string): [number, number][] {
  const boundary = cellToBoundary(cell) as [number, number][]; // [lat,lng]
  const ring: [number, number][] = boundary.map(([lat, lng]) => [lng, lat]);
  ring.push(ring[0]!); // close the ring
  return ring;
}

// Dissolved outline for an entire cell set -> GeoJSON MultiPolygon.
function cellSetToPolygon(cells: string[]): Feature<MultiPolygon> {
  const coords = cellsToMultiPolygon(cells, true); // isGeoJson=true => [lng,lat]
  return { type: "Feature", properties: {}, geometry: { type: "MultiPolygon", coordinates: coords } };
}

The same conversion with the Python bindings (h3-py v4):

import h3

# Single cell -> closed ring, h3-py's native (lat, lng) order.
def cell_to_ring(cell: str) -> list[tuple[float, float]]:
    boundary = list(h3.cell_to_boundary(cell))  # ((lat, lng), ...), open ring
    boundary.append(boundary[0])                # close the ring explicitly
    return boundary

# Dissolved outline for an entire cell set -> LatLngMultiPoly.
def cell_set_to_shape(cells: list[str]):
    return h3.cells_to_h3shape(cells, tight=True)  # LatLngMultiPoly, outer + hole rings

Note the ordering: h3.cell_to_boundary returns (lat, lng) pairs and, unlike cellsToMultiPolygon(cells, true) in h3-js, does not offer a geo_json flag in v4 — flip to (lng, lat) yourself before handing coordinates to GeoJSON/shapely, and cells_to_h3shape likewise returns LatLngPoly/LatLngMultiPoly objects in (lat, lng) order, not GeoJSON directly. The tested reference implementation for this conversion is the TypeScript in lib/.

Parameters

The cell set and its resolution; an optional densify sample count per edge if the caller needs great-circle-accurate arcs rather than chords; an optional simplification tolerance (see below).

Outputs

A GeoJSON Polygon or MultiPolygon Feature, EPSG:4326, closed rings, right-hand winding as produced by h3-js. Interior holes appear as additional rings when the input set has an enclosed gap.

Quality metrics

None are meaningful in the coverage sense — coverage_ratio, overreach_ratio, and underreach_ratio are all trivially 1, 0, 0 for an un-simplified output. The metric worth tracking instead is vertex count before/after any simplification step, since that is the only thing that can push this conversion out of the exact class.

Edge cases

Pentagons contribute five edges instead of six to the dissolve; no special handling is required for a correct ring-dissolve implementation, but a naive hex-only renderer that assumes six vertices per cell will corrupt a pentagon's ring. Cells whose true boundary crosses an icosahedron face seam have a boundary vertex sequence that is still valid but locally non-convex-looking near the seam; this is expected geometry, not a bug. Antimeridian-crossing cell sets produce rings whose longitude sign flips across ±180°; a naive consumer that does not split the ring at the antimeridian will render a polygon that wraps the entire globe instead of the small area actually covered — split into two polygons at ±180° before handing the result to a renderer or a spatial join that assumes a single unsplit ring.

Assumptions and limitations

This conversion assumes the caller wants the true footprint, not a simplified one. Any topology simplification pass (Douglas-Peucker, turf.simplify, or a platform's own polygon-upload simplifier) trades exactness for vertex count and moves this conversion from exact to approximate — measure coverage_ratio and overreach_ratio after simplification, since a "small" simplification tolerance in degrees can still cut off or add several percent of area at coarse resolutions where individual edges are long.

Edge cases affecting this page
  • - Geometries crossing ±180° longitude wrap incorrectly, producing world-spanning artifacts when treated as planar.
  • - 12 pentagon cells per resolution sit at icosahedron vertices; they break the 6-neighbour and regular-shape assumptions and have lower inscribed/circumscribed ratios.
  • - Cells spanning two icosahedron faces are distorted; edges are not symmetric and area varies.