Skip to content

Specify how non-finite numbers are encoded in attributes - #398

Open
bendichter wants to merge 2 commits into
zarr-developers:mainfrom
bendichter:non-finite-attributes
Open

bendichter wants to merge 2 commits into
zarr-developers:mainfrom
bendichter:non-finite-attributes

Conversation

@bendichter

Copy link
Copy Markdown

This adds a short section to the v3 core specification that says how NaN, Infinity and -Infinity are written in attributes. It addresses #141, whose last comment said a PR adding a representation would be welcome.

The Problem

The specification defines metadata documents with the JSON type system of RFC 8259, which has no representation for a number that is not finite. It gives fill_value a rule for this (the strings "NaN", "Infinity" and "-Infinity"), but says nothing for attributes. Implementations have each filled the gap differently, and the results do not interoperate.

zarr-python writes the bare tokens NaN and Infinity, which are not valid JSON. Most other implementations parse zarr.json with a strict JSON parser and cannot open a group or array that has one such attribute. We ran into this from two directions: zarrita.js could not open NWB files that hold "resolution": NaN, and Icechunk refuses to store the same metadata.

The table below summarizes a survey of what implementations do today. The details, with versions and sources, are at the end.

What is written for a NaN attribute Implementations
The string "NaN" zarrita.js, zarr-java, JZarr, netCDF-C (NCZarr)
The bare token NaN zarr-python, xarray, GDAL, hdmf-zarr
null, silently zarrs, tensorstore, zarr.js, z5
An error n5-zarr
Reading a bare NaN attribute Implementations
The node cannot be opened zarrita.js, zarrs, zarr-java, JZarr, netcdf-java, tensorstore, Icechunk, Neuroglancer, z5, zarr.js
Read as a number zarr-python, GDAL, netCDF-C

Two changes outside this repository make the question timely. zarr-developers/zarr-python#4400 adds a setting that will eventually make zarr-python refuse non-finite attributes, and it does not yet say what a writer should produce instead. Faster JSON libraries for Python (orjson and msgspec) reject the bare tokens on reading and write null in their place, so a library that changes its JSON backend stops reading the files that zarr-python writes today.

The Proposal

A non-finite number in attributes is written as one of the three strings that the specification already defines for fill values: "NaN", "Infinity" or "-Infinity". This applies to every value within attributes, at any depth.

A fill value has the array's data type to say that the string denotes a number. An attribute has nothing, so a rule is needed for a string that has the same characters. Such a string is written with the prefix _str_, and a reader removes one prefix:

Attribute value Encoded in zarr.json
the number NaN "NaN"
the number negative infinity "-Infinity"
the string NaN "_str_NaN"
the string _str_NaN "_str__str_NaN"
the string _str__str_NaN "_str__str__str_NaN"
the string _str_hello "_str_hello"
the strings nan, inf and N/A unchanged

Only a string that is zero or more _str_ prefixes followed by exactly one of the three reserved strings is affected, so no other string changes. An affected string always gains exactly one prefix when it is written and loses one when it is read, however many it already has. The encoding is reversible for every value. A reference implementation of about twenty lines is here:

Reference implementation
import math, re

_AFFECTED = re.compile(r"(?:_str_)*(?:NaN|Infinity|-Infinity)")


def encode(value):
    if isinstance(value, float) and not math.isfinite(value):
        return "NaN" if math.isnan(value) else ("Infinity" if value > 0 else "-Infinity")
    if isinstance(value, str):
        return "_str_" + value if _AFFECTED.fullmatch(value) else value
    if isinstance(value, list):
        return [encode(v) for v in value]
    if isinstance(value, dict):
        return {k: encode(v) for k, v in value.items()}
    return value


def decode(value):
    if isinstance(value, str) and _AFFECTED.fullmatch(value):
        return value[len("_str_"):] if value.startswith("_str_") else float(value)
    if isinstance(value, list):
        return [decode(v) for v in value]
    if isinstance(value, dict):
        return {k: decode(v) for k, v in value.items()}
    return value

The section also says that a reader should accept the bare tokens within attributes, so that data zarr-python has already written stays readable.

