How to Clip a NetCDF Grid to a Polygon in Python

Problem statement

Clipping a climate grid to a country, catchment or region is the first step of most regional analyses, and rioxarray makes it one call. That call decides which cells belong to the polygon by their centres — or, with all_touched=True, by any contact at all — and on a coarse grid that decision is the result. It also fails outright, or silently returns the wrong cells, when the grid's longitude convention, spatial dimensions or CRS are not what it expects.

Measured on the NCEP/NCAR Reanalysis 1 monthly air temperature at 2.5°, averaged over 1991–2020, against Natural Earth 50 m country polygons:

  • Belgium contains one cell centre. With all_touched=True it got 5 cells; the polygon's true share of the grid was 0.63 of a cell.
  • all_touched=True put the Netherlands in the Caribbean. Its overseas islands touched two tropical cells, and the mean rose from 10.11 °C to 16.57 °C.
  • Japan's mean was 12.04 °C by centres, 15.48 °C with all_touched and 12.73 °C weighted by the share of each cell inside the country.
  • On the grid left at 0–360 longitude, clipping Brazil raised NoDataInBounds, and the United States kept 4 cells out of 274.

Quick answer

Put the grid in the polygons' longitude convention, declare its spatial dimensions and CRS, crop to the bounding box, then clip:

import geopandas as gpd
import rioxarray  # noqa: F401  registers the .rio accessor
import xarray as xr

world = gpd.read_file("ne_50m_admin_0_countries.zip")
country = world[world.ADMIN == "United Kingdom"]

air = xr.open_dataset("air.mon.mean.nc")["air"]
air = air.assign_coords(lon=((air.lon + 180) % 360) - 180).sortby("lon")
air = air.rio.set_spatial_dims(x_dim="lon", y_dim="lat").rio.write_crs("EPSG:4326")
clipped = air.rio.clip_box(*country.total_bounds).rio.clip(country.geometry, country.crs)
print(dict(clipped.sizes), int(clipped.isel(time=0).notnull().sum()), "cells")
{'time': 938, 'lat': 3, 'lon': 3} 6 cells

Then count the cells you kept. If the polygon is small compared with a grid cell, weight cells by the share of their area inside it instead of clipping (Example 2).

Flow for clipping a NetCDF grid: convert longitude, declare spatial dimensions and CRS, crop to the bounding box, clip, count cells and weight by area.
Most clipping failures happen in the first two boxes, before any geometry is involved.

Step-by-step solution

1. Match the longitude convention

Natural Earth polygons, like almost all vector data, use longitudes from −180 to 180; the NCEP grid runs from 0 to 357.5. Clipped without converting, Brazil — entirely at negative longitudes — raised NoDataInBounds: No data found in bounds, and the United States returned 4 cells from the Aleutian Islands. Convert with ((lon + 180) % 360) - 180 and sort; the details are in longitude conventions explained.

2. Declare the spatial dimensions and the CRS

rioxarray needs to know which dimensions are x and y and what coordinate system the grid uses. It recognised NCEP's lat and lon from their attributes, but without a CRS the clip raised MissingCRS: CRS not found. Please set the CRS with 'rio.write_crs()'. For OISST, whose coordinates lack the attributes rioxarray looks for, it raised MissingSpatialDimensionError until rio.set_spatial_dims(x_dim="lon", y_dim="lat") was called. Set both explicitly every time.

3. Keep only one non-spatial dimension

A clip works on (time, lat, lon): the 938-month cube came back as (938, 3, 3) for the United Kingdom, with time intact. Four-dimensional data such as OISST's (time, zlev, lat, lon) is not supported; squeeze or select the extra dimension first.

4. Crop to the bounding box first

rio.clip builds a mask for the whole grid. On the 938-month cube, clipping the United Kingdom took 24.2 ms; cropping to its bounding box with rio.clip_box and then clipping took 5.5 ms. The saving grows with the grid.

