How to Write a Cloud-Optimised GeoTIFF in Python

On this page

Problem statement

Writing a COG looks like four settings and one extra step — tile it, compress it, pick a predictor, build overviews — and files that miss the extra step are the commonest failure in the whole area, because they look correct. A tiled, compressed GeoTIFF without overviews behaves acceptably for full-resolution windows and badly for anything zoomed out: measured over HTTP, a thumbnail of a 3,600 × 3,600 elevation model cost 0.606 MB with overviews and 30.7 MB without — the whole file.

There is a second failure that the usual recipe does not show. Writing a tiled GTiff in rasterio and calling build_overviews gives a file with overviews that GDAL's own COG validator rejects, because the overview data lands after the full-resolution image instead of before it (step 5). GDAL's COG driver writes the layout correctly, and it is one call.

Quick answer

from rasterio.shutil import copy as rio_copy
from osgeo_utils.samples.validate_cloud_optimized_geotiff import validate


def write_cog(src_path, dst_path, resampling="AVERAGE", blocksize=512):
    """GDAL's COG driver: tiles, compression, predictor, overviews and the layout, in one call."""
    rio_copy(src_path, dst_path, driver="COG", BLOCKSIZE=blocksize, COMPRESS="DEFLATE",
             PREDICTOR="YES", OVERVIEW_RESAMPLING=resampling, BIGTIFF="IF_SAFER")
    warnings, errors, _ = validate(dst_path, full_check=True)
    if errors:
        raise RuntimeError(f"{dst_path} is not a valid COG: {errors}")
    return dst_path


write_cog("dem.tif", "dem_cog.tif")
with rasterio.open("dem_cog.tif") as check:
    print(f"blocks {check.block_shapes[0]}, overviews {check.overviews(1)}, "
          f"{os.path.getsize('dem_cog.tif') / 1e6:.1f} MB, valid COG")
blocks (512, 512), overviews [2, 4, 8], 41.6 MB, valid COG

PREDICTOR="YES" lets the driver pick the right predictor for the data type, and the driver chooses overview levels down to about one tile on its own. The validation is not defensive programming for its own sake: it is the only check that sees the layout.

Five settings for a cloud-optimised GeoTIFF: tiled, block size, compression, predictor and overviews, with validation after writing. four are profile settings; the fifth is a separate calltiled=Truenot stripedblockxsize and blockysize512, multiples of 16compress + predictor2 for int, 3 for floatbuild_overviews()after writing the datavalidate the outputoverviews can silently not appearThe fifth is why so many files are tiled, compressed and not cloud-optimised spatialworkflow.io
Four are profile settings. The fifth is a separate call, which is why it gets forgotten.

Tiles, overview levels, resampling, compression, validation

1. Tile it

"tiled": True, "blockxsize": 512, "blockysize": 512

Both block dimensions must be multiples of 16, and both must be set — setting only blockysize on a tiled write is an error, and omitting tiled makes the block settings meaningless.

512 is a good default. 256 suits small rasters and tile servers; 1024 suits very large rasters read in big blocks.

2. Choose the overview levels from the image size

Take the coarsest level down to roughly one tile, so a full-extent view is a single read:

for width, height in ((4096, 4096), (3600, 3600)):
    largest = max(width, height)
    levels, factor = [], 2
    while largest / factor >= 256:
        levels.append(factor)
        factor *= 2
    print(f"{width} px: levels {levels}, coarsest {largest // levels[-1]} px")
4096 px: levels [2, 4, 8, 16], coarsest 256 px
3600 px: levels [2, 4, 8], coarsest 450 px

The COG driver does this itself — the elevation model above got [2, 4, 8]. The loop is for when you build overviews yourself.

3. Choose the overview resampling for the data

data resampling
elevation, reflectance, temperature average
land cover, classes, masks mode or nearest
already-binned counts sum if supported, else average

Averaging a classification produces class codes that do not exist. Measured on the WorldCover crop, whose legend has ten codes:

LEGEND = {10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100}
for resampling in ("AVERAGE", "MODE"):
    write_cog("landcover.tif", f"landcover_{resampling.lower()}.tif", resampling=resampling)
    with rasterio.open(f"landcover_{resampling.lower()}.tif") as ds:
        overview = ds.read(1, out_shape=(ds.height // 8, ds.width // 8))
    codes = set(np.unique(overview).tolist())
    print(f"{resampling:8} overview codes: {len(codes)}, of which {len(codes - LEGEND)} are not classes, "
          f"e.g. {sorted(codes - LEGEND)[:6]}")
AVERAGE  overview codes: 91, of which 80 are not classes, e.g. [11, 12, 13, 14, 15, 16]
MODE     overview codes: 9, of which 0 are not classes, e.g. []

This is the same rule as for any resampling, and it is applied once at write time rather than at every read — which makes getting it wrong more consequential.

4. Choose compression and predictor together

"compress": "deflate", "predictor": 2      # integers
"compress": "deflate", "predictor": 3      # floats

The predictor stores differences between adjacent values rather than the values themselves, which compresses far better on smooth data — and not on classes:

for path, dtype in (("dem.tif", "float32"), ("landcover.tif", "uint8")):
    with rasterio.open(path) as src:
        data, profile = src.read(1), src.profile
    raw = data.nbytes / 1e6
    for label, extra in (("no compression", {}), ("deflate", {"compress": "deflate"}),
                         ("deflate, predictor 2", {"compress": "deflate", "predictor": 2}),
                         ("deflate, predictor 3", {"compress": "deflate", "predictor": 3})):
        if extra.get("predictor") == 3 and dtype != "float32":
            continue
        out = f"test_{dtype}.tif"
        with rasterio.open(out, "w", **(profile | {"driver": "GTiff", "tiled": True, "blockxsize": 512,
                                                   "blockysize": 512} | extra)) as dst:
            dst.write(data, 1)
        print(f"{path:14} {label:22} {os.path.getsize(out) / 1e6:6.2f} MB ({raw / (os.path.getsize(out) / 1e6):5.2f}x)")
dem.tif        no compression          67.11 MB ( 0.77x)
dem.tif        deflate                 44.41 MB ( 1.17x)
dem.tif        deflate, predictor 2    37.59 MB ( 1.38x)
dem.tif        deflate, predictor 3    30.67 MB ( 1.69x)
landcover.tif  no compression          16.78 MB ( 1.00x)
landcover.tif  deflate                  0.97 MB (17.35x)
landcover.tif  deflate, predictor 2     1.14 MB (14.73x)

Three things in that table. The predictor matters for smooth continuous data: predictor 3 took the elevation model from 44.41 MB to 30.67 MB, and predictor 2 on the same floats helped less but still helped. On the land-cover classes it did the opposite — predictor 2 made the file 18% larger than plain deflate, because differences between class codes are not small and long runs of one class compress better as they are. And "no compression" is larger than the data: 3,600 is not a multiple of 512, so the edge tiles are padded out to full size.

5. Validate, always

The check most recipes use — tiled, with overviews — passes files that are not COGs. Here is the common recipe, a tiled GTiff with build_overviews, next to the COG driver's output:

from rasterio.enums import Resampling

with rasterio.open("dem.tif") as src:
    profile = src.profile | {"driver": "GTiff", "tiled": True, "blockxsize": 512, "blockysize": 512,
                             "compress": "deflate", "predictor": 3, "BIGTIFF": "IF_SAFER"}
    with rasterio.open("dem_gtiff.tif", "w", **profile) as dst:
        dst.write(src.read(1), 1)
        dst.build_overviews([2, 4, 8], Resampling.average)

for path in ("dem_gtiff.tif", "dem_cog.tif"):
    with rasterio.open(path) as ds:
        tiled = ds.block_shapes[0][0] not in (1, ds.height)
        looks_right = tiled and bool(ds.overviews(1))
    warnings, errors, _ = validate(path, full_check=True)
    print(f"{path:14} tiled with overviews: {looks_right}; validator errors: {len(errors)}")
    for error in errors:
        print("   ", error)
dem_gtiff.tif  tiled with overviews: True; validator errors: 3
    The offset of the first block of overview of index 1 should be after the one of the overview of index 2
    The offset of the first block of overview of index 0 should be after the one of the overview of index 1
    The offset of the first block of the main resolution image should be after the one of the overview of index 2
dem_cog.tif    tiled with overviews: True; validator errors: 0

The GTiff file has tiles and overviews and is not a COG: its overview data sits after the full-resolution image, so a reader asking for a thumbnail has to find it at the end of the file. The validator is osgeo_utils.samples.validate_cloud_optimized_geotiff, shipped with GDAL's Python bindings.

Overview levels halving a 4096 pixel image to 2048, 1024, 512 and 256, where the coarsest level fits in a single tile. stop when the coarsest level fits one tilefull resolution4096 pxoverview 22048 pxoverview 41024 pxoverview 8512 pxoverview 16256 px = one tile04400That is what makes a full-extent view a single request spatialworkflow.io
Stop when the coarsest level fits one tile. That makes a full-extent view a single request.

A self-configuring writer for a DEM and WorldCover

The data used on this page

Two real rasters of the same part of the Alps: one tile of the Copernicus 30 m digital elevation model (3,600 × 3,600 float32, published as a COG on AWS), and a 4,096 × 4,096 crop of ESA WorldCover 2021 at 10 m (uint8 land-cover classes), read from its public COG. Both are saved as plain striped GeoTIFFs, the way most rasters arrive before anyone thinks about the cloud.

import os

import numpy as np
import rasterio
from rasterio.windows import from_bounds

DEM = ("https://copernicus-dem-30m.s3.amazonaws.com/Copernicus_DSM_COG_10_N46_00_E010_00_DEM/"
       "Copernicus_DSM_COG_10_N46_00_E010_00_DEM.tif")
WORLDCOVER = ("https://esa-worldcover.s3.eu-central-1.amazonaws.com/v200/2021/map/"
              "ESA_WorldCover_10m_2021_v200_N45E009_Map.tif")
with rasterio.Env(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR"):
    for url, path, window_of in ((DEM, "dem.tif", None),
                                 (WORLDCOVER, "landcover.tif", lambda ds: from_bounds(
                                     10.2, 46.2, 10.2 + 4096 * ds.res[0], 46.2 + 4096 * ds.res[1], ds.transform))):
        if os.path.exists(path):
            continue
        with rasterio.open(url) as src:
            window = window_of(src) if window_of else None
            data = src.read(1, window=window)
            profile = src.profile | {"driver": "GTiff", "tiled": False, "compress": None,
                                     "height": data.shape[0], "width": data.shape[1],
                                     "transform": src.window_transform(window) if window else src.transform}
            for key in ("blockxsize", "blockysize", "interleave", "predictor"):
                profile.pop(key, None)
        with rasterio.open(path, "w", **profile) as dst:
            dst.write(data, 1)
for path in ("dem.tif", "landcover.tif"):
    with rasterio.open(path) as ds:
        print(f"{path:14} {ds.width} x {ds.height} {ds.dtypes[0]:8} striped {ds.block_shapes[0]}, "
              f"{os.path.getsize(path) / 1e6:.1f} MB")
dem.tif        3600 x 3600 float32  striped (1, 3600), 51.9 MB
landcover.tif  4096 x 4096 uint8    striped (2, 4096), 16.8 MB

Example 1 — a writer that chooses its own settings

The writer is a module, so Example 3 can hand it to worker processes.

%%writefile cogtools.py
import os

import rasterio
from osgeo_utils.samples.validate_cloud_optimized_geotiff import validate
from rasterio.shutil import copy as rio_copy

RESAMPLING = {"continuous": "AVERAGE", "categorical": "MODE", "classified": "NEAREST"}
PREDICTOR = {"continuous": "YES", "categorical": "NO", "classified": "NO"}    # see step 4


def write_cog(src_path, dst_path, kind="continuous", blocksize=512, compress="DEFLATE"):
    """Write a validated COG with the resampling the data needs."""
    with rasterio.open(src_path) as src:
        descriptions = src.descriptions
    rio_copy(src_path, dst_path, driver="COG", BLOCKSIZE=blocksize, COMPRESS=compress,
             PREDICTOR=PREDICTOR[kind], OVERVIEW_RESAMPLING=RESAMPLING[kind], BIGTIFF="IF_SAFER")
    warnings, errors, _ = validate(dst_path, full_check=True)
    if errors:
        raise RuntimeError(f"not a valid COG: {errors}")
    with rasterio.open(dst_path) as check:
        assert check.descriptions == descriptions
        print(f"  {os.path.basename(dst_path)}: {os.path.getsize(dst_path) / 1e6:7.2f} MB, "
              f"blocks {check.block_shapes[0]}, overviews {check.overviews(1)}")
    return dst_path
from cogtools import write_cog as write_validated_cog

write_validated_cog("dem.tif", "dem_example.tif", kind="continuous")
write_validated_cog("landcover.tif", "landcover_example.tif", kind="categorical")
  dem_example.tif:   41.58 MB, blocks (512, 512), overviews [2, 4, 8]
  landcover_example.tif:    1.49 MB, blocks (512, 512), overviews [2, 4, 8]

Example 2 — writing a large raster in windows

import numpy as np
import rasterio
from rasterio.enums import Resampling
from rasterio.windows import Window


def write_cog_windowed(dst_path, shape, transform, crs, generator,
                       dtype="float32", blocksize=512, nodata=np.nan):
    """Write a COG larger than memory, one block at a time.

    generator: yields (Window, ndarray) pairs covering the raster.
    """
    height, width = shape
    profile = dict(driver="GTiff", height=height, width=width, count=1,
                   dtype=dtype, crs=crs, transform=transform, nodata=nodata,
                   tiled=True, blockxsize=blocksize, blockysize=blocksize,
                   compress="deflate",
                   predictor=3 if str(dtype).startswith("float") else 2,
                   BIGTIFF="IF_SAFER")

    written = 0
    staging = dst_path + ".staging.tif"
    with rasterio.open(staging, "w", **profile) as dst:
        for window, block in generator:
            dst.write(block.astype(dtype), 1, window=window)
            written += block.size

    write_cog(staging, dst_path)              # overviews and the COG layout, then validated
    os.remove(staging)
    print(f"  wrote {written / 1e6:.1f} Mpx through a tiled staging file")
    return dst_path

A block-by-block write cannot produce the COG layout directly, because the layout puts the overviews first and they can only be computed once all the data exists. So the blocks go to a tiled staging file and the COG driver rewrites it — reading it back once, which is the price of the layout.

with rasterio.open("dem.tif") as src:
    shape, transform, crs = (src.height, src.width), src.transform, src.crs


def feet(path="dem.tif", size=512):
    """Elevation in feet, one 512 x 512 block at a time."""
    with rasterio.open(path) as src:
        for row in range(0, src.height, size):
            for col in range(0, src.width, size):
                window = Window(col, row, min(size, src.width - col), min(size, src.height - row))
                yield window, src.read(1, window=window) * 3.28084


from rasterio.windows import Window

write_cog_windowed("dem_feet.tif", shape, transform, crs, feet())
  wrote 13.0 Mpx through a tiled staging file

Example 3 — converting a directory, in parallel

import glob
import os
from concurrent.futures import ProcessPoolExecutor

import cogtools


def convert_directory(pattern, out_dir, kind="continuous", workers=4):
    """Convert every raster in a directory to a validated COG."""
    os.makedirs(out_dir, exist_ok=True)
    paths = sorted(glob.glob(pattern))

    def target(path):
        return os.path.join(out_dir,
                            os.path.splitext(os.path.basename(path))[0] + ".tif")

    todo = [(p, target(p)) for p in paths if not os.path.exists(target(p))]
    print(f"  {len(paths)} inputs, {len(todo)} to convert")

    results, failures = [], []
    with ProcessPoolExecutor(workers) as pool:
        futures = {pool.submit(cogtools.write_cog, src, dst, kind=kind): src
                   for src, dst in todo}
        for future in futures:
            try:
                results.append(future.result())
            except Exception as exc:
                failures.append((futures[future], f"{type(exc).__name__}: {exc}"))

    for path, error in failures:
        print(f"  ! {os.path.basename(path)}: {error}")
    print(f"  {len(results)} converted, {len(failures)} failed")
    return results, failures

Four quadrants of the elevation model as the "directory", converted, then converted again:

os.makedirs("inputs", exist_ok=True)
with rasterio.open("dem.tif") as src:
    for i, (row, col) in enumerate(((0, 0), (0, 1800), (1800, 0), (1800, 1800))):
        window = Window(col, row, 1800, 1800)
        with rasterio.open(f"inputs/quadrant_{i}.tif", "w", **(src.profile | {
                "width": 1800, "height": 1800, "transform": src.window_transform(window)})) as dst:
            dst.write(src.read(1, window=window), 1)
convert_directory("inputs/*.tif", "cogs")
convert_directory("inputs/*.tif", "cogs")
  4 inputs, 4 to convert
  4 converted, 0 failed
  4 inputs, 0 to convert
  0 converted, 0 failed

Skipping outputs that already exist makes the conversion resumable, which matters because COG conversion of a large archive is measured in hours and something always interrupts it.

Overviews, predictors and compression ratios

Why overviews are a separate step

Overviews are additional image data, not a header flag. They have to be computed by reading the full-resolution data and downsampling it, which is why they cannot be part of the profile.

That separation is why they get forgotten. The profile settings are visible at the top of the function and the build_overviews call is buried after the write loop — and omitting it produces a valid, tiled, compressed file that passes every casual inspection.

The only reliable defence is validating the output, which is why every example here does.

Why the predictor matters so much

Deflate compresses repeated byte patterns. Raw pixel values in a smooth raster are all different, so there is little to repeat.

The horizontal predictor stores each value as its difference from the previous one along the row. On smooth data those differences are small and cluster near zero, which compresses very well.

predictor=2 assumes integer arithmetic; predictor=3 is the floating-point variant, which reorders bytes so that exponents and mantissas group together. Measured on the elevation model, 2 on floats gave 37.59 MB against 30.67 MB for 3 — worse, though still better than no predictor. On categorical data a predictor turns long runs of one code into runs of zeros broken by large jumps at every boundary, and the land-cover file grew.

Why compression ratios vary so much

Measured in step 4, the elevation model and the land-cover crop compressed by very different factors with the same settings. That is not a bad setting; it is the data.

Satellite reflectance at 16 bits carries sensor noise in the low bits, and noise is incompressible by definition; smooth fields and classifications with long runs of one value compress well.

If a compression ratio disappoints, check whether the data is genuinely noisy before changing the settings. Quantising reflectance to fewer bits is a lossy choice with real consequences, and worth making deliberately if at all.

Why BIGTIFF=IF_SAFER

Classic TIFF uses 32-bit offsets and cannot exceed 4 GB. BigTIFF lifts that, at the cost of support in very old readers.

IF_SAFER writes BigTIFF when the result might exceed the limit and classic TIFF otherwise. Without it, a write that grows past 4 GB fails at the end — after hours of processing.

Compression ratios varying with the data rather than with the settings. the same settings, very different ratioswhymeasured ratioelevation, predictor 3✓smooth, small differences1.69×land cover, no predictor✓few values, long runs✓17.35×land cover, predictor 2✕predictor breaks the runs!14.73×A disappointing ratio is usually the data, not the settings spatialworkflow.io
The data decides the ratio; the predictor has to suit the data or it costs space.

COG validation, block sizes and categorical overviews

  • Validate with GDAL's COG validator, not by checking for overviews — a tiled GTiff with overviews can still fail it.
  • Set both blockxsize and blockysize, and tiled=True.
  • Block sizes must be multiples of 16.
  • predictor=3 for continuous floats, 2 for smooth integers, none for classes. Predictor 2 grew the land-cover file by 18%.
  • mode or nearest overviews for categorical data, never average.
  • Use the COG driver for the layout; a GTiff with build_overviews stores overviews after the image.
  • BIGTIFF=IF_SAFER so a large write does not fail at the end.
  • Skip existing outputs so a long conversion is resumable.

FAQ

How do I write a COG in Python?

Copy it with GDAL's COG driver — rasterio.shutil.copy(src, dst, driver="COG", ...) — and validate the result with GDAL's COG validator. A tiled GTiff with build_overviews looks right and fails the validator.

Why does my COG behave like an ordinary GeoTIFF?

Missing overviews, or overviews in the wrong place. Without them a downsampled read costs the full resolution — 30.7 MB against 0.606 MB in a measured comparison — and a GTiff written with build_overviews stores them after the image, which the COG validator rejects.

What block size should I use?

512 × 512 for most rasters, 256 for small ones or tile servers. Both dimensions must be multiples of 16 and both must be set.

What overview levels should I build?

Powers of two down to roughly one tile. For a 4096-pixel image that is [2, 4, 8, 16]; the COG driver chose [2, 4, 8] for the 3,600-pixel elevation model on its own.

Which compression should I use?

Deflate, with predictor=3 for continuous floats and 2 for smooth integers — and no predictor for categorical data, where it made a measured land-cover file 18% larger. ZSTD is often better where readers support it.

Why is my compressed file barely smaller?

Because the data is noisy. Compression depends on the data more than the settings; step 4 shows the same settings giving very different ratios for elevation and land cover.

Do I need GDAL's COG driver?

It guarantees the internal layout. Writing with GTiff plus build_overviews usually produces an acceptable file — validate either way.