Cloud-Optimized GeoTIFF for Raster Workloads

Serving raster imagery from object storage exposes a hard constraint that desktop GIS never had to face: a client should never download a 40 GB scene to render a 256-pixel map tile. Cloud-Optimized GeoTIFF (COG) solves this by reorganising an ordinary GeoTIFF so that its internal structure — tiles, overviews, and the directory that indexes them — supports partial reads. A browser, a tile server, or a Python analytics job can fetch a single tile at a single zoom level with one bounded HTTP range request, reading kilobytes instead of gigabytes. This is the raster counterpart to the columnar and streaming vector formats covered across Geospatial Storage Fundamentals & Format Comparison.

The format is deceptively simple: it is still a TIFF, readable by any GDAL-based tool with zero special handling. What makes it “cloud optimized” is the discipline of layout — square internal tiling instead of scanline strips, a full pyramid of reduced-resolution overviews, and a header placed at the front of the file so a reader learns where every tile lives before it fetches any pixels. Get that layout wrong and you have a technically valid GeoTIFF that behaves catastrophically over the network. This guide walks GIS data engineers through the structure, the GDAL COG driver and rasterio APIs, the codec trade-offs, and the boundary where COG is the right tool versus where a chunked array store like Zarr takes over.


Prerequisites

  • Python 3.10+ with rasterio>=1.3, GDAL>=3.4 (the standalone COG driver landed in GDAL 3.1), and rio-cogeo>=5.0 for validation
  • A source raster: a multi-band scene (Sentinel-2, Landsat, orthophotography) or a single-band continuous grid (DEM, land-surface temperature) with a defined CRS and nodata value
  • Object storage access: an S3-, GCS-, or Azure-compatible endpoint reachable through GDAL’s /vsi virtual filesystem layer for range-read testing
  • Familiarity with raster fundamentals: bands, data types (uint8, int16, float32), resampling methods, and the difference between tiled and stripped TIFF organisation
  • A tile-serving or analytics target: TiTiler, a WMTS endpoint, or a rasterio/xarray batch job whose access pattern you can profile

Architectural Foundations

A GeoTIFF is a TIFF with georeferencing tags. Internally, every TIFF is described by one or more Image File Directories (IFDs) — small tables of tags that record width, height, data type, and, crucially, the byte offsets where the actual pixel data lives. In a classic stripped GeoTIFF, pixels are stored in horizontal strips and the IFD often trails at the end of the file. A reader must therefore parse the whole file to discover the layout, which over HTTP means downloading everything.

Cloud-Optimized GeoTIFF re-imposes three rules on that structure:

  • Internal tiling. Pixels are stored as square tiles (commonly 256×256 or 512×512) rather than full-width strips. Each tile is an independently addressable, independently compressed block. Fetching the tile that covers a map viewport touches only that block’s byte range.
  • Overviews (pyramids). The file carries a stack of decimated copies of the raster — half resolution, quarter resolution, and so on — each stored as its own tiled IFD. A zoomed-out request reads a small overview tile instead of downsampling full-resolution pixels on the fly. The mechanics of choosing levels and resampling are covered in depth in Overview Pyramids and Internal Tiling for COG.
  • Front-loaded directory. All IFDs and the tile offset/byte-count arrays are written at the very start of the file. A client reads this header once — 8 to 64 KB for most scenes — learns the byte offset and length of every tile, and thereafter issues exactly one range request per tile it needs.

The diagram below shows how these three rules turn a viewport request into a single bounded read against object storage.

COG internal layout and range-request flow A diagram showing a Cloud-Optimized GeoTIFF file with a front-loaded header followed by full-resolution and overview tile blocks, and a client that reads the header once then fetches a single tile with one HTTP range request. COG file on object storage Header / IFDs tile offsets Full-resolution tiles 256×256 blocks Overview 1/2 Overview 1/4 byte 0 →→→ increasing file offset →→→ end of file Tile server / rasterio client viewport at zoom z 1. read header once 2. one range GET per tile zoom-out → overview tile

