Fixing Rasterio CRS Mismatch Errors
TL;DR: A rasterio mask or overlay that returns an all-nodata array is almost always a CRS mismatch — reproject the vector to src.crs (or align the second raster with WarpedVRT) and place an assert src.crs == other.crs guard before every masking call so the failure raises instead of returning empty data.
Why a CRS Mismatch Produces Empty Output, Not an Error
rasterio.mask.mask and the windowed clip patterns underneath it work by intersecting a geometry with the raster’s pixel grid. When the geometry and the raster share a CRS, that intersection is the field. When they do not — a field boundary in geographic degrees (EPSG:4326) clipped against a raster in UTM metres (EPSG:32615) — the geometry’s coordinates (roughly −93, 42) fall nowhere near the raster’s (roughly 448000, 4651000). The intersection is empty, and here is the trap: rasterio does not treat an empty intersection as an error. It treats it as a perfectly valid clip that happens to contain zero valid pixels, and hands back an array filled with the nodata value.
Nothing raises. A downstream np.nanmean over that array returns nan, a zonal-statistics loop records 0.0 for the field, and the corrupt value flows into the season’s record indistinguishable from a real measurement. On a vegetation-index pipeline this is the difference between “this field is stressed” and “this field has no data” — and the two look identical until someone audits the raw arrays. The same mechanism strikes when two rasters are combined: subtracting a NIR raster in one CRS from a red raster in another produces a shape-compatible but spatially nonsensical result, or a broadcast error if the grids also differ in size. This guide is the raster-specific fix within the broader debugging CRS and projection errors reference.
Prerequisites
Only one package differs in emphasis from the parent section — you need rasterio with its warp and vrt submodules, which ship in the standard wheel.
rasterio==1.3.10
geopandas==0.14.4
numpy==1.26.4
Install with:
pip install rasterio==1.3.10 geopandas==0.14.4 numpy==1.26.4
Input requirements:
- A multi-band or single-band GeoTIFF with a populated
.crsand.transform. - A field-boundary vector (GeoPackage, GeoJSON, or shapefile) with a populated
.crs. If its CRS isNone, assign it withset_crsfirst — see the parent guide, this fix assumes both CRS are known.
Step-by-Step
Step 1 — Detect the mismatch with a guard
Never mask before this assertion. It converts the silent empty-array failure into an immediate, informative exception.
import rasterio
import geopandas as gpd
from pyproj import CRS
def assert_crs_match(raster_crs, vector_crs) -> None:
a, b = CRS.from_user_input(raster_crs), CRS.from_user_input(vector_crs)
assert a.to_epsg() == b.to_epsg(), (
f"CRS mismatch: raster EPSG:{a.to_epsg()} != vector EPSG:{b.to_epsg()}. "
"Reproject the geometry to the raster CRS before masking — otherwise the "
"clip returns an all-nodata array with no error."
)
Step 2 — Reproject the clip geometry to the raster CRS
The cheap, correct fix for raster/vector mismatch is to move the (small) vector into the (large) raster’s CRS, not the reverse. Reprojecting a boundary is a handful of coordinates; reprojecting a 10 GB orthomosaic is expensive and lossy.
def load_geometry_in_raster_crs(vector_path: str, raster_crs) -> list:
gdf = gpd.read_file(vector_path)
assert gdf.crs is not None, "Vector CRS is None — set_crs before reprojecting"
gdf = gdf.to_crs(raster_crs) # match the raster exactly
assert_crs_match(raster_crs, gdf.crs)
return list(gdf.geometry)
Step 3 — Align a second raster on the fly with WarpedVRT
When two rasters disagree (a red band in EPSG:32615 and a NIR band delivered in EPSG:32616 from a neighbouring UTM zone), a WarpedVRT presents the second raster as if it were in the target CRS and grid, without writing a new file. Reads through the VRT are reprojected on demand.
from rasterio.vrt import WarpedVRT
from rasterio.enums import Resampling
def open_aligned(secondary_path: str, reference: rasterio.DatasetReader) -> WarpedVRT:
"""Return the secondary raster warped to the reference CRS, grid, and size."""
src = rasterio.open(secondary_path)
return WarpedVRT(
src,
crs=reference.crs,
transform=reference.transform,
width=reference.width,
height=reference.height,
resampling=Resampling.bilinear, # continuous data (reflectance/NDVI)
)
Step 4 — Materialise a reprojected raster with rasterio.warp.reproject
When a raster must be permanently moved into the working CRS (so downstream tools open it repeatedly without re-warping), write a new GeoTIFF with rasterio.warp.reproject. calculate_default_transform derives the destination grid.
Step 5 — Complete runnable script
This combines the pattern end to end: guard, reproject the geometry, clip, and — as the reusable utility — reproject a raster to a target EPSG on disk.
import numpy as np
import rasterio
from rasterio.warp import calculate_default_transform, reproject, Resampling
from rasterio.mask import mask
import geopandas as gpd
from pyproj import CRS
def assert_crs_match(raster_crs, vector_crs) -> None:
a, b = CRS.from_user_input(raster_crs), CRS.from_user_input(vector_crs)
assert a.to_epsg() == b.to_epsg(), (
f"CRS mismatch: raster EPSG:{a.to_epsg()} != vector EPSG:{b.to_epsg()}"
)
def reproject_raster(src_path: str, dst_path: str, dst_epsg: int,
resampling: Resampling = Resampling.bilinear) -> str:
"""Materialise src_path into EPSG:dst_epsg as a new GeoTIFF."""
dst_crs = CRS.from_epsg(dst_epsg)
with rasterio.open(src_path) as src:
transform, width, height = calculate_default_transform(
src.crs, dst_crs, src.width, src.height, *src.bounds
)
profile = src.profile.copy()
profile.update(crs=dst_crs, transform=transform,
width=width, height=height)
with rasterio.open(dst_path, "w", **profile) as dst:
for b in range(1, src.count + 1):
reproject(
source=rasterio.band(src, b),
destination=rasterio.band(dst, b),
src_transform=src.transform, src_crs=src.crs,
dst_transform=transform, dst_crs=dst_crs,
resampling=resampling,
)
return dst_path
def clip_raster_to_field(raster_path: str, vector_path: str) -> np.ndarray:
"""Clip a raster to a field boundary, guarding against the empty-clip trap."""
with rasterio.open(raster_path) as src:
gdf = gpd.read_file(vector_path)
assert gdf.crs is not None, "Vector CRS is None — set_crs first"
gdf = gdf.to_crs(src.crs) # move vector to raster CRS
assert_crs_match(src.crs, gdf.crs) # guard BEFORE masking
clipped, out_transform = mask(
src, list(gdf.geometry), crop=True, filled=True, nodata=src.nodata
)
# Fail loudly if the clip is empty — the classic silent CRS-mismatch symptom
valid = clipped != (src.nodata if src.nodata is not None else 0)
assert valid.any(), (
"Clip returned all-nodata — geometry does not overlap the raster even "
"after reprojection; check for a datum relabel or wrong source CRS"
)
return clipped
if __name__ == "__main__":
arr = clip_raster_to_field("ndvi_20240715.tif", "field_boundaries.gpkg")
print(f"clipped shape: {arr.shape}, valid pixels: {(arr != 0).sum():,}")
Inline verification — confirm alignment and non-empty output separately from the clip:
with rasterio.open("ndvi_20240715.tif") as src:
fields = gpd.read_file("field_boundaries.gpkg").to_crs(src.crs)
assert src.crs.to_epsg() == fields.crs.to_epsg()
# bounding boxes must physically overlap
b, fb = src.bounds, fields.total_bounds
assert min(b.right, fb[2]) > max(b.left, fb[0]), "No X overlap — still mismatched"
print("raster and vector overlap; safe to mask")
Gotchas & Edge Cases
- Reprojecting the raster when you only needed to reproject the vector. Warping a large orthomosaic to match a boundary is slow and resamples every pixel; move the geometry to the raster CRS instead (Step 2). Only materialise a reprojected raster (Step 4) when downstream tools genuinely need the raster itself in a new CRS.
- Wrong resampling on categorical rasters.
WarpedVRTandreprojectdefault matters: useResampling.nearestfor management-zone or mask-code rasters.bilinear/averageinterpolates class integers into meaningless fractional values, silently mislabeling zones fed to threshold mapping for crop health. - Same EPSG code, no overlap. If the guard passes but the clip is still empty, the raster or vector was relabelled with a CRS it is not actually in (a NAD83 layer tagged EPSG:4326). The bounding-box overlap assertion in the verification snippet catches this; the fix is a datum transform, covered in resolving pyproj datum shift warnings.
- WarpedVRT left open. A
WarpedVRTholds an open handle to the underlying dataset; use it inside awithblock or call.close()on both the VRT and its source, or a batch loop will exhaust file descriptors on large flight sets.
Frequently Asked Questions
Why does rasterio mask return all nodata instead of raising an error?
The clip geometry and the raster are in different coordinate systems, so the geometry falls entirely outside the raster extent and the intersection is empty. Rasterio treats a non-overlapping geometry as a valid clip that happens to contain no data, filling the output with nodata rather than raising. Reproject the geometry to the raster CRS before masking, and assert the two CRS are equal first.
Should I use WarpedVRT or rasterio.warp.reproject to fix a mismatch?
Use WarpedVRT when you need a raster to appear in a target CRS on the fly without writing a new file, for example to align two rasters inside a windowed loop. Use rasterio.warp.reproject when you want to materialise and save a reprojected GeoTIFF that downstream tools will open repeatedly. WarpedVRT avoids disk writes but recomputes on every read.
Which resampling method should I use when reprojecting NDVI rasters?
Use bilinear resampling for continuous data such as NDVI or reflectance so reprojection does not introduce blocky artefacts. Use nearest-neighbour for categorical rasters such as management-zone or mask codes, because averaging class integers produces meaningless in-between values. Choosing average or cubic on a class raster silently corrupts the zone labels.
Parent Guide
This guide is part of Debugging CRS and Projection Errors in Python — see there for the full diagnosis flowchart and the assertion guards that cover missing CRS, datum shift, and axis-order failures alongside this one.
Related
- Debugging CRS and Projection Errors in Python — the parent troubleshooting reference with the full diagnosis flowchart
- Resolving pyproj Datum Shift Warnings — the fix when a same-code clip still comes back empty because of a datum relabel
- Clipping Rasters to Field Boundaries with rasterio.mask — the full masking workflow this fix protects