5. Decide what "inside" means

By default a cell is kept when its centre lies inside the polygon; all_touched=True keeps every cell the polygon touches. On a 2.5° grid the difference is large:

country          centre cells  mean     all_touched cells  mean     share-weighted mean  (cells)
Belgium               1       10.72 °C          5         10.50 °C       10.83 °C          0.63
Netherlands           1       10.11 °C          7         16.57 °C       10.11 °C          0.78
Switzerland           1        6.66 °C          6          8.48 °C        6.67 °C          0.78
United Kingdom        6        9.51 °C         17         10.03 °C        9.86 °C          5.30
Japan                 9       12.04 °C         30         15.48 °C       12.73 °C          6.06
Chile                13        8.87 °C         39          8.91 °C        8.45 °C         12.30

The last column is the reference: each cell weighted by the share of its area inside the country and by cos(latitude). Centre cells were closer to it for most countries; all_touched was far off wherever a polygon has small outlying parts.

6. Check what all_touched added

The Netherlands' 7 touched cells included two in the Caribbean — at 17.5° N 62.5° W and 12.5° N 67.5° W, averaging over 26 °C — which its overseas islands covered by 0.04% and 0.29%. Japan's southern islands did the same over open Pacific. Listing the cells a clip keeps, with the share of each inside the polygon, catches this in seconds (Example 3).

7. Weight by cell share when polygons are small

When a polygon covers only a few cells, neither rule is adequate: centre-only can keep nothing, and all-touched counts a sliver as a whole cell. Weighting every cell by the share of its area inside the polygon uses all the information. For a per-polygon time series over many polygons, build those weights once and reuse them, as in extracting a time series per polygon.

8. Weight by area when you average

Clipped cells still have different areas. Average them with cos(latitude) weights — clipped.weighted(np.cos(np.deg2rad(clipped.lat))).mean(("lat", "lon")) — as in taking an area-weighted mean. The rule for which cells count and the rule for how they are weighted are separate decisions.

Bar chart of the error in country mean temperature from centre-only and all_touched clipping, against a share-weighted reference, for the Netherlands, Japan, Switzerland and Belgium.
all_touched was the riskier rule on a coarse grid: slivers and islands count as whole cells.

Code examples

Example 1 — a clip that fixes the usual obstacles and reports its cells

import geopandas as gpd
import numpy as np
import rioxarray  # noqa: F401
import xarray as xr


def clip_to_polygons(da, polygons, all_touched=False, x="lon", y="lat"):
    """Clip a latitude-longitude DataArray to polygons; return the clip and the number of cells kept."""
    if float(da[x].max()) > 180:
        da = da.assign_coords({x: ((da[x] + 180) % 360) - 180}).sortby(x)
    da = da.rio.set_spatial_dims(x_dim=x, y_dim=y).rio.write_crs("EPSG:4326")
    polygons = polygons.to_crs("EPSG:4326")
    box = da.rio.clip_box(*polygons.total_bounds, auto_expand=True)
    clipped = box.rio.clip(polygons.geometry, polygons.crs, all_touched=all_touched)
    first = clipped.isel({d: 0 for d in clipped.dims if d not in (x, y)})
    return clipped, int(first.notnull().sum())


def area_mean(da, x="lon", y="lat"):
    return da.weighted(np.cos(np.deg2rad(da[y]))).mean((y, x))


world = gpd.read_file("ne_50m_admin_0_countries.zip")
climate = xr.open_dataset("air.mon.mean.nc")["air"].sel(time=slice("1991", "2020")).mean("time")
for name in ["United Kingdom", "Belgium", "Japan"]:
    country = world[world.ADMIN == name]
    for touched in (False, True):
        clipped, cells = clip_to_polygons(climate, country, all_touched=touched)
        print(f"{name:15} all_touched={touched!s:5} {cells:3d} cells, mean {float(area_mean(clipped)):.2f} °C")
