Zonal Statistics over Millions of Polygons with exactextract

For very large polygon sets, compute exact coverage-weighted statistics directly and stream the results:

from exactextract import exact_extract

df = exact_extract(
    "ndvi_2026.tif", "fields.gpkg",
    ["mean", "count", "stdev", "frac"],        # coverage-weighted by default
    include_cols=["field_id"],
    output="pandas",
)
df.to_parquet("field_ndvi.parquet")

Coverage-weighted, exact, and fast enough for national parcel datasets on a single machine. This page belongs to zonal statistics and vector–raster integration in Core Raster Fundamentals & STAC Mapping.


Where the Time Goes in Zonal Statistics

Per-polygon overhead dominates at scale For a small field polygon, a rasterize-based tool spends most of its time on fixed per-polygon work: opening a window, allocating a mask, rasterizing and masking. The statistics themselves are a small fraction. exactextract computes coverage from the polygon edges in one pass and has far less fixed overhead, so the total per polygon is several times smaller. Time per small polygon rasterize + mask window rasterize mask stats ~1.4 ms exactextract coverage ~0.3 ms Across five million parcels that is two hours against twenty-five minutes, and the faster one is also the one with exact partial-pixel weighting.

At national scale the statistics are almost free and the overhead is everything. Five million parcels at a millisecond of overhead each is well over an hour before any value is summed. Removing per-polygon mask allocation is what makes the difference, and exact coverage — the weighting described in computing weighted zonal statistics for partial pixels — comes along for free.


Environment & Setup

Package Version pin Used for
exactextract >=0.2 Exact coverage-weighted zonal operations
geopandas >=0.14 Reading, reprojecting and partitioning polygons
pyarrow >=15.0 Streaming results to Parquet
rasterio >=1.3.0 Raster CRS and tiling information
pip install "exactextract>=0.2" "geopandas>=0.14" "pyarrow>=15.0" "rasterio>=1.3.0"

Complete Working Example

from concurrent.futures import ProcessPoolExecutor
from pathlib import Path

import geopandas as gpd
import pandas as pd
import rasterio
from exactextract import exact_extract

OPS = ["mean", "stdev", "count", "frac", "median"]


