Zonal Statistics & Raster Sampling in Python

Zonal statistics is the operation that turns a continuous surface into a table: mean elevation per catchment, dominant land-cover class per census tract, 90th-percentile temperature per neighbourhood. It is where the vector and raster halves of Spatial Analysis & Advanced Query Techniques meet, and it is deceptively easy to get subtly wrong — every implementation has to answer the same awkward question about partially covered pixels, and they answer it differently. This topic covers the pixel-in-polygon decision, nodata semantics, categorical versus continuous summaries, point sampling, and the memory strategy that keeps a per-zone loop from swallowing a multi-gigabyte scene. It consumes the datasets prepared in Raster Data Handling with Rasterio and xarray & rioxarray Raster Cubes, and sits beside Geometric Intersections & Overlays, which answers the same "how much of A is inside B" question for two vector layers.

Architecture & Data Structures

Strip away the libraries and a zonal statistic is a weighted group-by. The grouping key is a zone identifier taken from a GeoDataFrame; the values are pixels drawn from a 2D array; and the weight is how much of each pixel belongs to that zone. Four pieces of state control the whole computation:

import geopandas as gpd
import rasterio

zones = gpd.read_file("parcels.gpkg", layer="parcels")

with rasterio.open("ndvi_utm32n.tif") as src:
    grid_crs = src.crs               # EPSG:32632 — what the pixel coordinates mean
    transform = src.transform        # affine: (row, col) -> (easting, northing)
    fill = src.nodata                # the sentinel that must never enter a mean
    pixel_area = abs(transform.a * transform.e)   # m² per pixel in a metric CRS

# A zonal statistic is a group-by: key = zone id, values = pixels, weight = coverage
assert zones.crs is not None and zones.crs.equals(grid_crs), "align the CRSs first"
print(f"{pixel_area:.1f} m² per pixel")

The coverage rule has three practical settings. The centroid rule — GDAL's default, and what rasterstats does with all_touched=False — includes a pixel if and only if its centre falls inside the polygon. All-touched includes any pixel the polygon boundary so much as clips, which inflates the sample and drags the statistic toward whatever surrounds the zone. Exact coverage gives each pixel a weight between 0 and 1 equal to the fraction of its area inside the polygon, which is what exactextract computes analytically. On a zone that spans hundreds of pixels the three agree to a rounding error; on a zone a few pixels across they can disagree badly.

Centroid, all-touched and exact-fraction pixel weighting The same quadrilateral zone is overlaid on the same five-by-five pixel grid three times. Under the centroid rule only the nine pixels whose centres fall inside are counted, giving a mean of 0.62. Under all-touched every pixel the boundary clips is counted, eighteen in total, giving a mean of 0.55. Under exact coverage each boundary pixel carries its overlap fraction, from 0.00 to 1.00, summing to 9.14 cell equivalents and giving a mean of 0.61. One polygon, one grid, three answers centroid rule (default) count = 9 cells mean = 0.62 all_touched=True count = 18 cells mean = 0.55 exact coverage fraction 0.23 0.07 0.89 0.99 0.81 0.40 0.05 0.99 1.00 1.00 0.40 0.01 0.30 0.78 0.98 0.07 0.00 0.17 count = 9.14 cells mean = 0.61 Identical inputs — the coverage rule alone doubles the sample size and moves the mean by 0.07
The centroid rule under-samples the boundary, all_touched over-samples it, and exact coverage weights each partial pixel by the fraction actually inside — the only rule whose "count" can be fractional.

Notice the two extremes in the exact-coverage panel: cells at 0.01 and 0.00 are counted in full under all_touched and dropped entirely under the centroid rule, despite contributing almost nothing. That single observation explains most of the disagreement you will ever see between two zonal-statistics implementations.

Environment Configuration & Dependency Resolution

Four libraries cover the whole problem space, and they layer on the same GDAL/GEOS/PROJ foundation described in Raster Data Handling with Rasterio. Pin them together so a GDAL upgrade in one wheel cannot desynchronise the others.

python -m pip install \
  "rasterio>=1.3,<2" "geopandas>=1.0" "shapely>=2.0" \
  "rasterstats>=0.19" "exactextract>=0.2" \
  "rioxarray>=0.15" "xarray>=2024.3" "regionmask>=0.12" "numpy>=1.26"

On Windows, keep the whole stack from one source. A pip install rasterio alongside a conda install gdal puts two GDAL DLLs on the search path and produces an ImportError: DLL load failed at the first import rasterio — never a message about GDAL. If you already use conda, take rasterio, geopandas, rasterstats and exactextract from conda-forge in a single solve.

Vectorized Operations & Core Workflow

The canonical pipeline is four steps: load the zones, reproject them into the raster's CRS, run one summarising call over the whole layer, and join the resulting table back onto the GeoDataFrame by index. Everything else is a variation on it.

import geopandas as gpd
import pandas as pd
import rasterio
from rasterstats import zonal_stats

zones = gpd.read_file("census_tracts.gpkg")

with rasterio.open("ndvi_utm32n.tif") as src:
    if zones.crs != src.crs:
        zones = zones.to_crs(src.crs)   # move the vector; never resample the raster
    fill = src.nodata

# Passing the PATH (not an array) lets rasterstats read one window per feature
stats = zonal_stats(
    zones,                       # consumed via __geo_interface__ — the CRS is NOT carried
    "ndvi_utm32n.tif",
    stats=["count", "mean", "std", "median", "percentile_90"],
    nodata=fill,                 # explicit, in case the file carries no nodata tag
    all_touched=False,           # centroid rule
)

zones = zones.join(pd.DataFrame(stats, index=zones.index))
print(zones[["tract_id", "count", "mean", "percentile_90"]].head())

Two details in that block matter more than they look. zonal_stats consumes the zones through __geo_interface__, the GeoJSON-like mapping that a GeoDataFrame exposes — and __geo_interface__ carries geometry but no CRS. That is the mechanical reason a mismatch is silent rather than fatal, and the reason the to_crs line comes first. Second, results come back as a plain list of dicts in input order, so pd.DataFrame(stats, index=zones.index) is the correct join: never sort or filter the zones between the call and the join.

The exactextract equivalent is a single call that returns a DataFrame directly and carries the identifier column through for you:

import geopandas as gpd
from exactextract import exact_extract

zones = gpd.read_file("census_tracts.gpkg").to_crs("EPSG:32632")

summary = exact_extract(
    "ndvi_utm32n.tif",                      # path, rasterio dataset, or xarray DataArray
    zones,                                  # path or GeoDataFrame
    ["count", "mean", "stdev", "min", "max"],
    include_cols=["tract_id"],
    output="pandas",
)

# count is the SUM OF COVERAGE FRACTIONS — 9.14, not 9. It is a float on purpose.
print(summary.head())

A head-to-head of the two — API shape, weighting behaviour, and how each scales as zone count rises — is in Zonal Statistics with rasterstats vs exactextract. The short version: rasterstats for breadth and hackability, exactextract when partial pixels carry real weight or when there are a great many zones.

Zonal Aggregation & Sampling Details

Continuous versus categorical

A continuous raster (elevation, NDVI, temperature) supports the whole arithmetic family: mean, standard deviation, median, percentiles. A categorical raster (land cover, soil class, zoning code) supports none of them — the mean of class codes 10 and 50 is 30, which is a different class or no class at all. For categories you want tallies and fractions.

import geopandas as gpd
import pandas as pd
from rasterstats import zonal_stats

CLASS_MAP = {10: "tree_cover", 20: "shrubland", 40: "cropland", 50: "built_up", 80: "water"}

zones = gpd.read_file("census_tracts.gpkg").to_crs("EPSG:32632")

cover = zonal_stats(
    zones, "worldcover_utm32n.tif",
    categorical=True,
    category_map=CLASS_MAP,
    nodata=0,
    all_touched=False,
)
# -> [{'tree_cover': 812, 'cropland': 4410, 'built_up': 96}, ...]  raw pixel tallies

