Rechunking a Zarr Store Without a Full Rewrite
A store chunked for map reads is being hammered by time-series queries, and the fix is to change the chunk shape. That operation is a full shuffle: every output chunk draws from many input chunks, so the naive implementation — read the array, rechunk, write — needs the whole thing in memory or spills catastrophically. This page is the staged alternative and the cost model behind it, within the Zarr versus NetCDF topic.
Root cause: rechunking is an all-to-all data movement
Consider a store of shape (8760, 4096, 4096) chunked (1, 512, 512) — one timestep per chunk, good for maps. Rechunking to (8760, 32, 32) for time series means every output chunk needs one 32×32 patch from all 8,760 timesteps, and each of those patches lives in a different input chunk. The output chunk therefore touches 8,760 input chunks; the input chunk contributes to 256 output chunks.
import numpy as np
def shuffle_factor(shape, src_chunks, dst_chunks) -> float:
"""How many input chunks a typical output chunk touches."""
if len(shape) != len(src_chunks) != len(dst_chunks):
raise ValueError("shape and both chunk tuples must have the same rank")
touched = 1.0
for s, d in zip(src_chunks, dst_chunks):
touched *= np.ceil(d / s) if d > s else 1.0
return float(touched)
print(shuffle_factor((8760, 4096, 4096), (1, 512, 512), (8760, 32, 32))) # 8760.0
print(shuffle_factor((8760, 4096, 4096), (1, 512, 512), (24, 512, 512))) # 24.0
That number is the whole cost model. Dask’s default rechunk builds a task for each input-output pair, so a shuffle factor of 8,760 over 6,000 output chunks is fifty million tasks — a graph the scheduler cannot hold, let alone execute, as profiling Dask graph overhead sets out.
Step-by-step with production code
1. Confirm the current chunking really is the problem
Rechunking a terabyte store costs real money, and the read pattern that motivated it is often served better by a small change elsewhere. Measure before committing:
import logging
import time
import xarray as xr
logger = logging.getLogger(__name__)
def time_read_patterns(store: str) -> dict[str, float]:
"""Time the two access patterns that chunk shape trades between."""
ds = xr.open_zarr(store, consolidated=True)
var = next(iter(ds.data_vars))
t0 = time.perf_counter()
ds[var].isel(time=0).load()
map_s = time.perf_counter() - t0
t0 = time.perf_counter()
ds[var].isel(y=len(ds.y) // 2, x=len(ds.x) // 2).load()
series_s = time.perf_counter() - t0
logger.info("one map %.2f s · one time series %.2f s · ratio %.0f×",
map_s, series_s, series_s / max(map_s, 1e-9))
return {"map_s": map_s, "series_s": series_s}
A ratio under about twenty is usually not worth a rechunk; caching, a coarser overview or simply issuing the reads concurrently will close it. Above a hundred, the shape is genuinely wrong for the workload.
2. Stage the shuffle with rechunker
rechunker plans the intermediate shape from a memory budget you supply, writes to a temporary store, and then writes the target. Memory is bounded by the budget rather than by the array:
def rechunk_store(src: str, dst: str, tmp: str, target_chunks: dict,
max_mem: str = "2GB") -> None:
"""Staged rechunk with bounded memory. `tmp` must be deleted afterwards."""
import zarr
from rechunker import rechunk
source = zarr.open_consolidated(src, mode="r")
plan = rechunk(source, target_chunks=target_chunks, max_mem=max_mem,
target_store=dst, temp_store=tmp)
logger.info("rechunk plan: %s", plan)
plan.execute()
zarr.consolidate_metadata(dst)
logger.info("wrote %s and consolidated its metadata", dst)
Two things bite here. The temporary store is roughly the size of the array and is not cleaned up automatically — on object storage that is a real bill accruing until someone deletes it, and a lifecycle rule on the temp prefix is the reliable fix. And the target store needs its own consolidate_metadata call; the source’s .zmetadata describes the source’s chunk shape, and copying it produces a store whose metadata lies about its own layout, which is the failure the consolidated-metadata guide covers.
3. Consider two views instead of one rechunk
A rechunk exists to serve a second read pattern. Publishing a second store serves it too, and the arithmetic is often favourable: a rechunk costs one full read plus two full writes and leaves one store, while a second view costs one full read plus one full write and leaves two stores that are each optimal.
The trade is storage against compromise. Where the store is small relative to the budget, two views is strictly better — each reader gets an exact fit and neither pays for the other. Where storage dominates, or where the two views would drift out of sync as data is appended, one store and a compromise chunking is the right answer.
Verification and acceptance test
import numpy as np
import pytest
def test_rechunk_preserved_the_values(src: str, dst: str):
a = xr.open_zarr(src, consolidated=True)
b = xr.open_zarr(dst, consolidated=True)
assert a.dims == b.dims, f"dims changed: {a.dims} vs {b.dims}"
rng = np.random.default_rng(0)
for _ in range(20):
idx = {d: int(rng.integers(0, a.sizes[d])) for d in a.dims}
for v in a.data_vars:
x, y = float(a[v].isel(idx)), float(b[v].isel(idx))
assert np.isclose(x, y, equal_nan=True), f"{v} at {idx}: {x} vs {y}"
def test_the_new_store_has_its_own_consolidated_metadata(dst: str):
import zarr
z = zarr.open_consolidated(dst, mode="r")
for name, arr in z.arrays():
assert arr.chunks == TARGET_CHUNKS[name], (
f"{name} reports chunks {arr.chunks}; the metadata is the old snapshot")
Sampling random indices rather than comparing shapes is the point of the first test: a shuffle that transposed two axes produces a store of the correct shape holding the wrong values everywhere, and only a value comparison catches it.
Edge cases and gotchas
- The temp store outlives the job.
rechunkerdoes not delete it, and a failed run leaves it behind. Put it under a prefix with a short lifecycle rule, as in setting S3 lifecycle rules, rather than relying on a cleanup step that only runs on success. - Compression interacts with chunk size. A larger chunk usually compresses better, so the on-disk size after a rechunk is not simply the same bytes rearranged. Budget for the target store being 10–30% smaller or larger, and measure rather than assume.
- Appending after a rechunk needs the same shape. A store rechunked to
(8760, 32, 32)cannot have a single new timestep appended cheaply, because every chunk spans the whole time axis. Time-series chunking and append-friendliness are in direct opposition, and a store that must accept daily appends should not be chunked along its full time extent.
Related
- Zarr vs NetCDF for Cloud-Native Coastal Pipelines — the chunk-shape trade this operation is trying to correct
- Fixing Zarr Consolidated Metadata on GCS — the snapshot the new store needs of its own
- Profiling Dask Graph Overhead on Tile Workloads — why the naive rechunk graph cannot be scheduled