Why Strings and Not a Tagged Object

The discussion in #141 leaned toward an object such as {"@type": ...}, and the objection to plain strings there and in zarr-developers/zarr-python#412 was that a number could not be told apart from a string. I think strings with an escape rule are the better choice now, for these reasons.

  • Four implementations already write these strings for a non-finite attribute, and the specification already uses them for fill values. Protocol Buffers' JSON mapping uses the same three strings. An object form would be a second encoding for the same values within one document, and those implementations would have to change what they write.
  • The escape rule removes the ambiguity that the object form was meant to remove. zarrita.js shows the ambiguity as it stands: it writes {"a": NaN, "d": "NaN"} as {"a": "NaN", "d": "NaN"}.
  • A reader that does not know the rule still gets something it can use. float("NaN") in Python and Number("NaN") in JavaScript convert the string, where an object would need handling written for it.
  • The object form was attractive partly because it could carry a data type. For the three values in question there is no precision to preserve, and a finite float written in decimal survives a round trip without one.

null is not a workable alternative. It loses the value, and of the readers we tested, hdmf rejects it, netCDF-C returns the text null, and GDAL returns an empty string.

Compatibility

  • Data that holds the bare tokens is not changed by this, and readers are asked to keep accepting it.
  • Data written by zarrita.js, zarr-java, JZarr or netCDF-C, where "NaN" stands for a number, starts to be read as a number by every implementation that follows this section.
  • An attribute that holds one of the three strings as text is read as a number once a reader follows this section. I do not know how common that is. I would expect it to be rare, and the text nearly always means the number, but it is a change in meaning for existing data and is the main cost of this proposal.
  • No current reader converts the string "NaN" in an attribute to a number, apart from netCDF-C with its own type annotations. Every reader therefore needs a small change whichever encoding is chosen.

Open Questions

  1. The spelling of the prefix. _str_ describes itself to someone who meets it unprepared. A single _ is shorter and matches the leading underscore the ecosystem already treats as reserved. I do not have a strong preference.
  2. Core specification or convention. I have written this as a core rule because the current behavior produces documents that are not valid JSON, and because a file that does not declare a convention would stay ambiguous. If this ought to be a ZEP or a registered convention instead, I am glad to move it.
  3. Scope. The rule covers attributes only. A non-finite number could in principle appear in the configuration of a codec or another extension, and one rule for the whole document outside fill_value may be simpler.
  4. The change log. I added the entry under an "Unreleased" heading because I was not sure which version this would belong to.

Survey Details

Rows marked "tested" were run with the version given. Rows marked "source" come from reading the implementation's source and were not run.

Python and C libraries
Library Basis Writes for NaN Reads bare NaN Reads the string "NaN" as
zarr-python 3.4.1 (formats 2 and 3) and 2.18.7 tested bare NaN yes, as a float a string
xarray 2026.9.0 with zarr 3.4.1 tested bare NaN, with no special handling of attributes yes a string
GDAL 3.12.2 Zarr driver tested bare NaN; the fill value is "NaN" yes, as Float64 a string attribute
netCDF-C 4.9.3 (NCZarr, format 2) tested the strings "NaN", "Infinity", "-Infinity" yes a float when its own type annotation is present, otherwise a string
tensorstore 0.1.84, zarr3 driver tested null no, the array cannot be opened a string
hdmf-zarr 0.14.0 tested bare NaN yes fails with a type error
kerchunk 0.2.10 tested passes bare tokens through unchanged passes through passes through
Icechunk 2.3.0 tested stores the bytes it is given no, refuses the metadata passes through

JSON libraries for Python, all tested: json (CPython 3.12) and ujson 6.0.0 write and read the bare tokens. orjson 3.13.0 and msgspec 0.22.0 write null and reject the bare tokens. simplejson 4.2.0 raises on writing and rejects the bare tokens.

