Skip to content

fix: keep None in an array wherever it sits - #1975

Open
shcheklein wants to merge 1 commit into
mainfrom
fix/none-in-arrays
Open

fix: keep None in an array wherever it sits#1975
shcheklein wants to merge 1 commit into
mainfrom
fix/none-in-arrays

Conversation

@shcheklein

@shcheklein shcheklein commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

list[int | None] fails to save when the first element is None:

class Scores(dc.DataModel):
    vals: list[int | None]

chain.map(s=lambda: Scores(vals=[1, None]), output=Scores)   # ok
chain.map(s=lambda: Scores(vals=[None, 2]), output=Scores)   # UdfError

Three places decided how to treat a whole array by looking at one element of it.

convert_type read val[0]: a leading 1 matched the item type, so the array
was stored untouched and the None rode along; a leading None did not match,
so every element went to a converter — and None has nothing to convert.

flatten read value[0] the same way: a leading model meant model_dump() on
every element including the Nones, a leading None meant the models were left
raw, and neither could answer for a list of mixed shapes.

TypeReadConverter.float maps None to nan, which is right for a bare float
column and wrong for a nullable element — without that, these arrays would newly
save and then read back [1.0, nan]. Scalar Optional[float] already returns
None; arrays were the outlier.

What that changes

list[int | None] main here
[1, None] [1, None] [1, None]
[None, 2] UdfError [None, 2]
[None, None] UdfError [None, None]
list[float | None] main here
[1.0, None] [1.0, nan] [1.0, None]
[None, 2.0] UdfError [None, 2.0]

A model nested inside a dict item was converted in one order and not the other:

list[dict[str, Item] | None] main here
[{"a": Item(n=1)}, None] TypeError: Object of type Item is not JSON serializable [{'a': Item(n=1)}, None]
[None, {"a": Item(n=1)}] works works

And a None reached a non-nullable column unchecked, again depending on
where it sat:

dc.read_values(x=[[1, None]], output={"x": list[int]}).to_values("x")
# main: [[1, None]]   -- stored into Array(Int64)
# here: UdfError

On disk

Only arrays that contain a None change, and of those only the order that was
already inconsistent with itself:

list[dict | None] before after
[{"k": 1}, None] [{"k":1},null] unchanged
[None, {"k": 1}] ["null","{\"k\":1}"] [null,{"k":1}]

Arrays without a None take exactly the path they took before — list[int],
list[str], list[dict], list[Model], list[list[int]], tuple[int, ...],
tuple[str, ...], tuple[Model, Model], tuple[int, int], and mixed arrays
like ({"k": 1}, 2) are byte-identical, checked by writing each with both
versions and diffing the stored column.

Left for #1968

How main behaves today, untouched here, because fixing them changes the stored
shape of arrays that already round-trip:

  • A model beside a plain value is left raw. ({"a": Item(n=2)}, 1) raises;
    reverse the order and it writes.
  • Element order picks a mixed array's layout. ({"k": 1}, 2) stores
    [{'k': 1}, 2], (2, {"k": 1}) stores ['2', '{"k":1}'].
  • A tuple stores unlike the list of the same values. list[int] stores
    [1,2]; tuple[int, ...] stores ["1","2"] — what
    #1963 runs into.
  • Non-finite floats become null in dict or Any fields.
  • A nested list cannot hold a None at the outer level.
    list[list[int] | None] writes [[1, 2], [3]] but not [None, [1, 2]].

Stack

  1. this PRNone anywhere in an array
  2. fix: recognize every spelling of a nullable array element #1976 — the spellings of a nullable element type
  3. fix: store a list of models that admits a None #1977 — a list of models that admits a None

Replaces #1970, which carried all three at once.

`list[int | None]` fails to save when the first element is None:

    [1, None]      ->  [1, None]
    [None, 2]      ->  UdfError: None incompatible with Int64
    [None, None]   ->  UdfError

convert_type read val[0] to decide how to handle a whole array. A leading match
returned the array untouched and the None rode along with it; anything else
converted element by element, where a None had no type to convert and raised. So
the same annotation and the same values succeeded or failed on order alone.

Each element answers for itself now, and a None survives only where
item_type.dc_nullable says one belongs -- which also closes the other half of
it, a trailing None reaching a non-nullable column unchecked.

flatten decided the same way, from value[0]: a leading model meant model_dump()
on every element including the Nones, a leading None meant the models were left
raw, and one element could not answer for a list of mixed shapes. Every element
that carries a type has to agree before a shape is assumed.

Reading needed it too. TypeReadConverter.float turns None into nan, which is
right for a bare float column and wrong for a nullable element; without this
these arrays would save and then read back nan. A nullable element keeps its
None, as a nullable column already does.

Only arrays that hold a None change on disk, and of those only the order that
was already inconsistent with itself:

    [{"k": 1}, None]   [{"k":1},null]         unchanged
    [None, {"k": 1}]   ["null","{\"k\":1}"]   -> [null,{"k":1}]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.00000% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/datachain/lib/convert/flatten.py 71.42% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

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.

1 participant