Because each tile is compressed independently, codec choice is a per-tile decode cost paid on every fetch — which is why raster compression tuning differs so sharply from the whole-column compression used in Parquet columnar storage for GIS. A tile server may decode thousands of tiles per second, so a codec that shaves 5% off file size but doubles decode latency is a poor trade. That tension between ratio and decode speed is the subject of ZSTD vs DEFLATE Compression for Cloud-Optimized GeoTIFF.


Step-by-Step Workflow

1. Assess the Source Raster

Before writing anything, characterise the input. Band count, data type, and entropy drive every downstream decision. Eight-bit RGB orthophotography behaves very differently from a 32-bit float elevation model: the former tolerates lossy WEBP for visualisation tiles, the latter demands a lossless codec and usually a horizontal predictor.

python
# rasterio>=1.3, GDAL>=3.4
import rasterio

with rasterio.open("scene.tif") as src:
    print("bands:", src.count)
    print("dtype:", src.dtypes[0])
    print("size:", src.width, "x", src.height)
    print("crs:", src.crs)
    print("nodata:", src.nodata)
    print("blockxsize:", src.profile.get("blockxsize"))
    # A stripped source reports blockysize of 1 — a strong signal it is not COG-ready

If blockxsize is absent or blockysize is 1, the source is stored in scanline strips and must be re-tiled. Note the data type: continuous data (int16, float32) benefits from predictor 2 or 3, whereas categorical or palette rasters do not.

2. Choose Internal Tile Geometry

The internal tile (GDAL calls it the block) is the atomic unit of every read. A 256×256 block matches the web mapping tile convention and keeps per-request payloads small — ideal for interactive panning. A 512×512 block halves the number of tiles and the size of the offset directory, which helps analytics jobs that read large contiguous windows. The full trade-off, including how block size interacts with overview levels, is worked through in the overview and internal tiling reference.

python
# Rule of thumb: match block size to the dominant read pattern
block_size = 256 if serving_web_map_tiles else 512

3. Generate Overviews

Overviews are what make zoom-out cheap. Without them, rendering a continental view means reading and downsampling every full-resolution tile in frame. Build power-of-two levels down to roughly the size of a single tile, and pick a resampling method that suits the data — average for continuous imagery, nearest for categorical classifications, mode for thematic land-cover.

python
# Overview factors halve resolution each step until the top level is ~1 tile
factors = [2, 4, 8, 16, 32]
resampling = "average"   # use "nearest" for categorical/palette rasters

4. Select a Compression Codec

Every tile is compressed independently, so the codec sets both file size and per-tile decode cost. DEFLATE and LZW are the universally compatible lossless options; ZSTD is lossless and faster with better ratios where the reader supports it; WEBP offers dramatic size reductions for 8-bit visualisation tiles at the cost of being lossy (or near-lossless). Continuous data almost always wants a predictor. The selection logic below is expanded — with benchmark numbers — in the dedicated ZSTD vs DEFLATE comparison.

python
# Predictor 2 = horizontal differencing (integer continuous data)
# Predictor 3 = floating-point predictor (float32/float64 grids)
codec_profile = {
    "compress": "ZSTD",
    "zstd_level": 9,
    "predictor": 2,      # 3 for float rasters, 1 (off) for RGB imagery
}

5. Validate Range-Read Behaviour

A file is only cloud optimized if it behaves that way over the network. Use rio cogeo validate to confirm the header layout, then profile actual range requests against object storage. The production section below wraps writing and validation into one reusable function.

python
# rio-cogeo>=5.0
from rio_cogeo.cogeo import cog_validate

is_valid, errors, warnings = cog_validate("scene_cog.tif")
print("valid COG:", is_valid, errors, warnings)

Production-Ready Implementation

