Adding Internal Overviews with the Right Resampling

To add an internal overview pyramid to an existing raster, open it in append mode and build decimated levels with a resampling method that matches the data class:

import rasterio
from rasterio.enums import Resampling

with rasterio.open("composite.tif", "r+") as dst:
    dst.build_overviews([2, 4, 8, 16, 32], Resampling.average)
    dst.update_tags(ns="rio_overview", resampling="average")

Use Resampling.average for reflectance, elevation or index values, and Resampling.nearest for anything categorical. This is the overview half of Writing and Validating Cloud-Optimized GeoTIFFs.


Why This Arises in Remote Sensing Workflows

Overviews exist so that a reader asking for fewer pixels than the file holds can be served fewer bytes. Without them, a 512-pixel preview of a 10980-pixel band decimates the full-resolution data, which means reading it — 214 MB across the network to produce a thumbnail. With them, the same request reads a pre-computed level of about half a megabyte.

That is the performance argument, and it is the one everybody hears. The correctness argument is less obvious and matters more. Building an overview is a downsampling operation, so it applies a resampling kernel, and the kernel is baked permanently into the file. Any consumer who zooms out — a map, a QA sweep, a quicklook in a report — sees the resampled values, not the originals. If the wrong kernel was used, everything at that zoom level is wrong, and nothing about the file signals it.

The classic case is a scene classification or land-cover layer. Averaging the codes 4 and 6 gives 5, which is a different class entirely. The map looks plausible, the legend still renders, and every zoomed-out view of the mask now shows classes that were never observed. The same failure mode is examined for the general resampling case in Choosing the Right Resampling Method for Sentinel-2.

The pyramid a reader chooses from Five decimation levels above a 10980 pixel base. Each level holds a quarter of the pixels of the one below it, so the whole pyramid adds about a third to the file size. A request for a 512 pixel preview is served from the 1/16 level and transfers under a megabyte instead of the base level's 214 megabytes. Levels above a 10980 × 10980 uint16 band base — 10980 px — 214 MB 1/2 — 5490 px — 54 MB 1/4 — 2745 px 1/8 — 1373 px 1/16 — 686 px — 0.9 MB 1/32 — 343 px pyramid cost +33% file size built once, at write a 512 px preview served from 1/16 0.9 MB, not 214 MB chosen automatically Stopping the pyramid at 1/4 means every wider view falls back to decimating the 2745 px level.

Environment & Setup

Package Version Why
rasterio ≥1.3.0 build_overviews, overviews(), Resampling
GDAL ≥3.4.0 Performs the decimation and writes the levels
numpy ≥1.23 Needed for the verification statistics below
pip install "rasterio>=1.3.0" "numpy>=1.23"

Complete Working Example

This function picks the level list from the raster’s own size, applies a method chosen by data class, and records what it did in the file’s tags so the next reader does not have to guess.

Overview depth against the widest useful zoom Each overview level halves the linear size. Stopping at one quarter leaves a full-extent preview decimating a 2745 pixel level, while continuing to one thirty-second serves it from a 343 pixel level that fits a single screen. How deep the pyramid needs to go level longest side what it can serve base 10,980 px analysis at full resolution 1/4 2,745 px a large monitor, one tile 1/8 1,373 px a browser viewport 1/16 686 px a report figure or thumbnail 1/32 343 px a continental overview mosaic Stop when the coarsest level is a few hundred pixels — beyond that the levels cost more than they serve.
import math

import rasterio
from rasterio.enums import Resampling

CATEGORICAL_METHODS = {"nearest": Resampling.nearest, "mode": Resampling.mode}
CONTINUOUS_METHODS = {"average": Resampling.average, "gauss": Resampling.gauss}


def build_pyramid(
    path: str,
    *,
    categorical: bool = False,
    method: str | None = None,
    min_side: int = 512,
) -> list[int]:
    """Build internal overviews down to roughly min_side pixels. Returns the levels used."""
    with rasterio.open(path, "r+") as dst:
        longest = max(dst.width, dst.height)

        # Levels are powers of two until the coarsest level fits one screen
        n_levels = max(1, math.ceil(math.log2(longest / min_side)))
        factors = [2 ** i for i in range(1, n_levels + 1)]

        if method is None:
            method = "nearest" if categorical else "average"
        table = CATEGORICAL_METHODS if categorical else CONTINUOUS_METHODS
        if method not in table:
            raise ValueError(f"{method!r} is not valid for this data class")

        dst.build_overviews(factors, table[method])
        # Record the choice: the file is the only place a later reader will look
        dst.update_tags(ns="rio_overview", resampling=method)
        return factors


if __name__ == "__main__":
    print("reflectance:", build_pyramid("B04_10m.tif"))
    print("scene classification:", build_pyramid("SCL_20m.tif", categorical=True))

