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.

Five antimeridian symptoms and the operation that produced each. five symptoms, one causesymptomoperationcentroid in the wrong oceancentroidbounding box 360° widetotal_bounds, bbox joinspolygon stretched across the maprendering, union_allbuffer area too large, hullplanet-sizedbuffer, convex_hull39,768 km for a 292 km hopany planar distanceFiji: 44 parts, bounds −180 to 180, naive centroid 163.41° spatialworkflow.io
Five symptoms, one cause; each comes from a different function treating longitude as a plain number.

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.

Four antimeridian fixes compared. four fixesshift to 0–360split at 180projectkeeps features whole✓yes✕no✓yesoutput-ready✕no✓yes!dependsworks for distance✓yes✓yes✓yeseffort✓lowmedium✓lowECEF is the fourth: no seam at all, and the right answer for distances spatialworkflow.io
Four fixes; shifting is the simplest and splitting is what the output format wants.

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.

How to detect an antimeridian crossing automatically. detect before you operatetotal bounds close to 360° widethe clearest signalparts near both −180 and +180centroid far from the featureevery layer in the computation shiftednot just oneresult shifted back before writingassuming a Pacific dataset is fineFiji, Kiribati, Russia, the USShifting one layer and not the other produces an empty clip that looks like a data problem spatialworkflow.io
The fourth line is the one that turns a fix into a new bug.

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_bounds of 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.

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.