ZSTD vs DEFLATE Compression for Cloud-Optimized GeoTIFF
For a Cloud-Optimized GeoTIFF, choose ZSTD when every reader runs GDAL 2.3 or newer, and fall back to DEFLATE when you need universal compatibility — with a predictor set correctly, the two land within a few percent on ratio but ZSTD decodes faster. Because each COG tile is compressed and decompressed independently, the codec decision is really a decode-latency decision: a tile server may decompress thousands of blocks per second, so shaving encode-time ratio at the cost of slower decode is usually the wrong trade. LZW survives only for legacy toolchains. This page quantifies the trade-offs, shows the predictor settings that often matter more than the codec itself, and gives you a benchmark you can run on your own tiles. It is the codec companion to Cloud-Optimized GeoTIFF for Raster Workloads.
Quick-Reference Table
| Codec | Predictor | Typical ratio* | Relative decode speed | GDAL support | Primary use case |
|---|---|---|---|---|---|
| ZSTD (level 9) | 2 / 3 | 1.9–2.6× | Fastest | GDAL ≥ 2.3 with libzstd | New tile-serving and analytics pipelines |
| DEFLATE (zlib 6) | 2 / 3 | 1.7–2.2× | Moderate | Universal | Portable archival and interchange |
| LZW | 2 | 1.5–1.9× | Fast | Universal | Legacy toolchains predating DEFLATE/ZSTD |
*Continuous 16-bit and float rasters with an appropriate predictor; 8-bit RGB imagery ratios run lower for all three. Benchmark on your own data.
Why Decode Speed Dominates the Choice
The three lossless codecs available to a GeoTIFF — LZW, DEFLATE, and ZSTD — all descend from LZ77-style dictionary matching, but they occupy different points on the ratio-versus-speed curve. LZW is the oldest and simplest, with modest ratios and fast decode. DEFLATE (zlib) adds Huffman coding on top of dictionary matching for a meaningfully better ratio at moderate decode speed, and it is the lingua franca of TIFF compression: every reader supports it. ZSTD, the newest, pairs a finite-state entropy back-end with a configurable search window and, at comparable ratios, decodes several times faster than DEFLATE. The same properties that make ZSTD the modern default for vector Parquet — analysed across ZSTD compression levels for geospatial data — carry over to raster tiles, with one twist: a COG block is small, so the ratio gap between codecs narrows and decode speed becomes the deciding factor.
That twist is worth dwelling on. Compression codecs reward long, homogeneous input, but a 512×512 tile of uint16 data is only half a megabyte before compression. There is little room for a deep search window to find long-range matches, so ZSTD’s advanced levels add far less here than they do on a 256 MB Parquet row group. In practice ZSTD levels 6 to 9 capture nearly all the achievable ratio on tile-sized blocks; pushing to level 15 or beyond spends large amounts of encode CPU for a percent or two. The picture mirrors the vector-versus-raster level split: raster tiles want moderate levels and fast decode, not maximum ratio.
The lever that frequently beats the codec choice entirely is the predictor. A predictor transforms each pixel into the difference from its neighbour before compression, turning a smoothly varying elevation or reflectance surface into a stream of small residuals that any of the three codecs then compresses far better. On continuous 16-bit or float rasters, enabling predictor 2 (horizontal differencing) or predictor 3 (the floating-point predictor) commonly shrinks tiles by 30 to 50 percent — a bigger swing than the entire gap between DEFLATE and ZSTD. The predictor is nearly free at decode time. This is why the correct decision order is: set the predictor from the data type first, then choose the codec for the decode-speed and compatibility profile you need. On palette and 8-bit RGB imagery, where neighbouring pixels are not numerically ordered, the predictor gives little and can even enlarge tiles, so it is left off there.
Codec Selection in Practice
The function below writes the same source raster with several codec-and-predictor combinations and reports ratio and mean per-tile decode time, so the choice rests on measured numbers rather than folklore. It derives the predictor from the data type and skips ZSTD gracefully if the local GDAL build lacks libzstd.
# rasterio>=1.3, GDAL>=3.4, numpy>=1.24
from __future__ import annotations
import time
from pathlib import Path
import numpy as np
import rasterio
from rasterio.shutil import copy as rio_copy
_FLOAT = {"float32", "float64"}
_CONT_INT = {"int16", "uint16", "int32", "uint32"}
def _predictor_for(dtype: str) -> int:
if dtype in _FLOAT:
return 3
if dtype in _CONT_INT:
return 2
return 1
def compare_codecs(
src_path: str | Path,
scratch: Path = Path("/tmp/cog_codec_bench"),
codecs: tuple[str, ...] = ("DEFLATE", "ZSTD", "LZW"),
) -> list[dict]:
"""Benchmark COG codecs for ratio and per-tile decode time.
Returns one dict per codec with keys: codec, size_mb, ratio, decode_ms.
"""
src_path = Path(src_path)
scratch.mkdir(parents=True, exist_ok=True)
results: list[dict] = []
with rasterio.open(src_path) as src:
predictor = _predictor_for(src.dtypes[0])
raw_mb = (src.width * src.height * src.count *
np.dtype(src.dtypes[0]).itemsize) / 1024**2
base_profile = src.profile.copy()
for codec in codecs:
out = scratch / f"bench_{codec.lower()}.tif"
profile = dict(base_profile)
profile.update(driver="COG", compress=codec, blocksize=512,
predictor=predictor if codec != "LZW" or True else 1)
try:
with rasterio.open(src_path) as src:
rio_copy(src, out, **profile)
except rasterio.errors.RasterioError as exc:
results.append({"codec": codec, "error": str(exc)})
continue
size_mb = out.stat().st_size / 1024**2
# Time reading every tile once to approximate serving decode cost
with rasterio.open(out) as ds:
t0 = time.perf_counter()
for _, window in ds.block_windows(1):
ds.read(1, window=window)
n_tiles = sum(1 for _ in ds.block_windows(1))
elapsed_ms = (time.perf_counter() - t0) / max(n_tiles, 1) * 1000
results.append({
"codec": codec,
"size_mb": round(size_mb, 2),
"ratio": round(raw_mb / size_mb, 2) if size_mb else None,
"decode_ms": round(elapsed_ms, 3),
})
out.unlink(missing_ok=True)
return results
Validation
Run the comparison on a representative scene and read the two columns that matter: ratio and per-tile decode time. Expected shape of the output on a single-band int16 DEM with predictor 2:
{'codec': 'DEFLATE', 'size_mb': 61.4, 'ratio': 2.08, 'decode_ms': 0.42}
{'codec': 'ZSTD', 'size_mb': 55.9, 'ratio': 2.29, 'decode_ms': 0.19}
{'codec': 'LZW', 'size_mb': 72.1, 'ratio': 1.77, 'decode_ms': 0.31}
ZSTD typically shows both the best ratio and the fastest decode here, which is why it is the default for new work. Confirm the local GDAL build actually carries ZSTD before relying on it:
gdalinfo --formats | grep -i zstd # confirm codec availability
gdalinfo scene_cog.tif | grep COMPRESSION # verify what was actually written
If COMPRESSION=ZSTD does not appear on a file you wrote with ZSTD, the build silently fell back — treat that as a deployment blocker for any consumer of the file.
Edge Cases and Caveats
8-bit RGB visualisation imagery. For display-only tiles where lossless is not required, WEBP compresses 4–8× versus 2× for the lossless codecs, so the ZSTD-versus-DEFLATE question is moot — reach for WEBP instead and keep a lossless ZSTD copy as the analytical source. Never apply lossy WEBP to continuous data whose pixel values feed analysis.
Mixed-consumer fleets. If some downstream tools run old GDAL without libzstd, a ZSTD COG is unreadable to them and fails opaquely. When you cannot audit every reader, DEFLATE is the safe universal choice; its decode penalty is real but rarely fatal outside high-throughput tile serving. This portability-versus-performance call parallels the codec compatibility trade-offs discussed for ZSTD versus Snappy and LZ4 in GeoParquet.
Already-compressed or high-entropy inputs. Imagery that has been through lossy JPEG compression, or genuinely noisy sensor data near the entropy ceiling, resists all three codecs — expect ratios near 1.1–1.3× regardless of choice. Here, pick the fastest-decoding option (ZSTD at a low level) and do not waste encode CPU chasing a ratio the data cannot give.
Frequently Asked Questions
Is ZSTD or DEFLATE better for a Cloud-Optimized GeoTIFF?
ZSTD is the better default when every reader runs GDAL 2.3 or newer: at a mid-range level it delivers a slightly better ratio than DEFLATE while decoding noticeably faster, which matters when a tile server decompresses thousands of blocks per second. DEFLATE remains the right choice when you cannot guarantee ZSTD support across all consumers, because it is readable by essentially every TIFF stack ever shipped.
Does the predictor matter more than the codec choice?
Often, yes. On continuous integer or float rasters a horizontal or floating-point predictor commonly cuts tile size by 30 to 50 percent, a larger effect than switching between DEFLATE and ZSTD. Set predictor 2 for integer continuous data and predictor 3 for floating-point grids, then choose the codec. On palette or 8-bit RGB imagery the predictor gives little and can hurt, so leave it off there.
Which ZSTD level should I use for COG tiles?
Levels 6 to 9 are the practical range for COG tiles. They capture almost all the ratio ZSTD can reach on tile-sized blocks while keeping decode fast enough for interactive serving. Going above level 12 buys a percent or two of ratio for a large encode-time penalty, and because each tile is small the deeper search window has little extra context to exploit.
Related
- Cloud-Optimized GeoTIFF for Raster Workloads — parent guide: COG structure, GDAL driver, and layout
- Overview Pyramids and Internal Tiling for COG — how tile size interacts with codec choice
- ZSTD Compression Levels for Geospatial Data — the full ZSTD level model across geospatial workloads
- Optimal ZSTD Levels for Vector vs Raster Data — why raster favours moderate levels and fast decode
- ZSTD vs Snappy vs LZ4 for GeoParquet — the vector-side codec compatibility trade-off