Skip to content

Fix structured dtype v2 round-trip and silent loss of field offsets/padding#188

Draft
d-v-b wants to merge 7 commits into
mainfrom
claude/structured-dtype-bugfixes
Draft

Fix structured dtype v2 round-trip and silent loss of field offsets/padding#188
d-v-b wants to merge 7 commits into
mainfrom
claude/structured-dtype-bugfixes

Conversation

@d-v-b

@d-v-b d-v-b commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes two verified HIGH-severity bugs in structured-dtype handling. Addresses B9–B10 of #181.

Bug 1 — Nested structured dtypes failed the Zarr V2 JSON round-trip

check_structured_dtype_v2_inner (src/zarr/core/dtype/common.py) is a recursive type guard for the inner elements of a V2 structured-dtype name. For a nested field such as ["nested", [["x","<f4"],["y","<f8"]]], the last element is a list of [name, dtype] field pairs, but the function recursed into itself — which expects a single [name, dtype] pair — and returned False.

Consequence: Struct.to_json(zarr_format=2) emits exactly this nested form, then get_data_type_from_json(j, zarr_format=2) raised ValueError: No Zarr data type found. Zarr wrote V2 metadata it could not read back.

Fix: when the last element is a non-string Sequence, validate it with check_structured_dtype_name_v2 (the list-of-fields checker) instead of recursing as a single pair. Verified the full round-trip from_native_dtype → to_json(v2) → from_json → to_native_dtype for nested dtypes 2 and 3 levels deep, plus an end-to-end V2 array write/reopen/read.

Bug 2 — Structured dtypes silently lost field offsets / padding / alignment

Structured.from_native_dtype (src/zarr/core/dtype/npy/structured.py) records only (name, dtype) pairs, and to_native_dtype rebuilds with np.dtype([(name, dtype), ...]), which re-packs fields contiguously. So np.dtype([('a','i1'),('b','i8')], align=True) (itemsize 16) round-tripped to itemsize 9 — silently changing field offsets and misinterpreting stored chunk bytes (data corruption).

Decision — loud failure rather than layout preservation. numpy's only faithfully round-trippable representation of an aligned/padded structured dtype is dtype.descr, which inserts synthetic empty-named void padding fields (e.g. ('', '|V7')). The Zarr V2 structured name form is a list of [name, dtype] pairs and could carry those padding entries shape-wise, but doing so would require materially changing the public Structured.fields model (adding synthetic void fields), and would ripple through the V3 form, scalar handling, and all field-iteration logic — a large, risky change to field semantics that is not what the zarr-v2 / numcodecs ecosystem historically wrote. Given the hard constraints "reading existing packed data must keep working unchanged" and "no drive-by refactors," the surgical and safe choice is to fail loudly rather than silently corrupt.

Fix: from_native_dtype now detects a non-default-packed layout (a recursive check that compares the dtype's itemsize and per-field offsets against a contiguously-repacked rebuild, so padding inside a nested field is caught even when the outer dtype is packed) and raises a clear ValueError explaining that aligned/padded structured dtypes are not serializable. The error is a ValueError (not DataTypeValidationError) on purpose: the data type registry suppresses DataTypeValidationError as "does not match", but a padded layout does match this dtype class and must surface to the caller.

Reading existing stored data is unaffected — the from_json paths reconstruct packed dtypes exactly as before; only from_native_dtype of a non-packed numpy dtype now raises.

Tests

Added regression tests in tests/test_dtype/test_npy/test_structured.py: the exact repro cases plus a 2-level-nested case and align=True cases (top-level padding and nested-field padding), and a packed round-trip guard against false positives.

$ uv run pytest tests/test_dtype/ -q
719 passed, 5 skipped

$ uv run pytest tests/test_dtype/test_npy/test_structured.py -q
41 passed

$ uv run pytest tests/test_v2.py -q
27 passed

$ uv run mypy src/zarr/core/dtype/common.py src/zarr/core/dtype/npy/structured.py
Success: no issues found in 2 source files

Notes

  • A towncrier changelog entry is still needed before this PR is taken out of draft.

🤖 Generated with Claude Code

dependabot Bot and others added 7 commits May 31, 2026 19:28
…#176)

Bumps the actions group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [prefix-dev/setup-pixi](https://github.com/prefix-dev/setup-pixi) | `0.9.5` | `0.9.6` |
| [codecov/codecov-action](https://github.com/codecov/codecov-action) | `6.0.0` | `6.0.1` |
| [github/issue-metrics](https://github.com/github/issue-metrics) | `4.2.2` | `4.2.7` |
| [j178/prek-action](https://github.com/j178/prek-action) | `2.0.3` | `2.0.4` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.0` | `7.0.1` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `7.0.0` | `8.0.1` |
| [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) | `1.13.0` | `1.14.0` |
| [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.5.3` | `0.5.6` |



Updates `prefix-dev/setup-pixi` from 0.9.5 to 0.9.6
- [Release notes](https://github.com/prefix-dev/setup-pixi/releases)
- [Commits](prefix-dev/setup-pixi@1b2de7f...5185adf)

Updates `codecov/codecov-action` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](codecov/codecov-action@57e3a13...e79a696)

Updates `github/issue-metrics` from 4.2.2 to 4.2.7
- [Release notes](https://github.com/github/issue-metrics/releases)
- [Commits](github-community-projects/issue-metrics@c9e9838...1e38d5e)

Updates `j178/prek-action` from 2.0.3 to 2.0.4
- [Release notes](https://github.com/j178/prek-action/releases)
- [Commits](j178/prek-action@6ad8027...bdca6f1)

Updates `actions/upload-artifact` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](actions/upload-artifact@v7...043fb46)

Updates `actions/download-artifact` from 7.0.0 to 8.0.1
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](actions/download-artifact@v7...3e5f45b)

Updates `pypa/gh-action-pypi-publish` from 1.13.0 to 1.14.0
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](pypa/gh-action-pypi-publish@v1.13.0...cef2210)

Updates `zizmorcore/zizmor-action` from 0.5.3 to 0.5.6
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](zizmorcore/zizmor-action@b1d7e1f...5f14fd0)

---
updated-dependencies:
- dependency-name: prefix-dev/setup-pixi
  dependency-version: 0.9.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: codecov/codecov-action
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/issue-metrics
  dependency-version: 4.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: j178/prek-action
  dependency-version: 2.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: pypa/gh-action-pypi-publish
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.5.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…adding

Bug 1: Nested structured dtypes failed the Zarr V2 JSON round-trip. The inner
type guard check_structured_dtype_v2_inner recursed into itself for a nested
field's last element, but that element is a list of [name, dtype] field pairs,
not a single pair. It now validates that element with check_structured_dtype_name_v2,
so metadata written by to_json(zarr_format=2) can be read back.

Bug 2: Structured dtypes with non-default (aligned / padded) field layouts were
silently re-packed contiguously on round-trip, changing field offsets and itemsize
and corrupting stored bytes. from_native_dtype now detects non-packed layouts
(recursively) and raises a clear ValueError instead. Reading existing packed data
is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant