H3 Explained: How a Hexagonal Grid Indexes the Whole Earth

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

Table of the bit fields in the H3 index 89194ad14c3ffff: mode, resolution, base cell, nine digits and padding.
The trailing "ffff" is padding. Every unused digit is 7, which is binary 111.

Step-by-step solution

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 360 of 1,600 cell pairs around a resolution-8 pentagon.

In practice they rarely matter for land data. Only 31 of 13,464,117 GeoNames points fell inside a resolution-5 pentagon. 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

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,464,117 rows still indexed without an error. The median point moved 4,201 km.

Code examples

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

It took 3.5 s for two million cells. 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:

{'rows': 13464117, 'lat_out_of_range': 0, 'lon_out_of_range': 0, 'non_finite': 0}
{'rows': 13464117, 'lat_out_of_range': 4654000, '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.

Explanation

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 outline with its seven resolution-6 children overhanging the edge.
Drawn from real cell boundaries in a local equal-area projection. The six outer children each overhang the parent.

Edge cases or notes

Bar chart of mean H3 resolution-5 cell area in six latitude bands, all between 247 and 265 km².
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.9% on 2,000 cells, which is close enough for density maps.
  • Web Mercator areas are badly wrong. Hexagon areas in EPSG:3857 were 1.50× the true value at the median for occupied cells, and up to 56.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.46 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.