How to clip a dataset to an exclusive economic zone
Problem statement
Clipping to an EEZ is a gpd.clip call, and three things around it decide whether the result is right: which EEZ layer, whether the zone crosses the antimeridian, and whether you need the features that straddle the boundary kept, cut or dropped.
The antimeridian is the one that fails catastrophically. A country such as Fiji is stored as a single feature made of 44 parts spanning the full โ180 to 180 range, with 18 parts west of โ170ยฐ and 26 east of 170ยฐ. Its naive centroid longitude is 163.41ยฐE, which is over a thousand kilometres from the country, and every clip, buffer and bounding-box operation inherits that.
Quick answer
Check for an antimeridian crossing before anything else, then clip with the right predicate:
import geopandas as gpd, numpy as np
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())
eez = gpd.read_file("eez.gpkg")
zone = eez[eez["territory"] == "Fiji"]
print("crosses the antimeridian:", crosses_antimeridian(zone))
clipped = gpd.clip(data.to_crs(zone.crs), zone) # cuts geometries at the boundary
inside = gpd.sjoin(data.to_crs(zone.crs), zone, predicate="within") # keeps whole features
clip cuts; sjoin with within keeps only features entirely inside; sjoin with intersects keeps any feature that touches. Those are three different answers and the choice belongs to the analysis.
Step-by-step solution
1. Choose the zone layer deliberately
An authoritative EEZ layer from Marine Regions or a national hydrographic office carries the treaty boundaries and the disputed areas. A 200 NM buffer computed from a coastline is an approximation that understates the zone wherever straight baselines apply. Say which you used.
2. Detect an antimeridian crossing
A geometry whose parts reach both โ180 and +180 crosses it. So does a bounding box that spans nearly the whole world for a country that does not.
3. Shift to a 0โ360 frame if it does
The simplest reliable fix is to translate every negative longitude by +360, do the work, and translate back. Both the zone and the data have to be shifted, and the result has to be shifted back before writing.
from shapely.ops import transform
def to_0_360(geom):
return transform(lambda x, y, z=None: (np.where(x < 0, x + 360, x), y), geom)
4. Decide what happens to straddling features
- Cut them โ
gpd.clip. Right for area-based analysis; it invents boundaries in the attribute table, so any per-feature attribute is no longer valid for the cut piece. - Keep whole features that are entirely inside โ
predicate="within". Right when the features are indivisible. - Keep whole features that touch โ
predicate="intersects". Right for a screening filter.
5. Reproject before area work, not after
Areas and distances computed in degrees are meaningless. Clip in a geographic CRS if you like, then reproject to an equal-area projection for any statistic.
6. Watch the overlaps
EEZs overlap where entitlements conflict, and a point can legitimately be in two. A spatial join then duplicates rows, which is correct and surprising.
7. Check the counts and the area against the zone
Features dropped, features cut, area retained. A clip that keeps 3% of the data usually means a CRS mismatch rather than a small zone.
Code examples
Example 1 โ a clip that reports what it did
import geopandas as gpd, numpy as np
def clip_to_zone(data, zone, mode="clip", area_crs=None):
if data.crs != zone.crs:
data = data.to_crs(zone.crs)
z = zone.union_all()
if mode == "clip":
out = gpd.clip(data, zone)
cut = int((~out.geometry.geom_equals(
data.loc[out.index, "geometry"], align=True)).sum())
elif mode == "within":
out = data[data.within(z)]
cut = 0
else:
out = data[data.intersects(z)]
cut = 0
report = {"mode": mode, "in": len(data), "out": len(out),
"dropped": len(data) - len(out), "cut": cut}
if area_crs is not None and (data.geom_type.isin(["Polygon", "MultiPolygon"])).all():
before = data.to_crs(area_crs).area.sum() / 1e6
after = out.to_crs(area_crs).area.sum() / 1e6
report |= {"area_before_km2": round(before), "area_after_km2": round(after),
"area_retained": round(after / before, 4) if before else None}
return out, report
Example 2 โ the antimeridian, handled
import numpy as np, geopandas as gpd
from shapely.ops import transform
def shift_longitudes(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
zone = gpd.read_file("eez.gpkg").query("territory == 'Fiji'")
print("naive centroid longitude:", round(float(zone.geometry.centroid.x.iloc[0]), 2))
z360 = shift_longitudes(zone)
d360 = shift_longitudes(data.to_crs(zone.crs))
clipped = gpd.clip(d360, z360)
result = shift_longitudes(clipped, to_360=False)
print("centroid in the 0โ360 frame:", round(float(z360.geometry.centroid.x.iloc[0]), 2))
naive centroid longitude: 163.41
centroid in the 0โ360 frame: 178.52
163.41ยฐE is in the open Pacific, roughly 1,500 km west of Fiji. 178.52ยฐ is the country, whose shifted bounds run from 174.59 to 181.78. The shifted frame is what every operation needs.
Example 3 โ area statistics per zone, correctly projected
import geopandas as gpd, numpy as np
from pyproj import CRS
def per_zone_area(features, zones, name_col="territory"):
rows = []
for _, zone in zones.iterrows():
g = gpd.GeoDataFrame(geometry=[zone.geometry], crs=zones.crs)
cx, cy = zone.geometry.centroid.x, zone.geometry.centroid.y
crs = CRS.from_proj4(f"+proj=laea +lat_0={cy:.4f} +lon_0={cx:.4f} "
"+datum=WGS84 +units=m")
clipped = gpd.clip(features.to_crs(zones.crs), g)
rows.append({
name_col: zone[name_col],
"features": len(clipped),
"zone_km2": round(g.to_crs(crs).area.iloc[0] / 1e6),
"feature_km2": round(clipped.to_crs(crs).area.sum() / 1e6, 1) if len(clipped) else 0.0,
})
return gpd.pd.DataFrame(rows)
A per-zone Lambert azimuthal equal-area projection centred on each zone is the honest way to compare areas across a set of EEZs โ a single global equal-area projection distorts shape badly at high latitudes even though the areas are right.
Explanation
Why a naive centroid lands in the wrong ocean
The centroid is computed from the coordinates as numbers. A territory with parts at 177ยฐE and 179ยฐW is stored as parts at +177 and โ179, and their average is โ1 โ the Atlantic. Fiji's 44 parts average to 163.41, which is not as dramatic and is still 1,500 km from the country. Nothing about the value is flagged as impossible.
Why shifting beats splitting
You can split every geometry at the antimeridian and work with the pieces, and some pipelines do. Shifting to 0โ360 is simpler, keeps each feature whole, and works for every operation โ buffers, centroids, clips, bounding boxes โ provided everything in the computation is shifted the same way and shifted back before writing. The only trap is forgetting one of the layers.
Why clip and sjoin give different counts
clip intersects the geometries, so a polygon straddling the boundary appears with a smaller area and its original attributes โ which is fine for area and wrong for anything per-feature, such as a population count. within drops it. intersects keeps it whole, including the part outside the zone. Each is right for a different question and none is a default.
Why overlaps duplicate rows
Where two EEZs overlap, a point inside both matches both in a spatial join, and the join emits a row per match. That is the correct result and it breaks any downstream code that assumes one row per input feature. Either resolve the assignment explicitly or aggregate with a rule.
Edge cases or notes
- Check the CRS on both layers. A near-empty clip is usually a mismatch.
clipkeeps the attributes of a cut feature. They may no longer be true.- Zones can be multipart with islands thousands of kilometres apart.
- Some EEZs are disputed and appear in more than one layer's polygons.
- Buffer-derived zones understate where straight baselines apply.
- Reproject for area, always. Degrees-squared is not a unit.
- Write in โ180 to 180 unless the consumer expects otherwise.
- Record the zone source and version with the output.
Internal links
- A coastline or EEZ breaks at the antimeridian โ the failure in detail
- Maritime boundaries explained: EEZ, territorial sea and baselines โ where the zone comes from
- How to clip spatial data in Python with GeoPandas โ the general operation
- How to select features by location in GeoPandas โ the predicate choice
- Longitude conventions explained โ 0โ360 versus โ180 to 180
- How to choose a projected CRS for an area โ picking the equal-area projection
- Spatial join cardinality explained โ why overlaps duplicate rows
- How to calculate area and distance in GeoPandas โ measuring the result
FAQ
How do I clip data to an EEZ?
gpd.clip(data, zone) after matching the CRS โ but check first whether the zone crosses the antimeridian, and decide whether straddling features should be cut, dropped or kept whole.
Why is my Pacific EEZ clip empty or enormous?
Because the zone crosses the antimeridian. Shift every layer to a 0โ360 longitude frame, do the work, and shift back.
Should I use clip or a spatial join?
clip cuts geometries at the boundary, within keeps only whole features inside, intersects keeps whole features that touch. Pick by whether your features are divisible.
Why does my spatial join return more rows than it started with?
Because EEZs can overlap where entitlements conflict, and a feature inside two zones matches both.
Can I just buffer the coastline by 200 NM?
For an approximation, yes, in a suitable projection. It understates the zone wherever straight baselines apply and is not an authoritative boundary.
How do I compute areas per zone?
Reproject each zone and its clipped features to a Lambert azimuthal equal-area projection centred on that zone.