Zarr and Chunked Array Storage for Raster

A satellite archive is not a picture. It is a four-dimensional array — time, band, row, column — that happens to be displayable as pictures, and the difference matters the moment somebody asks a question the picture format cannot answer. “Show me this scene” is a two-dimensional read, and Cloud-Optimized GeoTIFF serves it beautifully. “Show me twenty years of weekly NDVI at this field boundary” is a read along the time axis, and answering it from a directory of ten thousand GeoTIFFs means opening ten thousand files to extract one pixel from each.

Zarr exists for the second question. It stores an N-dimensional array as a grid of independently compressed chunks, each chunk a separate object in cloud storage, with a small JSON document describing the shape, the chunk grid, the dtype, and the codec pipeline. A reader computes which chunks its slice intersects and fetches exactly those. There is no file to open and no header to walk — the layout is the index.

This page sits in Compression, Chunking & Spatial Indexing because chunk shape is the raster analogue of row group sizing: both decide the granularity at which a cloud reader can skip data, and both punish a layout chosen without reference to the read pattern.

Prerequisites

  • Python 3.10+ with zarr>=3.0, xarray>=2024.7, numcodecs>=0.13, dask>=2024.8, and fsspec>=2024.6
  • Object storage with a working credential path; the same store works on local disk, which is the right place to prototype a chunk shape
  • A stated read pattern. Not a guess — an actual statement of what queries the store will serve. Every decision below is downstream of it, and no default is safe without it.
  • Enough memory to hold several chunks per worker, since decompression is chunk-at-a-time and a 16 MB compressed chunk may expand to 100 MB or more

Chunking Is the Whole Design

An N-dimensional array has one physical layout and many possible access patterns, and a chunk grid is the compromise you strike between them. Consider a daily temperature grid: 7,300 time steps × 1,800 rows × 3,600 columns of float32.

Chunk it as (1, 1800, 3600) — one full map per chunk — and a single-day continental read costs one 26 MB object. Excellent. But a twenty-year time series at one grid cell must fetch all 7,300 chunks, decompress 190 GB, and discard all but 29 KB of it.

Chunk it as (7300, 1, 1) and that time series costs one tiny object, while a single-day map costs 6.5 million requests. Also a catastrophe, in the other direction.

The workable answer is in between, and it comes from the read pattern rather than from a rule of thumb.

How chunk shape decides which reads are cheap in a time-space raster cube Three panels each show the same cube of time by row by column, divided differently. The first slices it into thin horizontal layers, one full map per time step, which makes a single-date map read cost one chunk but a long time series cost thousands. The second slices it into tall vertical columns spanning all time steps at a single location, which reverses the trade. The third divides it into balanced blocks spanning a moderate span of time and a moderate spatial window, so both read types cost a handful of chunks. A caption notes that the balanced shape is the default worth starting from. Same cube, three chunk grids — each cheap for a different question Map-shaped (1, y, x) one date, whole continent: 1 chunk 20-year series at a point: 7,300 chunks Series-shaped (t, 1, 1) 20-year series at a point: 1 chunk one date, whole continent: millions Balanced (t/n, y/m, x/m) both reads: tens of chunks no read is optimal, none is ruinous Start from the balanced grid unless one access pattern genuinely dominates the store's traffic. If both patterns matter and both are hot, write the array twice with different chunk grids — storage is cheaper than over-fetch.

Two constraints bracket the choice. The lower bound is the request: an object-storage GET costs a round trip and a fraction of a cent regardless of size, so chunks below about a megabyte spend more on overhead than on payload. The upper bound is over-fetch: a chunk is the atomic unit of transfer and decompression, so a read that wants one pixel of a 200 MB chunk pays for 200 MB. Between one and sixteen megabytes compressed, both costs are tolerable. Inside that band, shape is what you tune.

The Workflow

1. Write the read pattern down before touching the data

Two sentences: “Readers pull full-resolution single-date windows of about 50 km, and twenty-year monthly series at individual field boundaries, roughly four to one in favour of the first.” That sentence determines every number below. Without it you are choosing a layout by taste.

2. Size in bytes, then shape to the pattern

