Skip to content

fix(io): keep null lists of structs when projecting Arrow data - #4065

Merged
Fokko merged 1 commit into
apache:mainfrom
DG47:fix/null-list-of-structs
Nov 11, 2026
Merged

Fokko merged 1 commit into
apache:mainfrom
DG47:fix/null-list-of-structs

Conversation

@DG47

@DG47 DG47 commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Closes #3833

Rationale for this change

ArrowProjectionVisitor.list rebuilds a list whose element is a struct with pa.LargeListArray.from_arrays(list_array.offsets, value_array). The offsets alone cannot express a null list, so every null list<struct<...>> came out as an empty list. The visitor runs on both the read and the write path; on write the empty list is persisted into the Parquet file, so the null is gone for every reader, not only for pyiceberg.

Reproduction from the issue (pyarrow 25.0.1):

l_struct  in=[[{'x': 1}], [], None, [{'x': 3}]]  out=[[{'x': 1}], [], [], [{'x': 3}]]
l_int     in=[[1], [], None, [3]]                out=[[1], [], None, [3]]

This PR rebuilds the list with pa.Array.from_buffers, reusing the validity and offsets buffers (and the array offset) of the original list and swapping in the projected element array. Passing mask=list_array.is_null() to from_arrays would fix the unsliced case, but Arrow rejects a mask together with sliced offsets (Null bitmap with offsets slice not supported) for LargeListArray, so sliced batches would start raising. Reusing the buffers keeps the array offset and avoids that.

The integration test test_null_list_and_map asserted the corrupted value with the correct assertion commented out; the correct assertion is restored.

Are these changes tested?

  • New unit test test__to_requested_schema_null_list_of_structs in tests/io/test_pyarrow.py, parametrized over pa.list_ and pa.large_list, covering a full batch and a sliced one. It fails on main (At index 2 diff: [] != None) and passes with this change.
  • Restored the real assertion in tests/integration/test_reads.py::test_null_list_and_map.
  • make test and make lint pass locally.

Are there any user-facing changes?

Null lists of structs are now preserved on read and write instead of being turned into empty lists.

@DG47

DG47 commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

@Fokko requesting review

@DG47

DG47 commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Hi, checking whether someone could take a look at this. It fixes #3833: projecting Arrow data turned null list<struct> values into empty lists. The new unit test fails on main, and the commented-out assertion in test_null_list_and_map is restored. Happy to rebase or change the approach if needed.

@DG47

DG47 commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

@kevinjqliu could you have a look at this one? It fixes #3833, where projecting Arrow data turned null list<struct> values into empty lists. It also restores the real assertion in test_null_list_and_map, which had been commented out around the bug. The branch is current with main.

@afrojuju1

Copy link
Copy Markdown

plus on this, it would be great to get this fix in @kevinjqliu

The projection visitor rebuilds a list of structs from its offsets alone,
which cannot represent a null list, so every null list<struct<...>> was
turned into an empty list. On the write path this is persisted into the
Parquet file and the null is lost for every reader.

Rebuild the list from the original validity and offsets buffers instead,
which also keeps the original array offset so sliced batches stay correct.

Closes apache#3833
@DG47
DG47 force-pushed the fix/null-list-of-structs branch from f37fc5c to 6df3d03 Compare November 10, 2026 15:08
@DG47

DG47 commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

@Fokko request you to review this

@Fokko
Fokko self-requested a review November 11, 2026 21:05

@Fokko Fokko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing this @DG47, this is a nasty bug 👍

# assert arrow_table["col_list_with_struct"].to_pylist() == [None, [{'test': 1}]]
# Once https://github.com/apache/arrow/issues/38809 has been fixed
assert arrow_table["col_list_with_struct"].to_pylist() == [[], [{"test": 1}]]
assert arrow_table["col_list_with_struct"].to_pylist() == [None, [{"test": 1}]]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@Fokko
Fokko added this pull request to the merge queue Nov 11, 2026
Merged via the queue into apache:main with commit c4cd513 Nov 11, 2026
20 checks passed
@slachiewicz

slachiewicz commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Related tests and fixes
#3834
apache/arrow#50956

krishan711 added a commit to krishan711/iceberg-python that referenced this pull request Nov 12, 2026
…of-structs

* upstream/main: (78 commits)
  fix(io): keep null lists of structs when projecting Arrow data (apache#4065)
  Skip unknown nested fields when resolving Avro read schemas (apache#4289)
  fix(expressions): reject non-finite floats on conversion to Decimal (apache#4287)
  fix: handle negative-scale Decimals in increment, decrement and truncate_decimal (apache#4285)
  Match NaN partition values with `IsNaN` in partition predicates (apache#4281)
  Fix append when a data column is named like a partition helper column (apache#4344)
  Widen decimal literal precision in scan filters (apache#4348)
  Add row-lineage support with Spark tests (apache#3972)
  Serialize purgeRequested as a lowercase boolean in REST drop_table (apache#4341)
  Validate each branch against its own commits on retry (apache#4317)
  Core: Compare upsert rows column by column instead of cell by cell (apache#4334)
  Core: Write added files under the partition spec they were planned with (apache#4338)
  Core: Check whether a failed last commit attempt landed before cleanup (apache#4339)
  REST: Use the standard User-Agent format (apache#4330)
  SQL: Accept an existing SQLAlchemy engine in SqlCatalog (apache#4336)
  Expose catalog labels on Table (apache#4321)
  Respect REST endpoint capabilities when checking view existence (apache#4337)
  Reject existing ref names in create_branch and create_tag (apache#4319)
  Assert every ref a transaction writes, not just the first (apache#4315)
  feat: send Idempotency-Key on REST mutations when the server advertises it (apache#4310)
  ...

# Conflicts:
#	pyiceberg/io/pyarrow.py
#	tests/io/test_pyarrow.py
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.

Null list<struct<...>> is written and read as an empty list

4 participants