Choosing COG Compression: ZSTD vs DEFLATE

When you write a Cloud-Optimized GeoTIFF, the compression codec you pick sets three numbers you will live with for the life of the file: how many bytes it occupies, how long each write takes, and how much CPU every reader burns to decompress a tile. DEFLATE is the decades-old default that every GDAL build understands; ZSTD is the modern codec that usually matches DEFLATE’s ratio while writing several times faster and decompressing faster too. This page compares them head to head — ratio, speed, the all-important PREDICTOR setting, and GDAL version support — with rasterio profiles you can paste for each. For the structure these bytes live inside, see Core Raster Fundamentals & STAC Mapping.

LZW earns a brief mention as the third common option: it is universally supported and fast to write but generally gives a worse ratio than either DEFLATE or ZSTD on continuous imagery, so it is a fallback rather than a contender for new pipelines.


Decision framework

The decision tree below routes you to a codec based on the two constraints that actually decide it: how old the readers might be, and whether you optimise for write throughput or archival size.

ZSTD vs DEFLATE decision tree for COGs A decision tree: if any reader may run GDAL older than 2.3 choose DEFLATE; otherwise choose ZSTD, using a low level for fast writes and a high level for archival size, and always set the predictor to match the dtype. Writing a COG pick a codec Any reader on GDAL < 2.3? old QGIS, legacy ArcGIS, embedded yes no DEFLATE universal, safe, slower write Optimise write speed or archival size? speed size ZSTD level 1–9 fast pipeline writes ZSTD level 15–22 smallest archive Always set PREDICTOR: 2 for integer, 3 for float the predictor, not the codec, drives most of the ratio

The single most important line in that diagram is the dashed box at the bottom: the predictor usually moves the compression ratio more than the choice between ZSTD and DEFLATE does. Get the predictor right for your dtype first, then let the codec choice turn on compatibility and speed.


Prerequisites

You need a recent rasterio linked against GDAL 3.1+ so the dedicated COG driver and ZSTD are both available:

pip install "rasterio>=1.3" "numpy>=1.24"
Library / component Min version Why required
rasterio 1.3 COG driver, creation options, profile writing
GDAL (via rasterio wheel) 3.1 Native COG driver; ZSTD built in from 2.3
numpy 1.24 Generate and inspect the test arrays
libzstd (bundled) any Backs the ZSTD codec inside GDAL

Conceptual prerequisites: you should understand internal tiling and overviews from Understanding Cloud-Optimized GeoTIFF Structure, and know how a reader pulls only a byte range — covered in How to Read COG Headers Without Downloading Full Files — because decompression cost is paid per tile, per range read. Confirm your GDAL build with rasterio.__gdal_version__ before choosing ZSTD.


Head-to-head

Dimension DEFLATE (zlib) ZSTD
Compression ratio good, level-tunable 1–9 equal or better, level 1–22
Write speed slow at high levels 2–5x faster at a comparable ratio
Decompression speed moderate fast — lower CPU per tile
Level range 1–9 (ZLEVEL) 1–22 (ZSTD_LEVEL)
GDAL support since ~1.x, universal since GDAL 2.3
Predictor support PREDICTOR 1/2/3 PREDICTOR 1/2/3
Reader ubiquity every GDAL, QGIS, ArcGIS modern stacks only
Best for maximum compatibility speed, cloud pipelines, big batches

The pattern is consistent: at a fixed target ratio, ZSTD writes faster and reads faster, but DEFLATE is understood by every reader ever shipped. If you control the whole consuming stack, ZSTD dominates on the metrics that cost money; if you publish to unknown clients, DEFLATE removes a compatibility risk.


When to choose DEFLATE

Choose DEFLATE when the file may be read by software you do not control or cannot date — legacy QGIS, older ArcGIS, an embedded viewer, or a partner’s fixed toolchain. DEFLATE has been in GDAL essentially forever, so a DEFLATE COG opens everywhere. It is also a fine default for integer masks where the ratio difference from ZSTD is small.

The benchmark below writes a float32 elevation-style raster as a COG with DEFLATE, the floating-point predictor, and a mid-high level, then reports the size and write time. Note predictor=3 — the float predictor is what makes DEFLATE competitive on continuous data.

import time
import numpy as np
import rasterio
from rasterio.transform import from_origin

# Smooth float32 surface — realistic for elevation or reflectance
data = (np.cumsum(np.random.rand(2048, 2048).astype(np.float32), axis=0)
        + np.cumsum(np.random.rand(2048, 2048).astype(np.float32), axis=1))
