H3 Explained: How a Hexagonal Grid Indexes the Whole Earth

On this page

Problem statement

H3 is simple to use: h3.latlng_to_cell(lat, lng, 9) returns 89194ad14c3ffff, and that string can go straight into a groupby. The trouble is what the call hides. People treat the result as a regular hexagon of fixed size that sits exactly inside its parent. Several of those assumptions turn out false when measured:

  • Cell area varies 1.98× within a resolution, and the variation has nothing to do with latitude. The largest resolution-5 hexagon (305.1 km²) is at 74.9° N, and the smallest (153.8 km²) is at 64.7° N, beside a pentagon off the Norwegian coast.
  • Not every cell is a hexagon. Each resolution has 12 pentagons. At resolution 5, 0.73% of cells have 7, 8 or 10 vertices when drawn.
  • Bad input does not always raise. latlng_to_cell(95, 10, 5) quietly returns a cell at 84.96° N, 170.54° W, on the far side of the pole.

None of these are bugs. Each one follows from how the grid is built, and knowing the construction tells you which of them your analysis has to handle.

Quick answer

import h3

cell = h3.latlng_to_cell(51.5007, -0.1246, 9)
print(cell, h3.str_to_int(cell))
print("resolution", h3.get_resolution(cell), "base cell", h3.get_base_cell_number(cell))
print("centre", h3.cell_to_latlng(cell))
print("area", round(h3.cell_area(cell, "km^2"), 4), "km²")
print("pentagon?", h3.is_pentagon(cell), "class III?", h3.is_res_class_III(cell))
89194ad14c3ffff 617438095025111039
resolution 9 base cell 12
centre (51.49987251358107, -0.12606627049286803)
area 0.0942 km²
pentagon? False class III? True

H3 in one paragraph: an icosahedron projected onto the sphere gives 122 base cells. Each is subdivided in steps of seven, 15 times over. A cell's index is its base cell plus one digit from 0 to 6 per step, packed into 64 bits. The hexadecimal string and the integer are the same index. This Westminster cell covers 0.0942 km², against the resolution-9 average of 0.1053 km².

The bit fields packed into the H3 index 89194ad14c3ffff. 89194ad14c3ffff = 617438095025111039fieldbitsvaluereserved10mode41 (a cell)edge / vertex30resolution49base cell712 of 0–121digits 1–9275 1 2 6 4 2 4 6 0digits 10–1518all 7 (padding)a parent is the same number with more digits set to 7cell_to_parent is bit arithmetic — no geometry involved spatialworkflow.io
The trailing "ffff" is padding. Every unused digit is 7, which is binary 111.

Icosahedron, aperture seven and the 64-bit index

The six fields packed into an H3 cell index, and how many bits each takes. 64 bits: 89194ad14c3ffff unpackedreserved1 bit0mode4 bits1 = cellreserved for edges, vertices3 bits0resolution4 bits9base cell7 bits12 of 12215 digits of 3 bits45 bits5 1 2 6 4 …A parent is the same number with the digits past its resolution set to 7 spatialworkflow.io
Every fact about a cell except its shape is readable straight out of these bits.

1. Start from an icosahedron

H3 places an icosahedron (20 triangular faces, 12 vertices) around the Earth. It projects each face onto the sphere with a gnomonic projection centred on that face. Of the regular polyhedra, the icosahedron has the most faces, which means the least distortion per face.

Each face carries a triangular lattice. The hexagons at resolution 0 give 122 base cells: 110 hexagons and 12 pentagons, with one pentagon at each icosahedron vertex. H3 orients the icosahedron so that all 12 vertices fall in the ocean. Measured against Natural Earth land polygons, not one resolution-5 pentagon centre is on land.

2. Subdivide in steps of seven

Every finer resolution divides each cell's area by about seven. A hexagon has seven children: one in the centre and six around it. A pentagon has six. The cell count per resolution follows directly:

print(h3.get_num_cells(5), 2 + 120 * 7 ** 5)     # 2016842 2016842