Other implementations
Implementation Basis JSON library Writes for NaN Reads bare NaN
zarrita.js 0.7.5 tested JSON.parse, and JSON.stringify with a replacer the string "NaN" no
zarr.js 0.6.3 source JSON.parse and JSON.stringify null no
zarrs source serde_json null no
zarr-java source Jackson with default settings the string "NaN" no
JZarr source Jackson with default settings the string "NaN" no
netcdf-java (reading only) source Jackson with default settings not applicable no
n5-zarr source Gson raises an error yes, as a string that converts to NaN
Neuroglancer source the browser's response.json() not applicable no
z5 source nlohmann::json null no

Zarr.jl depends on the major version of JSON.jl that is installed, and I have left it out of the summary for that reason. From its source, it writes null with the older version and raises with the newer one, and it rewrites some bare NaN tokens as strings before parsing.

Related

bendichter and others added 2 commits October 10, 2026 12:42
RFC 8259 has no representation for NaN or the infinities, and the
specification gives a rule for them only in fill_value. Implementations
have filled the gap in different ways: zarr-python writes the bare tokens
NaN and Infinity, which strict JSON parsers refuse, others write the
string "NaN", and others write null.

The new Attribute values section says that a non-finite number in
attributes is written as "NaN", "Infinity" or "-Infinity", the strings
already defined for fill values. A string with the same characters is
written with the prefix _str_, and a reader removes one prefix, so every
value can be read back as it was written. Readers are asked to keep
accepting the bare tokens so that existing data stays readable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@d-v-b

d-v-b commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

zarr-python writes the bare tokens NaN and Infinity, which are not valid JSON.

this comes from python's JSON library, and zarr-python's participation in it is something I want to deprecate. see zarr-developers/zarr-python#4400. unfortunately, downstream packages (xarray) rely on this deviant behavior, which is why we still support it.

A non-finite number in attributes is written as one of the three strings that the specification already defines for fill values: "NaN", "Infinity" or "-Infinity". This applies to every value within attributes, at any depth.

I don't think the spec should say anything about the contents of attributes except that it's a JSON object. encoding values that dont have a native JSON representation should be left to users, or organized groups of users via zarr conventions. a convention is free to re-use the fill value encodings, for example.

@bendichter

Copy link
Copy Markdown
Author

Thanks for the quick look. I agree a convention can carry the encoding, and I am glad to move that part to zarr-conventions, reusing the fill value encodings as you suggest.

I do think one piece belongs here. The divergence in the survey does not come from users choosing different encodings. It comes from what each implementation does when it is handed a float NaN: zarrita.js and zarr-java write the string "NaN", zarrs and tensorstore write null, and zarr-python and GDAL write the bare token. None of those is a choice the user made. Would you support narrowing this PR to a clarification that attributes must be valid RFC 8259 JSON, that a non-finite number has no representation in it, and that a writer must not emit the bare tokens or silently substitute another value? That seems consistent with zarr-developers/zarr-python#4400.

One question on the convention route: would zarr-python be open to applying a registered convention on write, for example as another value of attributes.non_finite_floats, or do you see that as living entirely in downstream packages such as xarray?

@d-v-b

d-v-b commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

Would you support narrowing this PR to a clarification that attributes must be valid RFC 8259 JSON

I think this sentence in the spec covers that:

Metadata documents are defined here using the JSON
type system defined in [RFC8259]_. In this section, the terms "value",
.

One question on the convention route: would zarr-python be open to applying a registered convention on write, for example as another value of attributes.non_finite_floats, or do you see that as living entirely in downstream packages such as xarray?

probably not. i think zarr-python should treat attributes as plain JSON, and leave special encodings to downstream packages

@bendichter

Copy link
Copy Markdown
Author

That is clear, thank you. I have written the encoding up as a convention: https://github.com/catalystneuro/zarr-non-finite-attributes. It reuses the fill value strings as you suggested, adds an escape so that a string with the same characters can still be stored, and applies only to a node that registers it, so the meaning of existing data does not change. The repository has reference implementations in Python and JavaScript, test vectors, and the survey from this PR.

It is a proposal and I have not tagged a version, so comments on the wording are welcome there. One thing I was unsure about is whether it fits the conventions framework, since it defines how the values of a node's attributes are encoded and has no properties of its own.

I will leave this PR open for a few days in case others see a reason to address this in the core specification, and close it otherwise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants