H3 Hierarchy Explained: Parents, Children and Why They Do Not Nest Exactly

On this page

Problem statement

H3 is sold as hierarchical: every cell has exactly one parent at each coarser resolution, and cell_to_parent finds it without touching any geometry. That invites an obvious workflow β€” index the points once at a fine resolution, then roll them up to whatever level a map or a report needs.

The workflow is sound, but it answers a slightly different question from assigning the same points at the coarse resolution directly. A hexagon cannot be tiled exactly by smaller hexagons, so the seven children of a cell poke out past its edges in six places and leave six notches uncovered.

Measured on all 13,472,317 GeoNames points: taking the res-5 parent of each point's res-9 cell, and assigning the same point directly at res 5, put 876,549 points β€” 6.51% β€” in different cells. Both routes agree on the grand total and disagree about which cell owns roughly one point in fifteen.

Neither route is wrong. Mixing them is: a roll-up table built one way and a filter built the other way will never reconcile, and nothing raises an error to tell you.

Quick answer

The hierarchy is logical, not geometric. Both lines below are correct H3, and they return different res-6 cells for the GeoNames coordinate of Paris:

import h3

lat, lng = 48.85341, 2.3488                 # Paris, from GeoNames
fine = h3.latlng_to_cell(lat, lng, 9)

print(h3.cell_to_parent(fine, 6))           # coarse cell via the hierarchy
print(h3.latlng_to_cell(lat, lng, 6))       # coarse cell by direct assignment
861fb4667ffffff
861fb4677ffffff

The rule that follows is short: choose one route per pipeline and use it everywhere. Either store the finest cell you need and derive every coarser level with cell_to_parent, or assign every level directly from coordinates β€” never a mixture.

The measured sizes of the effect: 7.14% of every child's area lies outside its parent, and between 6.12% and 7.15% of real points change cell depending on the route, whether the two resolutions are one level apart or fifteen.

Seven H3 children drawn over their parent hexagon: six stick out past its edges. a real res-5 cell over Paris and its seven res-6 childrendashed: the parent hexagonteal: the centre child β€” 0% outsideblue: six outer children β€” 8.3% outside each7.14% of child area lies outsidethe same 7.14% of the parent iscovered by neighbouring childrenone level down: no child centre escapestwo levels down: 12.24% of centres doMembership comes from the index digits, not from the outline spatialworkflow.io
Drawn from real cell boundaries projected to metres. The notches the children leave are filled by the children of the six neighbouring cells.

Children, parents and how far descendants escape

1. Know what a child is

paris5 = h3.latlng_to_cell(lat, lng, 5)
pentagon = h3.get_pentagons(5)[0]

print(len(h3.cell_to_children(paris5, 6)))     # 7
print(len(h3.cell_to_children(pentagon, 6)))   # 6
print(len(h3.cell_to_children(paris5, 8)))     # 7 ** 3
print(h3.cell_to_children_size(paris5, 15))
7
6
343
282475249

H3 is an aperture-7 system: seven children per hexagon, and 7ⁿ descendants n levels down. A pentagon has six children, one of which is again a pentagon; three levels down a res-5 pentagon has 286 descendants instead of 343, still with exactly one pentagon among them.

cell_to_children_size is the safe way to ask how many there are. Materialising 282 million res-15 cells to count them is not.

2. Read the parent straight out of the index

print(fine)
print(h3.cell_to_parent(fine, 8))
print(h3.cell_to_parent(fine, 6))
print([h3.get_index_digit(fine, r) for r in range(1, 10)])
891fb46625bffff
881fb46625fffff
861fb4667ffffff
[6, 6, 4, 3, 1, 4, 2, 2, 6]

A cell index holds a resolution, a base cell, and one digit from 0 to 6 per level. The parent keeps the leading digits, marks the rest as unused and lowers the resolution field. The growing run of f characters is those unused digits.