Seven small hexagons cannot rebuild a larger hexagon exactly, so the child lattice is rotated about 19.1° from its parent's. Resolutions then alternate between two orientations: even ones are Class II and odd ones are Class III. is_res_class_III is true for 1, 3, 5 … 15.

What the rotation implies for parents and children (children overhang the parent's outline) has its own guide: H3 hierarchy explained.

3. Read the 64-bit index

The index of 89194ad14c3ffff in binary:

0000100010010001100101001010110100010100110000111111111111111111
Bits Field Value here
1 reserved 0
4 mode (1 = cell) 1
3 reserved for edges and vertices 0
4 resolution 9
7 base cell 12
45 15 digits of 3 bits 5 1 2 6 4 2 4 6 0, then 7s

A parent is the same number with digits past its resolution set to 7 and the resolution field lowered. That is why cell_to_parent needs no geometry at all. It also explains the look of the strings: coarse cells end in long runs of f.

4. Account for the pentagons

Twelve pentagons exist at every resolution, always centred at the same 12 places. They have five neighbours instead of six, six children instead of seven, and they break some grid algorithms. grid_distance raised H3FailedError for 608 of the 1,600 pairs among 40 cells around a resolution-8 pentagon (Example 4).

In practice they rarely matter for land data. Only 31 of 13,472,195 GeoNames points fell inside a resolution-5 pentagon (Example 5). At resolution 9 pentagons are 12 cells out of 4.8 billion. Code that walks the grid still has to survive them.

5. Do not expect area to follow latitude

What drives cell size in a degree grid and in H3, and what that means for a density map. the averages are steady; the cells are nota degree gridH3 res 5size follows!latitude✓distance from face centretowards the poles✕cells collapse✓band means 247–265 km²within one levelone area per row!153.8–305.1 km²divide counts by✕a constant✓cell_area(cell)Only 51.3% of resolution-5 cells sit within ±10% of the mean area spatialworkflow.io
The nominal area of a resolution is an average, and a fifth of cells are nowhere near it.

Unlike a degree grid, H3 cells do not shrink towards the poles. They change size with their distance from the centre of their icosahedron face, where the gnomonic projection distorts least. Every cell at resolution 5, grouped by latitude band:

|lat|  0–15°  mean   249.7 km²
|lat| 15–30°  mean   257.3
|lat| 30–45°  mean   255.5
|lat| 45–60°  mean   247.0
|lat| 60–75°  mean   251.4
|lat| 75–90°  mean   265.4

The band means barely move, while individual cells range from 153.8 to 305.1 km². Only 51.3% of cells are within ±10% of the mean. For a density map, divide by cell_area(cell), not by the nominal area of the resolution.

6. Expect more than six vertices at odd resolutions

A Class III cell that straddles an icosahedron edge picks up extra vertices where the edge crosses it. Counting cell_to_boundary for every cell:

res 4 (Class II):   288,122 cells — 5 or 6 vertices only
res 5 (Class III):  2,016,842 cells — 11,760 with 7, 2,910 with 8, 12 with 10

Code that assumes six vertices, such as a fixed-shape NumPy array, breaks on 0.73% of resolution-5 cells and on a third of resolution-1 cells. See turning cells into polygons for a builder that handles any vertex count.

7. Validate coordinates before indexing

latlng_to_cell raises H3LatLngDomainError for NaN or infinite values. It does not raise for a latitude of 95, or for a longitude of 200. It wraps them:

(95, 10)    -> 85056d63fffffff  centre (84.958, -170.545)
(150, 10)   -> 85470a4ffffffff  centre (29.949, -169.941)
(51.5, 200) -> 85228307fffffff  centre (51.465, -159.988)

A longitude of 200 wrapping to −160 is harmless. A latitude of 95 wrapping over the pole means the columns were swapped, and H3 will not tell you. With GeoNames' columns swapped, all 13,472,195 rows still indexed without an error. The median point moved 7,016 km (Example 5).

Decoding indexes and profiling cell areas

The data used on this page

The points are GeoNames' full allCountries dump (every feature, about 13.5 million) from download.geonames.org, which is rebuilt daily — so counts drift a little from run to run. The land polygons for the pentagon check are Natural Earth's 1:10m land.

import os
import zipfile

import pandas as pd
import requests


def fetch(url, path):
    if not os.path.exists(path):
        with requests.get(url, stream=True, timeout=600) as reply:
            reply.raise_for_status()
            with open(path, "wb") as out:
                for chunk in reply.iter_content(1 << 20):
                    out.write(chunk)


GN = "https://download.geonames.org/export/dump/"
fetch(GN + "allCountries.zip", "allCountries.zip")        # about 420 MB
fetch("https://naciscdn.org/naturalearth/10m/physical/ne_10m_land.zip", "ne_10m_land.zip")

cols = {"usecols": [4, 5, 6, 8], "names": ["lat", "lon", "fclass", "country"]}
for name in ("allCountries",):
    if not os.path.exists(f"{name}.parquet"):
        with zipfile.ZipFile(f"{name}.zip") as z, z.open(f"{name}.txt") as f:
            pd.read_csv(f, sep="\t", header=None, quoting=3, keep_default_na=False,
                        **cols).to_parquet(f"{name}.parquet")
    print(f"{name}: {len(pd.read_parquet(f'{name}.parquet', columns=['lat'])):,} features")
allCountries: 13,472,195 features

Example 1 — decode an index into its fields

import h3


def decode_h3(cell):
    """Split an H3 cell index into the fields packed into its 64 bits."""
    i = h3.str_to_int(cell) if isinstance(cell, str) else int(cell)
    res = (i >> 52) & 0xF
    digit = lambda r: (i >> (3 * (15 - r))) & 0b111
    return {
        "mode": (i >> 59) & 0xF,                 # 1 = cell
        "resolution": res,
        "base_cell": (i >> 45) & 0x7F,           # 0–121
        "digits": [digit(r) for r in range(1, res + 1)],
        "padding_all_7": all(digit(r) == 7 for r in range(res + 1, 16)),
    }
cell = h3.latlng_to_cell(51.5007, -0.1246, 9)
print(decode_h3(cell))
print(decode_h3(h3.cell_to_parent(cell, 5)))
{'mode': 1, 'resolution': 9, 'base_cell': 12, 'digits': [5, 1, 2, 6, 4, 2, 4, 6, 0], 'padding_all_7': True}
{'mode': 1, 'resolution': 5, 'base_cell': 12, 'digits': [5, 1, 2, 6, 4], 'padding_all_7': True}

The parent's digits are a prefix of the child's. h3.get_index_digit(cell, r) returns the same digits, and the library functions are what production code should call. Decoding by hand is for seeing why two indexes share a prefix, or for finding the base cell in a SQL engine that only has bit operators.

Example 2 — the area profile of a whole resolution

import numpy as np
import h3


def area_profile(res, band=15):
    """Area of every cell at one resolution, summarised by latitude band."""
    cells = [c for base in h3.get_res0_cells() for c in h3.cell_to_children(base, res)]
    area = np.array([h3.cell_area(c, "km^2") for c in cells])
    lat = np.abs([h3.cell_to_latlng(c)[0] for c in cells])
    hexes = ~np.array([h3.is_pentagon(c) for c in cells])
    print(f"res {res}: {len(cells):,} cells, hexagons {area[hexes].min():.1f}–"
          f"{area[hexes].max():.1f} km² ({area[hexes].max() / area[hexes].min():.2f}×), "
          f"pentagons {area[~hexes].min():.1f} km²")
    for lo in range(0, 90, band):
        m = (lat >= lo) & (lat < lo + band)
        print(f"  |lat| {lo:2}–{lo + band:2}°  mean {area[m].mean():7.1f}  "
              f"min {area[m].min():7.1f}  max {area[m].max():7.1f}")
    return cells, area


cells5, area5 = area_profile(5)
res 5: 2,016,842 cells, hexagons 153.8–305.1 km² (1.98×), pentagons 127.8 km²
  |lat|  0–15°  mean   249.7  min   127.8  max   305.1
  |lat| 15–30°  mean   257.3  min   127.8  max   305.1
  |lat| 30–45°  mean   255.5  min   127.8  max   305.1
  |lat| 45–60°  mean   247.0  min   127.8  max   305.1
  |lat| 60–75°  mean   251.4  min   127.8  max   305.1
  |lat| 75–90°  mean   265.4  min   204.8  max   305.1

The smallest and largest values recur in almost every band, because each icosahedron face spans many latitudes and repeats the same distortion pattern. Resolution 6 has seven times as many cells, so profile a coarser level and scale the areas down by powers of seven.

Example 3 — refuse coordinates H3 would silently accept

import numpy as np


def check_latlng(df, lat="lat", lon="lon"):
    """Refuse coordinates H3 would silently accept and place somewhere else."""
    la, lo = df[lat].to_numpy(), df[lon].to_numpy()
    bad_lat = np.abs(la) > 90
    bad_lon = np.abs(lo) > 180
    report = {
        "rows": len(df),
        "lat_out_of_range": int(bad_lat.sum()),
        "lon_out_of_range": int(bad_lon.sum()),
        "non_finite": int((~np.isfinite(la) | ~np.isfinite(lo)).sum()),
    }
    if bad_lat.any() and not (np.abs(lo) > 90).any():
        report["hint"] = "every longitude fits in ±90 and some latitudes do not: columns swapped?"
    return report

On GeoNames as downloaded, and with the two columns swapped:

import pandas as pd

gn = pd.read_parquet("allCountries.parquet", columns=["lat", "lon"])
print(check_latlng(gn))
print(check_latlng(gn.rename(columns={"lat": "lon", "lon": "lat"})))
{'rows': 13472195, 'lat_out_of_range': 0, 'lon_out_of_range': 0, 'non_finite': 0}
{'rows': 13472195, 'lat_out_of_range': 4659214, 'lon_out_of_range': 0, 'non_finite': 0, 'hint': 'every longitude fits in ±90 and some latitudes do not: columns swapped?'}

The check catches a global swap because a third of the world's longitudes are beyond ±90. It cannot catch a swap in a dataset confined to, say, Europe, where both columns fit either range. There, compare the result with a known bounding box.

Example 4 — the geometry claims on this page, measured

import geopandas as gpd
from pyproj import Proj
from shapely.geometry import Point, Polygon
from shapely.ops import unary_union

# where the extremes are
hexes = ~np.array([h3.is_pentagon(c) for c in cells5])
for name, pick in (("largest", np.argmax), ("smallest", np.argmin)):
    i = np.flatnonzero(hexes)[pick(area5[hexes])]
    la, lo = h3.cell_to_latlng(cells5[i])
    near = min(h3.great_circle_distance((la, lo), h3.cell_to_latlng(p), "km") for p in h3.get_pentagons(5))
    print(f"{name} res-5 hexagon: {area5[i]:.1f} km² at {la:.1f}°, {lo:.1f}°, {near:,.0f} km from a pentagon")
print(f"within ±10% of the mean: {np.mean(np.abs(area5 / area5.mean() - 1) <= 0.1):.1%}")

# vertices per cell, and cells that cross the antimeridian
for res in (1, 4, 5):
    cells = cells5 if res == 5 else [c for b in h3.get_res0_cells() for c in h3.cell_to_children(b, res)]
    counts = pd.Series([len(h3.cell_to_boundary(c)) for c in cells]).value_counts().sort_index()
    extra = counts[counts.index > 6].sum()
    print(f"res {res}: {len(cells):,} cells, vertices {counts.to_dict()}, over six: {extra / len(cells):.2%}")
cross = sum(np.ptp([lng for _, lng in h3.cell_to_boundary(c)]) > 180 for c in cells5)
print(f"res-5 cells crossing the antimeridian: {cross:,}")

# pentagons: at sea, and what grid_distance makes of them
land = gpd.read_file("ne_10m_land.zip").union_all()
print("res-5 pentagon centres on land:",
      sum(land.contains(Point(h3.cell_to_latlng(p)[::-1])) for p in h3.get_pentagons(5)))
pent = h3.get_pentagons(8)[0]
near = sorted(h3.grid_disk(pent, 4))[:40]
fails = 0
for a in near:
    for b in near:
        try:
            h3.grid_distance(a, b)
        except h3.H3FailedError:
            fails += 1
print(f"grid_distance failures among 40 cells around a res-8 pentagon: {fails:,} of {len(near) ** 2:,}")

# the child lattice's rotation and overhang, measured in a local equal-area plane
def local(cell, centre):
    proj = Proj(proj="laea", lat_0=centre[0], lon_0=centre[1])
    return Polygon([proj(lng, lat) for lat, lng in h3.cell_to_boundary(cell)])


def vertex_angle(poly):
    x, y = np.asarray(poly.exterior.coords)[0]
    return np.degrees(np.arctan2(y, x)) % 60


rng = np.random.default_rng(0)
rot, over = [], []
for parent in rng.choice([c for c, h in zip(cells5, hexes) if h], 500, replace=False):
    centre = h3.cell_to_latlng(parent)
    outline = local(parent, centre)
    kids = [local(k, centre) for k in h3.cell_to_children(parent, 6)]
    d = abs(vertex_angle(outline) - vertex_angle(local(h3.cell_to_center_child(parent, 6), centre))) % 60
    rot.append(min(d, 60 - d))
    union = unary_union(kids)
    over.append(union.difference(outline).area / union.area)
print(f"child lattice rotation: median {np.median(rot):.1f}°; "
      f"children's area outside the parent: mean {np.mean(over):.2%}")
largest res-5 hexagon: 305.1 km² at 74.9°, 145.4°, 4,156 km from a pentagon
smallest res-5 hexagon: 153.8 km² at 64.7°, 10.3°, 12 km from a pentagon
within ±10% of the mean: 51.3%
res 1: 842 cells, vertices {6: 560, 7: 240, 8: 30, 10: 12}, over six: 33.49%
res 4: 288,122 cells, vertices {5: 12, 6: 288110}, over six: 0.00%
res 5: 2,016,842 cells, vertices {6: 2002160, 7: 11760, 8: 2910, 10: 12}, over six: 0.73%
res-5 cells crossing the antimeridian: 1,547
res-5 pentagon centres on land: 0
grid_distance failures among 40 cells around a res-8 pentagon: 608 of 1,600
child lattice rotation: median 19.1°; children's area outside the parent: mean 7.14%

Example 5 — GeoNames against the grid

import duckdb

con = duckdb.connect()
con.execute("load h3")
print(con.execute("""
    select count(*) filter (where h3_is_pentagon(h3_latlng_to_cell(lat, lon, 5))) as in_pentagons,
           count(*) as points
    from read_parquet('allCountries.parquet')""").fetchone())

for label, sql_lat, sql_lon in (("wrapped (95, 10)", "95", "10"), ("wrapped (150, 10)", "150", "10"),
                                ("wrapped (51.5, 200)", "51.5", "200")):
    cell = h3.latlng_to_cell(float(sql_lat), float(sql_lon), 5)
    la, lo = h3.cell_to_latlng(cell)
    print(f"{label:20} -> {cell}  centre ({la:.3f}, {lo:.3f})")

moved = con.execute("""
    with s as (select lat, lon, h3_latlng_to_cell(lon, lat, 5) as cell
               from read_parquet('allCountries.parquet'))
    select count(*), median(2 * 6371.0088 * asin(sqrt(
        pow(sin(radians(h3_cell_to_lat(cell) - lat) / 2), 2) +
        cos(radians(lat)) * cos(radians(h3_cell_to_lat(cell))) *
        pow(sin(radians(h3_cell_to_lng(cell) - lon) / 2), 2))))
    from s""").fetchone()
print(f"columns swapped: {moved[0]:,} rows indexed, median point moved {moved[1]:,.0f} km")
(31, 13472195)
wrapped (95, 10)     -> 85056d63fffffff  centre (84.958, -170.545)
wrapped (150, 10)    -> 85470a4ffffffff  centre (29.949, -169.941)
wrapped (51.5, 200)  -> 85228307fffffff  centre (51.465, -159.988)
columns swapped: 13,472,195 rows indexed, median point moved 7,016 km

Example 6 — how right the areas are, and what the keys cost

from pyproj import Geod, Transformer
import shapely

geod = Geod(ellps="WGS84")
sample = rng.choice(cells5, 2000, replace=False)
ratio = [abs(geod.polygon_area_perimeter(*zip(*[(lng, lat) for lat, lng in h3.cell_to_boundary(c)]))[0])
         / 1e6 / h3.cell_area(c, "km^2") for c in sample]
print(f"cell_area against WGS84 geodesic area, 2,000 cells: worst {max(abs(np.array(ratio) - 1)):.2%}")

occupied = [h3.int_to_str(c) for c, in con.execute(
    "select distinct h3_latlng_to_cell(lat, lon, 5) from read_parquet('allCountries.parquet')").fetchall()]
polys = [Polygon([(lng, lat) for lat, lng in h3.cell_to_boundary(c)]) for c in occupied]
keep = [i for i, p in enumerate(polys) if np.ptp(p.exterior.xy[0]) < 180 and max(abs(np.array(p.exterior.xy[1]))) < 85]
to_merc = Transformer.from_crs(4326, 3857, always_xy=True)
merc = shapely.area(shapely.transform([polys[i] for i in keep], lambda xy: np.column_stack(to_merc.transform(xy[:, 0], xy[:, 1]))))
true = np.array([h3.cell_area(occupied[i], "m^2") for i in keep])
print(f"Web Mercator area ÷ true area, {len(keep):,} occupied res-5 cells: "
      f"median {np.median(merc / true):.2f}×, max {np.max(merc / true):.1f}×")

keys = [h3.str_to_int(c) for c in cells5[:2_000_000]]
as_int = np.array(keys, dtype=np.uint64)
as_str = pd.Series(cells5[:2_000_000])
print(f"2,000,000 keys: {as_int.nbytes / 1e6:.1f} MB as uint64, "
      f"{as_str.memory_usage(deep=True) / 1e6:.1f} MB as a pandas column of Python strings")
cell_area against WGS84 geodesic area, 2,000 cells: worst 0.90%
Web Mercator area ÷ true area, 450,808 occupied res-5 cells: median 1.35×, max 127.7×
2,000,000 keys: 16.0 MB as uint64, 46.0 MB as a pandas column of Python strings

Why exactly twelve pentagons

How an H3 grid is built, from the icosahedron to the 122 base cells. where the 12 pentagons come fromicosahedron20 faces, the mostgnomonicgreat circles → lineslattice per facetidy triangles110 hexagons+ 12 pentagonsEuler forces exactly 12 — H3 only chose where to put them, and put all 12 in the ocean spatialworkflow.io
Each link in this chain is one of the three questions answered below.

Why an icosahedron and a gnomonic projection

Any flat grid laid on a sphere has to be distorted somewhere. The more faces the base polyhedron has, the less of the sphere each face covers, and the less the projection has to stretch. The icosahedron has the most faces of any regular solid.

The gnomonic projection maps great circles to straight lines. That keeps each face's triangular lattice tidy and makes cell edges great-circle arcs. The distortion grows towards a face's corners, so cell size depends on position within a face rather than on latitude.

Why there must be exactly 12 pentagons

A closed surface tiled only by hexagons and pentagons, three meeting at each vertex, needs exactly 12 pentagons. That follows from Euler's formula; a football is the familiar example. H3 cannot avoid them. It can only choose where they go, and it put them at the icosahedron's vertices, all over water. Their fixed position is why one list from h3.get_pentagons(res) covers every resolution.

Why aperture 7 and the rotation

Aperture 7 gives every hexagon exactly seven children: one centre child and six around it. Seven values fit in a 3-bit digit (0 to 6), and the eighth value, 7, is left over to mark unused digits. The cost is geometric: the children's lattice is rotated about 19.1°, their union is a jagged outline instead of the parent hexagon, and about 7.14% of the children's area lies outside the parent. The index hierarchy stays exact, since the digits are a clean prefix, while the geometry only approximates it. The hierarchy guide measures what that does to aggregations.

Why the index is an integer

Sixty-four bits per cell fits in a uint64 column, sorts cheaply, and compresses well. Two million keys took 16.0 MB as integers against 46.0 MB as Python strings. Sorting by the integer also keeps cells of the same base cell and coarse digits together, so range scans over a region stay local.

The hexadecimal string is the same number printed in base 16. Converting between the two with str_to_int and int_to_str is lossless.

A real H3 resolution-5 cell and its seven resolution-6 children, overhanging the parent outline. aperture 7: seven children, rotated, never an exact fitparent 85194ad3fffffffres 5, London7 children at res 6one centre, six aroundchild lattice turned ≈19.1°Class III → Class II7.14% of child arealies outside the parentDrawn from real boundaries in a local equal-area projection spatialworkflow.io
Drawn from real cell boundaries in a local equal-area projection. The six outer children each overhang the parent.

Wrapping latitudes, pentagon children and spherical areas

Mean area of every H3 resolution-5 cell in six latitude bands. res 5 mean cell area by latitude band (km²)|lat| 0–15°249.7|lat| 15–30°257.3|lat| 30–45°255.5|lat| 45–60°247.0|lat| 60–75°251.4|lat| 75–90°265.40320No latitude trend — yet only 51.3% of cells are within ±10% of the mean spatialworkflow.io
Five of the six bands still hold cells from 127.8 to 305.1 km², so the flat means hide a 2.4× range.
  • Invalid latitudes wrap instead of failing. A latitude of 95 indexes to a cell near 85° on the opposite meridian. Validate coordinate ranges first.
  • Pentagons have five neighbours and six children. grid_ring around one returns 5 cells, and some grid_distance calls near them raise H3FailedError.
  • Odd resolutions have extra vertices. 0.73% of resolution-5 cells draw with 7–10 vertices, so never assume six.
  • cell_area uses a sphere. It agreed with WGS84 geodesic areas to within 0.90% on 2,000 cells (Example 6), which is close enough for density maps.
  • Web Mercator areas are badly wrong. Hexagon areas in EPSG:3857 were 1.35× the true value at the median for the 450,808 occupied resolution-5 cells below 85°, and up to 127.7× (Example 6).
  • The index already contains its resolution, so cells from mixed resolutions can share a column. They still never compare equal.
  • The v4 API renamed nearly everything. geo_to_h3 became latlng_to_cell, and v3 code fails with an AttributeError.
  • Cells crossing the antimeridian draw as bands across the map unless their longitudes are shifted: 1,547 at resolution 5.

FAQ

What is H3?

A hierarchical grid of hexagonal cells covering the whole Earth at 16 resolutions, originally developed at Uber. Each cell has a 64-bit index that encodes its base cell, its resolution and its position within each coarser cell.

Are all H3 cells hexagons?

No. Every resolution has exactly 12 pentagons, placed at the vertices of the underlying icosahedron, all of them in the ocean. Only 31 of 13.47 million GeoNames points fell in a resolution-5 pentagon.

Are H3 cells the same size?

No. At resolution 5 hexagons range from 153.8 to 305.1 km², a 1.98× spread. The spread follows each cell's position on an icosahedron face, not its latitude.

What do the characters in an H3 index mean?

The string is a 64-bit integer in hexadecimal. It holds a mode, the resolution, a base cell from 0 to 121, and one 3-bit digit per resolution step. Unused digits are 7, which is why coarse cells end in runs of f.

Why did H3 accept a latitude of 95?

latlng_to_cell wraps out-of-range values instead of rejecting them, so 95° N lands near 85° N on the opposite meridian. Only NaN and infinite values raise H3LatLngDomainError, so range-check coordinates before indexing.

Is H3 the same as a hexbin?

No. A hexbin is laid out on a flat projection for one plot and has no identifiers that persist between datasets. H3 cells are fixed on the globe, so two datasets indexed separately still share keys.