Fixing H3 Hexagons That Stretch Across the Map at the Antimeridian
Problem statement
You draw H3 hexagons for data around Fiji, New Zealand, eastern Russia or Alaska, and a few of them become horizontal stripes that run the entire width of the map. Nothing raises. The polygons are valid. The bounds give it away:
cell = h3.latlng_to_cell(-16.8, 179.99, 3) # 839b43fffffffff, near Fiji
poly = Polygon([(lng, lat) for lat, lng in h3.cell_to_boundary(cell)])
print(poly.bounds, poly.is_valid)
# (-179.878, -17.872, 179.53, -16.781) True — 359.4° wide
The true cell is 1.2° wide. Its vertices sit on both sides of the 180° meridian, and a polygon in longitude–latitude coordinates joins them the long way round the world.
The stripe on the map is the harmless symptom. Measured on a 2,000,000-point sample of GeoNames, a spatial join against the 57 crossing polygons in a resolution-5 export matched 41,976 points, and not one of them was in the cell it was assigned to. 33,905 of those points were within 90° of the prime meridian. The 63 points that really were in those cells were not matched at all.
Quick answer
Split any cell wider than 180° at the meridian, so each part stays on its own side:
import h3
from shapely.affinity import translate
from shapely.geometry import MultiPolygon, Polygon, box
def cell_polygon(cell):
ring = [(lng, lat) for lat, lng in h3.cell_to_boundary(cell)]
lngs = [x for x, _ in ring]
if max(lngs) - min(lngs) <= 180:
return Polygon(ring)
shifted = Polygon([(x + 360 if x < 0 else x, y) for x, y in ring])
east = shifted.intersection(box(0, -90, 180, 90))
west = translate(shifted.intersection(box(180, -90, 360, 90)), xoff=-360)
return MultiPolygon([east, west])
After splitting, the same spatial join matched 62 points, all of them correct. The one remaining true point lay in a cell that surrounds a pole, which splitting cannot fix — step 6 covers it.
Step-by-step solution
1. Detect the cells by their width
A real H3 cell is never wider than a few degrees except near a pole. Any polygon whose longitude span exceeds 180° is a wrapped one:
bounds = gdf.geometry.bounds
wrapped = gdf[(bounds.maxx - bounds.minx) > 180]
print(len(wrapped), "of", len(gdf))
Test the width of each polygon, not the layer's total_bounds. A correctly split layer around Fiji still spans −180 to 180 in total.
2. Expect a handful at every resolution
The number of crossing cells is small and never zero:
res cells in the world crossing 180°
0 122 17
2 5,882 88
3 41,162 218
5 2,016,842 1,547
In real data the count depends on how much sits near the line. GeoNames holds 82,968 points with longitudes beyond ±170°; at resolution 5 they occupy 3,518 cells, of which 55 cross. A DuckDB export of every occupied resolution-5 cell in GeoNames held 57 wrapped polygons among 451,015.
Cells cross when their centre is close to the meridian: within 5° at resolution 2, 2° at resolution 3, 0.5° at resolution 5 and 0.1° at resolution 7.
3. Do not rely on validity or area checks
The wrapped polygon passes every check a pipeline normally runs:
is_valid True
geodesic area (pyproj.Geod) 11,380 km² the split version: 11,380 km²
h3.cell_area 11,418 km²
planar area in degrees 378.54 deg² the split version: 0.967 deg²
contains (lat −17.3, lng 0) True
Geodesic area is correct because pyproj.Geod measures each edge along the shortest path, so the step from −179.878° to 179.53° counts as 0.6°. Everything planar — bounds, contains, sjoin, .area, drawing and reprojection — takes the long way. Across GeoNames' 55 crossing cells, the planar area was 2,334.5 deg², seventeen times the 136.8 deg² of the other 3,463 cells combined.
4. Split at 180° for storage and joins
Keep the data in EPSG:4326 with every longitude inside −180..180, as a MultiPolygon with one part on each side. That is what the Quick answer function does, and it is what Natural Earth does for Fiji and Russia.
Measured on the sample: the split polygons stayed valid, the widest single part was 5.2° (a high-latitude cell), and the spatial join went from 41,976 wrong matches to 62 correct ones.
5. Shift the whole layer for a Pacific-centred map
For a map centred on 180°, a split cell draws as two pieces at opposite edges of a −180..180 frame. Shift instead: add 360 to negative longitudes so the Pacific is continuous.
Shift the whole layer, not only the wrapped cells. Measured on the 19 cells within two rings of the Fiji cell, shifting only the 5 crossing cells left the layer spanning −179.9 to 180.47° — the neighbours west of the line were still on the far side of the map. Example 2 applies the choice consistently.
6. Treat cells that contain a pole separately
A cell around a pole has vertices at every longitude, so no shift makes it narrow. Measured, the north-pole cell spans 180.3° of longitude at resolution 0 and 315.6° at resolution 3, and two crossing cells at each resolution contain a pole.
Splitting produces an invalid shape for these. Detect them — h3.latlng_to_cell(90, 0, res) and (-90, 0, res) name the two cells — and either build them in a polar projection or leave them out of a longitude–latitude layer.
7. Do not expect H3 or DuckDB to do it
The libraries return the raw ring:
h3.cells_to_geo([cell]) widest part 359.408°
h3.cells_to_geo(cell and its 6 neighbours) widest part 359.448°
DuckDB h3_cell_to_boundary_wkt(cell) width 359.408°
Any path from cells to polygons needs the check, including SQL exports built with st_geomfromtext(h3_cell_to_boundary_wkt(cell)).
Code examples
Example 1 — one cell, split or shifted, with poles refused
import h3
from shapely.affinity import translate
from shapely.geometry import MultiPolygon, Polygon, box
def cell_polygon(cell, mode="split"):
"""An H3 cell as a shapely geometry that does not wrap the world.
mode="split": a MultiPolygon cut at 180 degrees, every longitude in -180..180
mode="shift": one polygon with longitudes pushed past 180
"""
ring = [(lng, lat) for lat, lng in h3.cell_to_boundary(cell)]
lngs = [x for x, _ in ring]
if max(lngs) - min(lngs) <= 180:
return Polygon(ring)
res = h3.get_resolution(cell)
if cell in (h3.latlng_to_cell(90, 0, res), h3.latlng_to_cell(-90, 0, res)):
raise ValueError(f"{cell} contains a pole; build it in a polar projection instead")
shifted = Polygon([(x + 360 if x < 0 else x, y) for x, y in ring])
if mode == "shift":
return shifted
east = shifted.intersection(box(0, -90, 180, 90))
west = translate(shifted.intersection(box(180, -90, 360, 90)), xoff=-360)
parts = [g for g in (east, west) if not g.is_empty]
return MultiPolygon(parts) if len(parts) > 1 else parts[0]
split MultiPolygon [-180. -17.872 180. -16.781] valid True geodesic km2 11380 cell_area 11418
shift Polygon [179.213 -17.872 180.438 -16.781] valid True geodesic km2 11380 cell_area 11418
pole: 8001fffffffffff contains a pole; build it in a polar projection instead
The split result's bounds still read −180 to 180, because it has a part on each side. Its parts are each well under a degree wide.
Example 2 — a GeoDataFrame that draws correctly
import geopandas as gpd
import h3
from shapely.affinity import translate
def cells_to_gdf(cells, mode="split", values=None):
"""GeoDataFrame of H3 cells with antimeridian cells made drawable."""
cells = list(cells)
widths = [max(l) - min(l) for l in ([x for _, x in h3.cell_to_boundary(c)] for c in cells)]
frame = gpd.GeoDataFrame(
{"h3": cells, **(values or {})},
geometry=[cell_polygon(c, mode) for c in cells],
crs="EPSG:4326",
)
frame["crosses_180"] = [w > 180 for w in widths]
if mode == "shift": # move the whole layer, not only the wrapped cells
frame["geometry"] = frame.geometry.apply(
lambda g: g if g.bounds[0] >= 0 else translate(g, xoff=360))
print(f"{len(cells):,} cells, {sum(frame['crosses_180'])} crossing the antimeridian, mode={mode}")
return frame
19 cells, 5 crossing the antimeridian, mode=split
bounds [-180. -20. 180. -14.7] widest part 1.262 valid True
19 cells, 5 crossing the antimeridian, mode=shift
bounds [177.37 -20. 182.31 -14.7 ] widest part 1.262 valid True
With mode="split" the 19 cells around Fiji have no part wider than 1.3°, but the layer is in two pieces at opposite edges of the map. With mode="shift", the final step also moves every polygon west of the line, so the layer becomes one patch from 177.37° to 182.31°. Use that mode only for display, and never mix it with data in −180..180.
Example 3 — repair a file someone else exported
import numpy as np
import shapely
from shapely.affinity import translate
from shapely.geometry import box
def repair_wide_polygons(gdf, max_width=180.0):
"""Fix polygons built elsewhere (SQL exports, old files) that span the map."""
column = gdf.geometry.name
bounds = gdf.geometry.bounds
wide = (bounds.maxx - bounds.minx) > max_width
polar = []
def shift(coords):
return np.column_stack([np.where(coords[:, 0] < 0, coords[:, 0] + 360, coords[:, 0]), coords[:, 1]])
def split(geom):
shifted = shapely.transform(geom, shift)
if not shifted.is_valid: # the ring goes round a pole; shifting cannot fix it
polar.append(geom)
return geom
east = shifted.intersection(box(0, -90, 180, 90))
west = translate(shifted.intersection(box(180, -90, 360, 90)), xoff=-360)
return shapely.union(east, west) if not west.is_empty else east
out = gdf.copy()
out.loc[wide, column] = out.loc[wide, column].apply(split)
print(f"{int(wide.sum())} of {len(gdf):,} polygons wider than {max_width} degrees: "
f"{int(wide.sum()) - len(polar)} split at 180, {len(polar)} surround a pole and were left alone")
return out
Run on the DuckDB export of 451,015 resolution-5 hexagons:
57 of 451,015 polygons wider than 180.0 degrees: 56 split at 180, 1 surround a pole and were left alone
after repair: single parts wider than 180: 1 all valid True
planar area of the 57 rows: 2,349.5 deg² before, 23.51 after
Use gdf.geometry.name rather than "geometry": files written by DuckDB often call the column geom.
Explanation
Why the ring goes the long way
cell_to_boundary returns vertices on the sphere. Around the 180° meridian, some have longitudes near +179.5° and others near −179.9°. Shapely works in a flat plane where those two numbers are 359.4 units apart, and a polygon edge is a straight segment between them. The ring is closed, simple and valid — it just encloses the band between the vertices instead of the sliver across the line.
Why it inverts a spatial join
The naive polygon covers everything between −179.9° and +179.5° in its latitude band, which is nearly the whole world at that latitude except the real cell. So within accepts points in Africa, South America and the Indian Ocean, and rejects the points that really are in the cell. That is why the measured join was not partly wrong but entirely wrong: 41,976 false matches and 0 true ones.
Why geodesic area hides the problem
A geodesic calculation walks from vertex to vertex along the shortest path on the ellipsoid, and the shortest path from −179.878° to 179.53° is 0.6° across the meridian. So pyproj.Geod reported 11,380 km² for both the broken and the fixed polygon. An area check against h3.cell_area — a sensible validation elsewhere — passes a polygon that every planar operation misreads.
Why poles need a different answer
A ring around a pole passes through every longitude. There is no meridian to cut it at that leaves each part narrow, which is why shifting produced an invalid shape and why the repair left one polygon alone. In a polar stereographic projection the same cell is an ordinary small hexagon.
Edge cases or notes
- Country polygons already split. Natural Earth's Fiji and Russia have bounds of −180 to 180 because they are multi-part; do not "repair" them.
- H3 fills of those countries still produce wrapped cells. Fiji at resolution 6 filled 588 cells, and 6 crossed the line.
- Reprojecting a wrapped polygon keeps the error. Fix it in EPSG:4326 before transforming to any projected CRS.
- Web maps draw the long way too. Leaflet and MapLibre render the same stripe from the same GeoJSON.
- Shifted longitudes above 180 break other tools. Keep a 0–360 layer for display only; store and join the split version.
- Two cells per resolution contain a pole. Check for them explicitly; width alone cannot tell a pole cell from a wrapped one.
- Point data is not affected. Only polygons and lines with vertices on both sides of the line wrap.
Internal links
- How to turn H3 cells into a GeoDataFrame of polygons — the conversion this fix belongs in
- How to use H3 in DuckDB for grid aggregation at scale — where the 57 wrapped exports came from
- Fixing H3 cells in the wrong place: latitude and longitude order — the other way cell polygons end up wrong
- How to fill a polygon with H3 cells — fills near the line produce crossing cells
- Fixing H3 polygon fill that misses cells or returns nothing — the fill failure at the poles
- Choosing a map projection for display: what Web Mercator distorts — Pacific-centred and polar views
- Spatial join returns empty results in GeoPandas — other reasons a join silently misses
- How to calculate area and distance in GeoPandas correctly — why planar area in degrees misleads
FAQ
Why do some H3 hexagons stretch across the whole map?
Their vertices lie on both sides of the 180° meridian, and a longitude–latitude polygon joins them the long way. A cell 1.2° wide near Fiji came out 359.4° wide.
Are the stretched polygons invalid?
No. They pass is_valid and even a geodesic area check. They fail every planar operation: bounds, drawing, planar area and spatial joins.
How badly does it affect a spatial join?
Completely. Against 57 wrapped resolution-5 cells, a join of 2,000,000 sample points returned 41,976 matches, none correct, and missed all 63 real ones. Split polygons returned 62 matches, all correct.
Does h3 have an option to split cells at the antimeridian?
cells_to_geo and cell_to_boundary return the raw ring, and DuckDB's h3_cell_to_boundary_wkt does the same. Check the longitude span of every polygon you build and split the wide ones yourself.
Should I split or shift?
Split at 180° for data you store, join or reproject. Shift the whole layer into 0 to 360 longitude only for a map centred on the Pacific.
What about the cells at the poles?
They surround the pole and cover every longitude, so neither splitting nor shifting makes them narrow. Build them in a polar projection or leave them out of longitude–latitude layers.