No hexagon is consulted. A child belongs to its parent because its digits say so, not because the parent's outline contains it β€” which is the whole reason the two routes can disagree.

3. See how far the children escape

Project a parent and its descendants into metres around the parent's centre and measure the overlap (the function is Example 1):

res 5 ->  6: area outside  7.14%   centres outside  0.00%
res 5 ->  7: area outside  6.12%   centres outside 12.24%
res 5 ->  8: area outside  6.56%   centres outside  7.00%
res 5 -> 10: area outside  6.52%   centres outside  6.64%

Run over 200 random parents at resolutions 5, 6 and 8, the one-level figure was 7.14% every time, minimum and maximum identical to two decimals. It is a property of the aperture, not of where on Earth the cell sits.

4. Use the centre child when you need a representative inside the parent

centre = h3.cell_to_center_child(paris5, 10)
print(centre, h3.latlng_to_cell(*h3.cell_to_latlng(centre), 5) == paris5)
8a1fb4640007fff True

The centre child at any depth sits on the parent's centre, so it never escapes: 2,000 of 2,000 sampled res-5 cells mapped their res-10 centre child straight back. Other children are not so reliable β€” the centres of 936 of 14,700 res-7 descendants (6.37%) fell in a different res-5 cell from their logical parent.

5. Store the fine cell and derive the coarse ones

import pandas as pd

places = pd.DataFrame({"lat": [48.85341, 48.8566, 48.8606],
                       "lon": [2.3488, 2.3522, 2.3376]})
places["cell9"] = [h3.latlng_to_cell(a, b, 9) for a, b in zip(places.lat, places.lon)]
places["cell6"] = places.cell9.map(lambda c: h3.cell_to_parent(c, 6))
print(places[["cell9", "cell6"]])
             cell9            cell6
0  891fb46625bffff  861fb4667ffffff
1  891fb466257ffff  861fb4667ffffff
2  891fb467533ffff  861fb4677ffffff

Row 0 is the Paris coordinate from the quick answer. Assigned directly it belongs to 861fb4677ffffff; derived, it belongs to 861fb4667ffffff, and so does every other table in this pipeline that derives it. Consistency is the point, not which answer is "right".

6. Or assign every level from coordinates, and audit the difference

Direct assignment keeps each level geometrically honest β€” a point is always inside the hexagon that holds it β€” at the cost of needing the coordinates every time and of levels that do not nest. Before a pipeline relies on either assumption, count the disagreement on your own data (Example 2 does it in DuckDB in about two seconds for 13.5 million rows).

7. Compact a region with the logical hierarchy

compact_cells replaces every complete set of seven siblings with their parent, recursively. It uses digits, not outlines, so it is exact:

res 7: 8,317 cells -> 883 compacted (10.6%)  {4: 11, 5: 52, 6: 196, 7: 624}
res 9: 407,632 cells -> 7,252 compacted (1.8%)  {4: 11, 5: 48, 6: 170, 7: 658, 8: 1765, 9: 4600}

Switzerland at res 9 shrinks to 1.8% of its cell count, and uncompact_cells gives back exactly the original 407,632 cells. What does not survive is drawing the compacted set as hexagons of mixed sizes β€” covered in the edge cases below.

Share of 13.46 million GeoNames points whose coarse H3 cell depends on the route taken. points that change cell: parent of fine vs direct coarseres 9 β†’ res 8962,429 Β· 7.15%res 9 β†’ res 7824,181 Β· 6.12%res 9 β†’ res 5876,010 Β· 6.51%res 12 β†’ res 9882,561 Β· 6.55%res 15 β†’ res 5879,254 Β· 6.53%res 9 β†’ res 0836,928 Β· 6.22%08%The gap between resolutions barely matters: one point in fifteen, whichever pair spatialworkflow.io
Every disagreeing point in a 200,000-point sample landed in an immediate neighbour of the expected cell β€” never further.

