Clipping Rasters by Vector Geometry with rioxarray
Cutting a raster down to a study-area boundary looks like a one-liner, and it is — right up until the polygons arrive in a different projection, the result comes back empty, or the clipped GeoTIFF renders with a black rectangle where the outside should be transparent. This guide walks the whole rio.clip path for anyone working with xarray & rioxarray raster cubes inside Mastering Core Geospatial Python Libraries: CRS alignment first, then the four arguments that decide what the output actually contains, then getting it back out as a file other tools will read correctly.
Why This Approach / What Goes Wrong
.rio.clip() does two separate things in one call, and almost every surprise comes from confusing them. First it rasterizes your geometries onto the array's own grid, producing a boolean mask the same width and height as the raster. Second it applies that mask — setting everything outside to nodata — and optionally crops the array down to the mask's bounding window. Masking changes values; cropping changes the extent, the shape and the affine transform. drop is the switch between "same array, some cells blanked" and "smaller array".
The rasterization step is where the CRS bites. rioxarray builds the mask by feeding your geometry coordinates through the raster's affine transform, so the numbers in the geometry have to be in the raster's coordinate system. A GeoDataFrame in EPSG:4326 carries longitudes near 11.4 and latitudes near 50.9; a Sentinel-2 tile in a UTM zone has eastings near 412000. Hand the first to the second and the mask lands nowhere near the grid, and rioxarray raises rioxarray.exceptions.NoDataInBounds. That exception is the good outcome. The bad one is two projected CRSs that overlap numerically — a neighbouring UTM zone, or metres versus US survey feet — where the mask lands somewhere on the grid and you get a plausible-looking raster of the wrong place. clip accepts a crs= argument naming the CRS your geometries are in, and transforms them for you; nothing anywhere infers it.
Prerequisites
rioxarray>=0.15— supplies.rio.clip,.rio.clip_boxand therioxarray.exceptionsmodulexarray>=2024.7— the labelled array model the accessor attaches torasterio>=1.3.9— does the actual rasterization viarasterio.features.geometry_maskgeopandas>=1.0— reads the boundary layer and exposesunion_all()shapely>=2.0— the geometry objects passed straight toclippyproj>=3.6— the transformer behindto_crs()andcrs=
python -m pip install \
"rioxarray>=0.15" "xarray>=2024.7" "rasterio>=1.3.9" \
"geopandas>=1.0" "shapely>=2.0" "pyproj>=3.6"
Keep rioxarray and rasterio from one package source. The accessor calls rasterio's masking internals directly, so a pip rasterio wheel layered over a conda GDAL produces an import-time DLL failure rather than a clip error — the same rule laid out in Raster Data Handling with Rasterio.
Step-by-Step Implementation
1. Open the raster as a masked, chunked array and load the boundary.
masked=True is not cosmetic here: it promotes the array to float and turns the file's nodata into NaN, which gives the clip a value it can legally write outside the polygon. Skip it on integer data and you inherit the dtype problem covered in step 6.
import geopandas as gpd
import rioxarray # registers the .rio accessor
ndvi_cube = rioxarray.open_rasterio(
"ndvi_2023_summer.tif", masked=True, chunks={"x": 1024, "y": 1024}
).squeeze("band", drop=True)
floodplain = gpd.read_file("floodplain_boundary.gpkg")
print(ndvi_cube.rio.crs) # EPSG:32633
print(floodplain.crs) # EPSG:4326 <- different, and that is the whole problem
2. Align the CRS before anything else.
Two options, both correct. Reproject the vector layer with to_crs() and pass the geometries as-is, or leave the layer alone and tell clip which CRS the geometries are in via crs=. Reprojecting the vector side is almost always cheaper — a few hundred polygons versus tens of millions of pixels — and it leaves you with a geometry you can plot and sanity-check. Whichever you pick, never reproject the raster just to make a clip work: warping resamples every pixel, and the mechanics of doing it deliberately belong in reprojecting raster cubes with reproject_match. If you build a transformer by hand to check a corner coordinate, pass always_xy=True, because EPSG:4326 is formally latitude-first and PyProj honours that.
# Reproject the vector side — cheap, inspectable, and the mask is built from these numbers
floodplain = floodplain.to_crs(ndvi_cube.rio.crs)
assert floodplain.crs == ndvi_cube.rio.crs
# One dissolved geometry rasterizes faster than 800 separate rings
boundary = floodplain.union_all() # GeoPandas 1.0+; .unary_union on 0.x
3. Clip, and choose drop deliberately.
clip takes an iterable of geometries — Shapely objects or GeoJSON-like mappings — so [boundary], floodplain.geometry.values and floodplain.geometry all work. drop=True, the default, crops the array to the window that still holds data: the shape shrinks, the x/y coordinates shrink with it, and the affine transform moves. drop=False keeps the original grid intact and only blanks the outside, which is what you want whenever the result has to stay pixel-aligned with another layer in the same stack.
drop=True when the clip is the final product and drop=False when the array has to keep lining up with the rest of the cube.clipped = ndvi_cube.rio.clip(
[boundary],
crs=floodplain.crs, # state it even after to_crs — explicit beats implicit
drop=True,
)
print(ndvi_cube.shape) # (10980, 10980)
print(clipped.shape) # (1842, 2416) <- cropped to the mask window
4. Reach for all_touched=True only when the geometry is thin.
By default a pixel is kept when its centre falls inside the geometry. A 15 m riparian corridor on a 30 m grid can cross a column of pixels without covering a single centre, so the default rule selects nothing and clip raises NoDataInBounds on a geometry that plainly overlaps the raster. all_touched=True keeps every pixel the boundary intersects at all, which fixes slivers and over-samples everything else — the same trade-off that governs masking rasters with polygons in Python.
all_touched=True exists for the geometries that are narrower than a cell.from rioxarray.exceptions import NoDataInBounds
stream_buffer = gpd.read_file("stream_15m_buffer.gpkg").to_crs(ndvi_cube.rio.crs)
try:
corridor = ndvi_cube.rio.clip(stream_buffer.geometry, stream_buffer.crs)
except NoDataInBounds:
# Narrower than a pixel: fall back to touched-cell selection
corridor = ndvi_cube.rio.clip(
stream_buffer.geometry, stream_buffer.crs, all_touched=True
)
5. Invert the mask to punch holes instead of cutting shapes.
invert=True keeps what is outside the geometries — removing lakes from a terrain model, or blanking a restricted zone before publishing. Note that drop is still evaluated against the pixels that survive, and those surround the hole, so invert=True with drop=True normally crops nothing at all. Pass drop=False to make the intent obvious.
water = gpd.read_file("water_bodies.gpkg").to_crs(ndvi_cube.rio.crs)
# Everything except the lakes, on the original grid
land_only = ndvi_cube.rio.clip(
[water.union_all()], water.crs, invert=True, drop=False
)
6. Crop with clip_box first, and keep nodata honest.
clip rasterizes the mask at the raster's full width and height, so clipping a small parcel out of a 10980 × 10980 scene builds a 120-megapixel boolean array to keep a few thousand cells. clip_box is the cheap rectangular pre-crop: it slices by coordinate window with no rasterization at all. Run it first, then clip the much smaller result. Because the box is expressed in the raster's CRS units, take the bounds from the already-reprojected GeoDataFrame — and pad by a pixel so a boundary sitting exactly on a cell edge is not lost to rounding.
minx, miny, maxx, maxy = floodplain.total_bounds # already in the cube's CRS
pad = abs(ndvi_cube.rio.resolution()[0]) # one pixel of slack
subset = ndvi_cube.rio.clip_box(
minx - pad, miny - pad, maxx + pad, maxy + pad,
auto_expand=True, # widen the box if it degenerates to a single row/column
)
clipped = subset.rio.clip([boundary], floodplain.crs, drop=True)
Now the nodata question. clip blanks the outside with .where(), which produces NaN, and then fills those cells with .rio.nodata if one is set. On a float array opened with masked=True there is nothing to do — NaN is already the fill and it round-trips into a COG cleanly. On an integer array such as a land-cover class raster there is no integer that means "absent", so declare one before clipping rather than after.
7. Write the clip back out as a COG.
Clipping a Dask-backed cube stays lazy — the mask is built from the geometry, not from pixel values — so nothing is read until the write. Re-attach the CRS and nodata immediately before to_raster, because intermediate xarray operations drop non-dimension coordinates without warning.
import numpy as np
out = (
clipped
.rio.write_crs(ndvi_cube.rio.crs)
.rio.write_nodata(np.nan, encoded=True)
)
out.rio.to_raster(
"ndvi_floodplain_2023.tif",
driver="COG",
compress="DEFLATE",
predictor=2,
)
The COG driver builds the tiling and overview pyramid itself; the overview resampling choice for categorical versus continuous data is worked through in resampling and overviews when writing COGs, and the wider storage picture in Cloud-Native Geospatial Formats.
Verification
A clip is correct when the CRSs matched going in, the extent came out inside the geometry's bounds, and some data actually survived. Check all three — a clip that returns an array of pure NaN is not an error, it is a silent misalignment.
import numpy as np
# 1. The mask was built in the raster's own coordinates
assert floodplain.crs == ndvi_cube.rio.crs, "Reproject the vector layer before clipping"
# 2. The clipped extent sits inside the geometry bounds, within one pixel
res = abs(ndvi_cube.rio.resolution()[0])
cminx, cminy, cmaxx, cmaxy = clipped.rio.bounds(recalc=True)
gminx, gminy, gmaxx, gmaxy = floodplain.total_bounds
assert cminx >= gminx - res and cmaxx <= gmaxx + res
# 3. Something survived
valid = int(np.isfinite(clipped.values).sum())
assert valid > 0, "All-NaN result — geometry and raster do not really overlap"
print(f"{clipped.shape} kept, {valid} valid cells, CRS {clipped.rio.crs.to_epsg()}")
# (1842, 2416) kept, 2214883 valid cells, CRS 32633
print(clipped.rio.nodata) # nan
Round-trip the file too: reopen it and confirm the nodata tag and transform survived the write.
check = rioxarray.open_rasterio("ndvi_floodplain_2023.tif", masked=True)
assert check.rio.crs == clipped.rio.crs
assert np.isnan(check.rio.nodata)
print(check.rio.transform(recalc=True))
# | 10.00, 0.00, 418340.00|
# | 0.00,-10.00, 5637820.00|
Edge Cases & Debugging
NoDataInBoundson a geometry you can see overlapping. Either the geometry CRS never reachedclip(passcrs=) or the shape is narrower than a pixel — retry withall_touched=Truebefore assuming the data is wrong.OneDimensionalRasterfromclip_box. The requested box collapsed to a single row or column. Passauto_expand=True(withauto_expand_limitif the default of 3 is not enough) or pad the bounds by one pixel as in step 6.MissingCRSon the raster side. The array has nospatial_ref, which is normal for NetCDF input; declare the known CRS with.rio.write_crs("EPSG:4326")first — see reading NetCDF climate data with xarray.- Integer raster comes back with nonsense outside the polygon. No nodata was declared, so there was no legal fill value. Call
.rio.write_nodata(-9999)before the clip, or reopen withmasked=Trueand work in float. - The clip is slow and memory-hungry on a big scene. Pre-crop with
clip_box, or passfrom_disk=Trueso rasterio reads only the needed window — that path applies when the array still has its file source and silently falls back to the in-memory route otherwise. - You need one raster per polygon, not one raster for all of them.
clipunions the geometries into a single mask. Loop and clip per feature, or go straight to per-zone summaries with zonal statistics and raster sampling.
Frequently Asked Questions
Does clip reproject my geometries automatically?
Only when you tell it which CRS they are in. Passing crs=parcels.crs lets rioxarray transform the geometries onto the raster's coordinate system before rasterizing. Omit it and the geometries are assumed to already be in the raster's CRS — no check, no warning. Reprojecting the vector layer with to_crs() and passing crs= anyway costs nothing and makes both halves of the assumption visible.
Should I use clip or clip_box?
clip_box for a rectangle, clip for a shape. The box version is a pure coordinate-window slice with no rasterization, so it is dramatically cheaper and is the right first step on a large scene even when a polygon clip follows. Use clip alone only when the raster is already small relative to the geometry's bounding box.
Why is my clipped raster black instead of transparent?
The nodata tag did not make it into the file. to_raster writes what .rio.nodata holds at write time, and several xarray operations drop the attribute along the way. Call .rio.write_nodata(np.nan, encoded=True) on the final object immediately before writing, then reopen the file and assert on check.rio.nodata as in the verification block.
Does clipping a Dask-backed cube load it into memory?
No. The mask is derived from the geometry and the affine transform, never from pixel values, so clip extends the task graph like any other lazy operation and the time dimension is untouched. Data is read at .compute() or at to_raster. The exception is from_disk=True, which deliberately goes back to the file with rasterio to read a smaller window.