Fixing H3 Polygon Fill That Misses Cells or Returns Nothing
On this page
Problem statement
You fill a polygon with H3 cells and get less than you expected — or nothing at all:
h3.geo_to_cells(vatican, 7) # []
h3.geo_to_cells(switzerland_lv95, 5) # [] no error
h3.geo_to_cells(antarctica, 5) # H3FailedError() with an empty message
h3.polygon_to_cells(switzerland, 5)
# ValueError: Unrecognized type: <class 'shapely.geometry.polygon.Polygon'>
The empty results are the dangerous ones, because nothing tells you they are wrong. Measured by filling all 258 Natural Earth countries with the default settings, 87 received no cells at resolution 3, 27 at resolution 5 and still 7 at resolution 7 — among them the Vatican, the Coral Sea Islands and Scarborough Reef.
Partial results are worse still, because the count looks reasonable. At resolution 5, 136 of Indonesia's 264 polygon parts got no cell: 10,236 km² of islands missing from a fill that otherwise matched the country's area.
There are five distinct causes. Each has a symptom you can check for and a fix that was measured to work.
Quick answer
import h3
import shapely
gdf = gdf.to_crs(4326) # H3 fills need degrees
geom = shapely.make_valid(gdf.geometry.iloc[0])
cells = set()
for part in getattr(geom, "geoms", [geom]):
found = h3.geo_to_cells(part, res) # centre containment
if not found: # a part too small to hold a centre
found = h3.h3shape_to_cells_experimental(h3.geo_to_h3shape(part), res, contain="overlap")
cells.update(found)
For a polygon that surrounds a pole, cut it into longitude sectors before filling (Example 3). Measured on Antarctica's mainland at resolution 5, the whole polygon raised H3FailedError, while four 90° sectors returned 44,940 cells covering 12.06 million km² in 0.4 s.
Centre rule, small polygons, lost parts, projected input
1. Know the default rule: a cell's centre must be inside
geo_to_cells and polygon_to_cells include a cell only when its centre point falls inside the polygon. That is a sensible default. In a set of adjacent polygons each cell lands in exactly one of them, and the total cell area closely matches the polygon's area:
polygon res centre overlap full
Greece 7 22,303 ( 99.9%) 24,779 (111.1%) 20,036 (89.7%)
Switzerland 7 8,317 ( 99.7%) 8,709 (104.4%) 7,927 (95.0%)
Malta 5 2 (177.9%) 6 (533.5%) 0 ( 0.0%)
The percentages are the cells' area as a share of the polygon's geodesic area. Centre containment is accurate for large shapes and erratic for small ones: Malta's 326 km² got two cells covering 580 km², and full containment gave none.
2. Fix small polygons: raise the resolution or use overlap
A polygon smaller than a cell usually contains no centre. The Vatican, 0.012 km², got no centre cell at resolution 7 or 8 and one at resolution 9.
Two fixes, with different meanings:
- A finer resolution keeps centre containment's accuracy. Choose one whose average cell area is well below the smallest polygon that must appear.
- Overlap containment includes every cell the polygon touches. The Vatican got one cell at resolution 7. It never drops a polygon, and in a coverage it assigns boundary cells to more than one neighbour.
The overlap mode is exposed as h3shape_to_cells_experimental(shape, res, contain="overlap") in h3 4.5. The other options are "center", "full" and "bbox_overlap". The _experimental suffix is part of the name, so pin the version if you depend on it.
3. Fix multi-part polygons that lose their small parts
A MultiPolygon is filled as a whole, so every part too small for a centre vanishes, however large the rest is:
country res parts parts with no cell area lost
Indonesia 5 264 136 10,236 km²
Philippines 5 97 51 3,826 km²
Greece 6 74 17 280 km²
Norway 6 120 8 141 km²
Switching the whole country to overlap containment fixes the islands and inflates the mainland: Indonesia went from 7,037 cells to 8,807, 126% of its area. Rescue only the empty parts instead, and keep centre containment everywhere else — 319 extra cells for Indonesia, 53 for Greece (Example 2).
4. Fix projected coordinates: reproject to EPSG:4326
H3 reads coordinates as longitude and latitude in degrees. Switzerland in the Swiss LV95 CRS has coordinates around 2,485,468 by 1,075,552, and geo_to_cells returned 0 cells and no error. Always to_crs(4326) first, and check that the bounds are within ±180 and ±90 before filling.
5. Fix the input type: shapely goes to geo_to_cells
h3 4.x has two entry points. geo_to_cells accepts shapely geometries and GeoJSON-like mappings. polygon_to_cells accepts only H3's own LatLngPoly and LatLngMultiPoly shapes, with (lat, lng) pairs, and rejects anything else:
polygon_to_cells(shapely polygon) ValueError: Unrecognized type: <class 'shapely.geometry.polygon.Polygon'>
polygon_to_cells(GeoJSON dict) ValueError: Unrecognized type: <class 'dict'>
LatLngPoly with 2 points ValueError: Non-empty LatLngPoly loops need at least 3 points.
Building LatLngPoly by hand from a shapely ring is where coordinates get swapped, so prefer geo_to_cells whenever you start from shapely.
6. Fix polygons that surround a pole: fill in sectors
Antarctica's mainland polygon runs from −180° to 180° and down to −89.99999999999994° — a ring around the South Pole. It filled at resolutions 0 to 4 (6,431 cells at resolution 4) and raised H3FailedError from resolution 5 upwards. The exception's message is an empty string.
Clipping the polar tip off does not reliably help, and one version is worse than failing:
clipped at −89.99° 23,515 cells, no error about half of the 44,940 it should be
clipped at −86° H3FailedError
four 90° sectors 44,940 cells, 0 failures, 12.06 M km² in 0.4 s
Cut the polygon into longitude sectors, fill each, and union the cells. Eight 45° sectors gave the identical 44,940 cells. The polygon's own geodesic area is 12.16 million km²; centre containment accounts for the small difference.
7. Rule out swapped coordinates
A fill that returns a plausible number of cells in the wrong place is a coordinate-order bug, not a fill bug. Switzerland's ring passed to LatLngPoly in shapely's (lng, lat) order filled 13,851 cells near the Horn of Africa instead of 8,317 in the Alps. Check where the cells are, not only how many.
8. Holes are handled for you
Holes are subtracted correctly. Italy's mainland polygon has two, San Marino and the Vatican. At resolution 9 the fill with holes had 548 fewer cells than the outer ring alone, and all 547 of San Marino's own cells were among them.
Rejecting silent failures and rescuing empty parts
The data used on this page
Country outlines from Natural Earth's 1:10m admin-0 layer. 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")
print("ready")
ready
Example 1 — reject the inputs that fail silently
def check_fill_input(geom, crs=None):
"""Reject the inputs that make H3 fills silently empty or fail."""
if crs is not None and not getattr(crs, "is_geographic", False):
raise ValueError(f"geometry is in {crs}; reproject to EPSG:4326 first")
minx, miny, maxx, maxy = geom.bounds
if abs(miny) > 90 or abs(maxy) > 90 or abs(minx) > 180 or abs(maxx) > 180:
raise ValueError(f"bounds {geom.bounds} are not degrees - is this a projected CRS?")
if (miny < -89.9 or maxy > 89.9) and maxx - minx > 359:
raise ValueError("the polygon wraps a pole; fill it in longitude sectors (see fill_in_sectors)")
if geom.is_empty or not geom.is_valid:
raise ValueError("empty or invalid geometry; run shapely.make_valid first")
return geom
import geopandas as gpd
world = gpd.read_file("ne_10m_admin_0_countries.zip")
antarctica = max(world.loc[world.ADMIN == "Antarctica", "geometry"].iloc[0].geoms, key=lambda p: p.area)
swiss = gpd.GeoSeries(world.loc[world.ADMIN == "Switzerland", "geometry"], crs=world.crs)
for label, geom, crs in [("whole", antarctica, world.crs), ("projected", swiss.to_crs(2056).iloc[0], swiss.to_crs(2056).crs),
("projected, no crs", swiss.to_crs(2056).iloc[0], None), ("degrees", swiss.iloc[0], world.crs)]:
try:
check_fill_input(geom, crs)
print(label, "passed")
except ValueError as exc:
print(label, "->", exc)
whole -> the polygon wraps a pole; fill it in longitude sectors (see fill_in_sectors)
projected -> geometry is in EPSG:2056; reproject to EPSG:4326 first
projected, no crs -> bounds (2485468.010442513, 1075551.7941011817, 2831994.8583251485, 1295112.798875499) are not degrees - is this a projected CRS?
degrees passed
The first line is Antarctica, the last Switzerland in EPSG:4326.
The pole test compares against 89.9°, not 90°. Natural Earth's Antarctica reaches −89.99999999999994°, and a first version that tested miny <= -90 let it straight through.
Example 2 — rescue only the parts that came back empty
import h3
def fill_every_part(geom, res):
"""Centre containment for large parts, overlap for parts too small to hold a centre."""
parts = list(geom.geoms) if geom.geom_type == "MultiPolygon" else [geom]
cells, rescued = set(), 0
for part in parts:
found = h3.geo_to_cells(part, res)
if not found:
found = h3.h3shape_to_cells_experimental(h3.geo_to_h3shape(part), res, contain="overlap")
rescued += 1
cells.update(found)
print(f"res {res}: {len(parts)} parts, {rescued} rescued with overlap, {len(cells):,} cells")
return cells
import time
for name, res in [("Indonesia", 5), ("Greece", 6)]:
geom = world.loc[world.ADMIN == name, "geometry"].iloc[0]
start = time.perf_counter()
every = fill_every_part(geom, res)
took = time.perf_counter() - start
plain = h3.geo_to_cells(geom, res)
whole = h3.h3shape_to_cells_experimental(h3.geo_to_h3shape(geom), res, contain="overlap")
print(f" {name}: geo_to_cells {len(plain):,}, fill_every_part {len(every):,}, added {len(every) - len(plain)} "
f"({took:.2f}s); overlap on whole {len(set(whole)):,}")
res 5: 264 parts, 136 rescued with overlap, 7,356 cells
Indonesia: geo_to_cells 7,037, fill_every_part 7,356, added 319 (0.11s); overlap on whole 8,807
res 6: 74 parts, 17 rescued with overlap, 3,226 cells
Greece: geo_to_cells 3,173, fill_every_part 3,226, added 53 (0.04s); overlap on whole 4,104
Every island is represented, and the mainland keeps centre containment's area accuracy. Filling per part costs little: about a tenth of a second for Indonesia.
Example 3 — fill a polar polygon in sectors
import h3
from shapely.geometry import box
def fill_in_sectors(geom, res, width=90):
"""Fill a polygon that wraps a pole by cutting it into longitude sectors first."""
cells, failed = set(), 0
for west in range(-180, 180, width):
piece = geom.intersection(box(west, -90, west + width, 90))
if piece.is_empty:
continue
try:
cells.update(h3.geo_to_cells(piece, res))
except h3.H3BaseException:
failed += 1
area = sum(h3.cell_area(c, "km^2") for c in cells)
print(f"res {res}: {len(cells):,} cells from {360 // width} sectors, {failed} failed, {area / 1e6:.2f} M km2")
return cells
for res in (4, 5, 6):
fill_in_sectors(antarctica, res)
res 4: 6,431 cells from 4 sectors, 0 failed, 12.08 M km2
res 5: 44,940 cells from 4 sectors, 0 failed, 12.06 M km2
res 6: 314,558 cells from 4 sectors, 0 failed, 12.06 M km2
At resolution 4, where the whole polygon also worked, the sectors produced the same 6,431 cells, so cutting changes nothing where no cut is needed. The failed counter makes a sector that still fails visible instead of silently thinning the result.
Example 4 — every table on this page, measured
import time
import geopandas as gpd
import h3
import shapely
from pyproj import Geod
from shapely.geometry import Polygon, box
geod = Geod(ellps="WGS84")
world = gpd.read_file("ne_10m_admin_0_countries.zip")
country = lambda name: world.loc[world.ADMIN == name, "geometry"].iloc[0]
km2 = lambda g: abs(geod.geometry_area_perimeter(g)[0]) / 1e6
cells_km2 = lambda cells: sum(h3.cell_area(c, "km^2") for c in cells)
def fill(geom, res, mode="center"):
if mode == "center":
return set(h3.geo_to_cells(geom, res))
return set(h3.h3shape_to_cells_experimental(h3.geo_to_h3shape(geom), res, contain=mode))
for res in (3, 5, 7):
empty, failed = [], []
for name, g in zip(world.ADMIN, world.geometry):
try:
if not fill(g, res):
empty.append(name)
except h3.H3FailedError:
failed.append(name)
named = [n for n in ("Vatican", "Coral Sea Islands", "Scarborough Reef") if n in empty]
print(f"res {res}: {len(empty)} of {len(world)} countries get no cell ({', '.join(named)} among them); "
f"fill failed for {failed}")
for name, res in [("Greece", 7), ("Switzerland", 7), ("Malta", 5)]:
g = country(name)
print(name, res, " ".join(f"{m} {len(c):,} ({cells_km2(c) / km2(g):.1%})"
for m in ("center", "overlap", "full") for c in [fill(g, res, m)]))
vatican = country("Vatican")
print(f"Vatican ({km2(vatican):.3f} km²)", {r: len(fill(vatican, r)) for r in (7, 8, 9)},
"overlap at 7:", len(fill(vatican, 7, "overlap")))
for name, res in [("Indonesia", 5), ("Philippines", 5), ("Greece", 6), ("Norway", 6)]:
parts = list(country(name).geoms)
lost = [p for p in parts if not fill(p, res)]
rescue = set().union(*(fill(p, res, "overlap") for p in lost)) - fill(country(name), res)
print(f"{name} res {res}: {len(parts)} parts, {len(lost)} with no cell, {sum(km2(p) for p in lost):,.0f} km² lost, "
f"{len(rescue)} cells to rescue them")
indonesia = country("Indonesia")
over = fill(indonesia, 5, "overlap")
print(f"Indonesia centre fill covers {cells_km2(fill(indonesia, 5)) / km2(indonesia):.1%} of its area; all-overlap: "
f"{len(fill(indonesia, 5)):,} -> {len(over):,} cells, {cells_km2(over) / km2(indonesia):.0%}")
start = time.perf_counter()
print(f"Russia, already split at 180°: {len(fill(country('Russia'), 5)):,} cells in {time.perf_counter() - start:.1f} s")
lv95 = gpd.GeoSeries([country("Switzerland")], crs=4326).to_crs(2056).iloc[0]
print("Switzerland in LV95:", [round(v) for v in lv95.bounds[:2]], "->", len(fill(lv95, 5)), "cells")
for label, arg in [("shapely polygon", country("Switzerland")), ("GeoJSON dict", shapely.geometry.mapping(country("Switzerland"))),
("LatLngPoly with 2 points", None)]:
try:
h3.polygon_to_cells(arg if arg is not None else h3.LatLngPoly([(0, 0), (1, 1)]), 5)
except Exception as exc:
print(f"{label}: {type(exc).__name__}: {exc}")
for res in range(0, 7):
try:
print(f"Antarctica res {res}: {len(fill(antarctica, res)):,} cells")
except h3.H3FailedError as exc:
print(f"Antarctica res {res}: H3FailedError({str(exc)!r})")
for cut in (-89.99, -86):
try:
print(f"clipped at {cut}: {len(fill(antarctica.intersection(box(-180, cut, 180, 90)), 5)):,} cells")
except h3.H3FailedError:
print(f"clipped at {cut}: H3FailedError")
for n in (4, 8):
start = time.perf_counter()
sectors = set().union(*(fill(antarctica.intersection(box(x, -90, x + 360 / n, 90)), 5)
for x in range(-180, 180, 360 // n)))
print(f"{n} sectors: {len(sectors):,} cells, {cells_km2(sectors) / 1e6:.2f} M km² in {time.perf_counter() - start:.1f} s; "
f"polygon {km2(antarctica) / 1e6:.2f} M km²")
swiss = country("Switzerland")
swapped = h3.polygon_to_cells(h3.LatLngPoly(list(swiss.exterior.coords)), 7)
print(f"Switzerland with (lng, lat) given as (lat, lng): {len(swapped):,} cells centred near "
f"{[round(v, 1) for v in h3.cell_to_latlng(next(iter(swapped)))]}, against {len(fill(swiss, 7)):,}")
italy = max(country("Italy").geoms, key=lambda p: p.area)
with_holes, outer = fill(italy, 9), fill(Polygon(italy.exterior), 9)
san_marino = fill(country("San Marino"), 9)
print(f"Italy mainland: {len(italy.interiors)} holes; {len(outer - with_holes)} fewer cells with them; "
f"San Marino's {len(san_marino)} cells all among them: {san_marino <= outer - with_holes}")
res 3: 87 of 258 countries get no cell (Vatican, Coral Sea Islands, Scarborough Reef among them); fill failed for []
res 5: 27 of 258 countries get no cell (Vatican, Coral Sea Islands, Scarborough Reef among them); fill failed for ['Antarctica']
res 7: 7 of 258 countries get no cell (Vatican, Coral Sea Islands, Scarborough Reef among them); fill failed for ['Antarctica']
Greece 7 center 22,303 (99.9%) overlap 24,779 (111.1%) full 20,036 (89.7%)
Switzerland 7 center 8,317 (99.7%) overlap 8,709 (104.4%) full 7,927 (95.0%)
Malta 5 center 2 (177.9%) overlap 6 (533.5%) full 0 (0.0%)
Vatican (0.012 km²) {7: 0, 8: 0, 9: 1} overlap at 7: 1
Indonesia res 5: 264 parts, 136 with no cell, 10,236 km² lost, 319 cells to rescue them
Philippines res 5: 97 parts, 51 with no cell, 3,826 km² lost, 127 cells to rescue them
Greece res 6: 74 parts, 17 with no cell, 280 km² lost, 53 cells to rescue them
Norway res 6: 120 parts, 8 with no cell, 141 km² lost, 25 cells to rescue them
Indonesia centre fill covers 100.5% of its area; all-overlap: 7,037 -> 8,807 cells, 126%
Russia, already split at 180°: 64,726 cells in 2.7 s
Switzerland in LV95: [2485468, 1075552] -> 0 cells
shapely polygon: ValueError: Unrecognized type: <class 'shapely.geometry.polygon.Polygon'>
GeoJSON dict: ValueError: Unrecognized type: <class 'dict'>
LatLngPoly with 2 points: ValueError: Non-empty LatLngPoly loops need at least 3 points.
Antarctica res 0: 3 cells
Antarctica res 1: 18 cells
Antarctica res 2: 130 cells
Antarctica res 3: 917 cells
Antarctica res 4: 6,431 cells
Antarctica res 5: H3FailedError('')
Antarctica res 6: H3FailedError('')
clipped at -89.99: 23,515 cells
clipped at -86: H3FailedError
4 sectors: 44,940 cells, 12.06 M km² in 0.4 s; polygon 12.16 M km²
8 sectors: 44,940 cells, 12.06 M km² in 0.3 s; polygon 12.16 M km²
Switzerland with (lng, lat) given as (lat, lng): 13,851 cells centred near [8.2, 47.3], against 8,317
Italy mainland: 2 holes; 548 fewer cells with them; San Marino's 547 cells all among them: True
Why centre containment drops things
Why centre containment drops things
A cell either has its centre inside a polygon or it does not, so a polygon narrower than a cell can fall entirely between centres. The rule is chosen for coverages: adjacent polygons must not both claim a cell. Overlap containment breaks that promise, which is why it covered 111% of Greece at resolution 7 and 534% of Malta at resolution 5.
Why a projected polygon gives zero cells instead of an error
The fill does not know which CRS the numbers came from. Coordinates in metres are read as enormous angles, and the resulting shape contains no cell centres, so the answer is an empty set — a legitimate answer for a legitimate polygon, as far as H3 can tell. Only a bounds check before the call can distinguish "no cells" from "wrong units".
Why a ring around a pole fails
H3 treats a polygon as loops of vertices joined by great-circle arcs on the sphere. In the plane, Antarctica's ring is a tidy rectangle-bottomed shape: up the −180° meridian, along the coast, down 180° and back along the bottom edge of the map. On the sphere, that bottom edge is not an edge at all. The measured polygon has 724 vertices at latitude −89.99999999999994°, half a degree of longitude apart — all of them within micrometres of the South Pole. The loop runs into a single point and out again hundreds of times, and from resolution 5 up the fill could not resolve it and raised H3FailedError.
Clipping at −89.99° replaced those vertices with one edge from −180° to 180° along the clip line. On the sphere those two endpoints are the same place, so the loop no longer encloses the cap the way the flat drawing suggests. The fill completed and quietly covered about half of the continent. Cutting into sectors avoids the problem altogether: no piece surrounds the pole, so every piece is an ordinary polygon on the sphere.
Why the number of cells is not a check
Every failure above except the exception still returns a set of plausible size. Indonesia's centre-containment fill covered 100.5% of the country's area while missing 136 islands, because the extra cells along the big coastlines balanced the missing ones. Compare the cells against the polygon part by part, or at least count the parts that received nothing.
Valid geometry, date-line countries and fill time
- Invalid geometry was not the cause of any failure here, but repair invalid input with
make_validbefore filling anyway. - Antimeridian-crossing countries are fine when the polygon is already split at 180°: Russia filled 64,726 cells at resolution 5 in 2.7 s.
- Resolution drives fill time steeply. Each level multiplies the cell count by seven, and filling all 258 countries at resolution 7 took minutes rather than seconds.
fullcontainment is the strictest. It never includes a cell that crosses the edge, so small or thin polygons often get nothing.bbox_overlapis a fast superset for pre-filtering, not a fill.- The empty
H3FailedErrormessage makes logs useless; catchh3.H3BaseExceptionand log the polygon's bounds yourself. - Very fine resolutions on large countries are slow and huge; compact the result if you need to keep it.
Internal links
- How to fill a polygon with H3 cells — the containment modes in normal use
- Fixing H3 cells in the wrong place: latitude and longitude order — the fill that succeeds somewhere else
- Fixing H3 hexagons that stretch across the map at the antimeridian — the other trouble near 180°
- How to compact an H3 cell set to store a region cheaply — keeping a large fill small
- Choosing an H3 resolution: cell size, counts and what each level can show — matching cell size to the smallest polygon
- Fixing H3 AttributeError after upgrading: the v3 to v4 API rename — polyfill's old coordinate trap
- How to fix invalid geometries in Python (GeoPandas) — repairing input before the fill
- How to reproject spatial data in Python (GeoPandas) — getting to EPSG:4326 first
FAQ
Why does h3 geo_to_cells return an empty list?
Usually because no cell centre falls inside the polygon: it is smaller than a cell at that resolution. The other common cause is a projected CRS, which also returns an empty result with no error.
How do I make sure small islands get a cell?
Fill each part separately and fall back to overlap containment for parts that return nothing. That added 319 cells for Indonesia's 136 empty islands without inflating the mainland.
What does H3FailedError with no message mean?
In practice, a polygon H3 cannot resolve, most often one that surrounds a pole. Antarctica's mainland raised it from resolution 5 upwards; filling four 90° longitude sectors worked.
Why does polygon_to_cells reject my shapely polygon?
It only accepts H3's LatLngPoly shapes. Pass shapely geometries and GeoJSON-like mappings to geo_to_cells instead.
Should I use overlap containment for everything?
No. It counts boundary cells for every polygon they touch, so a coverage double-counts and small shapes inflate. Greece covered 111% of its area at resolution 7, and Malta 534% at resolution 5.
Can I just clip the pole off the polygon?
Not safely. Clipping Antarctica at −89.99° stopped the error but returned 23,515 cells instead of 44,940, with no warning. Fill in longitude sectors instead.