Skip to content

v3 structured dtype: per-field endianness still lost on metadata roundtrip (#4142 covers the write path only; breaks virtual/reference stores) #4147

Description

@emfdavid

Follow-up to #4141 / #4142

First — thank you @d-v-b for the incredibly fast turnaround on #4142! It fixed the case I hit first (zarr-written big-endian structured arrays now round-trip correctly).

While validating it end-to-end I found that the underlying issue #4141 described — the serialization itself dropping per-field endianness — is still present. #4142 resolved the symptom on the write path by byte-swapping structured data to native on encode, but the metadata serialization still loses per-field byte order. That leaves one important case broken: stores whose chunk bytes are not written by zarr — i.e. virtual / reference stores (VirtualiZarr referencing big-endian FITS binary tables → Icechunk). There is no write to swap, so the reopened-native metadata misreads the big-endian bytes, silently.

The serialization still drops per-field endianness (pure zarr, no extras)

import numpy as np
from zarr.core.dtype import parse_data_type
from zarr.core.metadata.v3 import ArrayV3Metadata

be = np.dtype([("flux", ">f4"), ("ivar", ">f4")])
meta = ArrayV3Metadata(
    shape=(3,), data_type=parse_data_type(be, zarr_format=3),
    chunk_grid={"name": "regular", "configuration": {"chunk_shape": (3,)}},
    chunk_key_encoding={"name": "default"}, fill_value=np.zeros((), be)[()],
    codecs=[{"name": "bytes", "configuration": {"endian": "little"}}],
    attributes={}, dimension_names=None,
)
print(meta.data_type)                                          # Struct(... Float32(endianness='big'))
print(ArrayV3Metadata.from_dict(meta.to_dict()).data_type)     # Struct(... Float32(endianness='little'))  ← dropped

The serialized form has nowhere to record byte order — each field is a bare type name:

"data_type": {"name": "struct", "configuration": {"fields": [
    {"name": "flux", "data_type": "float32"}, {"name": "ivar", "data_type": "float32"}]}}

so from_dict rebuilds every field as native. parse_data_type itself preserves >f4; the loss is purely in to_dictfrom_dict.

Why #4142 covers the write path but not virtual/reference reads

check zarr 3.2.1 post-#4142 (main)
to_dictfrom_dict preserves struct endianness ❌ (unchanged)
reopened data_type label little little
create_array(dtype=">f4 struct") → reopen values correct

On main the reopened label is still little, but the values are correct because encode swapped the bytes to native on the way to disk — write-swap and native-label are symmetric. A virtual/reference store never encodes through zarr (it points at the original big-endian FITS bytes in place), so that swap never runs, and the read is wrong. The only recourse for a consumer today is to reinterpret each field with .view('>f4') at the numpy boundary.

Why this one is harder (and needs a backward-compatible representation)

In the v3 model, byte order for scalars lives in the BytesCodec (endian), not the data type. A structured array has a single BytesCodec for all fields, so it cannot express per-field byte order — and the struct's serialized field spec ({"name", "data_type"}) has no slot for it either. So per-field endianness is currently unrepresentable in the serialized form.

A fix likely needs the structured-dtype field representation to carry per-field byte order in a backward-compatible way — e.g. an optional endianness (or a richer per-field data_type object) that defaults to native when absent, so existing serialized structs keep reading as they do today. Since the v3 structured dtype is still flagged unstable/unspecified (the UnstableSpecificationWarning points at zarr-extensions), this may also warrant a small spec decision on where per-field byte order lives. Happy to help test whatever shape you land on.

Real-world impact

Reading archival big-endian FITS binary tables (astronomy) in place via VirtualiZarr → Icechunk, with no reshard. The bytes are referenced, never rewritten, so the write-side swap from #4142 doesn't apply and consumers get silently wrong values without the .view('>f4') workaround.

Versions

Reproduced on released zarr==3.2.1 and on main at the #4142 merge commit (bbe27d7). numpy 2.x, Python 3.12.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions