H3 Hierarchy Explained: Parents, Children and Why They Do Not Nest Exactly
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,464,117 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,010 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.
Step-by-step solution
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 884 of 14,700 res-7 descendants (6.01%) 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.
Code examples
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
A local Lambert azimuthal equal-area projection keeps the areas honest at any latitude. The output for the Paris res-5 cell is the table in step 3; 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
res 9 -> 8: 962,429 of 13,464,117 points change cell (7.15%)
res 9 -> 5: 876,010 of 13,464,117 points change cell (6.51%)
res 15 -> 5: 879,254 of 13,464,117 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,016 disagreements, 6.51%, so the DuckDB extension and h3-py agree.
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)
The output is the step 7 table. 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.
Explanation
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: 12,798 of 12,798 for res 9 to 5, and 14,392 of 14,392 for res 9 to 8. 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
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.
Edge cases or notes
- 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_parentwith a finer resolution givesH3ResMismatchError: Invalid parent resolution 6 for cell 0x851fb467fffffff.;cell_to_childrenwith a coarser one givesH3ResDomainError. - 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_georefuses mixed resolutions. On a compacted set it raisedH3ResMismatchError; 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_parentgave 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_sizeanswered 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.
Internal links
- H3 explained: how a hexagonal grid indexes the whole Earth โ the index layout the hierarchy lives in
- Choosing an H3 resolution โ deciding which level to store as the fine cell
- How to assign points to H3 cells in Python โ creating the stored cell column
- How to aggregate points into H3 hexagons and map them โ roll-ups in a real pipeline
- How to compact an H3 cell set โ storing regions with the logical hierarchy
- How to turn H3 cells into a GeoDataFrame โ drawing cells, and why to uncompact first
- Discrete global grids explained โ why geohash and S2 nest exactly and H3 does not
- How to use H3 in DuckDB โ running the roll-up audit in SQL at scale
- The modifiable areal unit problem explained โ why moving 6.5% of points between zones changes results
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.01% of other res-7 descendants had centres in a neighbouring res-5 cell.