A coastline or EEZ breaks at the antimeridian

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 disappears, 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ยฐ โ€” roughly 1,500 km from the country.

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)

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.

Triage of five antimeridian symptoms and the operation that produced each.
Five symptoms, one cause; each comes from a different function treating longitude as a plain number.

Step-by-step solution

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 = gdf.total_bounds
suspicious = (b[2] - b[0]) > 350

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 disappears or explodes buffer
distance is 20,000 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.

Grid comparing shifting to 0-360, splitting at 180, projecting and using ECEF across what each fixes.
Four fixes; shifting is the simplest and splitting is what the output format wants.

Code examples

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

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

Use this on the way out, not during processing: splitting doubles the feature count and any per-feature attribute then describes half a feature.

Explanation

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 the parts are unevenly distributed; it is simply a number with no geographic meaning.

Why a buffer disappears

buffer operates in the coordinate plane. A polygon with parts at +177 and โˆ’179 has a convex hull spanning 356ยฐ, and a buffer of that is a shape covering most of the plane whose intersection with anything useful is either empty or the whole world. The failure mode depends on what happens next, which is why the symptom varies.

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.

Checklist for detecting and handling an antimeridian crossing: a 360-degree bounding box, parts at both extremes, a distant centroid, shifting every layer and shifting back.
The fourth line is the one that turns a fix into a new bug.

Edge cases or notes

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