def partition(polygons_path: str, raster_path: str, tile_px: int = 4096) -> dict[tuple, gpd.GeoDataFrame]:
    """Group polygons by the raster tile containing their centroid."""
    gdf = gpd.read_file(polygons_path)
    with rasterio.open(raster_path) as src:
        if gdf.crs != src.crs:
            gdf = gdf.to_crs(src.crs)
        inv = ~src.transform
    cols, rows = zip(*[inv * (p.x, p.y) for p in gdf.geometry.centroid])
    gdf["_tile"] = [(int(r) // tile_px, int(c) // tile_px) for r, c in zip(rows, cols)]
    return {k: g.drop(columns="_tile") for k, g in gdf.groupby("_tile")}


def run_partition(args) -> str:
    key, part, raster_path, out_dir = args
    df = exact_extract(raster_path, part, OPS, include_cols=["field_id"], output="pandas")
    out = Path(out_dir) / f"part_{key[0]:03d}_{key[1]:03d}.parquet"
    df.to_parquet(out, index=False)
    return str(out)


def zonal_at_scale(polygons_path: str, raster_path: str, out_dir: str,
                   *, workers: int = 8) -> pd.DataFrame:
    Path(out_dir).mkdir(parents=True, exist_ok=True)
    parts = partition(polygons_path, raster_path)
    jobs = [(k, g, raster_path, out_dir) for k, g in parts.items()]
    with ProcessPoolExecutor(max_workers=workers) as pool:
        files = list(pool.map(run_partition, jobs))
    return pd.concat((pd.read_parquet(f) for f in files), ignore_index=True)

Partitioning by the raster’s tiles is what keeps the reads efficient. Each worker reads one compact region — a few internal tiles of a COG — rather than every worker seeking across the whole file, which on object storage turns scattered requests into cached, near-sequential ones. The parallelism model follows the scene-per-task pattern in scaling raster processing with Dask, expressed here with a plain process pool.


Choosing Operations Carefully

Operations are not equally cheap Mean, sum, count and frac are single-pass and cheap, and weighted by coverage. Standard deviation is also single-pass. Median and quantiles require collecting and sorting each polygon's values, which is several times more expensive for large polygons. Mode and unique-value operations suit categorical rasters. Ask only for what you need operation cost use for mean, sum, count, frac single pass continuous rasters, totals stdev, min, max single pass spread and range median, quantile collect and sort robust summaries with outliers mode, unique, frac per class per-class tallies categorical rasters Dropping one median from the list can halve a national run's wall clock.

For categorical rasters — land cover per parcel, for instance — the useful operations are per-class fractions rather than means. Asking for the coverage-weighted fraction of each class gives a composition per polygon that sums to one, which is almost always what a downstream analysis wants and is far more informative than a single mode.


Memory and Output at Scale

Two things break naive runs at this size. Holding every result in memory until the end fails once the result table itself runs to gigabytes; writing one Parquet file per partition and concatenating lazily avoids that entirely, and leaves a restartable record if the run is interrupted. And loading every polygon into every worker multiplies memory by the worker count; passing only each partition’s polygons to its worker, as above, keeps each process’s footprint proportional to its own share.

A third, quieter issue is polygon validity. National parcel datasets routinely contain self-intersections and slivers, and a single invalid geometry can raise inside a worker and lose a whole partition. Running make_valid once before partitioning, and dropping empty geometries, is cheap insurance. The general validity handling is covered in rasterizing vector polygons onto a raster grid.


Verification

Every polygon in, one row out The result table should contain exactly one row per input polygon identifier with no duplicates. Polygons entirely outside the raster should appear with a count of zero rather than be missing. A spot check of a handful of polygons against an independent method confirms the values themselves. Reconcile before publishing row count = polygons in unique ids no double counting spot check 20 polygons, independent A missing partition shows up as a row-count shortfall long before anyone reads the values.
result = zonal_at_scale("fields.gpkg", "ndvi_2026.tif", "out/zonal")
n_in = len(gpd.read_file("fields.gpkg", columns=["field_id"]))
assert len(result) == n_in, f"{n_in - len(result)} polygons missing from the output"
assert result["field_id"].is_unique, "a polygon was counted in two partitions"

Row-count reconciliation is the check that catches a failed partition, which in a parallel run is easy to miss because the other partitions succeed and the concatenation looks complete. Follow it with a spot check of twenty polygons against the transparent reference method so the values themselves are confirmed, not only their number.


Common Errors

Some polygons are missing from the output

A partition failed, often on an invalid geometry. Validate geometries before partitioning and check row counts after.

Workers run out of memory

Each received the whole polygon set. Pass only the partition to each worker, never the full layer.

Every value is NaN for a region

The polygons are in a different CRS from the raster, or that region of the raster is entirely nodata. Reproject before partitioning, and check the count column to tell the two cases apart.

Median makes the run very slow

It collects and sorts every polygon’s pixels. Drop it unless a robust statistic is genuinely needed.

Results differ from rasterstats for small polygons

That is expected: rasterstats uses the pixel-centre rule, exactextract weights by coverage. The difference shrinks as polygons grow, and for small parcels the weighted value is the more faithful one.


Frequently Asked Questions

Q: Why is exactextract faster than rasterize-based approaches? Because it computes each pixel’s coverage fraction directly from the polygon edges rather than rasterizing a mask per polygon, and it walks the raster once per polygon’s bounding box. For many small polygons that removes most of the per-polygon overhead that dominates simpler tools.

Q: How should the polygons be partitioned? By the raster’s own tiling — assign each polygon to the tile containing its centroid. Each job then reads one compact region of the raster from disk or object storage, which keeps reads sequential and caches effective.

Q: What about polygons that cross tile boundaries? Assigning by centroid keeps each polygon in exactly one partition, and the job reads whatever part of the raster its polygons need, including across the tile edge. The partition only groups work; it does not clip geometry.

Q: Can this run against a remote COG? Yes, and partitioning by tile matters even more there, since it turns each worker’s reads into a compact set of range requests. Set the GDAL options described in diagnosing slow COG reads with GDAL VSI logging in each worker’s environment.