Repository navigation
Name mapping is applied to top-level columns only, so nested fields of migrated files read as NULL #3374
Description
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.defaultmetadata 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
fieldsforelement.- Map types should contain mappings in
fieldsforkeyandvalue.- Struct types should contain mappings in
fieldsfor 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.
- Use schema visitor in ArrowReader name mapping #1845 (open) says the name mapping added by feat(reader): Add PartitionSpec support to FileScanTask and RecordBatchTransformer #1821 "might not handle nested types properly" and asks for a schema visitor. It was filed as a feature request and doesn't mention that the result is NULL values.
- fix: use name-mapped field IDs for projection and predicate pushdown #2612 (merged) made projection and predicates use the name-mapped ids, for top-level columns.
- perf(reader): speed up row group filtering and name mapping #3269 (open) speeds up the same lookup and keeps it top-level.
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
comphead commentedon Oct 9, 2026
That sounds pretty serious
fix(reader): apply name mapping to nested fields
fix(reader): apply name mapping to nested fields (apache#3384)