Skip to content

fix(reader): read list views as lists so projection can select their leaves - #3467

Open
comphead wants to merge 2 commits into
apache:mainfrom
comphead:fix-3393-list-view
Open

comphead wants to merge 2 commits into
apache:mainfrom
comphead:fix-3393-list-view

Conversation

@comphead

@comphead comphead commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Which issue does this PR close?

What changes are included in this PR?

parquet-rs reads a Parquet LIST as ListView or LargeListView when the file's embedded Arrow schema says so. The projection mask is built with arrow-schema's Fields::filter_leaves, which treats list views as leaves. So a list view column read as NULL whenever another column was projected, and a list view of structs shifted the leaf index of every later column, which was then read from the wrong Parquet leaf.

configure_arrow_reader_metadata now calls coerce_list_views, which rewrites every ListView and LargeListView to List and LargeList in the schema hint passed to parquet-rs, at any depth and keeping field metadata. Name mapping, fallback IDs, and INT96 coercion then start from that schema. The rewrite is a single pass, and for a schema without list views it returns None without allocating. The transformer then handles these columns as it already handles List and LargeList.

This also re-enables INT96 coercion for files that contain a list view. ArrowSchemaVisitor::visit_type has no list view arm, so coerce_int96_timestamps used to fail on such a schema and silently skip the coercion. That follows from reading the code, and I did not run it.

Iceberg Java, Spark, and DuckDB select columns on the Parquet schema, so they do not hit this. DataFusion keeps list views and counts their leaves with its own walker (count_leaves in nested_schema_pruning.rs). arrow-rs main (60.0.0) still treats list views as leaves in filter_leaves.

Are these changes tested?

Yes.

  • test_read_list_view_columns in pipeline.rs reads the file from ListView columns read as NULL and shift later columns to the wrong Parquet leaf #3393, a list_view<struct<a, b>> followed by an int, and checks both columns. On main that read fails with cannot promote ListView(...) to List(...), and projecting only the int returns NULL.
  • test_coerce_list_views in projection.rs checks the rewrite at every nesting position, with unchanged fields before and after a list view, and that a schema without list views is left alone.

As a spot check, I broke the change in 15 ways, one at a time, from skipping the rewrite in the pipeline to dropping the fields around the first list view. Each mutation failed at least one of the two tests.

AI Disclosure

Drafted with Claude Code. The behavior on main was checked against main at b3470c846.

…leaves

Fields::filter_leaves, which builds the projection mask, treats ListView
and LargeListView as leaves. A list view column therefore read as NULL
next to another column, and a list view of structs shifted the leaf
index of every later column, so those columns were read from the wrong
Parquet leaf.

Rewrite list views to List and LargeList in the schema hint passed to
parquet-rs, before name mapping and INT96 coercion.

Closes apache#3393
- Rewrite list views in one pass that returns None for a schema without
  any, and drop contains_list_view.
- Rename list_views_as_lists to coerce_list_views, after
  coerce_int96_timestamps, and merge the pipeline branches that both
  return the file schema.
- Move the reader test next to the INT96 reader tests to reuse
  metadata_projection_task and scan_task, and cover every arm of the
  rewrite in the schema test.
@comphead
comphead marked this pull request as ready for review October 15, 2026 23:20
Copilot AI balanced review requested due to automatic review settings October 15, 2026 23:20
@comphead comphead added the bug Something isn't working label Oct 15, 2026
@comphead
comphead requested a review from mbutrovich October 15, 2026 23:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

The focused change preserves metadata and fits existing reader handling, with no blocking issues identified; tests were not executed.

0 open findings

What changed in this PR

Fixes #3393 by normalizing Arrow list views before projection, preventing missing list values and incorrect selection of later columns.

Changes:

  • Recursively converts list views to lists while preserving metadata.
  • Applies normalization before field-ID handling and timestamp coercion.
  • Adds schema-conversion and reader regression tests.
File Description
crates/​iceberg/​src/​arrow/​reader/​projection.rs Adds recursive normalization and unit coverage.
crates/​iceberg/​src/​arrow/​reader/​pipeline.rs Integrates normalization and adds a reader regression test.
crates/​iceberg/​src/​arrow/​reader/​mod.rs Makes the helper available within the reader module.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@ewoodbury ewoodbury 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.

Chiming in with a short review, fix looks good. I checked the main correctness claims locally against a8df712, and they all line up:

  • On main, projecting only id after a list_view<struct<a, b>> returns all NULLs, and projecting both columns errors. Both read correctly here.
  • test_read_list_view_columns fails with the rewrite disabled. Reader tests, clippy, fmt and rustdoc are clean, and the branch merges cleanly with main.
  • These also read correctly here (all broken on main): LargeListView, a row filter on the column after the list view, and files without field IDs through name mapping (including a pyarrow-written file) and through position fallback.
  • iceberg-java 1.12.0 reads the same file identically.
  • The INT96 claim holds: main reads 0 for a 3333-01-01 value next to a list view, and this branch reads it correctly.

Rewriting once in the schema hint is the right approach since every later step sees a plain List, with no public API change.

As a nit, I left one test suggestion inline regarding the name mapping branch for imported files, just to pin that fix.