Fix the target compressed chunk size first — 4 MB is a sound starting point for most cloud raster. Divide by the expected compression ratio to get the uncompressed budget, divide by the item size to get the element count, then distribute those elements across the axes in proportion to how much of each axis a typical read consumes.

3. Build the codec pipeline in the right order

A Zarr codec pipeline runs filters before the compressor. For numeric raster the order that matters is: an optional shuffle to group like bytes, then the compressor. Shuffle is nearly free and frequently worth more than two compression levels. Choosing the compressor itself is the subject of Blosc codec selection for Zarr geospatial arrays.

4. Align writes to chunk boundaries

A write that partially covers a chunk must read the existing chunk, decompress it, patch it, recompress it, and put it back. Under concurrency, two workers doing that to the same chunk will lose one of the updates — there is no locking. Partition write jobs on chunk boundaries and the problem disappears entirely.

5. Consolidate the metadata

Without consolidation, opening a store with 300 variables costs 300 sequential small GETs before any data moves, and on a high-latency link that is minutes. One consolidated object collapses it to a single request.

Opening a Zarr store with and without consolidated metadata Two request timelines. Without consolidation the client issues one small metadata request per group and per array, shown as a long run of sequential round trips, before any data chunk is fetched. With consolidation a single request retrieves the whole hierarchy, after which the client fetches only the data chunks its slice intersects, in parallel. The lower timeline is dramatically shorter even though both fetch the same amount of actual data. Without consolidated metadata — one round trip per array, sequential … 300 more, sequentially … chunk chunk metadata discovery dominates: minutes before the first data byte on a high-latency link With consolidated metadata — one request, then parallel data reads zarr.json (whole tree) chunk chunk chunk chunk … fetched concurrently, only what the slice intersects same data volume, one round trip of discovery instead of three hundred Cost of consolidation: one extra object written at the end of the job, refreshed whenever the hierarchy changes. Cost of forgetting it: every reader pays the discovery penalty on every open, forever.

Production Implementation

The function below derives a chunk shape from a declared read pattern and a byte budget, writes the store with a shuffle-plus-compressor pipeline, and consolidates the metadata. It refuses to write a shape whose chunks fall outside the workable band rather than producing a store that will be expensive to read.

python
# Requires: zarr>=3.0, xarray>=2024.7, numcodecs>=0.13, numpy>=1.26  (Python 3.10+)
from __future__ import annotations

import math
from dataclasses import dataclass

import numpy as np
import xarray as xr
import zarr
from numcodecs import Blosc

MIN_CHUNK_BYTES = 1 << 20        # 1 MiB — below this, request overhead dominates
MAX_CHUNK_BYTES = 16 << 20       # 16 MiB — above this, over-fetch dominates


@dataclass(frozen=True)
class ReadPattern:
    """How much of each axis a typical read consumes, as a fraction 0-1."""
    time: float
    y: float
    x: float

    def weights(self) -> tuple[float, float, float]:
        total = self.time + self.y + self.x
        if total <= 0:
            raise ValueError("read pattern must consume some part of some axis")
        return (self.time / total, self.y / total, self.x / total)


def chunk_shape_for(
    shape: tuple[int, int, int],
    itemsize: int,
    pattern: ReadPattern,
    *,
    target_bytes: int = 4 << 20,
    expected_ratio: float = 3.0,
) -> tuple[int, int, int]:
    """Distribute a byte budget across axes in proportion to the read pattern.

    An axis a reader traverses gets a long chunk edge; an axis it slices gets
    a short one. Returns an uncompressed chunk shape.
    """
    if target_bytes < MIN_CHUNK_BYTES or target_bytes > MAX_CHUNK_BYTES:
        raise ValueError(
            f"target {target_bytes} lies outside the workable 1-16 MiB band"
        )
    uncompressed_budget = target_bytes * expected_ratio
    elements = uncompressed_budget / itemsize
    wt, wy, wx = pattern.weights()

    # Solve k so that (k*wt)*(k*wy)*(k*wx) == elements, then clamp per axis.
    k = (elements / max(wt * wy * wx, 1e-12)) ** (1 / 3)
    raw = (k * wt, k * wy, k * wx)
    chunks = tuple(
        max(1, min(int(dim), int(math.floor(edge))))
        for dim, edge in zip(shape, raw)
    )
    return chunks  # type: ignore[return-value]


