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.

A naive H3 cell polygon stretched across all longitudes, and the same cell split into two slivers at 180 degrees. cell 839b43fffffffff near Fiji, drawn two waysnaive polygon: 359.4° wide−180°0°180°lng 0 counts as insidevalid, planar-wrongsplit at 180°: 1.2° in two parts−180°0°180°lng 0 is outsideMultiPolygon, correctBand height exaggerated; the cell spans −17.9° to −16.8° latitude spatialworkflow.io
A polygon has no idea the map wraps. Given vertices at −179.9° and +179.5°, it takes the only path it knows: across the whole world.

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)).

Spatial join results for 2,000,000 GeoNames points against naive and split antimeridian-crossing H3 polygons. 2,000,000 sample points joined to the crossing resolution-5 cellspolygonsmatchescorrectreal points missed57 naive42,096076 of 7655 split + 2 polar72724 (pole cells)33,877 of the false matches lay within 90° of the prime meridianA wrapped polygon inverts the join rather than blurring it spatialworkflow.io
The naive join is not slightly wrong. It returned forty thousand matches, none of them right, and missed every real one.

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.

Choosing between splitting at 180 degrees, shifting to 0–360 and a polar projection for H3 cells that cross the antimeridian. a cell polygon is wider than 180°What is the polygon for?storage, joins, reprojectionsplit at 180°MultiPolygon in −180..180a Pacific-centred mapshift layer to 0–360for display onlythe cell contains a polepolar projectionor drop · 2 per resolutionCheck each polygon's width, never the layer's total bounds spatialworkflow.io
Split for data, shift for a Pacific map, and handle the two pole cells on their own.

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.

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.