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:
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.
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.
