A coastline or EEZ breaks at the antimeridian
On this page
Problem statement
A dataset that spans 180° longitude produces a specific and unmistakable set of failures: a country stretched all the way around the world, a centroid in the wrong ocean, a bounding box covering the entire planet, a buffer that overlaps itself, and polygons with a thin sliver connecting two halves.
The cause is that longitude wraps and coordinates do not. A territory with parts at 177°E and 179°W is stored as +177 and −179, and every function that works with those numbers as plain coordinates — centroid, bounds, buffer, dissolve, clip, distance — treats them as 356° apart rather than 4°.
Measured on the Natural Earth Fiji polygon: one feature, 44 parts, bounds running from −180 to +180, 18 parts west of −170° and 26 east of 170°, and a naive centroid longitude of 163.41° — in open sea 1,190 km west of the westernmost island, and 1,605 km from the country's true centroid (Example 4).
Quick answer
Detect the crossing, shift to a 0–360 frame, work, shift back:
import numpy as np, geopandas as gpd
from shapely.ops import transform
def crosses_antimeridian(gdf, margin=10.0):
b = gdf.explode(index_parts=False).bounds
return bool((b.minx < -180 + margin).any() and (b.maxx > 180 - margin).any())
def shift(gdf, to_360=True):
def fn(x, y, z=None):
x = np.asarray(x)
return (np.where(x < 0, x + 360, x) if to_360 else np.where(x > 180, x - 360, x), y)
out = gdf.copy()
out["geometry"] = out.geometry.apply(lambda g: transform(fn, g))
return out
if crosses_antimeridian(zone):
z = shift(zone)
result = shift(gpd.clip(shift(data), z), to_360=False)
print(f"crosses: {crosses_antimeridian(zone)}; {len(result):,} of {len(data):,} earthquakes clipped")
print("Fiji in the 0-360 frame:", [round(float(v), 2) for v in shift(fiji).total_bounds],
round(shift(fiji).geometry.iloc[0].centroid.x, 2))
crosses: True; 2,538 of 13,512 earthquakes clipped
Fiji in the 0-360 frame: [174.59, -21.71, 181.78, -12.48] 178.52
In the shifted frame the same Fiji polygon has bounds from 174.59 to 181.78 and a centroid longitude of 178.52 — the country.
Detect the crossing, then shift or split
1. Detect the crossing rather than assuming
A geometry whose parts reach both extremes crosses it. So does one whose bounding box spans nearly 360° for a feature that obviously does not.
b = fiji.total_bounds
suspicious = (b[2] - b[0]) > 350
print(suspicious)
True
2. Know the symptoms so you recognise them elsewhere
| symptom | operation |
|---|---|
| centroid in the wrong ocean | centroid |
| bounding box covers the world | total_bounds, sjoin with a bbox |
| polygon stretched across the map | rendering, union_all |
| buffer overlaps itself across the seam | buffer, then an area |
| distance of 40,000 km for 300 km | any planar distance |
3. Shift to 0–360 for the work
Add 360 to every negative longitude. Everything in the computation must be shifted — the data, the mask, the study area — and the result shifted back before writing.
4. Or split at the antimeridian, if the consumer needs −180 to 180
Splitting each geometry along the 180° meridian gives valid −180 to 180 features at the cost of turning one feature into two. GeoJSON's specification recommends this; antimeridian on PyPI implements it properly.
5. Work in a projected CRS where you can
A polar or Pacific-centred projection has no seam where your data is. For a study area entirely in the Pacific, projecting once at the start removes the whole problem.
6. Use ECEF for distances
Converting to Earth-centred Cartesian coordinates and using a k-d tree makes nearest-neighbour and distance queries immune to the seam, because there is no seam in three dimensions.
7. Write in the convention the consumer expects
GeoJSON should be −180 to 180 and split at the meridian. Internal processing can use whatever is convenient, provided the boundary between the two is explicit.
Fiji and its zone across the 180th meridian
The data used on this page
Fiji from Natural Earth's 1:10m countries; Fiji's exclusive economic zone from the Marine Regions EEZ layer, through its WFS; and every M4.5+ earthquake in the USGS catalogue between 5° and 30° S and 160°E and 160°W for 2015–2024, as the data to clip.
import io
import os
import subprocess
import geopandas as gpd
import requests
if not os.path.exists("ne_10m_admin_0_countries.zip"):
subprocess.run(["curl", "-sfLO", "https://naciscdn.org/naturalearth/10m/cultural/ne_10m_admin_0_countries.zip"],
check=True)
world = gpd.read_file("ne_10m_admin_0_countries.zip")
fiji = world[world["ADMIN"] == "Fiji"][["ADMIN", "geometry"]]
wfs = requests.get("https://geo.vliz.be/geoserver/MarineRegions/wfs", timeout=300, params={
"service": "WFS", "version": "1.0.0", "request": "GetFeature", "typeName": "MarineRegions:eez",
"CQL_FILTER": "territory1='Fiji'", "outputFormat": "application/json"})
zone = gpd.read_file(io.BytesIO(wfs.content))[["geoname", "geometry"]]
quakes = requests.get("https://earthquake.usgs.gov/fdsnws/event/1/query", timeout=300, params={
"format": "geojson", "starttime": "2015-01-01", "endtime": "2025-01-01", "minmagnitude": 4.5,
"minlatitude": -30, "maxlatitude": -5, "minlongitude": 160, "maxlongitude": 200})
data = gpd.read_file(io.BytesIO(quakes.content))[["mag", "geometry"]].to_crs(zone.crs)
print(len(fiji), "Fiji;", len(zone), "EEZ;", len(data), "earthquakes")
1 Fiji; 1 EEZ; 13512 earthquakes
Example 1 — diagnose a layer
import numpy as np, geopandas as gpd
def antimeridian_report(gdf, margin=10.0):
parts = gdf.explode(index_parts=False)
b = parts.bounds
west = int((b.minx < -180 + margin).sum())
east = int((b.maxx > 180 - margin).sum())
total = gdf.total_bounds
return {
"features": len(gdf), "parts": len(parts),
"parts_near_minus_180": west, "parts_near_plus_180": east,
"crosses": bool(west and east),
"total_bounds": np.round(total, 3).tolist(),
"bbox_width_deg": round(float(total[2] - total[0]), 2),
"naive_centroid_lon": round(float(gdf.geometry.centroid.x.mean()), 2),
}
print(antimeridian_report(fiji))
{'features': 1, 'parts': 44, 'parts_near_minus_180': 18, 'parts_near_plus_180': 26, 'crosses': True, 'total_bounds': [-180.0, -21.711, 180.0, -12.475], 'bbox_width_deg': 360.0, 'naive_centroid_lon': 163.41}
A bounding box 360° wide on a feature that is 7° wide is the single clearest signal there is.
Example 2 — a context manager that shifts and restores
import contextlib, numpy as np, geopandas as gpd
from shapely.ops import transform
def _shift(geom, to_360):
def fn(x, y, z=None):
x = np.asarray(x)
return (np.where(x < 0, x + 360, x) if to_360
else np.where(x > 180, x - 360, x), y)
return transform(fn, geom)
@contextlib.contextmanager
def pacific_frame(*gdfs):
"""Shift every layer to 0–360 for the duration of the block."""
shifted = []
for g in gdfs:
s = g.copy()
s["geometry"] = s.geometry.apply(lambda q: _shift(q, True))
shifted.append(s)
try:
yield shifted if len(shifted) > 1 else shifted[0]
finally:
pass
with pacific_frame(zone, data) as (z, d):
clipped = gpd.clip(d, z)
clipped["geometry"] = clipped.geometry.apply(lambda q: _shift(q, False))
print(len(clipped), "earthquakes in the zone;", [round(float(v), 2) for v in clipped.total_bounds])
2538 earthquakes in the zone; [-180.0, -24.35, 180.0, -13.04]
The important property is that both layers are shifted together. Shifting one and not the other produces an empty clip, which looks like a data problem.
Example 3 — split at the meridian for output
import numpy as np, geopandas as gpd
from shapely.geometry import box
from shapely.ops import transform
def split_at_antimeridian(gdf):
"""Cut 0–360 geometries at 180 and return them in −180 to 180."""
west = box(-180, -90, 180, 90)
east = box(180, -90, 540, 90)
pieces = []
for row in gdf.itertuples():
g = row.geometry
for clip_box, wrap in ((west, False), (east, True)):
part = g.intersection(clip_box)
if part.is_empty:
continue
if wrap:
part = transform(lambda x, y, z=None: (np.asarray(x) - 360, y), part)
pieces.append({**row._asdict(), "geometry": part})
out = gpd.GeoDataFrame(pieces, crs=gdf.crs).drop(columns=["Index"], errors="ignore")
return out
pieces = split_at_antimeridian(shift(fiji))
print(len(pieces), "features;", [[round(v, 2) for v in p.bounds] for p in pieces.geometry])
2 features; [[174.59, -21.71, 180.0, -12.48], [-180.0, -20.67, -178.22, -16.12]]
Use this on the way out, not during processing: splitting doubles the feature count and any per-feature attribute then describes half a feature.
Example 4 — the symptoms, measured
import shapely
from pyproj import Geod, Transformer
geod = Geod(ellps="WGS84")
g, s360 = fiji.geometry.iloc[0], shift(fiji).geometry.iloc[0]
naive, true = g.centroid, s360.centroid
print(f"centroid: naive {naive.x:.2f}, true {true.x:.2f}; "
f"{geod.inv(naive.x, naive.y, true.x - 360 if true.x > 180 else true.x, true.y)[2] / 1000:,.0f} km apart, "
f"{geod.inv(naive.x, naive.y, s360.bounds[0], naive.y)[2] / 1000:,.0f} km from the westernmost land")
print(f"convex hull: {g.convex_hull.area:,.0f} deg² against {s360.convex_hull.area:,.1f} in the 0-360 frame")
print(f"0.25° buffer: {g.buffer(0.25).area:.2f} deg² against {s360.buffer(0.25).area:.2f}")
suva, lakeba = (178.4419, -18.1416), (-178.8025, -18.2222) # two Fijian islands, either side of 180°
to_merc = Transformer.from_crs(4326, 3857, always_xy=True)
(x1, y1), (x2, y2) = to_merc.transform(*suva), to_merc.transform(*lakeba)
print(f"Suva to Lakeba: {np.hypot(x2 - x1, y2 - y1) / 1000:,.0f} km in Web Mercator, "
f"{geod.inv(*suva, *lakeba)[2] / 1000:,.0f} km on the ellipsoid")
centroid: naive 163.41, true 178.52; 1,605 km apart, 1,188 km from the westernmost land
convex hull: 2,475 deg² against 38.3 in the 0-360 frame
0.25° buffer: 11.11 deg² against 10.45
Suva to Lakeba: 39,768 km in Web Mercator, 292 km on the ellipsoid
The buffer is the quiet one. It does not vanish: each part is buffered on its own side of the line, the two buffers overlap across 180° without joining, and the area comes out 6% too large.
Far-away centroids and oversized buffers
Why the centroid lands so far away
A centroid is an area-weighted average of coordinates. With 26 parts near +178 and 18 near −179, the arithmetic pulls the result towards the middle of the numeric range rather than the middle of the geography. Fiji's 163.41° is not the midpoint of ±180 because most of the land area is on the eastern-hemisphere side; it is simply a number with no geographic meaning, 1,605 km from the true centroid.
Why a buffer comes out too large
buffer operates in the coordinate plane, one part at a time. The parts just west of 180° are buffered out to +180.25 and the parts just east of it to −180.25, and nothing joins the two: the overlap across the seam is counted twice, and Fiji's 0.25° buffer measured 11.11 square degrees against 10.45 in the 0–360 frame. Anything whole-shape is worse — the convex hull spanned 2,475 square degrees against 38, because it has to reach from −180 to +180 — and whether the symptom looks like "empty" or "the whole world" depends on the operation that comes next.
Why splitting is the standard for output and not for processing
Splitting yields geometries that every consumer handles correctly, which is why GeoJSON's specification recommends it. It also turns one feature into two, so any attribute that describes the whole — a population, an area, an identifier — is now attached to two rows. Doing it as the last step before writing keeps the processing clean and the output correct.
Why ECEF has no seam
Longitude is a coordinate on a cylinder, and a cylinder has a seam. Earth-centred Cartesian coordinates are three numbers on a sphere, and a sphere does not. Any operation expressible as a distance or a nearest-neighbour query — which is most of them — is simplest in ECEF and immune to the whole problem.
Polar data, 0-360 rasters and wide bounds
- Polar data has the same shape of problem at the poles, where longitude is degenerate.
- Raster grids in 0–360 need a roll, not a slice, to subset across the meridian.
total_boundsof 360° width is the fastest detector.- Two bounding boxes is the STAC convention for a crossing item.
- Dissolving across the seam produces a sliver through the whole world.
- Tiles handle it by construction; a web map is not evidence the data is fine.
- Check both layers. Shifting one is worse than shifting neither.
- Fiji, Kiribati, Russia, Antarctica and the US all cross it.
Internal links
- How to clip a dataset to an exclusive economic zone — where this most often bites
- Maritime boundaries explained: EEZ, territorial sea and baselines — zones that routinely cross
- Longitude conventions explained — 0–360 versus −180 to 180
- H3 hexagons break at the antimeridian — the same seam in a grid system
- How to compute distance to the coast for many points — the ECEF approach
- NetCDF maps are shifted or flipped — the raster version
- How to fix invalid geometries in GeoPandas — after splitting
- Points plot in the ocean at latitude-longitude zero — a different coordinate failure
FAQ
Why is my Pacific country stretched across the whole map?
Because its parts are stored at both +177 and −179 and the renderer joins them across the numeric gap. Shift to a 0–360 frame or split the geometries at 180°.
Why is the centroid in the wrong ocean?
A centroid averages coordinates. With parts at both ends of the longitude range, the average is a number with no geographic meaning — 163.41° for Fiji.
How do I detect the problem automatically?
Check whether the total bounding box is close to 360° wide, or whether parts exist near both −180 and +180.
Should I shift or split?
Shift for processing, split for output. Splitting doubles the feature count, which breaks per-feature attributes.
Does a projected CRS fix it?
For a study area entirely on one side, yes — a Pacific-centred projection has no seam where your data is.
What about distances?
Convert to Earth-centred Cartesian coordinates and use a k-d tree. There is no seam in three dimensions.