Arc::clone(arrow_metadata.schema()),
name_mapping,
)?
apply_name_mapping_to_arrow_schema(Arc::clone(file_schema), name_mapping)?

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.

Imported files are probably the most common source of list views. iceberg-rust's writers emit List, and a pyarrow pa.list_view(...) file has no field IDs, so it is read through this name-mapping branch.

This branch doesn't seem to be covered by the new tests yet. If I pass the uncoerced arrow_metadata.schema() here, both tests still pass. Meanwhile, a pyarrow-written lv list_view<struct<a, b>>, id file read with a name mapping goes back to returning id as all NULLs, and to erroring when lv is also projected.

I think it would be worth having test_read_list_view_columns also read the file through a name mapping, for example by writing the same batch without the field-ID metadata and setting with_name_mapping on the task. Not blocking.

@kevinjqliu

Copy link
Copy Markdown
Contributor

Thanks for the PR! I'm not sure if this should be fixed in the iceberg code, it seems like a bug in arrow-rs related to filter_leaves. Thanks for reporting it upstream (apache/arrow-rs#11612), looks like it's already fixed on main in apache/arrow-rs#11657, just not released yet.

im a bit worried about the maintenance burden of working around it here, since we'd be patching arrow types one at a time (FixedSizeList hits a similar issue). And list views in parquet files seem pretty rare.

What do you think about picking up the fix from upstream arrow-rs/parquet-rs instead, and keeping #3393 open until then? 😄

@laskoviymishka laskoviymishka 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.

Approving. Fixing this on the ARROW:schema hint, before the ID branches, means INT96 and the row-number install get the fix for free. Since Parquet has no list-view type, the rewrite produces the same List shape schema_to_arrow already gives us.

The inline comments are follow-ups, not blockers. +1 to @ewoodbury's name-mapping test, and I'd pin position fallback the same way, since leaf-position ID assignment is exactly what this bug broke. The other two are small hardening. Landing them here or in a follow-up PR are both fine with me.

I'll leave this open for a day or two in case anyone else wants a look.

// otherwise, use applyNameMapping() followed by pruneColumns() when configured, or
// addFallbackIds() followed by pruneColumnsFallback() for position-based fallback.
// The fast path returns early without materializing an owned schema.
let without_list_views = coerce_list_views(arrow_metadata.schema());

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.

The comment block above only covers the field-ID strategy, but this step now runs before all of it and changes the emitted type. I'd call that out here:

Suggested change
let without_list_views = coerce_list_views(arrow_metadata.schema());
// Rewrite list views to lists first: `filter_leaves` treats them as leaves (#3393),
// so everything below, including the emitted batches, sees `List`/`LargeList`.
let without_list_views = coerce_list_views(arrow_metadata.schema());

That also explains the without_list_views.is_some() arm further down. Callers who wrote ListView now get List back. That's the right contract for an Iceberg list, but it's worth a line in the PR description too.

apply_name_mapping_to_arrow_schema(Arc::clone(file_schema), name_mapping)?
} else {
add_fallback_field_ids_to_arrow_schema(arrow_metadata.schema())
add_fallback_field_ids_to_arrow_schema(file_schema)

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.

This is the position-fallback counterpart to @ewoodbury's name-mapping note. The test file carries field IDs, so nothing reaches this line. Fallback IDs are assigned by leaf position, and leaf position is exactly what the list-view bug threw off. Writing the batch without IDs and reading it twice, with and without a name mapping, would pin both branches in one test. Making lv a LargeListView in one of those runs would also cover that arm end to end. Fine as a follow-up.

}
// `Fields::filter_leaves` treats `ListView` and `LargeListView` as leaves, so projection
// cannot select their elements even with IDs.
// List views were already rewritten to lists by `coerce_list_views`.

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.

This comment is now a precondition on every caller of apply_name_mapping_to_arrow_schema, but the other => other.clone() arm still lets a list view through silently. I'd state the precondition in that function's doc and add debug_assert!(!matches!(other, DataType::ListView(_) | DataType::LargeListView(_))) here. Then a new call site that skips coerce_list_views fails in tests instead of quietly bringing back the all-NULL projection.

let id = HashMap::from([(PARQUET_FIELD_ID_META_KEY.to_string(), "9".to_string())]);
let int = |name: &str| Field::new(name, DataType::Int32, true);
let element = |data_type| Arc::new(Field::new("element", data_type, true));
let ints = element(DataType::Int32);

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.

Only nested carries an ID, so this test would still pass if the rewrite dropped metadata on a list element or on the fields inside a struct. I'd give ints and the fields inside s IDs too, since projection selects by exactly those fields.

@mbutrovich

Copy link
Copy Markdown
Collaborator

What do you think about picking up the fix from upstream arrow-rs/parquet-rs instead, and keeping #3393 open until then? 😄

Agreed, especially since I think it'll make the 60.1.0 maintenance release this week. apache/arrow-rs#10525

@comphead

Copy link
Copy Markdown
Collaborator Author

okie, lets wait for 60.1.0 and btw we already have Arrow 60 migration opened in #3257

This branch has not been deployed

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

Labels

bug Something isn't working datafusion

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ListView columns read as NULL and shift later columns to the wrong Parquet leaf

6 participants