# Turn tallies into shares; absent classes become 0, not NaN
shares = pd.DataFrame(cover, index=zones.index).fillna(0)
shares = shares.div(shares.sum(axis=1), axis=0)

exactextract expresses the same idea with area weights rather than pixel counts, which is the more defensible number when zones are small relative to the cell size:

import geopandas as gpd
from exactextract import exact_extract

zones = gpd.read_file("census_tracts.gpkg").to_crs("EPSG:32632")

frac = exact_extract(
    "worldcover_utm32n.tif", zones,
    ["frac", "majority", "variety"],
    include_cols=["tract_id"],
    output="pandas",
)
# `frac` expands into one frac_<value> column per class present, summing to 1 per zone

majority gives the modal class, variety the number of distinct classes — a cheap heterogeneity index. Whatever you do, never resample a categorical raster with bilinear or cubic interpolation on the way in; nearest neighbour is the only correct choice, as covered under Resampling & Overviews When Writing COGs.

Nodata, and what count really means

Nodata handling is where quiet errors live. If the GeoTIFF declares a nodata value, rasterio and both statistics libraries honour it. If it does not — common with older DEMs that use -9999 as a convention — every one of those sentinels is treated as a real measurement and your catchment mean comes back around -3000. Pass nodata= explicitly whenever you did not write the file yourself. NaN is a special case: it never compares equal to itself, so a nodata=nan tag only works through masked reads, not through equality tests you write by hand.

Once nodata is excluded, count acquires three different meanings that are easy to conflate. In rasterstats it is the number of valid pixels selected by the coverage rule. In exactextract it is the sum of coverage fractions over valid pixels — a float. And neither is the same as the number of cells in the zone's bounding box. When a zone returns count of 0 and every other statistic as None, it means no pixel satisfied the rule: the zone is smaller than a cell, sits entirely on nodata, or — most often — is in the wrong CRS.

Reading only what a zone needs

Reading a whole scene into memory to summarise a handful of parcels is the single most common way a zonal job dies. A 40 000 × 40 000 float32 band is 6.4 GB before you have computed anything. The fix is a windowed read per zone: convert the zone's bounds to a pixel window, read only that window, build the mask at that window's transform, and discard both before moving on. Peak memory then tracks the largest zone, not the raster. rasterstats already does this internally when you hand it a path — one of the better reasons to pass a path rather than a pre-read array.

Full-array read versus per-zone windowed read On the left, a whole forty thousand by forty thousand float32 band is decoded into memory at once, filling a 6.4 gigabyte resident buffer and raising MemoryError before any statistic is produced. On the right, the same file stays on disk and only the small window covering each zone is decoded, so peak memory equals one window — roughly a quarter of a megabyte — and stays constant no matter how large the raster is. Where the memory goes src.read(1) — whole band ortho.tif 40 000 x 40 000 px, float32 every pixel decoded, every time peak RAM 6.4 GB MemoryError long before the first statistic src.read(1, window=win) — per zone same file, still on disk one window per zone, decoded then dropped peak RAM ~ 0.25 MB constant, whatever the raster size Windowed reads trade one huge allocation for many tiny ones — and let a zonal job run on a laptop
Peak memory is set by the largest zone window, not by the raster — which is why a per-feature windowed loop scales to scenes that a single read() cannot open.

When a library's built-in statistics are not enough — a trimmed mean, a bespoke index, a histogram you want to keep — write the loop yourself. rasterio.features.geometry_mask burns the geometry onto exactly the window you read, so the mask and the data always share a transform:

import geopandas as gpd
import numpy as np
import rasterio
from rasterio.features import geometry_mask
from rasterio.windows import Window, from_bounds


def zone_pixels(src, geom):
    """Return the valid pixel values of one zone, reading only its window."""
    win = from_bounds(*geom.bounds, transform=src.transform)
    win = win.round_offsets().round_lengths()
    win = win.intersection(Window(0, 0, src.width, src.height))
    if win.width < 1 or win.height < 1:
        return np.array([], dtype="float32")

    data = src.read(1, window=win, masked=True)          # masked array honours nodata
    inside = geometry_mask(
        [geom],
        out_shape=data.shape,
        transform=src.window_transform(win),
        invert=True,          # True where the pixel centre falls INSIDE the geometry
        all_touched=False,
    )
    return data[inside & ~data.mask].astype("float32")


