Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2b2413a
Support reading Zarr V3 rectilinear (variable-sized) chunks
TomNicholas Sep 16, 2026
c06afed
Use read_chunk_sizes instead of write_chunk_sizes for rectilinear reads
TomNicholas Sep 16, 2026
3fb191b
Add tests for mixed regular/rectilinear dims and dask-backed reads
TomNicholas Sep 16, 2026
76a4042
Organize rectilinear chunk read tests into a parametrized class
TomNicholas Sep 16, 2026
54d6fc5
Fix rectilinear test expectations for zarr's actual regular-grid check
TomNicholas Sep 16, 2026
8e83b52
Handle rectilinear shard grids when reading (not just rectilinear chu…
TomNicholas Sep 16, 2026
eac2340
Address review feedback: PR reference, error message, region-write docs
TomNicholas Sep 16, 2026
ef8385b
Validate encoding['shards'] on write; it bypassed the chunks guard
TomNicholas Sep 17, 2026
d189174
Fix regression: int encoding['shards'] crashed the new write guard
TomNicholas Sep 17, 2026
9fa0678
Validate encoding before resizing an existing array on append
TomNicholas Sep 17, 2026
84c0ef3
Only flag encoding['shards'] as rectilinear when it nests sequences
TomNicholas Sep 17, 2026
f706ca5
Normalise rectilinear chunk encodings across zarr-python versions
TomNicholas Sep 17, 2026
ae75687
Clarify rectilinear write errors for region and append writes
TomNicholas Sep 17, 2026
2ebea81
Expand a bare int encoding['shards'] to a per-dimension tuple
TomNicholas Sep 17, 2026
ea0da51
Shorten comments and test docstrings
TomNicholas Sep 17, 2026
3e09f56
Merge remote-tracking branch 'upstream/main' into zarr-rectilinear-ch…
TomNicholas Sep 29, 2026
d4ab93b
Share one helper for the rectilinear chunks/shards write errors
TomNicholas Sep 29, 2026
4f9b2eb
Fix opening a rectilinear array with a zero-length dimension into dask
TomNicholas Sep 29, 2026
f7954f9
Clarify that rectilinear reads don't raise the minimum zarr version
TomNicholas Sep 29, 2026
9f0d02d
Merge remote-tracking branch 'upstream/main' into zarr-rectilinear-ch…
TomNicholas Sep 29, 2026
6757228
Mark zero-length rectilinear test as requiring dask
TomNicholas Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions doc/user-guide/io.md
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,35 @@ ds.to_zarr(
The number of chunks on Tair matches our dask chunks, while there is now only a single
chunk in the directory stores of each coordinate.

(io.zarr.rectilinear-chunks)=

### Variable-sized (rectilinear) chunks

Zarr v3 supports _rectilinear_ chunk grids, where chunk sizes vary along one
or more dimensions. This is useful when natural data boundaries (yearly
chunks of a daily time series, per-tile spatial extents) don't align to a
regular grid. Reading such arrays requires `zarr-python >= 3.2`, with the
experimental feature enabled:

```python
import zarr

with zarr.config.set({"array.rectilinear_chunks": True}):
roundtrip = xr.open_zarr("rectilinear.zarr", zarr_format=3)
roundtrip.chunks["x"] # e.g. (10, 20, 30)
```

```{note}
xarray can currently only *read* rectilinear-chunked Zarr V3 arrays, not
write them. Rectilinear arrays must be created with `zarr-python` directly
(or another tool, such as Icechunk) before being opened with `xr.open_zarr`.
This also means a Dataset opened from a rectilinear-chunked store cannot be
written back with {py:meth}`Dataset.to_zarr`, including writing to a
`region` of the store -- both raise a `TypeError` naming the affected
variable. Write support is tracked in
[GH11279](https://github.com/pydata/xarray/pull/11279).
```

### Groups

Nested groups in zarr stores can be represented by loading the store as a
Expand Down
8 changes: 8 additions & 0 deletions doc/whats-new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ v2026.07.1 (unreleased)

New Features
~~~~~~~~~~~~
- Support reading Zarr V3 arrays with rectilinear (variable-sized) chunk
grids. Using this feature needs ``zarr-python >= 3.2`` with
``zarr.config.set({"array.rectilinear_chunks": True})``; xarray's minimum
supported zarr version is unchanged. Writing rectilinear chunks from
xarray is not yet supported (:pull:`11592`, extracted from :pull:`11279`).
By `Max Jones <https://github.com/maxrjones>`_ and
`Tom Nicholas <https://github.com/TomNicholas>`_.

- Added `PyArrowCapsule interface <https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html#arrow-pycapsule-interface>`_
to :py:class:`DataArray` (``__arrow_c_schema__`` and ``__arrow_c_stream__``), enabling near zero-copy
export to pyarrow, polars or duckdb.
Expand Down
103 changes: 87 additions & 16 deletions xarray/backends/zarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,20 @@ async def async_getitem(self, key):
)


def _rectilinear_encoding_error(key: str, value, name) -> TypeError:
"""Error for a rectilinear (variable-sized) chunk or shard spec, which xarray
can read but not yet write."""
return TypeError(
f"encoding[{key!r}]={value!r} for variable {name!r} is a rectilinear "
"(variable-sized) grid, e.g. read from a store opened with "
"zarr.config.set({'array.rectilinear_chunks': True}). xarray can read "
"rectilinear grids but not yet write them, including with `region` or "
"`append_dim`. To write a regular grid to a new array instead, clear "
f"the encoding (`del ds[{name!r}].encoding[{key!r}]`) and make sure the "
"variable's own chunks are uniform (e.g. `ds.chunk({dim: size})`)."
)


def _determine_zarr_chunks(enc_chunks, var_chunks, ndim, name):
"""
Given encoding chunks (possibly None or []) and variable chunks
Expand Down Expand Up @@ -368,6 +382,8 @@ def _determine_zarr_chunks(enc_chunks, var_chunks, ndim, name):

for x in enc_chunks_tuple:
if not isinstance(x, int):
if isinstance(x, list | tuple):
raise _rectilinear_encoding_error("chunks", enc_chunks_tuple, name)
raise TypeError(
"zarr chunk sizes specified in `encoding['chunks']` "
"must be an int or a tuple of ints. "
Expand All @@ -383,6 +399,27 @@ def _determine_zarr_chunks(enc_chunks, var_chunks, ndim, name):
return enc_chunks_tuple


def _compact_chunk_sizes(
chunk_sizes: tuple[tuple[int, ...], ...],
) -> tuple[int | tuple[int, ...], ...]:
"""Replace a dask-style listing of chunk sizes with a single int along any
dimension where it describes a regular grid, e.g. ((10, 10, 5),) -> (10,).

Gives a consistent representation across zarr-python versions.
"""
compacted: list[int | tuple[int, ...]] = []
for sizes in chunk_sizes:
sizes = tuple(sizes)
if not sizes:
# zero-length dimension: dask rejects an empty tuple here
compacted.append((0,))
elif len(set(sizes[:-1])) <= 1 and sizes[-1] <= sizes[0]:
compacted.append(sizes[0])
else:
compacted.append(sizes)
return tuple(compacted)


def _get_zarr_dims_and_attrs(zarr_obj, dimension_key, try_nczarr):
# Check for attributes and dimension name metadata as discussed in the Zarr encoding
# specification https://docs.xarray.dev/en/stable/internals/zarr-encoding-spec.html
Expand Down Expand Up @@ -507,6 +544,18 @@ def extract_zarr_variable_encoding(
if chunks is None:
chunks = "auto"
encoding["chunks"] = chunks

# Reject rectilinear shards (a sequence of sequences) like we do for chunks.
# Zarr also accepts an int, tuple of ints, "auto" or a dict here.
shards = encoding.get("shards")
if isinstance(shards, list | tuple) and any(
isinstance(x, list | tuple) for x in shards
):
raise _rectilinear_encoding_error("shards", shards, name)
if isinstance(shards, integer_types):
# Expand to a tuple: zarr-python 3.2.x crashes on an int shard spec.
encoding["shards"] = variable.ndim * (int(shards),)

return encoding


Expand Down Expand Up @@ -861,16 +910,36 @@ def open_store_variable(self, name):
)
attributes = dict(attributes)

try:
chunks = tuple(zarr_array.chunks)
except NotImplementedError:
# Rectilinear chunk grid (zarr-python >= 3.2): `.chunks` raises, so
# read the per-chunk sizes instead, e.g. ((10, 20, 30),).
chunks = zarr_array.read_chunk_sizes
# Normalise to an int per regular dim and a tuple per rectilinear dim,
# since what zarr-python returns above varies between versions.
chunks = _compact_chunk_sizes(
tuple(x if isinstance(x, tuple) else (x,) for x in chunks)
)
preferred_chunks = dict(zip(dimensions, chunks, strict=True))

encoding = {
"chunks": zarr_array.chunks,
"preferred_chunks": dict(zip(dimensions, zarr_array.chunks, strict=True)),
"chunks": chunks,
"preferred_chunks": preferred_chunks,
}

try:
shards = zarr_array.shards
except NotImplementedError:
# Rectilinear shard grid: `.shards` raises, so read the per-shard
# (i.e. outer/storage chunk) sizes instead, e.g. ((1, 2),).
shards = _compact_chunk_sizes(zarr_array.write_chunk_sizes)

encoding.update(
{
"compressors": zarr_array.compressors,
"filters": zarr_array.filters,
"shards": zarr_array.shards,
"shards": shards,
}
)
if self.zarr_group.metadata.zarr_format == 3:
Expand Down Expand Up @@ -1198,6 +1267,21 @@ def set_variables(
else:
del v.encoding["_FillValue"]

# We need to do this for both new and existing variables to ensure we're not
# writing to a partial chunk, even though we don't use the `encoding` value
# when writing to an existing variable. See
# https://github.com/pydata/xarray/issues/8371 for details.
# Note: Ideally there should be two functions, one for validating the chunks and
# another one for extracting the encoding.
# Must run before any resize below, so a rejected encoding can't
# leave behind a resized array that was never written to.
encoding = extract_zarr_variable_encoding(
v,
raise_on_invalid=vn in check_encoding_set,
name=vn,
zarr_format=3 if is_zarr_v3_format else 2,
)

zarr_shape = None
write_region = self._write_region if self._write_region is not None else {}
write_region = {dim: write_region.get(dim, slice(None)) for dim in dims}
Expand All @@ -1223,19 +1307,6 @@ def set_variables(
zarr_shape = zarr_array.shape
region = tuple(write_region[dim] for dim in dims)

# We need to do this for both new and existing variables to ensure we're not
# writing to a partial chunk, even though we don't use the `encoding` value
# when writing to an existing variable. See
# https://github.com/pydata/xarray/issues/8371 for details.
# Note: Ideally there should be two functions, one for validating the chunks and
# another one for extracting the encoding.
encoding = extract_zarr_variable_encoding(
v,
raise_on_invalid=vn in check_encoding_set,
name=vn,
zarr_format=3 if is_zarr_v3_format else 2,
)

# When shards are specified, dask chunks must align with shard boundaries
# (not just zarr chunk boundaries) to avoid data corruption during
# parallel writes. See https://github.com/pydata/xarray/issues/10831
Expand Down
3 changes: 3 additions & 0 deletions xarray/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ def get_dask_chunkmanager():
requires_zarr_v3 = requires_zarr
has_zarr_v3_dtypes, requires_zarr_v3_dtypes = _importorskip("zarr", "3.1.0")
has_zarr_v3_async_oindex, requires_zarr_v3_async_oindex = _importorskip("zarr", "3.1.2")
has_zarr_rectilinear_chunks, requires_zarr_rectilinear_chunks = _importorskip(
"zarr", "3.2.0"
)
if has_zarr:
import zarr

Expand Down
Loading
Loading