Date: 2026-07-02
Status: approved (brainstorming), pending implementation plan
Scope: xarray Zarr backend (xarray/backends/zarr.py)
The Zarr backend flattens a zarr array's metadata into individual .encoding
keys (chunks, compressor/compressors, filters, serializer, shards,
fill_value) with xarray-chosen names, then reconstructs zarr.create(**kwargs)
from them on write. This projection is lossy and format-blind:
-
The read path branches on the installed zarr-python version (
_zarr_v3()), not the store format, so reading a v2 store with zarr-python 3 labels its numcodecs codecs with the v3-style keycompressors. -
On write, xarray passes those codec objects straight to
zarr.create()with no format awareness. A v2→v3 round-trip of any compressed array therefore fails deep inside zarr:TypeError: Expected a BytesBytesCodec. Got <class 'numcodecs.blosc.Blosc'> instead.(Verified on zarr-python 3.2.1; fails with or without an explicit
encoding=.) -
fill_valuehandling is split across attrs and encoding depending on format anduse_zarr_fill_value_as_mask(xarray issues #10269, #10646; mirrors zarr-python #2322).
The root cause is that .encoding never carries the array's actual, self-
describing metadata document — only a renamed, format-ambiguous subset.
Carry the storage-layout portion of a zarr array's metadata in .encoding as a
single, spec-shaped, self-describing metadata fragment, and drive both read and
write through zarr-python's own to_dict()/from_dict(). Make v2⇄v3 round-trips
work, localize all format-specific logic, and keep existing user code working
during a deprecation period.
- Single self-describing key. One
encoding["zarr_array_metadata"]holding a v2-or-v3 metadata dict. The dict carries its ownzarr_format, so it is a discriminated union; there is never a second copy to disagree with it. Format conversion is an explicit write-time function, not stored state. - Variable wins on overlaps. The fragment may contain
shape,data_type,attributes,dimension_names, but on write those are regenerated from theVariable/attrs/dimsand overwrite whatever the fragment holds. The fragment is authoritative only for storage layout. - Variable/resolved value wins (authoritative overwrite). As implemented,
the write path overwrites the storage-layout fields that matter —
shape,dimension_names, chunk grid,data_type, andfill_value— from the write itself (via_set_chunk_shape/_set_dtype/_set_fill_value), so a stale fragment value is superseded rather than treated as a conflict (e.g. opening then rechunking, then writing, uses the new chunks).merge_flat_aliasesretains a defensivechunksconflict check at the seam boundary (unit-tested), but because the fast path resolves chunks authoritatively it does not surface as an error throughto_zarr. NOTE: this supersedes the original "alias conflict is an error" intent — the matured design favors authoritative overwrite, which is simpler and avoids false conflicts on legitimate rechunking. - Read populates both; flat keys stay fully supported for now. Read sets
both the fragment and the derived flat aliases so existing code keeps working.
Emitting a
DeprecationWarningon flat-key use is DEFERRED to a separate follow-up PR (in trial it turned ~296 existing tests red, since settingchunks/compressorsis ubiquitous — deprecating the common keys needs its own careful scoping). Hard removal is a later change beyond that. This PR adds the fragment as the preferred path without deprecating anything. - Read via
to_dict; create by persisting metadata buffers. Read useszarr_array.metadata.to_dict(). Write buildsArrayV{2,3}Metadata.from_dict(canonical_dict)and persists itsto_buffer_dict(prototype)output to the array's store path. No kwarg translation. VERIFIED (zarr-python 3.2.1):from_dictalone constructs an in-memory handle but does NOT persist — a persist step is required. Migrate this single create call to a persisting upstream create-from-metadata API when zarr-python offers one (the "C later" path; the seam is the only call site). zarr-metadatatyping only (near-term). Import the v2/v3 metadataTypedDicts from thezarr-metadatapackage underif TYPE_CHECKING:to annotate the fragment (ArrayMetadataV2 | ArrayMetadataV3). No runtime dependency for now. This flips to a runtime (optional, zarr-extra) dependency if/when the dataclass direction below lands.- Seam now, dataclass later. Build the internal representation seam against
plain dicts first so xarray is not blocked on a
zarr-metadatarelease; swap the seam's backing tozarr-metadatadataclasses in a follow-up. The backend call sites do not change when that swap happens.
CF/decode-reversal keys (_FillValue, scale_factor, add_offset, dtype,
units, calendar, _Unsigned, ...) are unchanged and remain flat, produced by
the CF coding layer. preferred_chunks remains a flat, read-only hint. The new
fragment covers only storage layout: codecs/compressor/filters/serializer, chunk
grid, chunk-key encoding, and array-level fill_value.
All interaction with the fragment goes through one small internal module that
owns: reading a store array into the representation, applying Variable-owned
field overrides, reconciling chunks, converting between formats, and writing back
out (to_dict/from_dict). The rest of the backend never touches fragment
internals directly. The backing of this representation is deliberately swappable:
it starts as a plain dict (see decision 6) and can later become a
zarr-metadata dataclass without changing the backend call sites. This seam is
what keeps the dict-vs-dataclass choice an implementation detail.
encoding["zarr_array_metadata"] = zarr_array.metadata.to_dict().- Derive legacy flat aliases from the fragment for back-compat.
- Because the fragment comes from
metadata.to_dict(), it is always format- correct — this removes the current "installed-version, not store-format" labeling bug at the fragment level.
Replaces _create_new_array. The append/region/existing-array paths (which
open rather than create) are unchanged.
- Normalize to one canonical metadata dict: start from
zarr_array_metadataif present; fold in flat aliases; a disagreeing flat alias raisesValueErrornaming the field. - Variable wins: regenerate
shape,data_type,attributes,dimension_namesfrom the Variable/attrs/dims and overwrite them in the dict. - Reconcile chunks with dask/sharding via the existing chunk logic
(
_determine_zarr_chunks, alignment checks); write the resolved chunk grid into the dict. - Convert format if needed (see below).
- Persist the array: build
ArrayV{2,3}Metadata.from_dict(canonical_dict)and write itsto_buffer_dict(prototype)output to the array's store path. (from_dictconstructs but does not persist; the seam owns this persist step.)
A single convert_zarr_metadata(metadata_dict, target_format) function produces a
target-format metadata dict when the target differs from the fragment's
zarr_format, or raises when a codec has no cross-format equivalent. This is the
one home for v2⇄v3 logic and is what makes the previously-failing v2→v3 round-trip
succeed.
The fragment is annotated as ArrayMetadataV2 | ArrayMetadataV3 using
zarr-metadata TypedDicts imported under TYPE_CHECKING. Runtime values are
plain dicts from zarr-python's to_dict/from_dict.
The seam is designed so its backing can later become a dataclass-based metadata
representation from zarr-metadata (which the user maintains) instead of plain
dicts. Dataclasses fit the four operations the seam performs: to_dict/from_dict
round-trip, field override via dataclasses.replace (Variable-wins),
isinstance-dispatched format conversion, and clean equality/copy/pickle for the
append and dask paths. That would flip decision #6 (making zarr-metadata an
optional runtime dependency in the zarr extra) and pin a minimum version.
Sequencing (decided): build the seam against plain dicts now so xarray work is
not gated on a zarr-metadata release; swap the seam's backing to dataclasses in
a follow-up once those ship upstream. The seam is the single place that change
touches.
- Read continues to emit the flat keys (derived from the fragment).
- Users may still set flat keys; they remain fully supported in this PR (no
warning). Emitting a
DeprecationWarningis deferred to a follow-up PR (see decision 4), and hard removal is later still.
- Round-trip fidelity: v2→v2, v3→v3, and v2→v3 / v3→v2 (currently broken).
merge_flat_aliasesdefensivechunksconflict check (unit-level); end-to-end a stale fragment + rechunk resolves to the new chunks (resolved value wins, no error).- Variable-owned fields (shape/dims/chunk-grid/dtype/fill_value) always win over stale
fragment fields, end-to-end through
to_zarr. fill_valuematrix across formats anduse_zarr_fill_value_as_mask.- (Deferred to follow-up PR)
DeprecationWarningon flat-key use.
- RESOLVED:
from_dictdoes not persist; write path persistsArrayV{2,3}Metadata.to_buffer_dict(prototype)to the store (mechanism "A"). Migrate to an upstream persisting create-from-metadata API when available. - How runtime-only store behaviors absent from the metadata document
(
write_empty_chunks/config,overwrite) are applied alongsidefrom_dict. - Exact derivation of legacy flat aliases on read — match today's output vs. make them strictly format-correct. Leaning: match today's output to avoid a second behavior change, since the flat keys are deprecated anyway.
The maintainer of zarr-metadata is open to evolving it, so a natural follow-up
is a dataclass-based metadata representation (frozen dataclasses for v2/v3 array
metadata plus a format-convert API). That would back the representation seam
instead of dicts and simplify exactly the operations this design leans on:
- field override →
dataclasses.replace(...)rather than dict-key mutation; - format conversion → a method/function returning the other dataclass, dispatched
by type rather than by inspecting a
zarr_formatkey; - equality/copy/pickle for the append-compare and dask paths, for free;
- typing with real classes and no
Any.
It would flip decision 6 to a runtime (optional) dependency and pin a minimum
zarr-metadata version. It is deliberately sequenced after the dict-backed seam
(decision 7) so xarray work is not gated on a cross-repo release. Not in scope
for the first implementation.
- Removing the flat encoding keys.
- Non-zarr backends (this fragment is zarr-specific; netCDF backends ignore it).
- Changes to the CF coding layer.
Builds on the landed refactor PR (branch claude/zarr-encoding-refactor): the
consolidated encoding-key constants and the split validator/create-kwargs
separation. This design subsumes the previously-planned Phase 3 (v2⇄v3 codec
translation layer) and Phase 4 (unifying fill_value handling).