Choosing Zarr Chunk Shapes for Time-Series Raster

A chunk shape is a claim about how the data will be read, and the cost of being wrong is measured in orders of magnitude rather than percentages. The same 190 GB temperature cube answers a twenty-year point series in 0.1 seconds or 21 minutes depending on a three-number decision made once at write time. This page is the arithmetic behind that decision, extending Zarr and chunked array storage for raster.

Quick Reference

Read pattern Chunk shape (t, y, x) Chunks per point series Chunks per single-date map Primary Use Case
Map browsing only (1, 1800, 3600) 7,300 1 Daily tile generation, visual QA
Point extraction only (7300, 8, 8) 1 101,250 Station-style time-series services
Mixed, spatial bias (24, 256, 256) 305 98 Dashboards with occasional deep dives
Mixed, temporal bias (512, 128, 128) 15 392 Trend analytics over modest windows
Unknown mix (128, 512, 512) 58 28 The safe default to start from

The Arithmetic

Three constraints bound the choice, and they resolve in order.

The byte budget comes first. A compressed chunk below about a megabyte spends more on request overhead than payload; above about sixteen megabytes, any partial read over-fetches badly. Pick a target inside that band — 4 MB is a good default — and divide by the expected compression ratio to get an uncompressed byte budget, then by the item size to get an element budget.

The read pattern distributes it. If a typical read consumes 100% of the time axis and 0.05% of each spatial axis, the chunk should be long in time and short in space, in roughly that proportion. Expressed as weights that sum to one, the element budget E splits so that the product of the three edges equals E while their ratios match the weights.

Alignment adjusts it. Having derived edges of, say, (418, 137, 137), round them to something the data and the queries actually use: 365 for a year of daily steps, 256 for a spatial tile boundary. A chunk grid aligned to the query grid halves the reads for the most common query, which is worth more than a few per cent of byte-size optimality.

From byte budget and read weights to a chunk shape A four-stage derivation. The first stage fixes a four megabyte compressed target and divides by an expected compression ratio of three to give a twelve megabyte uncompressed budget, then by four bytes per float32 element to give three million elements. The second stage states the read weights: a typical query consumes most of the time axis and a small fraction of each spatial axis. The third stage distributes the element budget across the axes in proportion to those weights, producing raw edges. The fourth stage rounds those edges to a calendar period and a tile boundary, giving the final shape. 1 · byte budget 4 MB compressed ÷ ratio 3 → 12 MB ÷ 4 B → 3.0 M elements 2 · read weights time 0.72 y 0.14 · x 0.14 from the stated pattern 3 · raw edges t 418 y 137 · x 137 product ≈ the budget 4 · aligned t 365 y 128 · x 128 a year, a tile Why step 4 matters more than it looks A one-year query against a 418-step chunk grid straddles two chunks almost every time. Against a 365-step grid it lands inside one — halving the reads for the most common query. Alignment to the query grid beats optimising byte size within the workable band. The 3.4 MB chunk that aligns is better than the 4.0 MB chunk that does not.

Implementation

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

import math
from dataclasses import dataclass

MIN_CHUNK_BYTES = 1 << 20
MAX_CHUNK_BYTES = 16 << 20


@dataclass(frozen=True)
class AxisWeights:
    """Fraction of each axis a representative read consumes."""
    time: float
    y: float
    x: float

    def normalised(self) -> tuple[float, float, float]:
        total = self.time + self.y + self.x
        if total <= 0:
            raise ValueError("weights must sum to something positive")
        return self.time / total, self.y / total, self.x / total


