Fixing H3 Hexagons That Stretch Across the Map at the Antimeridian
On this page
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:
import h3
from shapely.geometry import Polygon
cell = h3.latlng_to_cell(-16.8, 179.99, 3) # near Fiji
poly = Polygon([(lng, lat) for lat, lng in h3.cell_to_boundary(cell)])
print(cell, [round(v, 3) for v in poly.bounds], poly.is_valid,
f"{poly.bounds[2] - poly.bounds[0]:.1f} degrees wide")
839b43fffffffff [-179.878, -17.872, 179.53, -16.781] True 359.4 degrees 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 42,096 points, and not one of them was in the cell it was assigned to. 33,877 of those points were within 90° of the prime meridian. The 76 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 72 points, all of them correct. The four remaining true points lay in the cells that surround the poles, which splitting cannot fix — step 6 covers them.
Detect wide cells, then split or shift them
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,973 points with longitudes beyond ±170°; at resolution 5 they occupy 3,524 cells, of which 55 cross. A DuckDB export of every occupied resolution-5 cell in GeoNames held 57 wrapped polygons among 451,134.
Cells cross when their centre is close to the meridian, and the finer the resolution, the closer it has to be.
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 137.0 deg² of the other 3,469 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 2.0°, and the spatial join went from 42,096 wrong matches to 72 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)).
Cells that draw correctly across the date line
The data used on this page
Every feature in the GeoNames allCountries dump as a point. 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)
if not os.path.exists("geonames_all.parquet"): # every GeoNames feature, 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=[4, 5], names=["lat", "lon"],
quoting=3).to_parquet("geonames_all.parquet")
print(len(pd.read_parquet("geonames_all.parquet", columns=["lat"])))
13472317
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]
import numpy as np
from pyproj import Geod
geod = Geod(ellps="WGS84")
fiji = h3.latlng_to_cell(-16.8, 179.99, 3)
for mode in ("split", "shift"):
g = cell_polygon(fiji, mode)
print(f"{mode} {g.geom_type} {np.round(g.bounds, 3)} valid {g.is_valid} "
f"geodesic km2 {abs(geod.geometry_area_perimeter(g)[0]) / 1e6:.0f} cell_area {h3.cell_area(fiji, 'km^2'):.0f}")
try:
cell_polygon(h3.latlng_to_cell(90, 0, 0))
except ValueError as exc:
print("pole:", exc)
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
around_fiji = h3.grid_disk(fiji, 2)
for mode in ("split", "shift"):
layer = cells_to_gdf(around_fiji, mode)
widest = max(max(p.bounds[2] - p.bounds[0] for p in getattr(g, "geoms", [g])) for g in layer.geometry)
print(f" bounds {np.round(layer.total_bounds, 2)} widest part {widest:.3f} valid {layer.is_valid.all()}")
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
import duckdb
import geopandas as gpd
con = duckdb.connect()
con.execute("install h3 from community; load h3")
export = con.execute("""select distinct h3_cell_to_boundary_wkt(h3_latlng_to_cell(lat, lon, 5)) as wkt
from 'geonames_all.parquet' where lat is not null""").df()
exported = gpd.GeoDataFrame(geometry=gpd.GeoSeries.from_wkt(export.wkt), crs="EPSG:4326")
before = exported.geometry.bounds
wide_rows = (before.maxx - before.minx) > 180
repaired = repair_wide_polygons(exported)
after = [max(p.bounds[2] - p.bounds[0] for p in getattr(g, "geoms", [g])) for g in repaired.geometry]
print(f"after repair: single parts wider than 180: {sum(w > 180 for w in after)} all valid {repaired.is_valid.all()}")
print(f"planar area of the {int(wide_rows.sum())} rows: {exported[wide_rows].area.sum():,.1f} deg² before, "
f"{repaired[wide_rows].area.sum():,.2f} after")
57 of 451,134 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
That is the DuckDB export of every resolution-5 hexagon GeoNames occupies, 451,134 of them.
Use gdf.geometry.name rather than "geometry": files written by DuckDB often call the column geom.
Example 4 — the measurements behind the steps
import pandas as pd
from shapely.geometry import Point
wide = lambda c: np.ptp([lng for _, lng in h3.cell_to_boundary(c)]) > 180
for res in (0, 2, 3, 5):
every = list(h3.uncompact_cells(h3.get_res0_cells(), res))
crossing = [c for c in every if wide(c)]
poles = [c for c in crossing if c in (h3.latlng_to_cell(90, 0, res), h3.latlng_to_cell(-90, 0, res))]
print(f"res {res}: {len(every):>9,} cells, {len(crossing):>5,} crossing 180°, {len(poles)} of them polar")
for res in (0, 3):
north = h3.latlng_to_cell(90, 0, res)
print(f"north-pole cell at res {res} spans {np.ptp([lng for _, lng in h3.cell_to_boundary(north)]):.1f}° of longitude")
points = pd.read_parquet("geonames_all.parquet").dropna()
edge = points[points.lon.abs() > 170]
cells = pd.Series([h3.latlng_to_cell(a, b, 5) for a, b in zip(edge.lat, edge.lon)]).unique()
crossing = [c for c in cells if wide(c)]
naive = lambda c: Polygon([(lng, lat) for lat, lng in h3.cell_to_boundary(c)])
print(f"GeoNames beyond ±170°: {len(edge):,} points in {len(cells):,} resolution-5 cells, {len(crossing)} crossing; "
f"planar area {sum(naive(c).area for c in crossing):,.1f} deg² against "
f"{sum(naive(c).area for c in cells if c not in crossing):,.1f} for the rest")
p = naive(fiji)
split = cell_polygon(fiji)
print(f"Fiji cell: valid {p.is_valid}, geodesic {abs(geod.geometry_area_perimeter(p)[0]) / 1e6:,.0f} km² "
f"(split {abs(geod.geometry_area_perimeter(split)[0]) / 1e6:,.0f}), cell_area {h3.cell_area(fiji, 'km^2'):,.0f} km², "
f"planar {p.area:.2f} deg² (split {split.area:.3f}), contains (-17.3, 0): {p.contains(Point(0, -17.3))}")
only = gpd.GeoSeries([cell_polygon(c, "shift") for c in around_fiji]) # shifts the crossing cells alone
print(f"shifting only the crossing cells leaves the layer spanning {np.round(only.total_bounds[[0, 2]], 2)}")
geo = h3.cells_to_geo([fiji])["coordinates"][0]
ring = h3.cells_to_geo(list(h3.grid_disk(fiji, 1)))["coordinates"][0]
wkt = gpd.GeoSeries.from_wkt([con.execute(f"select h3_cell_to_boundary_wkt('{fiji}')").fetchone()[0]])[0]
print(f"cells_to_geo width {np.ptp([x for x, _ in geo]):.3f}°, with neighbours {np.ptp([x for x, _ in ring]):.3f}°, "
f"DuckDB WKT {wkt.bounds[2] - wkt.bounds[0]:.3f}°")
occupied = pd.Series([h3.latlng_to_cell(a, b, 5) for a, b in zip(points.lat, points.lon)]).unique()
wrapped = [c for c in occupied if wide(c)]
sample = points.sample(2_000_000, random_state=0)
truth = pd.Series([h3.latlng_to_cell(a, b, 5) for a, b in zip(sample.lat, sample.lon)], index=sample.index)
pts = gpd.GeoDataFrame(geometry=gpd.points_from_xy(sample.lon, sample.lat), index=sample.index, crs="EPSG:4326")
for label, build in [("naive", naive), ("split", lambda c: cell_polygon(c) if c not in (
h3.latlng_to_cell(90, 0, 5), h3.latlng_to_cell(-90, 0, 5)) else naive(c))]:
polys = gpd.GeoDataFrame({"cell": wrapped}, geometry=[build(c) for c in wrapped], crs="EPSG:4326")
hit = gpd.sjoin(pts, polys, predicate="within")
right = (truth.loc[hit.index].values == hit.cell.values).sum()
far = (sample.loc[hit.index, "lon"].abs() < 90).sum()
print(f"{label}: {len(hit):,} matches against {len(wrapped)} wrapped cells, {right} correct, "
f"{far:,} within 90° of the prime meridian; {truth.isin(wrapped).sum()} points truly in those cells")
widest = max(q.bounds[2] - q.bounds[0] for g in polys.geometry for q in getattr(g, "geoms", [g])
if q.bounds[2] - q.bounds[0] <= 180)
polar = polys.cell.isin([h3.latlng_to_cell(90, 0, 5), h3.latlng_to_cell(-90, 0, 5)])
print(f"widest single part after splitting (the polar cell aside): {widest:.1f}°; "
f"all valid {polys[~polar].is_valid.all()}; polar cells left as they were: {int(polar.sum())}")
res 0: 122 cells, 17 crossing 180°, 2 of them polar
res 2: 5,882 cells, 88 crossing 180°, 2 of them polar
res 3: 41,162 cells, 218 crossing 180°, 2 of them polar
res 5: 2,016,842 cells, 1,547 crossing 180°, 2 of them polar
north-pole cell at res 0 spans 180.3° of longitude
north-pole cell at res 3 spans 315.6° of longitude
GeoNames beyond ±170°: 82,973 points in 3,524 resolution-5 cells, 55 crossing; planar area 2,334.5 deg² against 137.0 for the rest
Fiji cell: valid True, geodesic 11,380 km² (split 11,380), cell_area 11,418 km², planar 378.54 deg² (split 0.967), contains (-17.3, 0): True
shifting only the crossing cells leaves the layer spanning [-179.9 180.47]
cells_to_geo width 359.408°, with neighbours 359.448°, DuckDB WKT 359.408°
naive: 42,096 matches against 57 wrapped cells, 0 correct, 33,877 within 90° of the prime meridian; 76 points truly in those cells
split: 72 matches against 57 wrapped cells, 72 correct, 0 within 90° of the prime meridian; 76 points truly in those cells
widest single part after splitting (the polar cell aside): 2.0°; all valid True; polar cells left as they were: 2
Rings that go the long way
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.
Pre-split countries and web maps
- 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 42,096 matches, none correct, and missed all 76 real ones. Split polygons returned 72 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.