Resampling and Overviews When Writing Cloud-Optimized GeoTIFFs
A Cloud-Optimized GeoTIFF is an ordinary GeoTIFF whose bytes are arranged so a client can fetch a useful piece of it without downloading the rest, and three creation choices decide whether that arrangement actually holds: the internal block size, the overview factor set, and the resampling kernel used to build those overviews. This guide is for anyone publishing analysis-ready rasters with Raster Data Handling with Rasterio in Mastering Core Geospatial Python Libraries; the read side of the same contract is covered in windowed reads from a Cloud Optimized GeoTIFF.
Why This Approach / What Goes Wrong
The format specification is short: the image must be internally tiled, it must carry a pyramid of reduced-resolution copies, and the directory structures that index them must sit at the front of the file, ahead of the pixel data they point at. A reader opens the file with one small ranged request, learns where every tile of every level lives, and then asks for exactly the tiles it needs. Break any of the three and the file still opens perfectly in QGIS or rasterio.open() — it simply stops being cheap to read remotely, silently, with no error anywhere in your pipeline.
Three distinct failures produce that outcome, and they need different fixes. The first is a file that is tiled but has no pyramid: every zoomed-out request decodes full-resolution tiles and downsamples them on the client, so a continental preview costs gigabytes of transfer. The second is a pyramid written in the wrong place. Opening a plain GTiff for writing, calling build_overviews(), and closing appends the overview directories and their pixels after the main image, because GDAL has already streamed the full-resolution tiles to disk by then. The bytes are all there, the pyramid works locally, and rio cogeo validate rejects the file — correctly, because a remote client would have to seek to the tail before it could plan a single read.
The third failure is structural perfection with wrong numbers in it. Overviews are new pixels, computed by a kernel you chose, and the kernel has to match what the values mean. Averaging four land-cover codes produces a code that names no class; averaging four elevations produces exactly the elevation the level should hold. Nothing validates this — the file is a valid COG either way, and the damage only shows up as a zoomed-out map full of classes nobody defined.
COPY_SRC_OVERVIEWS guarantee.Prerequisites
rasterio>=1.3— bundles a GDAL 3.4+ with the dedicatedCOGdriver andrasterio.shutil.copyrio-cogeo>=5.0—rio cogeo create,validateandinfo, plus thecog_translatePython APInumpy>=1.24— array comparisons in the verification step- A GDAL build compiled with ZSTD and LERC if you intend to use them (conda-forge's is)
conda install -c conda-forge "rasterio=1.3.*" "rio-cogeo=5.0.*" "numpy=1.26.*"
Install the whole stack from conda-forge in one solve — Rasterio links GDAL, which links PROJ, and the compression codecs are compiled into that same GDAL. A pip wheel mixed with a system GDAL is the usual reason COMPRESS=ZSTD raises at write time on one machine and works on another.
Step-by-Step Implementation
1. Inspect the source and confirm it is georeferenced. A COG without a CRS is a thumbnail, not a dataset, so fail loudly here rather than shipping one.
import rasterio
src_path = "dem_utm33n.tif" # 20 000 x 20 000 float32 elevation model
with rasterio.open(src_path) as src:
if src.crs is None:
raise ValueError(f"{src_path} has no CRS — assign one before writing a COG")
print(src.crs, src.dtypes[0], src.nodata) # EPSG:32633 float32 -9999.0
print(src.width, src.height) # 20000 20000
print(src.block_shapes[0]) # (1, 20000) — striped source
print(src.overviews(1)) # [] — no pyramid yet
Keep the source CRS. A COG destined for a web map does not need to be in Web Mercator: rio-tiler-style tile servers reproject on the fly, and re-gridding a metric UTM archive into EPSG:3857 resamples every pixel and destroys the equal-ground-spacing property that elevation and reflectance analysis depends on. Reproject a display copy if you need one; never the archive.
2. Derive the overview factor set from the raster's size. The rule is mechanical: keep halving until the coarsest level fits inside a single internal block, so that a whole-scene preview is one tile fetch. Stopping early is the most common under-build — a pyramid ending at 4× on a 20 000-pixel raster still forces a 5 000-pixel decode for a thumbnail.
def overview_factors(width: int, height: int, blocksize: int = 512) -> list[int]:
"""Halve until the coarsest level fits inside one internal block."""
factors, level = [], 1
while max(width, height) // 2 ** (level - 1) > blocksize:
factors.append(2 ** level)
level += 1
return factors
print(overview_factors(20000, 20000)) # [2, 4, 8, 16, 32, 64]
print(overview_factors(3000, 1800)) # [2, 4, 8]
Use 512 × 512 blocks as the default for imagery and elevation. 256 makes each request smaller but multiplies the number of tile offsets in the header — a 20 000-pixel scene jumps from 1 600 to 6 400 entries per level — while 1 024 wastes bandwidth on any client that wants a small window. The block size and the pyramid depth are the same decision: the pyramid stops when a level fits one block. On multi-band files keep the default pixel interleave as well, so one tile fetch returns every band for that footprint; band interleave forces a separate range request per band and turns a four-band patch read into four.
3. Choose the resampling kernel from what the band means. This is the only step in the recipe that no validator can check for you, and the only one that changes the data rather than its packaging.
from rasterio.enums import Resampling
OVERVIEW_RESAMPLING = {
"elevation": Resampling.average, # continuous float — mean is meaningful
"reflectance": Resampling.average,
"landcover": Resampling.mode, # class codes — majority, never a mean
"cloud_mask": Resampling.nearest, # binary 0/1 — keep it binary
"rgb_basemap": Resampling.average, # appearance only
}
kernel = OVERVIEW_RESAMPLING["elevation"]
print(kernel.name) # average
One dependency turns average from correct into corrupting: nodata. GDAL excludes nodata pixels from an averaged overview only when the band declares a nodata value or carries an internal mask. On a scene where fill is -9999 but src.nodata is None, every coarse level near the edge averages real elevations with fill and produces a dark fringe that spreads one pixel wider at each level. Set nodata before you build, or write an internal mask.
4. Choose compression and predictor together. The predictor is a reversible pre-transform that makes neighbouring values compress better, and it is typed: predictor=2 (horizontal differencing) for integers, predictor=3 (floating-point differencing) for float32/float64, predictor=1 for byte imagery and anything already compressed. Setting 2 on floats gains nothing and may raise; setting 3 on integers is invalid.
DEFLATE with the right predictor is the safe default — every GDAL, browser-side reader and cloud tiler in existence decodes it. ZSTD compresses slightly better and writes two to three times faster, which matters when you are re-encoding thousands of scenes. LERC is the specialist: it is designed for continuous rasters and takes a max_z_error bound, so you can trade a guaranteed maximum per-pixel error for a large size reduction on elevation or reflectance. max_z_error=0 is lossless.
Compression level is worth far less tuning than it looks. Pushing ZSTD_LEVEL from 9 to 22 typically buys a few percent for several times the write time, and ZLEVEL above 9 on DEFLATE behaves the same way; the predictor and the block size move the number much further. The overviews inherit the main image's codec, so whatever you choose here is also what every zoomed-out read has to decode — one more reason to keep the archive on something universally supported and reserve LERC for the products whose error budget you actually control.
5. Write the COG in one pass with the COG driver. GDAL 3.1+ ships a driver that handles tiling, pyramid construction and byte ordering itself. Drive it through rasterio.shutil.copy, which issues a straight CreateCopy from the source dataset.
from rasterio.shutil import copy as rio_copy
rio_copy(
src_path,
"dem_cog.tif",
driver="COG",
blocksize=512,
compress="DEFLATE",
predictor="YES", # COG driver: YES/NO/STANDARD/FLOATING_POINT
overview_resampling="AVERAGE",
num_threads="ALL_CPUS",
bigtiff="IF_SAFER",
)
Two details save an afternoon here. PREDICTOR on the COG driver takes a named value, not the numeric GTiff one — "YES" resolves to horizontal differencing for integers and floating-point differencing for floats, so it is the portable spelling. And prefer rio_copy over rasterio.open("dem_cog.tif", "w", driver="COG", ...): the COG driver is copy-only, so Rasterio silently falls back to a buffered writer that holds the entire dataset in memory until close, which a 20 000² raster will not survive.
6. When you need an explicit factor set, build the pyramid yourself and hand it over. Use this when the array is produced by your own pipeline, when the overviews are expensive enough to build exactly once, or when the kernel differs from what you would want the driver to guess — a classified raster resampled with mode being the standard case.
import rasterio
from rasterio.enums import Resampling
from rasterio.io import MemoryFile
from rasterio.shutil import copy as rio_copy
with rasterio.open("landcover_utm33n.tif") as src:
classes = src.read(1) # uint8 class codes
profile = src.profile.copy()
profile.update(
driver="GTiff", tiled=True, blockxsize=512, blockysize=512,
compress="DEFLATE", predictor=2, # numeric here — GTiff, integers
)
factors = overview_factors(profile["width"], profile["height"])
with MemoryFile() as memfile:
with memfile.open(**profile) as mem:
mem.write(classes, 1)
mem.build_overviews(factors, Resampling.mode) # majority, never a mean
mem.update_tags(ns="rio_overview", resampling="mode")
rio_copy(
mem,
"landcover_cog.tif",
driver="COG",
overviews="FORCE_USE_EXISTING", # reuse the pyramid, do not rebuild
blocksize=512,
compress="DEFLATE",
predictor="YES",
num_threads="ALL_CPUS",
)
MemoryFile keeps the intermediate in RAM, so use it only for rasters that fit; for anything larger write the tiled GTiff to a scratch path and copy from there. The band-indexing rules for multi-band sources are covered in reading multi-band TIFFs with Rasterio — read and write band by band rather than materialising a full (bands, rows, cols) stack.
7. Or drive the whole thing from rio-cogeo for batch work. The CLI is the shortest correct path when you are re-encoding a directory, and its Python API takes a profile dict you can version-control.
rio cogeo create dem_utm33n.tif dem_cog.tif \
--cog-profile deflate \
--overview-resampling average \
--blocksize 512
from rio_cogeo.cogeo import cog_translate
from rio_cogeo.profiles import cog_profiles
dst_profile = cog_profiles.get("zstd") # deflate | zstd | lerc | lzw | webp
dst_profile.update(blockxsize=512, blockysize=512, predictor=3) # float32
cog_translate(
"dem_utm33n.tif",
"dem_cog_zstd.tif",
dst_profile,
overview_resampling="average",
quiet=False,
)
Verification
Two things need proving: that the file satisfies the layout rules, and that the pyramid you asked for is the pyramid that landed. The validator answers the first, Rasterio answers the second.
rio cogeo validate dem_cog.tif
# dem_cog.tif is a valid cloud optimized GeoTIFF
rio cogeo info dem_cog.tif
# IFD
# Id Size BlockSize Decimation
# 0 20000x20000 512x512 0
# 1 10000x10000 512x512 2
# ...
import numpy as np
import rasterio
from rasterio.windows import Window
from rio_cogeo.cogeo import cog_validate
is_valid, errors, warnings = cog_validate("dem_cog.tif")
assert is_valid, errors
print("warnings:", warnings) # warnings: []
with rasterio.open("dem_cog.tif") as cog:
assert cog.block_shapes[0] == (512, 512), cog.block_shapes[0]
factors = cog.overviews(1)
print("overview factors:", factors) # overview factors: [2, 4, 8, 16, 32, 64]
assert factors, "no pyramid — every zoomed-out read decodes full resolution"
coarsest_px = max(cog.width, cog.height) / factors[-1]
assert coarsest_px <= 512, f"coarsest level is {coarsest_px:.0f} px — add a level"
# Lossless codec: the full-resolution pixels must survive the re-encode
win = Window(10000, 10000, 512, 512)
with rasterio.open(src_path) as src:
assert np.array_equal(src.read(1, window=win), cog.read(1, window=win))
print("dem_cog.tif: valid COG, 6 overview levels, pixels unchanged")
With a lossy max_z_error, replace the equality check with the bound you paid for — assert np.abs(src.read(1, window=win) - cog.read(1, window=win)).max() <= 0.01 — which is the whole point of LERC over a generic lossy codec.
Edge Cases & Debugging
- Pyramid present but reads are still slow. The overviews were built with GDAL's default internal overview block of 128 × 128, so a coarse read costs sixteen requests instead of one. Wrap manual
build_overviews()calls inrasterio.Env(GDAL_TIFF_OVR_BLOCKSIZE="512"), or let theCOGdriver do it. - Dark fringe or halo along the nodata edge.
averagefolded fill values into real data because the band declared no nodata. Setnodataon the profile before building, or write an internal mask, then rebuild — resampling never re-reads the mask afterwards. rio cogeo validatefails on a file that opens fine. Overviews were appended after the main image by a plainGTiffwrite. Re-copy through theCOGdriver, or usedriver="GTiff", copy_src_overviews=True— the ordering is a property of the copy, not of the pixels.PREDICTORrejected or useless.predictor=2onfloat32yields no compression benefit andpredictor=3on integers is invalid; theCOGdriver wants"YES"/"STANDARD"/"FLOATING_POINT"whileGTiffwants1/2/3.Compression method not supportedfor ZSTD or LERC. The codec is not compiled into your GDAL.gdalinfo --format GTifflists theCOMPRESSvalues that build actually accepts — check before standardising a pipeline on one.- Write fails past 4 GB with a TIFF size error. Compressed output defeats GDAL's size estimate, so pass
bigtiff="IF_SAFER"rather than trusting the default.
Frequently Asked Questions
How much does the overview pyramid add to the file? About a third. Each level holds a quarter of the pixels of the one above, so the series converges to 1/3 of the full-resolution payload regardless of how deep you go. That makes pyramid depth effectively free: stopping at 4× to "save space" saves under 5% of the file while forcing every client to decode a 5 000-pixel image for a thumbnail.
Can I add overviews to an existing COG without rewriting it?
No — not while keeping it a COG. Overview directories have to sit ahead of the full-resolution pixel data, and there is no room to insert them into a finished file, so GDAL appends them and the layout stops validating. Re-copy the file through the COG driver instead; on a lossless codec the operation is a decode-and-re-encode with no data change, which the pixel-equality assertion in the verification step confirms.
Should the overviews live in a sidecar .ovr file?
Not for a COG. External .ovr pyramids are a fine local optimisation, but a remote client that only has a URL to the .tif will never find them, and rio cogeo validate will report the file as having no overviews at all. Keep everything internal — that is the format's entire premise.
Does a COG have to be in Web Mercator to serve a web map?
No. Tile servers reproject on read, so a COG in its native UTM zone serves web tiles perfectly well, and keeping the archive in a metric projected CRS preserves the equal-ground-spacing that analysis needs. Use rio cogeo create --web-optimized only for a display copy whose blocks must align exactly with the EPSG:3857 tile grid, and keep it alongside the UTM original rather than replacing it.