Auditing a roll-up before trusting it

The data used on this page

Country outlines from Natural Earth's 1:10m admin-0 layer; every feature in the GeoNames allCountries dump as a point. GeoNames is edited daily, so counts drift a little between downloads.

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)


fetch("https://naciscdn.org/naturalearth/10m/cultural/ne_10m_admin_0_countries.zip",
      "ne_10m_admin_0_countries.zip")
if not os.path.exists("geonames_all.parquet"):          # every GeoNames feature, about 400 MB zipped
    fetch("https://download.geonames.org/export/dump/allCountries.zip", "allCountries.zip")
    with zipfile.ZipFile("allCountries.zip") as z, z.open("allCountries.txt") as f:
        pd.read_csv(f, sep="\t", header=None, usecols=[4, 5], names=["lat", "lon"],
                    quoting=3).to_parquet("geonames_all.parquet")
print(len(pd.read_parquet("geonames_all.parquet", columns=["lat"])))
13472317

Example 1 β€” measure how much of a parent's descendants lie outside it

import h3
import numpy as np
from pyproj import Transformer
from shapely import contains_xy
from shapely.geometry import Polygon


def escape_share(parent, child_res):
    """Share of descendant area, and of descendant centres, outside the parent hexagon."""
    plat, plng = h3.cell_to_latlng(parent)
    to_m = Transformer.from_crs(
        "EPSG:4326", f"+proj=laea +lat_0={plat} +lon_0={plng} +units=m", always_xy=True
    )

    def project(cell):
        lngs, lats = zip(*[(b, a) for a, b in h3.cell_to_boundary(cell)])
        return Polygon(zip(*to_m.transform(lngs, lats)))

    outline = project(parent)
    kids = h3.cell_to_children(parent, child_res)
    shapes = [project(k) for k in kids]
    total = sum(s.area for s in shapes)
    inside = sum(s.intersection(outline).area for s in shapes)

    centres = np.array([to_m.transform(b, a) for a, b in map(h3.cell_to_latlng, kids)])
    centre_out = 1 - contains_xy(outline, centres[:, 0], centres[:, 1]).mean()
    return 1 - inside / total, centre_out


paris5 = h3.latlng_to_cell(48.85341, 2.3488, 5)
for child_res in (6, 7, 8, 10):
    area, centres = escape_share(paris5, child_res)
    print(f"res 5 -> {child_res:2}: area outside {area:6.2%}   centres outside {centres:6.2%}")
res 5 ->  6: area outside  7.14%   centres outside  0.00%
res 5 ->  7: area outside  6.12%   centres outside 12.24%
res 5 ->  8: area outside  6.56%   centres outside  7.00%
res 5 -> 10: area outside  6.52%   centres outside  6.64%

A local Lambert azimuthal equal-area projection keeps the areas honest at any latitude. This is the step 3 table, for the Paris res-5 cell; per child, the centre child has 0% outside and each of the six outer children 8.3%.

Example 2 β€” audit a roll-up on real data before trusting it

import duckdb


def audit_rollup(parquet, fine, coarse, lat="lat", lon="lon", threads=4):
    """How many points land in a different coarse cell depending on the route taken."""
    con = duckdb.connect()
    con.execute(f"set threads = {threads}")
    con.execute("install h3 from community; load h3")
    total, moved = con.execute(f"""
        select count(*),
               count(*) filter (
                   where h3_cell_to_parent(h3_latlng_to_cell({lat}, {lon}, {fine}), {coarse})
                      <> h3_latlng_to_cell({lat}, {lon}, {coarse}))
        from read_parquet('{parquet}')
        where {lat} is not null and {lon} is not null
    """).fetchone()
    print(f"res {fine} -> {coarse}: {moved:,} of {total:,} points change cell ({moved / total:.2%})")
    return moved / total


for fine, coarse in [(9, 8), (9, 5), (15, 5)]:
    audit_rollup("geonames_all.parquet", fine, coarse)
res 9 -> 8: 963,038 of 13,472,317 points change cell (7.15%)
res 9 -> 5: 876,549 of 13,472,317 points change cell (6.51%)
res 15 -> 5: 879,799 of 13,472,317 points change cell (6.53%)

Each run took 1.9–2.3 s on four threads. The same comparison in pure Python on a 200,000-point sample gave 13,003 disagreements, 6.50% (Example 4), so the DuckDB extension and h3-py agree to within the sampling.

Example 3 β€” compact a region and prove the round trip

import geopandas as gpd
import h3


def compact_region(geometry, res):
    """Fill a polygon, compact the result, and prove the round trip is lossless."""
    cells = h3.geo_to_cells(geometry, res)
    packed = h3.compact_cells(cells)
    assert set(h3.uncompact_cells(packed, res)) == set(cells)

    by_res = {}
    for cell in packed:
        r = h3.get_resolution(cell)
        by_res[r] = by_res.get(r, 0) + 1
    print(f"res {res}: {len(cells):,} cells -> {len(packed):,} compacted "
          f"({len(packed) / len(cells):.1%})  {dict(sorted(by_res.items()))}")
    return packed


countries = gpd.read_file("zip://ne_10m_admin_0_countries.zip")
switzerland = countries.loc[countries.ADMIN == "Switzerland", "geometry"].iloc[0]
packed = compact_region(switzerland, 9)
res 9: 407,632 cells -> 7,252 compacted (1.8%)  {4: 11, 5: 48, 6: 170, 7: 658, 8: 1765, 9: 4600}

This is the step 7 line. Ireland, with a longer coastline, compacted less well: 691,520 res-9 cells to 18,044, or 2.6%. Ragged edges leave incomplete sibling sets that cannot be merged.

Example 4 β€” the sampled figures on this page, with fixed seeds

import random

import pandas as pd

random.seed(0)
parents = [h3.latlng_to_cell(random.uniform(-60, 70), random.uniform(-180, 180), 5) for _ in range(2000)]
back = sum(h3.cell_to_parent(h3.cell_to_center_child(c, 10), 5) == c for c in parents)
kids = [(c, k) for c in parents[:300] for k in h3.cell_to_children(c, 7)]
elsewhere = sum(h3.latlng_to_cell(*h3.cell_to_latlng(k), 5) != c for c, k in kids)
print(f"centre child maps back: {back:,} of {len(parents):,}; other res-7 descendants with a centre "
      f"in a neighbouring res-5 cell: {elsewhere:,} of {len(kids):,} ({elsewhere / len(kids):.2%})")
one_level = [escape_share(c, 6)[0] for c in parents[:200]]
print(f"one level down, 200 parents: area outside {min(one_level):.2%} .. {max(one_level):.2%}")

sample = pd.read_parquet("geonames_all.parquet").dropna().sample(200_000, random_state=0)
for fine, coarse in [(9, 5), (9, 8)]:
    moved = next_door = 0
    for lat, lng in zip(sample["lat"], sample["lon"]):
        derived = h3.cell_to_parent(h3.latlng_to_cell(lat, lng, fine), coarse)
        direct = h3.latlng_to_cell(lat, lng, coarse)
        if derived != direct:
            moved += 1
            next_door += h3.grid_distance(derived, direct) == 1
    print(f"res {fine} -> {coarse} on 200,000 points: {moved:,} differ ({moved / len(sample):.2%}), "
          f"{next_door:,} of them one step away")
print("pentagon: res-5 descendants three levels down:", len(h3.cell_to_children(h3.get_pentagons(5)[0], 8)))

from pyproj import Geod
from shapely.geometry import shape
from shapely.ops import unary_union