transform = from_origin(500000, 5100000, 10, 10)

deflate_profile = {
    "driver": "COG",
    "dtype": "float32",
    "width": data.shape[1],
    "height": data.shape[0],
    "count": 1,
    "crs": "EPSG:32632",
    "transform": transform,
    "compress": "DEFLATE",
    "predictor": 3,          # 3 = floating-point predictor (use 2 for integers)
    "level": 7,              # DEFLATE level 1-9; ZLEVEL alias
    "blocksize": 512,        # COG driver internal tile size
}

t0 = time.perf_counter()
with rasterio.open("dem_deflate.tif", "w", **deflate_profile) as dst:
    dst.write(data, 1)
dt = time.perf_counter() - t0

import os
size_mb = os.path.getsize("dem_deflate.tif") / 1e6
print(f"DEFLATE: {size_mb:.2f} MB in {dt:.2f}s (predictor=3, level=7)")

DEFLATE at level 7 with the right predictor lands close to ZSTD on size for smooth data; the visible cost is write time, which climbs steeply toward level 9. For a one-off archival write where compatibility is paramount, that time is acceptable. For a pipeline writing thousands of tiles, it is exactly where ZSTD starts to pay off.


When to choose ZSTD

Choose ZSTD when you own the consuming stack and you write at volume or read at scale. It reaches DEFLATE’s ratio at a much lower write cost, scales to level 22 when you want a smaller archive, and decompresses faster so parallel readers spend less CPU per tile.

The benchmark below writes the same array with ZSTD, matching predictor=3, and a tuned ZSTD_LEVEL. Swap the array to an integer dtype and you would set predictor=2; the codec call is otherwise identical.

import time, os
import numpy as np
import rasterio
from rasterio.transform import from_origin

data = (np.cumsum(np.random.rand(2048, 2048).astype(np.float32), axis=0)
        + np.cumsum(np.random.rand(2048, 2048).astype(np.float32), axis=1))
transform = from_origin(500000, 5100000, 10, 10)

zstd_profile = {
    "driver": "COG",
    "dtype": "float32",
    "width": data.shape[1],
    "height": data.shape[0],
    "count": 1,
    "crs": "EPSG:32632",
    "transform": transform,
    "compress": "ZSTD",
    "predictor": 3,          # match the dtype: 3 float, 2 integer
    "level": 15,             # ZSTD_LEVEL 1-22; higher = smaller, slower
    "blocksize": 512,
}

t0 = time.perf_counter()
with rasterio.open("dem_zstd.tif", "w", **zstd_profile) as dst:
    dst.write(data, 1)
dt = time.perf_counter() - t0

size_mb = os.path.getsize("dem_zstd.tif") / 1e6
print(f"ZSTD: {size_mb:.2f} MB in {dt:.2f}s (predictor=3, level=15)")

At level 15 ZSTD typically writes several times faster than DEFLATE level 7 for a comparable or smaller file, and the gap widens as you raise DEFLATE toward level 9. The decompression advantage matters most in the cloud: when many Dask workers each pull tiles from the same COG, ZSTD’s cheaper decompression lowers the aggregate CPU spent turning bytes back into pixels, so decompression is less likely to become the bottleneck behind the network.


Where LZW fits, and reading the benchmark numbers

LZW is the third codec you will meet on GeoTIFFs, and it is worth placing precisely. It is universally supported — older than DEFLATE in the GDAL lineage — and it writes quickly, which is why so many legacy archives are LZW. On continuous imagery, though, it generally gives a worse ratio than either DEFLATE or ZSTD at their default levels, because its dictionary approach suits repetitive byte patterns more than the smooth gradients of reflectance or elevation. LZW does still benefit from a matching PREDICTOR, so a PREDICTOR=2 LZW integer mask can compress respectably. Treat LZW as a compatibility fallback for byte and integer categorical rasters, not as a first choice for float continuous data where ZSTD with predictor=3 will beat it on both size and read speed.

When you run the two benchmark scripts above, read the three numbers together rather than fixating on size. Size tells you storage and egress cost; write time tells you pipeline throughput when you are producing thousands of tiles; and a third number the scripts do not print — decompression time per tile — tells you the recurring cost every reader pays forever. A file is written once but read thousands of times, so a codec that shaves decompression CPU compounds across the life of the archive. This is why ZSTD’s read-side advantage often matters more than its write-side advantage: the write happens in one batch job, but the reads happen on every map tile request, every Dask task, and every downstream index computation.