The function below converts an arbitrary source raster into a validated Cloud-Optimized GeoTIFF using the GDAL COG driver through rasterio. It picks a predictor from the data type, builds overviews, streams tiles rather than loading the whole array, and fails loudly if validation does not pass.

python
# rasterio>=1.3, rio-cogeo>=5.0, GDAL>=3.4
from __future__ import annotations

import logging
from pathlib import Path

import rasterio
from rasterio.enums import Resampling
from rasterio.shutil import copy as rio_copy
from rio_cogeo.cogeo import cog_validate

logger = logging.getLogger(__name__)

_FLOAT_DTYPES = {"float32", "float64"}
_CONTINUOUS_INT = {"int16", "uint16", "int32", "uint32"}


def build_cog(
    src_path: str | Path,
    dst_path: str | Path,
    *,
    compress: str = "ZSTD",
    zstd_level: int = 9,
    block_size: int = 512,
    overview_resampling: str = "average",
) -> None:
    """Convert a raster to a validated Cloud-Optimized GeoTIFF.

    Args:
        src_path: Source raster readable by GDAL/rasterio.
        dst_path: Destination COG path (.tif). Overwritten if present.
        compress: Tile codec — ZSTD, DEFLATE, LZW, or WEBP.
        zstd_level: ZSTD encoder level (ignored for non-ZSTD codecs).
        block_size: Internal tile edge in pixels (256 or 512 recommended).
        overview_resampling: 'average' for continuous, 'nearest' for categorical.

    Raises:
        ValueError: If the produced file fails COG validation.
    """
    src_path, dst_path = Path(src_path), Path(dst_path)

    with rasterio.open(src_path) as src:
        dtype = src.dtypes[0]
        if dtype in _FLOAT_DTYPES:
            predictor = 3           # floating-point predictor
        elif dtype in _CONTINUOUS_INT:
            predictor = 2           # horizontal differencing
        else:
            predictor = 1           # off — palette/8-bit RGB

        profile = src.profile.copy()
        profile.update(
            driver="COG",
            compress=compress,
            blocksize=block_size,
            overview_resampling=overview_resampling,
            BIGTIFF="IF_SAFER",
            NUM_THREADS="ALL_CPUS",
        )
        if compress.upper() == "ZSTD":
            profile["level"] = zstd_level
            profile["predictor"] = predictor
        elif compress.upper() in {"DEFLATE", "LZW"}:
            profile["predictor"] = predictor

        # The COG driver builds overviews and front-loads the directory itself.
        try:
            rio_copy(src, dst_path, **profile)
        except rasterio.errors.RasterioError as exc:
            logger.error("COG write failed for %s: %s", src_path, exc)
            raise

    is_valid, errors, warnings = cog_validate(str(dst_path))
    for w in warnings:
        logger.warning("COG warning (%s): %s", dst_path.name, w)
    if not is_valid:
        raise ValueError(f"{dst_path} is not a valid COG: {errors}")

    logger.info(
        "Wrote valid COG %s (codec=%s, block=%d, predictor=%d)",
        dst_path, compress, block_size, predictor,
    )


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    build_cog("scene.tif", "scene_cog.tif", compress="ZSTD", block_size=512)

Codec and Layout Reference

Representative figures for a 3-band, 8-bit orthophoto scene and a single-band float32 DEM, written with the COG driver on a 4-core cloud VM. Ratios and decode speeds vary with imagery entropy and hardware — benchmark on your own data.

Codec Predictor Lossless Ratio (imagery) Decode speed Primary use case
DEFLATE 2 Yes 1.7–2.2× Moderate Maximum-compatibility archival and interchange
LZW 2 Yes 1.5–1.9× Fast Legacy toolchains that predate DEFLATE/ZSTD support
ZSTD 2 / 3 Yes 1.9–2.6× Fast Default for new pipelines: best ratio-to-decode balance
WEBP n/a Lossy* 4–8× Fast 8-bit RGB visualisation tiles where lossy is acceptable