def write_zarr_store(
    dataset: xr.Dataset,
    target: str,
    pattern: ReadPattern,
    *,
    variable: str,
    clevel: int = 5,
) -> dict[str, object]:
    """Write a chunked, shuffled, consolidated Zarr store and report the layout."""
    if variable not in dataset:
        raise KeyError(f"{variable!r} is not in the dataset")
    array = dataset[variable]
    if array.ndim != 3:
        raise ValueError("expected a (time, y, x) array")

    chunks = chunk_shape_for(array.shape, array.dtype.itemsize, pattern)
    chunk_bytes = int(np.prod(chunks)) * array.dtype.itemsize
    if chunk_bytes > MAX_CHUNK_BYTES * 8:
        raise ValueError(f"uncompressed chunk of {chunk_bytes} bytes is unmanageable")

    # SHUFFLE regroups bytes of like significance before the compressor sees
    # them; on float32 raster it is worth more than two extra clevels.
    compressor = Blosc(cname="zstd", clevel=clevel, shuffle=Blosc.SHUFFLE)

    encoding = {variable: {"chunks": chunks, "compressor": compressor}}
    try:
        dataset.to_zarr(target, mode="w", encoding=encoding, consolidated=True)
    except (ValueError, OSError) as exc:
        raise RuntimeError(f"zarr write to {target} failed: {exc}") from exc

    store = zarr.open(target, mode="r")
    written = store[variable]
    return {
        "chunks": chunks,
        "uncompressed_chunk_bytes": chunk_bytes,
        "stored_bytes": int(written.nbytes_stored()),
        "logical_bytes": int(written.nbytes),
        "ratio": round(written.nbytes / max(written.nbytes_stored(), 1), 2),
        "chunk_count": int(np.prod([
            math.ceil(d / c) for d, c in zip(array.shape, chunks)
        ])),
    }

Verifying the layout is as important as choosing it. The check that matters is chunks touched per representative read:

python
# Requires: zarr>=3.0 — count the chunks a real slice would touch
import math


def chunks_touched(shape, chunks, selection) -> int:
    """How many chunks a slice intersects — the number that predicts cost."""
    n = 1
    for dim, chunk, sel in zip(shape, chunks, selection):
        start, stop, _ = sel.indices(dim)
        if stop <= start:
            return 0
        n *= math.floor((stop - 1) / chunk) - math.floor(start / chunk) + 1
    return n


# A 20-year monthly series at one grid cell, on a (24, 256, 256) chunk grid:
print(chunks_touched((7300, 1800, 3600), (24, 256, 256),
                     (slice(0, 7300), slice(900, 901), slice(1800, 1801))))
# → 305 chunks; at ~1.5 MB each that is ~450 MB transferred to return 29 KB.
# Widen the time edge to 512 and it drops to 15 chunks.

Reference Matrix

Measured on a daily 1 km land-surface temperature cube, float32, 7,300 × 1,800 × 3,600, stored on S3 and read from compute in the same region.

Chunk shape Compressed chunk Store size Single-date map read 20-year point series Primary Use Case
(1, 1800, 3600) 8.6 MB 62 GB 1 chunk, 0.4 s 7,300 chunks, 21 min Daily map browsing, tile generation, visual QA
(7300, 1, 1) 12 KB 141 GB 6.5 M chunks, hours 1 chunk, 0.1 s Point-station extraction only, never spatial
(24, 256, 256) 1.4 MB 66 GB 98 chunks, 2.1 s 305 chunks, 47 s Mixed workloads with a spatial bias
(512, 128, 128) 2.9 MB 68 GB 392 chunks, 6.8 s 15 chunks, 3.2 s Time-series analytics over modest windows
(128, 512, 512) 11 MB 64 GB 28 chunks, 1.4 s 58 chunks, 12 s Balanced default for an unknown mix
Two stores, both shapes 130 GB 1.4 s 3.2 s High-traffic archives where both reads are hot