United Kingdom  all_touched=False   6 cells, mean 9.51 °C
United Kingdom  all_touched=True   17 cells, mean 10.03 °C
Belgium         all_touched=False   1 cells, mean 10.72 °C
Belgium         all_touched=True    5 cells, mean 10.50 °C
Japan           all_touched=False   9 cells, mean 12.04 °C
Japan           all_touched=True   30 cells, mean 15.48 °C

auto_expand=True lets the bounding-box crop grow when a polygon is narrower than a grid cell, instead of raising an error for a one-cell-wide result.

Example 2 — weight cells by the share inside the polygon

import shapely


def share_weighted_mean(da, polygons, x="lon", y="lat"):
    """Mean of a 2-D grid, weighting each cell by its share inside the polygons and by cos(latitude)."""
    geom = shapely.union_all(polygons.to_crs("EPSG:4326").geometry.values)
    dx = abs(float(da[x][1] - da[x][0])) / 2
    dy = abs(float(da[y][1] - da[y][0])) / 2
    lon2, lat2 = np.meshgrid(da[x].values, da[y].values)
    boxes = shapely.box(lon2 - dx, lat2 - dy, lon2 + dx, lat2 + dy)
    share = shapely.area(shapely.intersection(boxes, geom)) / (4 * dx * dy)
    weights = share * np.cos(np.deg2rad(lat2))
    ok = (weights > 0) & np.isfinite(da.values)
    return float((da.values[ok] * weights[ok]).sum() / weights[ok].sum()), float(share.sum())


climate180 = climate.assign_coords(lon=((climate.lon + 180) % 360) - 180).sortby("lon")
for name in ["Belgium", "Netherlands", "Japan", "Chile"]:
    country = world[world.ADMIN == name]
    reference, cells = share_weighted_mean(climate180, country)
    centre = float(area_mean(clip_to_polygons(climate, country)[0]))
    touched = float(area_mean(clip_to_polygons(climate, country, all_touched=True)[0]))
    print(f"{name:12} share-weighted {reference:6.2f} ({cells:.2f} cells) | centre {centre:6.2f} | all_touched {touched:6.2f}")
Belgium      share-weighted  10.83 (0.63 cells) | centre  10.72 | all_touched  10.50
Netherlands  share-weighted  10.11 (0.78 cells) | centre  10.11 | all_touched  16.57
Japan        share-weighted  12.73 (6.06 cells) | centre  12.04 | all_touched  15.48
Chile        share-weighted   8.45 (12.30 cells) | centre   8.87 | all_touched   8.91

Cell shares are computed in degrees, which is right for a regular latitude–longitude grid: the cos(latitude) weight supplies the change in area with latitude.

Example 3 — list the cells that are barely inside

def barely_inside(da, polygons, threshold=0.05, x="lon", y="lat"):
    clipped, _ = clip_to_polygons(da, polygons, all_touched=True, x=x, y=y)
    geom = shapely.union_all(polygons.to_crs("EPSG:4326").geometry.values)
    half = abs(float(da[y][1] - da[y][0])) / 2
    cells = clipped.stack(cell=(y, x)).dropna("cell")
    for lat, lon, value in zip(cells[y].values, cells[x].values, cells.values):
        cell = shapely.box(lon - half, lat - half, lon + half, lat + half)
        share = shapely.area(shapely.intersection(cell, geom)) / (2 * half) ** 2
        if share < threshold:
            print(f"cell ({lat:5.1f}, {lon:6.1f})  {value:6.2f} °C  {share:.2%} inside")


barely_inside(climate, world[world.ADMIN == "Netherlands"])
cell ( 52.5,    2.5)   10.65 °C  1.33% inside
cell ( 50.0,    2.5)   11.50 °C  0.01% inside
cell ( 50.0,    5.0)   10.72 °C  2.50% inside
cell ( 17.5,  -62.5)   26.21 °C  0.04% inside
cell ( 12.5,  -67.5)   26.47 °C  0.29% inside