Hold everything except the codec constant when you compare — same array, same blocksize, same predictor, same overview strategy — or you will attribute to the codec a difference that really came from tiling or the predictor. In particular, a DEFLATE file with predictor=3 and a ZSTD file with predictor=1 is not a codec comparison; it is a predictor comparison wearing a codec’s name. The verification helper below round-trips the pixels so you can be sure any size difference is honest and lossless, not the result of an accidental lossy option.


Verification & testing

Never trust a codec choice by size alone — round-trip the data and confirm the pixels are byte-identical and the compression is actually recorded in the header. A wrong predictor can inflate the file, and a codec your GDAL cannot decode fails only at read time:

import numpy as np
import rasterio

def verify_cog_compression(path: str, expected_compress: str, original: np.ndarray) -> None:
    """Assert a written COG uses the expected codec and round-trips losslessly."""
    with rasterio.open(path) as src:
        prof = src.profile
        tags = src.tags(ns="IMAGE_STRUCTURE")
        read_back = src.read(1)

    # Codec is recorded in the profile / structure tags
    codec = prof.get("compress", "").upper() or tags.get("COMPRESSION", "").upper()
    assert codec == expected_compress.upper(), \
        f"expected {expected_compress}, header says {codec}"

    # DEFLATE and ZSTD are lossless — pixels must match exactly
    assert np.array_equal(read_back, original), "round-trip changed pixel values"
    print(f"{path}: codec={codec}, lossless round-trip OK")

Run it against both output files. The lossless assertion guards against the rare mistake of pairing a lossy option (JPEG on a byte raster) with continuous data; ZSTD and DEFLATE are both fully lossless, so any mismatch means a write bug, not a codec property.


Pitfalls & troubleshooting

Wrong predictor enlarges the filePREDICTOR=3 on integer data or PREDICTOR=2 on float32 can make the output larger than no predictor at all. The predictor must match the dtype: 2 for integers, 3 for floats. If a “compressed” file is bigger than expected, check this first.

ERROR 6: ZSTD codec not available — the reader’s GDAL predates 2.3 or was built without libzstd. Confirm rasterio.__gdal_version__ on the consuming side; if you cannot guarantee it, publish DEFLATE instead. This is the single reason DEFLATE still exists in new pipelines.

level ignored or clipped — DEFLATE accepts 1–9 while ZSTD accepts 1–22; passing 15 to DEFLATE is clamped and passing 9 to ZSTD leaves most of its range unused. Use ZSTD_LEVEL/level within each codec’s valid band, and remember higher levels trade write time for marginal size gains.

Untiled output is not a valid COG — writing with the plain GTiff driver without tiled=True produces a striped GeoTIFF that fails COG validation regardless of codec. Use driver="COG" (as above) or set tiled=True, blockxsize=512, blockysize=512 on the GTiff driver so range reads work.

Overviews compressed differently from the base — with the GTiff driver, overviews can be written with a different codec than the full-resolution data unless you set COMPRESS_OVERVIEW. The COG driver keeps them consistent; if you build overviews manually, set the overview compression explicitly or readers pay a mixed decompression cost.


FAQ

Is ZSTD always better than DEFLATE for COGs?

For most workloads ZSTD gives a similar or better compression ratio at a fraction of the write time, and it decompresses faster, so it is the better default when your whole toolchain runs GDAL 2.3 or newer. DEFLATE remains the safer choice for maximum compatibility because it has been in GDAL for decades and every reader understands it, which matters if consumers may run old software.

Which PREDICTOR value should I use for float versus integer rasters?

Use PREDICTOR=2 (horizontal differencing) for integer rasters such as int16 reflectance or uint8 masks, and PREDICTOR=3 (floating-point predictor) for float32 or float64 data. Predictor 3 on integers or predictor 2 on floats can enlarge the file rather than shrink it, so match the predictor to the dtype. Leaving PREDICTOR=1 (none) is safe but usually gives a worse ratio on smooth imagery.

Does ZSTD decompression cost more per Dask worker?

No — the opposite. ZSTD decompresses faster than DEFLATE at comparable ratios, so when many Dask workers each read tiles from the same COG the aggregate CPU spent on decompression is lower with ZSTD. That makes ZSTD attractive for cloud pipelines where hundreds of range reads are decompressed in parallel and decompression, not the network, can become the bottleneck.