*WEBP supports a near-lossless mode; true lossless WEBP is limited to 8-bit data and gives lower ratios than the figures above.

For the ratio-versus-decode-speed trade-off between the lossless codecs in detail, see ZSTD vs DEFLATE for COG, which also covers how these choices echo the vector-versus-raster ZSTD level guidance from the compression topic area.


When COG Beats Zarr and NetCDF

COG and chunked array stores like Zarr solve overlapping problems, and teams routinely over-reach with one where the other fits. The dividing line is dimensionality and access pattern:

  • Reach for COG when the data is 2D or few-band imagery served as map tiles or read as spatial windows: satellite scenes, orthophotography, DEMs, single-date classifications. One self-describing file carries the pyramid, any GDAL client reads it with no runtime, and every browser mapping stack and tile server speaks it natively.
  • Reach for Zarr or NetCDF when the data is a high-dimensional cube — stacked time steps, atmospheric levels, or dozens of spectral bands — where chunking must span more than the two spatial axes. A COG can hold many bands, but it has no first-class time or depth axis, so temporal analytics degrade into managing thousands of separate files.

For a mosaic of thousands of scenes, a common production pattern keeps each scene as a COG and layers a STAC catalogue plus a dynamic tiler on top, rather than rechunking everything into one array store. The single-file portability that makes COG awkward for cubes is exactly what makes it ideal for tile serving, much as GeoParquet and FlatGeobuf trade off analytical scanning against streaming range reads on the vector side.


Failure Modes and Gotchas

Anti-pattern Symptom Fix
Stripped (untiled) source written straight to object storage Every tile request downloads full image rows; latency scales with raster width Re-tile with tiled=True and a 256/512 block, or use the COG driver which enforces tiling
No overviews built Zoom-out reads decode every full-resolution tile in frame; tile server CPU saturates Build power-of-two overviews with data-appropriate resampling before publishing
Directory left at end of file (plain GTiff, no rewrite) Clients issue a full-file GET to find tile offsets; range reads defeated Use the COG driver, or run a rewrite pass so the IFD and offset arrays sit at the front
Lossy WEBP on continuous data Elevation or reflectance values silently altered; downstream analysis wrong Restrict WEBP to 8-bit RGB visualisation; use ZSTD/DEFLATE with a predictor for continuous data
Predictor left off for float32/int16 grids 30–50% larger tiles than necessary at the same codec Set predictor 2 for integer continuous data, predictor 3 for floating-point grids
Overviews built with average on categorical rasters Class codes blended into meaningless intermediate values Resample categorical/palette rasters with nearest or mode

Frequently Asked Questions

When does Cloud-Optimized GeoTIFF beat Zarr or NetCDF for raster serving?

Cloud-Optimized GeoTIFF wins for 2D or few-band imagery served as map tiles, because it packs a georeferenced pyramid and internal tiles into one file that any GDAL client, tile server, or browser mapping stack reads directly over HTTP range requests. Zarr and NetCDF win for high-dimensional array cubes — time, depth, or spectral axes — where chunking across more than three dimensions matters more than single-file portability.

Do I have to use the GDAL COG driver, or can I write a valid COG with GTiff?

Both produce valid files, but the COG driver is safer. It enforces tiling, builds overviews, and front-loads the IFD and tile-offset directory so the header sits in the first few kilobytes. Writing with the GTiff driver requires you to set tiled, blockxsize, and blockysize manually, build overviews explicitly, and run a rewrite pass to move the directory to the front. The COG driver folds all of that into one call.

How large is the header a client must read before the first tile?

For a well-formed Cloud-Optimized GeoTIFF the leading header — image file directory, geokeys, and the tile offset and byte-count arrays — is typically 8 to 64 KB. GDAL fetches it in one or two range requests, caches it, and thereafter issues exactly one bounded range request per tile. A file whose directory sits at the end forces a full-file scan and defeats the format.


← Back to Geospatial Storage Fundamentals & Format Comparison

Continue exploring