Two things are worth noticing. The level list is derived rather than hard-coded, so a small clipped scene does not get five pointless levels and a continental mosaic does not stop three levels short. And the method is recorded in a namespaced tag, which is the convention rasterio itself uses and the only durable place to keep that fact.


Variant Patterns

1. Categorical layers, and why mode beats nearest at depth

nearest picks one source pixel per output pixel — usually the top-left of the block — so at deep decimation levels a small but locally dominant class can vanish entirely because the sampled corner happened to be something else. mode picks the most frequent class in the block, which preserves the visual composition of a class map much better at 1/16 and beyond.

import rasterio
from rasterio.enums import Resampling

with rasterio.open("landcover.tif", "r+") as dst:
    # mode is slower to build but keeps small classes visible when zoomed out
    dst.build_overviews([2, 4, 8, 16, 32], Resampling.mode)
    dst.update_tags(ns="rio_overview", resampling="mode")

Both preserve the invariant that matters: every value in the pyramid is a value that exists in the legend.

2. Rebuilding a pyramid that was built wrongly

Overviews can be replaced, but the file will not shrink automatically — GDAL leaves the old blocks in place. The reliable route is to clear them and rewrite the file.

import rasterio
from rasterio.shutil import copy as rio_copy

# 1. Drop the existing pyramid
with rasterio.open("mask.tif", "r+") as dst:
    dst.build_overviews([])          # empty list removes internal overviews

# 2. Rewrite through the COG driver, which builds a fresh pyramid in the right order
rio_copy("mask.tif", "mask_fixed.tif", driver="COG",
         overview_resampling="nearest", compress="DEFLATE", blocksize=512)

Rewriting also restores header-first layout, which an in-place rebuild does not guarantee — the ordering point made in the parent topic.

3. Overviews for a multi-band file

build_overviews operates on the dataset, so all bands get the same levels and the same method. When a file mixes continuous bands with a class band — reflectance plus an embedded mask — that shared method is a genuine conflict, and the answer is to split the class layer into its own file rather than to compromise on the kernel. That separation also makes the mask reusable, which is how the masking workflows in Cloud and Shadow Masking Strategies expect to consume it.


Verifying the Pyramid Is Right

Two checks catch the failures that matter, and both run in seconds.

The first is the level list itself: src.overviews(1) should return the factors you asked for, and its last entry should reduce the longest side to a few hundred pixels. An empty list means the build silently did nothing — usually because the file was opened read-only and the error was swallowed.

The second is a value check on categorical data. Read the coarsest overview level and compare its unique values against the base level’s legend. Any value that is not in the legend proves the wrong kernel was used.

import numpy as np
import rasterio

with rasterio.open("landcover.tif") as src:
    levels = src.overviews(1)
    print("levels:", levels, "tags:", src.tags(ns="rio_overview"))

    base_classes = set(np.unique(src.read(1, out_shape=(1, 512, 512),
                                          resampling=rasterio.enums.Resampling.nearest)))
    coarse = src.read(1, out_shape=(1, src.height // levels[-1], src.width // levels[-1]))
    invented = set(np.unique(coarse)) - base_classes
    assert not invented, f"overviews invented class values: {sorted(invented)}"
What each kernel does to a class histogram At full resolution the class map holds only the codes 3, 4, 5 and 6. Overviews built with averaging spread values across the gaps between codes, producing classes that do not exist. Overviews built with mode keep the histogram on the original codes while shifting their proportions slightly toward the locally dominant class. Unique values in the coarsest level base level 3 4 5 6 average red bars are values with no legend entry mode same four codes, different proportions The assertion above is exactly this comparison, expressed as a set difference. It costs one coarse read and catches the mistake before the file is published.

Common Errors

build_overviews runs but overviews(1) returns an empty list

The dataset was opened read-only, or opened with a driver that cannot store internal overviews. Open with "r+" and confirm the driver is GTiff.

A .ovr file appeared next to the raster

GDAL fell back to an external pyramid because the file could not be modified in place — often a permissions problem or a read-only mount. Fix the write access; an external pyramid costs an extra request per open on remote reads and is easy to lose in a copy.

Overview build is extremely slow on a large mosaic

gauss and mode are considerably more expensive than average and nearest. Use them where the data class demands it, and set GDAL_NUM_THREADS=ALL_CPUS so the decimation is parallelised.


Frequently Asked Questions

Q: Internal overviews or an external .ovr file? Internal, for anything that will be read remotely. An external .ovr is a second object that the reader has to discover and fetch, which costs an extra request per open and breaks entirely if the sidecar is not copied alongside the raster.

Q: Why does my class map gain new class values after adding overviews? Average or bilinear resampling was used. Averaging class codes produces values between codes, which are new classes that mean nothing. Rebuild the overviews with nearest or mode.

Q: Do overviews slow down full-resolution reads? No. A full-resolution read addresses the base level directly. Overviews only add file size, roughly a third for a full pyramid, and are read only when a request asks for fewer pixels than the base level holds.