Blosc Codec Selection for Zarr Geospatial Arrays
Three choices in a Zarr codec pipeline — shuffle filter, compressor, level — and their importance runs in exactly that order. The shuffle filter is nearly free and frequently worth more than several compression levels; the compressor decides whether you are optimising for bytes or for decode speed; the level, on geospatial raster, is the one that matters least and gets tuned first. This page continues Zarr and chunked array storage for raster.
Quick Reference
| Data | Shuffle | Compressor | Level | Primary Use Case |
|---|---|---|---|---|
| float32 continuous (temperature, NDVI) | SHUFFLE |
ZSTD | 3–5 | Cloud-stored analytical cubes |
| int16 scaled values | SHUFFLE |
ZSTD | 3 | Satellite reflectance archives |
| uint8 classification | BITSHUFFLE |
ZSTD | 5 | Land-cover and mask layers |
| Boolean masks | BITSHUFFLE |
ZSTD | 5 | Quality flags, valid-data masks |
| Anything local and latency-critical | SHUFFLE |
LZ4 | 5 | Page-cached or NVMe-backed reads |
| Already-compressed payloads | NOSHUFFLE |
none | — | Pre-encoded imagery tiles |
What Each Stage Actually Does
Shuffle is a byte transposition, not a compression. Given n values of k bytes each, it emits all the first bytes, then all the second bytes, and so on. Nothing is removed; the data is merely reordered so that bytes of equal significance become neighbours.
The effect on a raster is large because of how floating-point numbers are laid out. In a chunk of temperatures spanning 280–300 K, every value has the same exponent and a nearly identical high mantissa byte, while the low byte varies with sensor noise. Interleaved, the byte stream reads [stable, stable, varying, noise, stable, stable, varying, noise, …] — a pattern with period four that most compressors model poorly. Shuffled, it reads as one long run of nearly-identical bytes, then another, then the noisy ones at the end. The first two runs compress to almost nothing.
Bit shuffle does the same at bit granularity, which pays when values are small integers or booleans and the interesting structure is below the byte level. It costs more CPU than byte shuffle and is wasted on wide floats.
The compressor then does ordinary entropy coding on the reordered stream. LZ4 is extremely fast and moderately effective; ZSTD is slower and noticeably smaller. Which one wins depends entirely on whether the chunk is arriving over a network.
Choosing and Measuring
# Requires: numcodecs>=0.13, numpy>=1.26, zarr>=3.0 (Python 3.10+)
from __future__ import annotations
import time
from dataclasses import dataclass
import numpy as np
from numcodecs import Blosc
@dataclass(frozen=True)
class CodecResult:
label: str
ratio: float
encode_mb_s: float
decode_mb_s: float
def score(self, link_mb_s: float) -> float:
"""Effective throughput over a link of the given speed.
A chunk must both arrive and decode; whichever is slower governs, so a
codec that halves the bytes is only a win if its decode still outruns
the link at the new size.
"""
arrival = link_mb_s * self.ratio # effective MB/s of source data
return min(arrival, self.decode_mb_s)
def shuffle_for(dtype: np.dtype) -> int:
"""Byte shuffle for wide numerics, bit shuffle for narrow ones."""
if dtype.kind == "b" or dtype.itemsize == 1:
return Blosc.BITSHUFFLE
return Blosc.SHUFFLE
def evaluate(sample: np.ndarray, candidates: dict[str, Blosc]) -> list[CodecResult]:
"""Measure ratio and both throughputs on a real chunk, not synthetic data."""
if sample.nbytes < (1 << 20):
raise ValueError("use a realistic chunk-sized sample — small samples mislead")
raw = sample.tobytes()
megabytes = len(raw) / 1e6
results = []
for label, codec in candidates.items():
start = time.perf_counter()
encoded = codec.encode(sample)
encode_s = time.perf_counter() - start
start = time.perf_counter()
decoded = codec.decode(encoded)
decode_s = time.perf_counter() - start
if not np.array_equal(np.frombuffer(decoded, dtype=sample.dtype).reshape(sample.shape), sample):
raise RuntimeError(f"{label} did not round-trip — refusing to recommend it")
results.append(CodecResult(
label=label,
ratio=len(raw) / len(encoded),
encode_mb_s=megabytes / max(encode_s, 1e-9),
decode_mb_s=megabytes / max(decode_s, 1e-9),
))
return sorted(results, key=lambda r: -r.ratio)
def recommend(sample: np.ndarray, link_mb_s: float) -> CodecResult:
"""Pick the codec with the best effective throughput over this link."""
sh = shuffle_for(sample.dtype)
candidates = {
"lz4 l5": Blosc(cname="lz4", clevel=5, shuffle=sh),
"zstd l1": Blosc(cname="zstd", clevel=1, shuffle=sh),
"zstd l3": Blosc(cname="zstd", clevel=3, shuffle=sh),
"zstd l5": Blosc(cname="zstd", clevel=5, shuffle=sh),
"zstd l9": Blosc(cname="zstd", clevel=9, shuffle=sh),
"zstd l3 noshuffle": Blosc(cname="zstd", clevel=3, shuffle=Blosc.NOSHUFFLE),
}
return max(evaluate(sample, candidates), key=lambda r: r.score(link_mb_s))
Validation
Report ratio and decode throughput in the same table, always. A codec chosen on ratio alone will eventually be chosen at level 19, where compression is slow, decode is mediocre, and the extra four per cent buys nothing.
# Requires: the module above — measure on a real chunk from the real array
import zarr
import numpy as np
store = zarr.open("temperature.zarr", mode="r")
sample = np.asarray(store["t2m"][0:24, 0:512, 0:512]) # one chunk-sized block
for result in evaluate(sample, {...}):
print(f"{result.label:<20} ratio {result.ratio:5.2f}x "
f"encode {result.encode_mb_s:7.0f} MB/s decode {result.decode_mb_s:7.0f} MB/s")
Expected ranges on float32 geophysical raster with byte shuffle: LZ4 around 2.2× at 2,500 MB/s decode; ZSTD level 3 around 3.1× at 700 MB/s; ZSTD level 9 around 3.4× at 620 MB/s but four times the encode cost. Without shuffle, every ratio drops by roughly a third — which is the number that settles the argument about whether shuffle is worth its CPU.
Edge Cases and Caveats
Benchmarking on synthetic data. Random arrays are incompressible and constant arrays compress infinitely; neither tells you anything. Always sample a real chunk from the real array, at the real chunk size, because compressibility is a property of the data and varies enormously between a temperature field and a classification mask.
Ignoring the memory cost of a high ratio. A chunk that compresses 8× expands to eight times its stored size in a worker’s memory, and concurrency multiplies that. Budget worker memory on the uncompressed chunk size times the concurrency — the constraint that Zarr chunk sizing also has to respect.
Compressing data whose precision was never reduced. If the low mantissa bits are sensor noise, no codec removes them and every level costs CPU for nothing. Reducing precision to the data’s real accuracy first — the raster analogue of coordinate precision reduction — often doubles the achievable ratio at any level.
Frequently Asked Questions
What does the shuffle filter do and why does it help so much?
It transposes the bytes of an array so that the first byte of every value sits together, then the second byte of every value, and so on. In a float32 raster the first byte carries the sign and high exponent bits, which barely change between neighbouring pixels, while the last byte is noise. Interleaved, a compressor sees an alternating pattern it cannot model; shuffled, it sees a long run of near-identical bytes followed by a noisy run, and the first run compresses very well. The cost is a fast in-memory permutation.
Should I use LZ4 or ZSTD inside Blosc?
Decide by where the bottleneck is. LZ4 decompresses at several gigabytes per second, so it is effectively free and the right choice when chunks arrive over a fast local link or come from a page cache. ZSTD produces noticeably smaller chunks at maybe a quarter of LZ4’s decode speed, which wins whenever bytes cross a network — the smaller payload arrives sooner than the faster decode could have finished. For cloud object storage, ZSTD at a low level is usually correct.
Does a higher compression level help on raster data?
Much less than people expect, because a raster chunk’s compressibility is dominated by the shuffle filter and by how noisy the low-order bits are, not by how hard the entropy coder works. On typical float32 geophysical data, moving from ZSTD level 1 to level 5 buys perhaps eight per cent and costs twice the compression time; moving to level 9 buys another two per cent for four times the time again. Spend the effort on the shuffle and on the data’s precision instead.
Related
- Zarr and Chunked Array Storage for Raster — parent guide: chunk grids, metadata, and the read path
- Choosing Zarr Chunk Shapes for Time-Series Raster — the sizing that assumes a compression ratio from here
- ZSTD Compression Levels for Geospatial Data — the same level-selection argument on the vector side
- ZSTD vs Snappy vs LZ4 for GeoParquet — the codec comparison for columnar storage