catchments = gpd.read_file("catchments.gpkg")

with rasterio.open("dem_utm32n.tif") as src:
    catchments = catchments.to_crs(src.crs)
    for row in catchments.itertuples():
        values = zone_pixels(src, row.geometry)
        if values.size == 0:
            continue                                      # zone off-grid or all nodata
        p5, p50, p95 = np.percentile(values, [5, 50, 95])
        print(f"{row.catchment_id}: relief {p95 - p5:.1f} m, median {p50:.1f} m")

geometry_mask returns True outside the geometry by default, which suits NumPy's masked-array convention but is the reverse of what most people expect — invert=True flips it to "True inside". Getting that backwards produces statistics for the complement of every zone, and the numbers look plausible enough to ship. Full masking recipes, including crop=True and multipart handling, are in Masking Rasters with Polygons in Python.

Sampling at point locations

Point sampling is the degenerate case where the zone is a single coordinate. rasterio's sample() is the fastest route: it takes an iterable of (x, y) pairs in the raster's CRS and yields one array per point.

import geopandas as gpd
import numpy as np
import rasterio

sensors = gpd.read_file("air_quality_sensors.geojson")

with rasterio.open("lst_utm32n.tif") as src:
    sensors = sensors.to_crs(src.crs)                  # sample() takes raster-CRS coords
    coords = [(geom.x, geom.y) for geom in sensors.geometry]
    sensors["lst_c"] = [record[0] for record in src.sample(coords, indexes=1)]
    fill = src.nodata

# Points outside the grid come back as the nodata value — no exception is raised
sensors["lst_c"] = sensors["lst_c"].replace(fill, np.nan)
print(sensors["lst_c"].isna().sum(), "sensors fell outside the scene")

sample() is nearest-neighbour: it returns the value of the cell containing the point, with no interpolation. For a smooth surface such as elevation or temperature that produces a visible staircase along a transect; rasterstats.point_query(..., interpolate="bilinear") blends the four surrounding cells instead. For a class raster, bilinear is meaningless and interpolate="nearest" is mandatory. The trade-offs, plus how to sample a time series of bands at once, are worked through in Sampling Raster Values at Point Locations.

Cubes: one mask, every time step

When the raster is a stack — twelve monthly composites, a decade of daily temperature — you do not want twelve independent zonal passes. Build the zone mask once and reduce along the spatial dimensions, letting xarray broadcast over time and dask chunk the work:

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

cube = xr.open_dataset("modis_lst_2024.nc", chunks={"time": 12})["lst"]
zones = gpd.read_file("admin2.gpkg").to_crs("EPSG:4326")

regions = regionmask.from_geopandas(zones, numbers="zone_id", name="admin2")
mask3d = regions.mask_3D(cube["lon"], cube["lat"])      # bool, dims (region, lat, lon)

# On a lon/lat grid a cell's ground area shrinks toward the poles — weight by cos(lat)
weights = np.cos(np.deg2rad(cube["lat"]))
zonal_mean = cube.weighted(mask3d * weights).mean(dim=("lat", "lon"))

zonal_mean.compute().to_dataframe(name="lst_mean").to_parquet("admin2_lst_2024.parquet")

Two regionmask details bite people. It wraps longitudes into −180…180 by default, so on a projected grid with x/y coordinates you must pass wrap_lon=False or the mask comes back empty. And mask_3D applies the centroid rule; regionmask>=0.12 adds mask_3D_frac_approx for approximate fractional coverage on regular lon/lat grids when small regions matter.

CRS Alignment & Projection Pipeline

Every zonal statistic assumes zones and pixels are indexed in the same coordinate system, and nothing in the stack enforces it. rasterstats receives bare GeoJSON geometries with the CRS already stripped; rasterio.mask compares raw numbers; a NumPy mask has no concept of projection at all. When the two disagree the coordinates simply do not overlap, and you get an empty result rather than an error.

