Fixing H3 Polygon Fill That Misses Cells or Returns Nothing

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.

Triage table of five H3 polygon fill failures and the fix for each.
Three of the five return an empty or short result with no error, so the check has to come from you.

Step-by-step solution

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,793  (111.2%)  20,036  (89.7%)
Switzerland     7     8,317  ( 99.7%)   8,709  (104.4%)   7,927  (95.0%)
Malta           5         2  (177.9%)       7  (622.4%)       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 9,036, 129% 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.

Bar chart of the share of polygon parts that received no H3 cell under centre containment for Indonesia, the Philippines, Greece and Norway.
Archipelagos lose half their parts at resolution 5, and the total cell count still looks right.

Code examples

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
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
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.12s); overlap on whole 9,036
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.05s); overlap on whole 4,128

Every island is represented, and the mainland keeps centre containment's area accuracy. Filling per part costs little: 0.12 s 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
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.

Explanation

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 622% 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.

Table of four ways to fill Antarctica's mainland at H3 resolution 5 and the result of each.
The clip that silences the error is the worst of the four: no exception, and half the cells.

Edge cases or notes

  • Invalid geometry was not the usual cause. A self-intersecting bowtie filled 1,597 cells both before and after make_valid; still repair invalid input before filling.
  • Antimeridian-crossing countries are fine when the polygon is already split at 180ยฐ: Russia filled 64,726 cells at resolution 5 in 2.9 s.
  • Resolution drives fill time steeply. Filling all 258 countries took 12 s at resolution 5 and 414 s at resolution 7.
  • full containment is the strictest. It never includes a cell that crosses the edge, so small or thin polygons often get nothing.
  • bbox_overlap is a fast superset for pre-filtering, not a fill.
  • The empty H3FailedError message makes logs useless; catch h3.H3BaseException and 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.

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 622% 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.