The last row deserves emphasis because teams resist it. Storing the same array twice, chunked two ways, roughly doubles storage cost — and object storage is the cheapest thing in the stack. If both access patterns are hot, two stores beat one compromised store on every axis except a storage bill that was never the binding constraint. The same reasoning drives the tiering decisions in tiered storage for large spatial datasets.

Why a partial-chunk write loses data under concurrency Two workers write adjacent regions that overlap the same chunk. Each reads the chunk, decompresses it, patches its own region, recompresses and writes it back. Because the reads happen before either write lands, the second write is based on the pre-update state and silently discards the first worker\u2019s data. Nothing errors, and the store remains structurally valid. A shared chunk written by two workers worker A: read chunk 7 patches rows 0-31 worker B: read chunk 7 patches rows 32-63 write chunk 7 (A + original) write chunk 7 (B + original) B lands last and was built from the pre-A state — A\u2019s rows are gone, with no error anywhere. The fix is not locking; it is partitioning the write job on chunk boundaries so this never arises.

Failure Modes and Gotchas

Inheriting a chunk shape from the source files. Converting a directory of daily GeoTIFFs with default settings produces a store chunked one-day-per-chunk, because that is how the inputs were shaped. The conversion succeeds, the store is valid, and it is precisely the layout that makes time-series reads unusable — which was the reason for converting. Set the chunk shape explicitly in the encoding, every time.

Concurrent partial-chunk writes. Two workers writing different regions that share a chunk will each read-modify-write it, and the second put silently discards the first worker’s data. There is no error and no warning. Partition the write job on chunk boundaries; if the job cannot be partitioned that way, serialise the writes.

Forgetting to re-consolidate after appending. Consolidated metadata is a snapshot. Append a new time step without refreshing it and readers using the consolidated path will not see the new data, while readers bypassing it will — an inconsistency that is maddening to debug. Re-consolidate at the end of every write job.

Ignoring the uncompressed chunk size. A 4 MB compressed chunk of highly repetitive integer data may expand to 400 MB. Workers sized for the compressed figure will hit the memory ceiling under concurrency. Budget memory on the uncompressed size times the concurrency, not on the stored size.

Reaching for Zarr when a COG would do. A single two-dimensional orthophoto read by spatial window is exactly the case Cloud-Optimized GeoTIFF was designed for, and it carries an ecosystem of readers, tile servers, and viewers that a Zarr store does not. Use Zarr when a third axis is genuinely part of the query; do not use it because it is newer.

Frequently Asked Questions

When is Zarr a better fit than Cloud-Optimized GeoTIFF?

Whenever the data has more than two dimensions that readers actually slice. A COG is superb for a single two-dimensional image read by spatial window, which is the common case for basemaps and orthophotos. Zarr wins when a third or fourth axis — time, spectral band, ensemble member, depth — is part of the query, because it can chunk along those axes so a time-series read touches a handful of objects instead of opening thousands of separate images.

How large should a Zarr chunk be?

Between roughly one and sixteen megabytes compressed. Below one megabyte the fixed cost of an object-storage request — TLS, round trip, per-request billing — dominates the useful payload, and a modest read turns into thousands of tiny GETs. Above about sixteen megabytes any read that wants a sliver of a chunk still pays for the whole chunk, so over-fetching swamps the saving. Within that band, shape matters far more than exact size.

What does the shuffle filter actually do before compression?

It regroups bytes so that similar bytes sit together. In a float32 array the first byte of every value is the sign and high exponent bits, which barely change across neighbouring pixels, while the last byte is noisy. Interleaved, the compressor sees an alternating pattern it cannot exploit; after a byte shuffle it sees a long run of near-identical high bytes followed by the noisy ones, and the run compresses. On typical scientific raster this is worth ten to forty per cent on its own, at almost no CPU cost.

Why does opening a Zarr store sometimes take longer than reading the data?

Because without consolidated metadata the client fetches one small JSON object per array and per group to discover the hierarchy, and a store with hundreds of variables therefore costs hundreds of sequential round trips before a single data byte moves. Writing consolidated metadata collapses that into one request. It is the single highest-value configuration change on a store with many variables and it costs one line at write time.


← Back to Compression, Chunking & Spatial Indexing

Continue exploring