Three slivers along the Dutch border and two Caribbean cells were each less than 3% inside the country.

The function assumes square cells, as on the 2.5° grid here.

Explanation

Why the cell-centre rule is the default

rioxarray rasterises the polygons onto the grid with GDAL's rules: a cell is inside when its centre is. That matches how most raster tools behave and never counts a cell twice when adjacent polygons are clipped separately. Its weakness is resolution: a polygon smaller than a cell may contain no centre, and a polygon a few cells across keeps or loses whole cells on a coin toss of where its edge falls.

Why all_touched is worse more often than it looks

all_touched=True fixes the empty result for small polygons by keeping every cell that any part of the polygon reaches. Real boundaries are ragged — coastlines, islands, enclaves, overseas territories — so a country touches many cells it barely overlaps, and each of them counts as fully as a cell in its interior. On a 2.5° grid the touched set was 2.8 to 7 times the centre set for the countries tested.

Why resolution changes the choice

The same polygons on OISST's 0.25° grid kept 63 centre cells for Belgium and 88 touched cells — 1.4 times as many, against 5 times at 2.5°. As cells shrink relative to the polygon, the edge cells become a small share of the total and both rules converge on the share-weighted answer. On fine grids the default is fine; on coarse ones, weight by share.

Why CRS and dimensions must be declared

A NetCDF file has no GDAL-style geotransform or CRS. rioxarray infers the transform from the coordinate values once it knows which dimensions are spatial, and it needs a CRS to reproject polygons onto the grid. Polygons in another CRS work when their CRS is passed: the United Kingdom in British National Grid clipped to 17 cells with all_touched=True when the CRS was given, and raised NoDataInBounds when it was not.

Bar chart of the number of grid cells kept by centre-only and all_touched clipping for five countries on the 2.5 degree NCEP grid.
On a coarse grid the two rules keep very different numbers of cells for the same polygon.

Edge cases or notes

  • Polygons with no cell centre clip to an all-NaN array rather than raising; count the cells.
  • Multipart countries clip every part, including distant islands.
  • Clipping does not reduce memory much on its own unless you crop to the bounding box first.
  • A mask with shapely.contains_xy gave an identical series to rio.clip in 18.1 ms on the full cube, and needs no CRS handling for lat/lon polygons.
  • Curvilinear model grids cannot be clipped this way; use cell-centre masks on the 2-D coordinates or regrid first.
  • Regridding before clipping changes the answer again; see regridding explained.
  • Writing the clip to GeoTIFF needs the spatial dims and CRS set as above; see converting NetCDF to GeoTIFF.

FAQ

How do I clip a NetCDF file to a shapefile in Python?

Open it with xarray, convert longitude to −180–180 if needed, call rio.set_spatial_dims and rio.write_crs, crop with rio.clip_box and clip with rio.clip using the polygons and their CRS.

Why does rioxarray clip raise NoDataInBounds?

Usually because the grid uses 0–360 longitude and the polygon has negative longitudes, or because the polygons' CRS was not passed. Clipping Brazil on the unconverted NCEP grid raised it.

Should I use all_touched when clipping climate data?

Rarely on coarse grids. It gave the Netherlands two Caribbean cells and a mean of 16.57 °C instead of about 10.1 °C; weight cells by their share inside the polygon instead.

What if my polygon is smaller than a grid cell?

A centre-only clip may keep nothing. Weight the cells by the share of each inside the polygon; Belgium covered 0.63 of a 2.5° cell in total.

Why does rioxarray say it cannot find the x or y dimension?

The coordinates lack the attributes it uses to recognise them. Call rio.set_spatial_dims(x_dim="lon", y_dim="lat") before clipping.

Does clipping keep the time dimension?

Yes. Clipping the 938-month cube to the United Kingdom returned a 938 × 3 × 3 array with the time coordinate intact.