Skip to content

Name mapping is applied to top-level columns only, so nested fields of migrated files read as NULL #3374

Closed

Description

@andygrove

Apache Iceberg Rust version

main @ 776648bee4c52ece8a057cfcf0d7756416299ba8

Describe the bug

apply_name_mapping_to_arrow_schema in crates/iceberg/src/arrow/reader/projection.rs assigns name-mapped field ids to the top-level fields of a Parquet file's Arrow schema only. A struct, list or map column gets its own id, but the fields inside it keep none, even when the mapping lists them under fields. The projection works on leaf columns by field id, so it never selects those nested leaves.

What the reader returns then depends on the rest of the projection. If the task also projects a top-level primitive column, the projection mask reads just that column, and RecordBatchTransformer fills the struct, list or map column with NULL as if the file did not contain it. If the task projects only nested columns, no leaf matches at all, get_arrow_projection_mask_with_field_ids falls back to ProjectionMask::all(), and the values come out right. So the same file returns NULL or real values depending on whether a top-level column is read alongside.

That is a silent wrong result for any table with nested columns that was migrated with migrate, snapshot or add_files, since those set schema.name-mapping.default. Apache DataFusion Comet hit it in its native Iceberg scan: apache/datafusion-comet#6790. The function is the same at bb1e4a4 (the rev Comet 1.1 pins) and on main.

To Reproduce

Added to the tests in crates/iceberg/src/arrow/reader/projection.rs, next to test_read_parquet_with_name_mapping_uses_mapped_field_ids:

#[tokio::test]
async fn name_mapping_nested_struct() {
    // 1: id int, 2: s struct<3: a int, 4: b int>
    let schema = Arc::new(
        Schema::builder()
            .with_schema_id(1)
            .with_fields(vec![
                NestedField::optional(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
                NestedField::optional(
                    2,
                    "s",
                    Type::Struct(StructType::new(vec![
                        NestedField::optional(3, "a", Type::Primitive(PrimitiveType::Int)).into(),
                        NestedField::optional(4, "b", Type::Primitive(PrimitiveType::Int)).into(),
                    ])),
                )
                .into(),
            ])
            .build()
            .unwrap(),
    );

    // A Parquet file with no field ids at any level.
    let struct_fields = arrow_schema::Fields::from(vec![
        Field::new("a", DataType::Int32, true),
        Field::new("b", DataType::Int32, true),
    ]);
    let arrow_schema = Arc::new(ArrowSchema::new(vec![
        Field::new("id", DataType::Int32, true),
        Field::new("s", DataType::Struct(struct_fields.clone()), true),
    ]));

    // The mapping covers the struct's fields, as Iceberg Java's MappingUtil.create writes it.
    let name_mapping = Arc::new(NameMapping::new(vec![
        MappedField::new(Some(1), vec!["id".to_string()], vec![]),
        MappedField::new(Some(2), vec!["s".to_string()], vec![
            MappedField::new(Some(3), vec!["a".to_string()], vec![]),
            MappedField::new(Some(4), vec!["b".to_string()], vec![]),
        ]),
    ]));

    let tmp_dir = TempDir::new().unwrap();
    let path = format!("{}/1.parquet", tmp_dir.path().to_str().unwrap());
    let s_col = arrow_array::StructArray::new(
        struct_fields,
        vec![
            Arc::new(arrow_array::Int32Array::from(vec![0, 1, 2])) as ArrayRef,
            Arc::new(arrow_array::Int32Array::from(vec![0, -1, -2])) as ArrayRef,
        ],
        None,
    );
    let batch = RecordBatch::try_new(arrow_schema.clone(), vec![
        Arc::new(arrow_array::Int32Array::from(vec![0, 1, 2])) as ArrayRef,
        Arc::new(s_col) as ArrayRef,
    ])
    .unwrap();
    let mut writer = ArrowWriter::try_new(File::create(&path).unwrap(), batch.schema(), None).unwrap();
    writer.write(&batch).unwrap();
    writer.close().unwrap();

    for project_field_ids in [vec![1, 2], vec![2]] {
        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current()).build();
        let tasks = Box::pin(futures::stream::iter(
            vec![Ok(FileScanTask::builder()
                .with_file_size_in_bytes(std::fs::metadata(&path).unwrap().len())
                .with_start(0)
                .with_length(0)
                .with_data_file_path(path.clone())
                .with_data_file_format(DataFileFormat::Parquet)
                .with_schema(schema.clone())
                .with_project_field_ids(project_field_ids.clone())
                .with_case_sensitive(false)
                .with_name_mapping(Some(name_mapping.clone()))
                .build()
                .unwrap())]
            .into_iter(),
        )) as FileScanTaskStream;
        let batches: Vec<RecordBatch> =
            reader.read(tasks).unwrap().stream().try_collect().await.unwrap();
        let s = batches[0].column(batches[0].num_columns() - 1).as_struct();
        println!("{project_field_ids:?}: {} null structs, a={:?}, b={:?}", s.null_count(), s.column(0), s.column(1));
    }
}
project_field_ids null s s.a s.b
[1, 2] 3 of 3 [null, null, null] [null, null, null]
[2] 0 [0, 1, 2] [0, -1, -2]

Through Comet, the same thing shows up for list element fields and map value fields.

Expected behavior

Both projections return [0, 1, 2] and [0, -1, -2], as Iceberg Java does.

What Iceberg Java does. ParquetSchemaUtil.applyNameMapping runs ApplyNameMapping over the whole file schema. It calls nameMapping.find(currentPath()) for every struct, list, map and primitive at every depth, and looks up list elements and map keys and values as element, key and value, whatever the file calls them.

Spec. The column projection rules say:

  • Use schema.name-mapping.default metadata to map field id to columns without field id as described below and use the column if it is present.

And for the mapping itself:

  • fields: An optional list of field mappings for child field of structs, maps, and lists.

...

  • List types should contain a mapping in fields for element.
  • Map types should contain mappings in fields for key and value.
  • Struct types should contain mappings in fields for their child fields.

Possible fix. Apply the mapping recursively. For each Arrow field, find its name among the current level's mapped fields, assign the id, and recurse into the matched field's fields() for a struct's children, a list's element (as element) and a map's key and value (as key and value), the way Java's visitor does.

Willingness to contribute

I would be willing to contribute a fix for this bug with guidance from the Iceberg community.


Related upstream work.

This report was drafted with LLM assistance (Claude Code); the behaviour was verified with a Rust reproducer against iceberg-rust main at 776648b, and end to end through Comet against Spark with Iceberg Java 1.5.2 and 1.11.0.

Activity

  1. comphead commented on Oct 9, 2026

    @comphead
    Collaborator

    That sounds pretty serious

  2. added a commit that references this issue on Oct 12, 2026
    e780575
  3. added a commit that references this issue on Oct 12, 2026
    b55cda1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions