Fixing an H3 Map That Misleads: Unequal Cell Areas and Empty Cells
On this page
Problem statement
A hexagon map of counts looks like the most honest map there is: same-shaped cells, a number in each, a colour ramp. It is easy to publish one that says something the data does not.
Two things go wrong, and neither raises an error.
The cells are not the same size. At resolution 5 the 12 pentagons cover 127.8 km² each and the largest hexagon covers 305.1 km², a ratio of 2.39. A count is partly a measure of area. Measured on 13.47 million GeoNames points, dividing each count by its cell's area changed the class of 10.2% of cells on a seven-class quantile map and replaced 28 of the top 100 cells.
The empty cells are missing. Aggregating points with a group-by returns only cells that contain something. Over Chad at resolution 6, 65.6% of the land cells were empty, so the mean over the cells in the frame was 2.06 points while the mean over the country was 0.71 — nearly three times lower. And a land-only cover of Norway dropped 111,836 of its 608,561 points, 18%, because coastal and sea features fell in cells whose centres were offshore.
Quick answer
Make both corrections before choosing colours:
import h3
counts = points.groupby("h3").size() # occupied cells only
cover = h3.h3shape_to_cells_experimental(
h3.geo_to_h3shape(country), res, contain="overlap") # every cell touching the region
full = counts.reindex(cover, fill_value=0) # empty cells become zeros
area = [h3.cell_area(c, "km^2") for c in full.index]
density = full / area # map this, not the count
Then decide what the map is for. A density map answers "where is it concentrated?" A count map answers "where is the most of it?" and should say so in its legend.
Measure the area spread, divide, add the zeros
1. Measure the area spread at your resolution
Every cell's area is available from h3.cell_area. Measured across all 2,016,842 cells at resolution 5:
mean 252.9 km² min 127.8 (pentagons) smallest hexagon 153.8 max 305.1
5th percentile 187.8 median 257.2 95th percentile 299.7
The middle 90% of cells span a factor of 1.6; the full hexagon range is 1.98. Coarse resolutions are more uniform: in random samples of hexagons the largest-to-smallest ratio was 1.21 at resolution 0, 1.94 at resolution 3 and 1.99 at resolution 9.
2. Do not expect the pattern to follow latitude
Geohash and web map tiles shrink steadily towards the poles, so their distortion is easy to picture. H3's is not like that. The correlation between cell area and absolute latitude was 0.016, and the full 127.8–305.1 km² range appeared in six of the nine 10° latitude bands.
Cell size depends on position relative to the icosahedron's vertices, where the pentagons sit. The smallest hexagon at resolution 5 was at 64.7° N, 10.3° E, right next to the pentagon at 64.7° N, 10.5° E. A map of counts therefore has small, patchy area distortions scattered across the world, not a gradient anyone would notice.
3. Divide by area before comparing cells
The correction is one line and changes more than the correlation suggests:
all 451,134 occupied resolution-5 cells
Spearman rank correlation, count vs density 0.9951
top 1,000 cells in both rankings 739
top 100 cells in both rankings 72
cells changing quintile class 10.6%
cells changing 7-class quantile 10.2%
A rank correlation of 0.995 sounds like nothing changed. But classed maps and top-N lists are decided at boundaries, and area differences of up to 2× move cells across them. The busiest resolution-5 cell held 6,716 points in 172.5 km²; the next held 4,718 in 245.8 km², so its density was half the first's rather than 70%.
4. Add the empty cells back
A group-by knows nothing about cells where nothing happened. Build the full cover of the region and reindex with zeros:
country res cells in cover empty mean, occupied mean, all median, occupied median, all
Chad 6 32,059 65.6% 2.06 0.71 1 0
UK 7 53,100 26.8% 2.57 1.88 2 1
Norway 6 14,447 13.1% 39.57 34.38 28 20
Every summary statistic, every classification scheme computed from the data and every legend changes. A quantile map of Chad without the empty cells has no class for "nothing here" — two thirds of the country.
5. Cover coastal regions with overlap, not centres
The default fill keeps a cell only if its centre is inside the polygon, so coastal cells with offshore centres are left out, along with every point in them:
country points outside the cover (centre) outside the cover (overlap)
Norway 111,836 28,971
UK 9,230 3,306
Chad 144 23
Norway's losses were mostly sea and landform features: 64,314 hypsographic and 27,743 hydrographic records, and 92,241 of the lost points were within one ring of a covered cell. An overlap cover keeps most of them. The 28,971 it still missed lie in cells that do not touch the Natural Earth polygon at all — features further offshore.
6. Flag pentagons if the map is global
The 12 pentagons are 50% smaller than the median cell at resolution 5 and have five neighbours instead of six. At resolution 5 none of them is on land, and in the GeoNames aggregation one occupied cell was a pentagon. A global ocean dataset will meet all twelve. h3.is_pentagon identifies them.
7. Label the map for what it shows
A density map normalised by cell_area and a count map are different claims. Put the unit in the legend — points per km², or points per cell — and state the resolution. Readers compare hexagons as if they were equal; the legend is the only place to tell them they are not.
Counts with area, density and pentagon flags
The data used on this page
Every feature in the GeoNames allCountries dump, with its feature class and country, and Natural Earth's 1:10m countries. GeoNames is edited daily, so counts drift a little.
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.parquet"): # 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=[1, 4, 5, 6, 8], quoting=3,
names=["name", "lat", "lon", "fclass", "country"],
dtype={"fclass": str, "country": str}).to_parquet("geonames.parquet")
print(len(pd.read_parquet("geonames.parquet", columns=["fclass"])), "GeoNames features")
13472317 GeoNames features
Example 1 — counts with area, density and pentagon flags
import h3
def add_area_and_density(counts, unit="km^2"):
"""counts: a pandas Series of point counts indexed by H3 cell."""
frame = counts.rename("n").to_frame()
res = h3.get_resolution(frame.index[0])
frame["area"] = [h3.cell_area(c, unit) for c in frame.index]
frame["density"] = frame["n"] / frame["area"]
frame["area_vs_average"] = frame["area"] / h3.average_hexagon_area(res, unit)
frame["pentagon"] = [h3.is_pentagon(c) for c in frame.index]
spread = frame["area"].max() / frame["area"].min()
print(f"{len(frame):,} cells at res {res}: area {frame['area'].min():.1f}-{frame['area'].max():.1f} {unit} "
f"(max/min {spread:.2f}), pentagons {int(frame['pentagon'].sum())}")
return frame
import geopandas as gpd
import h3.api.basic_int as h3i
import pandas as pd
points = pd.read_parquet("geonames.parquet").dropna(subset=["lat", "lon"])
cell_of = lambda frame, res: pd.Series([h3.int_to_str(h3i.latlng_to_cell(a, b, res))
for a, b in zip(frame.lat.tolist(), frame.lon.tolist())], index=frame.index)
points["h3_5"] = cell_of(points, 5)
world5 = add_area_and_density(points.groupby("h3_5").size())
norwegian = points[points.country == "NO"].assign(h3_6=lambda f: cell_of(f, 6))
norway6 = add_area_and_density(norwegian.groupby("h3_6").size())
451,134 cells at res 5: area 127.8-305.1 km^2 (max/min 2.39), pentagons 1
15,805 cells at res 6: area 18.2-31.0 km^2 (max/min 1.70), pentagons 1
The second line is Norway alone. A single country still spans a factor of 1.7, so the correction matters locally as well as globally. area_vs_average is useful for a quick look at which cells a count map flatters.
Example 2 — a complete cover with zeros
import h3
def complete_cells(region, counts, res, contain="overlap"):
"""Every cell covering the region, with zeros where nothing was counted."""
shape = h3.geo_to_h3shape(region)
cover = h3.h3shape_to_cells_experimental(shape, res, contain=contain)
full = counts.reindex(cover, fill_value=0).rename("n")
outside = counts[~counts.index.isin(cover)].sum()
empty = float((full == 0).mean())
print(f"{len(cover):,} cells ({contain}); {100 * empty:.1f}% empty; "
f"mean {full.mean():.2f} over all cells vs {counts[counts.index.isin(cover)].mean():.2f} over occupied; "
f"{int(outside):,} points fell outside the cover")
return full
countries = gpd.read_file("ne_10m_admin_0_countries.zip")
norway = countries.loc[countries.ADMIN == "Norway", "geometry"].iloc[0]
print("Norway")
for contain in ("center", "overlap"):
complete_cells(norway, norwegian.groupby("h3_6").size(), 6, contain)
Norway
14,447 cells (center); 13.1% empty; mean 34.38 over all cells vs 39.57 over occupied; 111,836 points fell outside the cover
17,118 cells (overlap); 15.1% empty; mean 34.70 over all cells vs 40.53 over occupied; 28,971 points fell outside the cover
The outside figure is the check that matters. A cover that silently discards points makes every downstream statistic describe a smaller dataset than the one you loaded.
Example 3 — how much the story changes
from scipy.stats import spearmanr
def count_versus_density(frame, top=1000, shift=0.10):
"""How much the map's story changes when counts are divided by cell area."""
rho = spearmanr(frame["n"], frame["density"]).correlation
top_count = set(frame.nlargest(top, "n").index)
top_density = set(frame.nlargest(top, "density").index)
pct_count = frame["n"].rank(pct=True)
pct_density = frame["density"].rank(pct=True)
moved = (pct_count - pct_density).abs() > shift
print(f"Spearman {rho:.4f}; top {top} by count and by density share {len(top_count & top_density)}; "
f"{100 * moved.mean():.1f}% of cells move more than {int(100 * shift)} percentile points")
return frame.assign(pct_count=pct_count, pct_density=pct_density)
count_versus_density(world5, 1000, 0.10)
count_versus_density(world5, 100, 0.05)
count_versus_density(norway6, 100, 0.10)
Spearman 0.9951; top 1000 by count and by density share 739; 0.0% of cells move more than 10 percentile points
Spearman 0.9951; top 100 by count and by density share 72; 8.6% of cells move more than 5 percentile points
Spearman 0.9983; top 100 by count and by density share 95; 0.0% of cells move more than 10 percentile points
The first two lines are the global aggregation; the third is Norway at resolution 6. Few cells move far, but many move a little, and a little is enough to cross a class break or leave a top-100 list.
Example 4 — the tables in the steps, measured
import numpy as np
every = list(h3.uncompact_cells(h3.get_res0_cells(), 5))
area = pd.Series([h3.cell_area(c, "km^2") for c in every], index=every)
hexes = area[[not h3.is_pentagon(c) for c in every]]
q = area.quantile([0.05, 0.5, 0.95])
print(f"res 5: mean {area.mean():.1f} min {area.min():.1f} smallest hexagon {hexes.min():.1f} max {area.max():.1f} "
f"p5 {q[0.05]:.1f} median {q[0.5]:.1f} p95 {q[0.95]:.1f}")
rng = np.random.default_rng(0)
for res in (0, 3, 9):
sample = [h3.latlng_to_cell(np.degrees(np.arcsin(rng.uniform(-1, 1))), rng.uniform(-180, 180), res) for _ in range(20000)]
a = [h3.cell_area(c, "km^2") for c in set(sample) if not h3.is_pentagon(c)]
print(f"res {res}: hexagon max/min in a random sample {max(a) / min(a):.2f}")
lat = np.array([abs(h3.cell_to_latlng(c)[0]) for c in every])
bands = pd.Series(area.values).groupby(lat // 10).agg(["min", "max"])
print(f"correlation of area with |latitude| {np.corrcoef(area.values, lat)[0, 1]:.3f}; bands holding the full range: "
f"{int(((bands['min'] == area.min()) & (bands['max'] == area.max())).sum())} of {len(bands)}")
print("smallest hexagon at", [round(v, 1) for v in h3.cell_to_latlng(hexes.idxmin())],
"; pentagons near it:", [[round(v, 1) for v in h3.cell_to_latlng(p)] for p in h3.get_pentagons(5)
if h3.great_circle_distance(h3.cell_to_latlng(p), h3.cell_to_latlng(hexes.idxmin())) < 100])
for k in (5, 7):
by_count = pd.qcut(world5["n"].rank(method="first"), k, labels=False)
by_density = pd.qcut(world5["density"].rank(method="first"), k, labels=False)
print(f"{k} quantile classes: {(by_count != by_density).mean():.1%} of cells change class")
busiest = world5.nlargest(2, "n")
print(busiest[["n", "area", "density"]].round(1).to_string())
land = countries.union_all()
print("pentagons at res 5 on land:", sum(land.contains(gpd.points_from_xy([h3.cell_to_latlng(p)[1]], [h3.cell_to_latlng(p)[0]])[0])
for p in h3.get_pentagons(5)), "; occupied pentagons:", int(world5.pentagon.sum()))
for name, code, res in [("Chad", "TD", 6), ("United Kingdom", "GB", 7), ("Norway", "NO", 6)]:
shape = h3.geo_to_h3shape(countries.loc[countries.ADMIN == name, "geometry"].iloc[0])
pts = points[points.country == code]
counts = pts.groupby(cell_of(pts, res)).size()
rows = {}
for contain in ("center", "overlap"):
cover = h3.h3shape_to_cells_experimental(shape, res, contain=contain)
full = counts.reindex(cover, fill_value=0)
rows[contain] = (len(cover), (full == 0).mean(), counts[counts.index.isin(cover)], full,
int(counts[~counts.index.isin(cover)].sum()))
n, empty, occupied, full, _ = rows["center"]
print(f"{name:15} res {res}: {n:,} cells, {empty:.1%} empty, mean {occupied.mean():.2f} occupied / {full.mean():.2f} all, "
f"median {occupied.median():.0f} / {full.median():.0f}; outside the cover {rows['center'][4]:,} (centre), "
f"{rows['overlap'][4]:,} (overlap)")
if code == "NO":
cover = set(h3.h3shape_to_cells_experimental(shape, res, contain="center"))
lost = pts[~cell_of(pts, res).isin(cover)]
ring = cover | {n for c in cover for n in h3.grid_disk(c, 1)}
print(" lost Norwegian points by feature class:", lost.fclass.value_counts().head(2).to_dict(),
f"; within one ring of the cover: {cell_of(lost, res).isin(ring).sum():,}")
wide = set(h3.h3shape_to_cells_experimental(shape, res, contain="overlap"))
still = pts[~cell_of(pts, res).isin(wide)]
print(f" {len(pts):,} Norwegian points; {(still.lat > 74).sum():,} of those the overlap cover misses lie north of 74°")
res 5: mean 252.9 min 127.8 smallest hexagon 153.8 max 305.1 p5 187.8 median 257.2 p95 299.7
res 0: hexagon max/min in a random sample 1.21
res 3: hexagon max/min in a random sample 1.94
res 9: hexagon max/min in a random sample 1.99
correlation of area with |latitude| 0.016; bands holding the full range: 0 of 9
smallest hexagon at [64.7, 10.3] ; pentagons near it: [[64.7, 10.5]]
5 quantile classes: 10.6% of cells change class
7 quantile classes: 10.2% of cells change class
n area density
h3_5
8530e0abfffffff 6716 172.5 38.9
852aa847fffffff 4718 245.8 19.2
pentagons at res 5 on land: 0 ; occupied pentagons: 1
Chad res 6: 32,059 cells, 65.6% empty, mean 2.06 occupied / 0.71 all, median 1 / 0; outside the cover 144 (centre), 23 (overlap)
United Kingdom res 7: 53,100 cells, 26.8% empty, mean 2.57 occupied / 1.88 all, median 2 / 1; outside the cover 9,230 (centre), 3,306 (overlap)
Norway res 6: 14,447 cells, 13.1% empty, mean 39.57 occupied / 34.38 all, median 28 / 20; outside the cover 111,836 (centre), 28,971 (overlap)
lost Norwegian points by feature class: {'T': 64314, 'H': 27743} ; within one ring of the cover: 92,241
608,561 Norwegian points; 0 of those the overlap cover misses lie north of 74°
Unequal cells and missing zeros
Why H3 cells differ in area at all
H3 starts from an icosahedron, projects a hexagonal grid onto each face with a gnomonic projection, and needs 12 pentagons at the vertices to close the surface. The projection stretches cells more the further they are from a face centre, and the pentagons pinch the grid around them. The result is a set of cells that are equal in topology but not in area.
That is also why the variation is independent of latitude: the icosahedron's vertices and faces are placed without regard to the equator, and one pentagon at resolution 5 sits at 64.7° N while another sits at 2.3° N.
Why a high rank correlation still changes the map
Spearman's correlation measures whether the two orderings agree overall. Maps and rankings depend on local order at a few thresholds. With 451,134 cells, a cell only needs to move a few percentile points to cross from one class of a 7-class map into the next, and 8.6% of cells moved more than five. The two measures agree about the world and disagree about the map.
Why missing zeros bias everything upwards
Summarising only occupied cells conditions every statistic on "something was here". The mean, the median, the class breaks and the colour of the lowest class all come from a dataset with no zeros in it. In sparse regions that condition removes most of the country: two thirds of Chad's cells. The map then shows variation among the few occupied cells as if it described the whole area.
Why centre containment loses coastal data
A region's cover built with centre containment excludes every cell whose centre is outside the polygon, even if most of the cell is on land and most of its points are on the coast. Norway's coastline is long and fractal at a 30 km² cell size, and GeoNames records bays, skerries and headlands along all of it. Overlap containment includes those cells and kept 82,868 more of Norway's points.
Overlap covers and mostly-empty fine resolutions
- Overlap covers extend beyond the region. Cells that are mostly sea get low densities; clip the display, not the counts, if that looks wrong.
- Fine resolutions make most cells empty or single-point. At resolution 7 the median occupied GeoNames cell holds one point; aggregate coarser before normalising.
cell_areais exact per cell;average_hexagon_area(res)is only the resolution's mean and should not be used as the divisor.- Pentagons have five neighbours. k-ring smoothing around them averages fewer cells; flag them in any neighbourhood statistic.
- Area-weighting is a density, not a rate. For people or events per population, divide by the population in the cell, not its area.
- Colour classes should come from the complete frame, zeros included, or the lowest class describes the emptiest occupied cells rather than empty land.
- Compacted cell sets mix resolutions. Uncompact before mapping counts from them.
Internal links
- Choosing an H3 resolution: cell size, counts and what each level can show — how many points each cell will hold
- How to aggregate points into H3 hexagons and map them — the aggregation these corrections apply to
- How to fill a polygon with H3 cells — centre, overlap and full containment
- Fixing H3 polygon fill that misses cells or returns nothing — covers that lose parts of the region
- Discrete global grids explained: H3, S2, geohash and why cells beat coordinates — area distortion across grid systems
- Counts, rates and densities: what a demographic map should show — the same choice for census areas
- Choropleth classification explained — why class breaks amplify small changes
- The modifiable areal unit problem explained — the cell size is a choice too
FAQ
Are all H3 hexagons the same size?
No. At resolution 5 hexagons range from 153.8 to 305.1 km², and the 12 pentagons are 127.8 km². The middle 90% of cells span a factor of about 1.6.
Are H3 cells smaller near the poles?
Not systematically. The correlation between cell area and latitude was 0.016; size depends on position relative to the icosahedron's vertices, where the pentagons are.
Should I map counts or densities in hexagons?
Densities, unless the map is explicitly about totals. Dividing by cell_area changed the class of 10.2% of cells on a 7-class quantile map and replaced 28 of the top 100 cells.
Why is the average count per hexagon too high?
A group-by only returns occupied cells, so empty cells are missing from the average. Over Chad the mean was 2.06 over occupied cells and 0.71 over all cells covering the country.
Why are points missing when I restrict the map to a country?
The country's cover probably uses centre containment, which drops coastal cells whose centres are offshore. Norway lost 111,836 points that way; an overlap cover lost 28,971.
Do pentagons matter for a regional map?
Rarely. At resolution 5 none of the 12 pentagons is on land. Global and ocean datasets meet them, and they are about half the area of a median cell.