Always reproject the vector, not the raster. Reprojecting zones is a lossless coordinate transform on a few thousand vertices. Reprojecting the raster resamples every pixel, changes values through interpolation, and costs orders of magnitude more. Establish the canonical CRS with Coordinate Systems with PyProj and let the raster define it for this operation.

import geopandas as gpd
import rasterio

zones = gpd.read_file("parcels.gpkg")

with rasterio.open("ndvi_utm32n.tif") as src:
    if zones.crs is None:
        raise ValueError("zones carry no CRS — declare it with set_crs() before anything")
    if not zones.crs.equals(src.crs):
        zones = zones.to_crs(src.crs)

    # Fail loudly when the extents do not actually meet, instead of returning None
    left, bottom, right, top = src.bounds
    zx0, zy0, zx1, zy1 = zones.total_bounds
    if zx1 < left or zx0 > right or zy1 < bottom or zy0 > top:
        raise ValueError(f"zones {zones.total_bounds} lie outside raster {src.bounds}")
A CRS mismatch produces empty statistics, not an error Before reprojection the zones span easting 7.60 to 7.80 in EPSG:4326 while the raster spans 380000 to 420000 metres in EPSG:32632, so the two extents never intersect and zonal_stats returns count zero and mean None for every zone with no exception raised. After calling zones.to_crs on the raster CRS both extents are in EPSG:32632 and overlap, and the same call returns a count of 4812 pixels and a mean of 0.61. The silent failure: extents that never meet Before — zones left in EPSG:4326 zones EPSG:4326 x 7.60 - 7.80 extents disjoint ndvi_utm32n.tif EPSG:32632 x 380k - 420k [{'count': 0, 'mean': None}, ...] ✗ no exception raised After — zones.to_crs(src.crs) zones EPSG:32632 x 381k - 386k extents overlap ndvi_utm32n.tif EPSG:32632 x 380k - 420k [{'count': 4812, 'mean': 0.61}, ...] ✓ every zone populated Assert the CRSs match and the extents intersect — an all-None result column is the only other warning you get
Degrees and metres never overlap numerically, so a mismatch returns empty statistics rather than raising — assert the extents intersect before you trust a single row.

The choice of which projected CRS still matters once they agree. Area-weighted statistics assume every pixel covers the same ground area, which holds in a local UTM zone and fails in a geographic CRS, where a cell's north–south extent is constant in degrees but its east–west extent shrinks with the cosine of latitude. Over a country-scale extent use an equal-area projection (Lambert Azimuthal Equal Area, Albers) so pixel area stays constant; over a lon/lat cube apply the cos(lat) weighting shown above. Never compute area-weighted statistics in EPSG:3857 — Web Mercator's scale factor grows away from the equator, so pixels near the poles claim many times the ground area they actually cover. Web Mercator belongs at the tile-render boundary and nowhere else.

One more alignment trap: if you are combining several rasters per zone — NDVI over a cloud mask, say — they must share a grid, not merely a CRS. Two scenes in EPSG:32632 with different origins or resolutions will not align cell-for-cell. Snap one to the other with rioxarray's reproject_match, or read both through a WarpedVRT onto a common grid, before any joint statistic. The worked example for the NDVI case is Computing NDVI Zonal Means per Parcel.

Production Export & Integration

Zonal statistics almost always feed something else: a dashboard, a model, a choropleth. The output is a tidy table keyed by zone id, so integration is mostly about keeping that key trustworthy.

import geopandas as gpd
from exactextract import exact_extract

parcels = gpd.read_file("parcels.gpkg").to_crs("EPSG:32632")

summary = exact_extract(
    "ndvi_utm32n.tif", parcels, ["mean", "count"],
    include_cols=["parcel_id"], output="pandas",
).rename(columns={"mean": "ndvi_mean", "count": "cell_equivalents"})

parcels = parcels.merge(summary, on="parcel_id", validate="one_to_one")