compact_region(countries.loc[countries.ADMIN == "Ireland", "geometry"].iloc[0], 9)
geod = Geod(ellps="WGS84")
fine = shape(h3.cells_to_geo(h3.geo_to_cells(switzerland, 9)))
drawn = unary_union([shape(h3.cells_to_geo([c])) for c in packed])        # mixed sizes, drawn as they are
km2 = lambda g: abs(geod.geometry_area_perimeter(g)[0]) / 1e6
print(f"Switzerland drawn compacted {km2(drawn):,.0f} kmΒ², uncompacted {km2(fine):,.0f} kmΒ², "
      f"symmetric difference {km2(fine.symmetric_difference(drawn)) / km2(fine):.1%}")
try:
    h3.cells_to_geo(packed)
except Exception as exc:
    print("cells_to_geo on the compacted set:", type(exc).__name__)
centre child maps back: 2,000 of 2,000; other res-7 descendants with a centre in a neighbouring res-5 cell: 936 of 14,700 (6.37%)
one level down, 200 parents: area outside 7.14% .. 7.14%
res 9 -> 5 on 200,000 points: 13,003 differ (6.50%), 13,003 of them one step away
res 9 -> 8 on 200,000 points: 14,392 differ (7.20%), 14,392 of them one step away
pentagon: res-5 descendants three levels down: 286
res 9: 691,520 cells -> 18,044 compacted (2.6%)  {4: 17, 5: 80, 6: 323, 7: 1255, 8: 4178, 9: 12191}
Switzerland drawn compacted 40,366 kmΒ², uncompacted 41,436 kmΒ², symmetric difference 2.6%
cells_to_geo on the compacted set: H3ResMismatchError

Why hexagons cannot nest

Why hexagons cannot nest

A square or a rectangle splits into smaller ones with nothing left over, which is why geohash and S2 cells nest perfectly. Hexagons have no such subdivision. H3 approximates it: seven hexagons, each a seventh of the area, arranged as a centre plus a ring and rotated against the parent.

The rotation is what makes the areas balance. The measured sum of child areas equalled the parent area to four decimal places, but 7.14% of that area lies beyond the parent's edges β€” and an identical 7.14% of the parent is covered by the children of its six neighbours. Every notch is filled by a neighbour's protrusion.

Why the share settles near 6.5% instead of growing

If the error compounded, a res-15 descendant would be far adrift from its res-5 ancestor. It is not, because successive resolutions rotate in alternating directions. The pieces of a grandchild that stick out of a child partly fold back inside the grandparent.

Measured on area: 7.14% one level down, 6.12% at two, 6.56% at three, 6.52% at five. Measured on points: 7.15%, 6.12%, 6.51%, and 6.53% for res 15 to res 5. The points track the areas closely because, at the scale of a thin fringe along a cell edge, real points are spread about as evenly as area is.

Why a point never moves more than one cell

The outline of a deep descendant is a fractal-edged version of its ancestor's hexagon, differing only in a fringe along the boundary. A point in that fringe has two candidate coarse cells, and both touch the edge it sits on.

Measured on a 200,000-point sample, every disagreeing point landed in a cell exactly one grid step from the expected one: 13,003 of 13,003 for res 9 to 5, and 14,392 of 14,392 for res 9 to 8 (Example 4). That is why a k=1 neighbourhood absorbs the difference whenever you need to compare the two routes.

Why one level down no centre escapes, but two levels down many do

How many descendant centres fall outside their ancestor, by how many levels down. the escaping starts at the grandchildrenlevels downcentres outside the ancestor1 β€” children0.00%2 β€” grandchildren12.24%37.00%56.64%as the fringe fills in, the centre share converges on the area shareOne level is structural: the six outer children have their centres comfortably inside spatialworkflow.io
The grandchildren of the protruding corners sit wholly in the fringe, which is where the jump comes from.

The six outer children of a hexagon have their centres comfortably inside the parent, so the 0.00% in step 3 is structural. Their outer corners are what cross the edge.

At the grandchild level the protruding corners have children of their own, and those sit wholly in the fringe: 12.24% of res-7 centres fell outside their res-5 grandparent. With more levels the fringe fills in, and the centre share converges on the area share β€” 7.00% at three levels, 6.64% at five.

Why the index is still the right design

A logical hierarchy makes every roll-up a bit operation, every compaction exact, and every aggregate from a stored fine cell internally consistent: the counts in the seven children always sum to the count in the parent. A geometric hierarchy would give up all three for a fringe of a few per cent.

The cost is that "the res-5 cell of this point" has two defensible meanings. The index gives you both; the pipeline has to choose.

Choosing how a pipeline gets from fine H3 cells to coarse ones. one route per pipelinewhat must the coarse level guarantee?totals that nestderive from fine cellcell_to_parentthe true containing hexagonassign each levellatlng_to_cell per resboth, in one systemreconcile first6.5% of rows disagreeEither route is correct on its own; the silent failure is joining one to the other spatialworkflow.io
Compaction, roll-ups and parent filters all use the first route, so most pipelines should too.

Pentagon children and compacted sets

  • Pentagons have six children. One of them is a pentagon, and a res-5 pentagon has 286 descendants three levels down rather than 343.
  • Asking in the wrong direction raises. cell_to_parent with a finer resolution gives H3ResMismatchError: Invalid parent resolution 6 for cell 0x851fb467fffffff.; cell_to_children with a coarser one gives H3ResDomainError.
  • The same resolution is allowed. cell_to_parent(cell, get_resolution(cell)) returns the cell itself, which keeps generic roll-up code simple.
  • A compacted set is not a drawing. Switzerland's 7,252 compacted cells drawn as mixed-size hexagons covered 40,366 kmΒ² against 41,436 kmΒ² for the res-9 cells, a 2.6% symmetric difference. Uncompact before drawing.
  • cells_to_geo refuses mixed resolutions. On a compacted set it raised H3ResMismatchError; on the uncompacted cells it returned the correct 41,436 kmΒ² outline.
  • Grand totals always agree. Only the allocation between neighbouring cells differs, so a check on the sum will not detect a mixed pipeline.
  • DuckDB and h3-py agree. The community extension's h3_cell_to_parent gave 6.51% over all rows, the same share h3-py gave on a 200,000-point sample.
  • Never enumerate deep children to count them. cell_to_children_size answered 282,475,249 for res 5 to 15 instantly.
  • Store at the finest level anything will ever need. Deriving coarser levels later is free; recovering a finer cell from a coarse one is impossible without the coordinates.

FAQ

Do H3 children fit exactly inside their parent?

No. Seven children have the same total area as the parent, but 7.14% of that area lies outside the parent's edges, balanced by the children of neighbouring cells reaching in.

Why does the parent of a fine cell differ from the directly assigned coarse cell?

Because the parent is defined by the index digits, not by geometry. For points in the fringe along a cell edge the logical parent is a neighbour of the containing cell β€” 6.51% of 13.5 million GeoNames points at res 9 to res 5.

Which route should I use to aggregate to a coarser resolution?

Derive from a stored fine cell when counts must nest and totals must roll up cleanly; assign directly when each level must be the geometrically containing hexagon. Use one of them consistently.

Does the error get worse the further apart the resolutions are?

No. It settles near 6.5%: 7.15% for one level, 6.12% for two, and 6.53% from res 15 all the way to res 5, because alternating rotations fold the fringe back in.

Is compacting a cell set lossy because of this?

No. Compaction works on the index digits, and uncompacting Switzerland's 7,252 compacted cells returned exactly the original 407,632 res-9 cells. Only drawing the compacted cells as hexagons is inaccurate.

Which child is guaranteed to lie inside its parent?

The centre child. cell_to_center_child mapped back to its parent for 2,000 of 2,000 sampled cells, whereas 6.37% of other res-7 descendants had centres in a neighbouring res-5 cell.