def derive_chunks(
    shape: tuple[int, int, int],
    itemsize: int,
    weights: AxisWeights,
    *,
    target_compressed: int = 4 << 20,
    expected_ratio: float = 3.0,
    align: tuple[int, int, int] | None = None,
) -> tuple[int, int, int]:
    """Distribute a byte budget across axes, then snap to useful boundaries."""
    if not MIN_CHUNK_BYTES <= target_compressed <= MAX_CHUNK_BYTES:
        raise ValueError(f"{target_compressed} bytes is outside the 1-16 MiB band")

    elements = (target_compressed * expected_ratio) / itemsize
    wt, wy, wx = weights.normalised()
    k = (elements / max(wt * wy * wx, 1e-12)) ** (1 / 3)
    raw = (k * wt, k * wy, k * wx)

    chunks = []
    for dim, edge, step in zip(shape, raw, align or (1, 1, 1)):
        value = max(1, min(dim, int(math.floor(edge))))
        if step > 1:
            # Snap down to the nearest multiple so a query aligned to `step`
            # lands inside one chunk rather than straddling two.
            value = max(step, (value // step) * step)
            value = min(value, dim)
        chunks.append(value)
    return tuple(chunks)  # type: ignore[return-value]


def chunks_touched(
    shape: tuple[int, ...], chunks: tuple[int, ...], selection: tuple[slice, ...]
) -> int:
    """The number that predicts cost: how many chunks a slice intersects."""
    if not len(shape) == len(chunks) == len(selection):
        raise ValueError("shape, chunks and selection must have the same rank")
    total = 1
    for dim, chunk, sel in zip(shape, chunks, selection):
        start, stop, _ = sel.indices(dim)
        if stop <= start:
            return 0
        total *= math.floor((stop - 1) / chunk) - math.floor(start / chunk) + 1
    return total


def compare_shapes(
    shape: tuple[int, int, int],
    itemsize: int,
    candidates: dict[str, tuple[int, int, int]],
    queries: dict[str, tuple[slice, slice, slice]],
) -> None:
    """Score every candidate against every representative query."""
    header = f"{'shape':<20}" + "".join(f"{name:>22}" for name in queries)
    print(header)
    for label, chunks in candidates.items():
        mb = math.prod(chunks) * itemsize / 1e6
        row = f"{str(chunks):<20}"
        for selection in queries.values():
            row += f"{chunks_touched(shape, chunks, selection):>22,}"
        print(f"{row}   ({mb:.1f} MB uncompressed)")

Validation

Score every candidate shape against every representative query before writing a byte. The table this produces settles arguments that would otherwise be settled by preference.

python
# Requires: the module above — score candidates against real queries
SHAPE = (7300, 1800, 3600)          # 20 years daily, 1 km global land
QUERIES = {
    "20y point series": (slice(0, 7300), slice(900, 901), slice(1800, 1801)),
    "1 day continent":  (slice(4000, 4001), slice(200, 1400), slice(400, 2800)),
    "1 year, 50 km box": (slice(3650, 4015), slice(900, 950), slice(1800, 1850)),
}
CANDIDATES = {
    "map-shaped":    (1, 1800, 3600),
    "series-shaped": (7300, 8, 8),
    "balanced":      (128, 512, 512),
    "aligned":       (365, 128, 128),
}
compare_shapes(SHAPE, 4, CANDIDATES, QUERIES)

Expected pattern: no shape wins every column, the aligned shape wins the year-window query decisively, and any candidate scoring above roughly 1,000 chunks on a query you actually run should be rejected outright — at 4 MB each that is gigabytes of transfer for a small answer.

Scoring four candidate shapes against three real queries A matrix with four candidate chunk shapes as rows and three representative queries as columns, each cell shaded by how many chunks that query touches under that shape. The map-shaped candidate is excellent for a single-date continental read and catastrophic for a twenty-year point series. The series-shaped candidate is the reverse. The balanced candidate is moderate everywhere. The aligned candidate is moderate on the first two and clearly best on the one-year window, because its time edge matches a year of daily steps. Chunks touched — lower is better 20y point series 1 day, continent 1 year, 50 km box (1, 1800, 3600) 7,300 1 365 (7300, 8, 8) 1 45,000 49 (128, 512, 512) 58 28 12 (365, 128, 128) 21 190 1 No row wins everywhere — which is the point. Pick the row whose worst column you can live with. If two columns are equally hot and no row serves both, that is the signal to write the array twice. Alignment between the query grid and the chunk grid The same one-year query drawn against two chunk grids. Against a grid whose time edge is three hundred and sixty-five steps, the query lands inside a single chunk. Against a grid whose time edge is four hundred and eighteen steps, the same query straddles two chunks in almost every year, doubling the reads for the most common query even though the chunk size is nominally better tuned. One-year query against two time-chunk grids Edge = 365 1 chunk Edge = 418 2 chunks The shaded span is the same query in both rows. Only the grid moved. A slightly worse byte size that aligns beats a perfect byte size that does not.

Edge Cases and Caveats

Chunk edges larger than the array. Requesting a 365-step time edge on a 200-step array silently produces a single chunk spanning everything, and the store then behaves like an unchunked file. Clamp every edge to the dimension, as the implementation does, and log when clamping happens.

Ragged final chunks. When a dimension is not a multiple of the chunk edge, the last chunk along that axis is partial — smaller, faster to read, and perfectly valid. It is only a problem if the ragged edge is tiny, which happens when the chunk edge is just under a divisor of the dimension. Nudging the edge to a clean divisor removes a class of confusing benchmark results.

Weights taken from a benchmark rather than from users. Synthetic benchmarks read uniformly; real users read a few hot regions repeatedly. If the access log is available, derive the weights from it. If it is not, state the assumption explicitly in the store’s metadata so a later reader knows what the layout was optimised for — the same discipline as recording a quantization grid.

Frequently Asked Questions

How do I calculate how many chunks a query will read?

For each axis, take the slice’s start and stop, divide both by the chunk edge on that axis, floor them, subtract and add one — that is the number of chunks the slice spans on that axis. Multiply across axes for the total. The arithmetic takes a few lines of code and answers the only question that matters about a chunk shape, which is why it belongs in the pipeline rather than in a notebook nobody re-runs.

Should the time edge of a chunk match a calendar period?

Where readers slice by calendar period, yes, and it is worth some inefficiency to get it. If most queries ask for one year of daily data, a time edge of 365 or 366 means a year is one chunk deep instead of straddling two, halving the reads for the most common query. Aligning the chunk grid to the query grid is usually worth more than fine-tuning the byte size within the workable band.

What if two read patterns want opposite chunk shapes?

Write the array twice with different chunk grids and route each query to the store that suits it. Storage roughly doubles, which is the cheapest resource in the stack, and both patterns get their optimal layout instead of both getting a compromise that serves neither. This is only wasteful if one of the two patterns is rare, in which case give the common one the good layout and let the rare one over-fetch.


← Back to Zarr and Chunked Array Storage for Raster