parcels.to_parquet("parcel_ndvi.parquet")                       # GeoParquet, CRS in metadata
parcels.drop(columns="geometry").to_csv("parcel_ndvi.csv", index=False)

The validate="one_to_one" argument is worth the keystrokes: it turns a duplicated parcel_id — the usual consequence of an earlier explode or overlay — into an immediate MergeError rather than a row-count explosion nobody notices until the choropleth looks odd. Beyond the file drop:

Performance checklist. Pass a path, not a pre-read array, so reads stay windowed. Keep zones and raster in the same CRS to skip a reprojection per call. Build overviews on the raster if a coarse statistic is acceptable and read the reduced resolution instead. Dissolve zones you will only ever report together, using the patterns in Dissolving & Aggregating Features by Attribute, so you compute one statistic instead of two hundred. And prefer one call over the whole layer to a Python loop of single-feature calls — both libraries amortise setup across features.

Choosing a zonal statistics tool A decision tree with four branches. If zones are only a few pixels across or partial cells must count fractionally, use exactextract for area-exact coverage weights. If the raster is continuous with hundreds to thousands of zones and standard summary statistics, use rasterstats. If the data is a multi-band or time-series cube already held in xarray, use rioxarray with regionmask. If the statistic is one no library exposes, drop to rasterio.mask with NumPy. Which tool for this zonal job? answer the coverage question first Zones a few pixels across, or partial cells must count exactextract area-exact weights fractional count scales to many zones exact_extract() Continuous raster, ordinary summary stats per polygon rasterstats windowed per feature add_stats hook categorical=True zonal_stats() Multi-band or time- series cube already held in xarray rioxarray + regionmask one mask, all steps dask-chunked reduce cos(lat) weighting mask_3D() A statistic no library exposes, or you want the pixels rasterio.mask + NumPy full mask control you own nodata hand-rolled windows geometry_mask() Zone size relative to cell size decides the branch — everything else is convenience
Start from the ratio of zone size to cell size: once zones are small enough for boundary pixels to matter, only exact coverage weighting gives a defensible number.

Windows / Platform Edge Cases & Debugging

Frequently Asked Questions

Should I use all_touched=True or the default? Default (centroid) when zones are large relative to the cell size — it matches GDAL, QGIS and PostGIS Raster, so your numbers reconcile with everyone else's. all_touched=True only when zones are small or narrow enough that the centroid rule would return nothing, and then accept that boundary values are over-represented. If the answer needs to be defensible, use exact coverage weighting instead of choosing between two approximations.

Why does exactextract report a fractional count? Because count is the sum of coverage fractions, not a pixel tally. A zone covering nine whole cells plus slivers of eight more reports something like 9.14. That is the correct denominator for an area-weighted mean, and it is also a useful diagnostic: a count far below 1 tells you the zone is smaller than a cell and the mean rests on a single pixel.

Can I reproject the raster instead of the zones? You can, but you should not. Reprojecting the vector moves a few thousand vertices exactly; reprojecting the raster resamples every pixel and changes the values you are about to average. Reserve raster reprojection for the case where several rasters must be forced onto one grid, and even then use reproject_match with nearest for categorical data.

How do I get zonal statistics without loading the whole raster? Hand the reader a file path rather than a NumPy array. rasterstats and exactextract both read one window per feature, so peak memory follows the largest zone rather than the scene. Only pass an array plus an affine when the data is already in memory for another reason.

What is the right statistic for a land-cover raster? Never the mean — class codes are labels, not quantities. Use categorical=True in rasterstats for per-class pixel tallies, or frac, majority and variety in exactextract for area shares, modal class and class diversity. If you also resample such a raster anywhere in the pipeline, nearest neighbour is the only valid method.

Do zonal means need an equal-area projection? For a single UTM zone, no — pixel area is effectively constant, so a plain mean is already area-weighted. For continental extents or lon/lat grids, yes: pixel ground area varies with latitude, so use an equal-area CRS or apply cos(lat) weights. EPSG:3857 is wrong for this in every case.