This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git
The following commit(s) were added to refs/heads/main by this push:
new 12ba4b74d8 fix: reject duplicate field names during CSV and Parquet
schema inference (#24387)
12ba4b74d8 is described below
commit 12ba4b74d8383ffaecca7a51da7cfec324ec8f26
Author: batu <[email protected]>
AuthorDate: Thu Aug 20 18:27:49 2026 +0000
fix: reject duplicate field names during CSV and Parquet schema inference
(#24387)
## Which issue does this PR close?
- Closes #24381.
- Closes #12852.
## Rationale for this change
When a Parquet file has multiple columns with the same name and
compatible types, DataFusion merges them during schema inference. The
scan succeeds without warning but returns only one of those columns.
PyArrow can produce such a file via its public API:
```python
left = pa.table({"id": [1, 2, 3], "value": [10, 20, 30]})
right = pa.table({"id": [1, 2, 3], "value": [100, 200, 300]})
joined = left.join(right, keys="id") # left_suffix/right_suffix default
to None
pq.write_table(joined, path) # writes id, value, value
```
DataFusion 54.0.0 returns ['id', 'value'] for this file; the [100, 200,
300] column is missing.
Other readers either preserve the data or reject the file:
| Reader | Result |
| --- | --- |
| `pyarrow` `ParquetFile.read()` | keeps all three columns |
| `pyarrow` `pq.read_table()` | `ArrowInvalid: Multiple matches for
FieldRef.Name(value)` |
| `pyarrow` dataset | `ArrowInvalid: Can't unify schema with duplicate
field names` |
| `duckdb` | keeps all three, renames the second to `value_1` |
| `polars` | `DuplicateError` |
| `datafusion` | **succeeds, missing column(s)** |
The same bug is behind two issues #24381 and #12852. CSV and Parquet
schema inference both pass each file's schema to `Schema::try_merge`,
which matches fields by name. Duplicate names in one file are merged
before the scan. Thus, column(s) disappear silently.
## What changes are included in this PR?
This PR adds `ensure_unique_field_names` to `datafusion-datasource`. It
is called for each inferred CSV and Parquet schema before the merge. It
uses the existing `SchemaError::DuplicateUnqualifiedField` error and
adds the file location to the message.
Duplicate names are rejected. The PR does not rename columns or change
how fields with unique names are merged across files.
## Are these changes tested?
Yes. Two regression tests in order to cover the CSV and Parquet paths:
- `datafusion/core/src/datasource/file_format/parquet.rs` —
`infer_schema_rejects_duplicate_field_names`
- `datafusion/core/src/datasource/file_format/csv.rs` —
`infer_schema_rejects_duplicate_header_names`
Both tests create their inputs in a temporary directory; no fixtures are
added. Both fail on `main` and pass with this change. On `main`, the CSV
test infers `Schema { fields: [id, value] }` from a three-column header,
matching the behavior reported in #12852.
## Are there any user-facing changes?
Yes. CSV and Parquet schema inference now returns a clear error when an
inferred
file schema repeats a field name. The error names the file and the
repeated
column instead of returning an incomplete schema.
---------
Co-authored-by: Andrew Lamb <[email protected]>
---
datafusion/core/src/datasource/file_format/csv.rs | 27 +++++++++++++++++
.../core/src/datasource/file_format/parquet.rs | 34 ++++++++++++++++++++++
datafusion/core/tests/data/duplicate_header.csv | 2 ++
datafusion/datasource-csv/src/file_format.rs | 15 +++++++++-
datafusion/datasource-parquet/src/file_format.rs | 16 +++++++++-
datafusion/datasource/src/file_format.rs | 29 ++++++++++++++++--
datafusion/sqllogictest/test_files/csv_files.slt | 7 +++++
7 files changed, 125 insertions(+), 5 deletions(-)
diff --git a/datafusion/core/src/datasource/file_format/csv.rs
b/datafusion/core/src/datasource/file_format/csv.rs
index 90d7eb3b41..447d5244f9 100644
--- a/datafusion/core/src/datasource/file_format/csv.rs
+++ b/datafusion/core/src/datasource/file_format/csv.rs
@@ -1628,4 +1628,31 @@ mod tests {
Ok(())
}
+
+ #[tokio::test]
+ async fn infer_schema_rejects_duplicate_header_names() -> Result<()> {
+ let directory = tempfile::tempdir()?;
+ let path = directory.path().join("duplicate_header.csv");
+ std::fs::write(&path, "id,value,value\n1,10,100\n")?;
+
+ let store = Arc::new(LocalFileSystem::new()) as _;
+ let meta = crate::test::object_store::local_unpartitioned_file(&path);
+
+ let ctx = SessionContext::new().state();
+ let error = CsvFormat::default()
+ .with_has_header(true)
+ .infer_schema(&ctx, &store, std::slice::from_ref(&meta))
+ .await
+ .expect_err("duplicate header names must not infer a schema")
+ .to_string();
+
+ assert!(
+ error.contains("duplicate unqualified field name")
+ && error.contains("value")
+ && error.contains("duplicate_header.csv"),
+ "unexpected error: {error}"
+ );
+
+ Ok(())
+ }
}
diff --git a/datafusion/core/src/datasource/file_format/parquet.rs
b/datafusion/core/src/datasource/file_format/parquet.rs
index 0f5db4a057..bbd9d0937a 100644
--- a/datafusion/core/src/datasource/file_format/parquet.rs
+++ b/datafusion/core/src/datasource/file_format/parquet.rs
@@ -1822,4 +1822,38 @@ mod tests {
Ok(())
}
+
+ #[tokio::test]
+ async fn infer_schema_rejects_duplicate_field_names() -> Result<()> {
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("id", DataType::Int64, false),
+ Field::new("value", DataType::Int64, false),
+ Field::new("value", DataType::Int64, false),
+ ]));
+ let batch = RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int64Array::from(vec![1, 2, 3])) as ArrayRef,
+ Arc::new(Int64Array::from(vec![10, 20, 30])) as ArrayRef,
+ Arc::new(Int64Array::from(vec![100, 200, 300])) as ArrayRef,
+ ],
+ )?;
+
+ let store = Arc::new(LocalFileSystem::new()) as _;
+ let (meta, _files) = store_parquet(vec![batch], false).await?;
+
+ let ctx = SessionContext::new().state();
+ let error = ParquetFormat::default()
+ .infer_schema(&ctx, &store, &meta)
+ .await
+ .expect_err("duplicate field names must not infer a schema")
+ .to_string();
+
+ assert!(
+ error.contains("duplicate unqualified field name") &&
error.contains("value"),
+ "unexpected error: {error}"
+ );
+
+ Ok(())
+ }
}
diff --git a/datafusion/core/tests/data/duplicate_header.csv
b/datafusion/core/tests/data/duplicate_header.csv
new file mode 100644
index 0000000000..0b2719a8ec
--- /dev/null
+++ b/datafusion/core/tests/data/duplicate_header.csv
@@ -0,0 +1,2 @@
+id,value,value
+1,10,100
diff --git a/datafusion/datasource-csv/src/file_format.rs
b/datafusion/datasource-csv/src/file_format.rs
index c0d22b80f0..a094fdc3bf 100644
--- a/datafusion/datasource-csv/src/file_format.rs
+++ b/datafusion/datasource-csv/src/file_format.rs
@@ -41,6 +41,7 @@ use datafusion_datasource::file::FileSource;
use datafusion_datasource::file_compression_type::FileCompressionType;
use datafusion_datasource::file_format::{
DEFAULT_SCHEMA_INFER_MAX_RECORD, FileFormat, FileFormatFactory,
+ ensure_unique_field_names,
};
use datafusion_datasource::file_scan_config::{FileScanConfig,
FileScanConfigBuilder};
use datafusion_datasource::file_sink_config::{FileSink, FileSinkConfig};
@@ -397,12 +398,24 @@ impl FileFormat for CsvFormat {
)
})?;
records_to_read -= records_read;
- schemas.push(schema);
+ schemas.push((&object.location, schema));
if records_to_read == 0 {
break;
}
}
+ let mut seen = HashSet::new();
+ for (location, schema) in &schemas {
+ ensure_unique_field_names(schema, &mut seen).map_err(|err| {
+ DataFusionError::Context(
+ format!("Error when processing CSV file {location}"),
+ Box::new(err),
+ )
+ })?;
+ }
+ drop(seen);
+
+ let schemas = schemas.into_iter().map(|(_, schema)| schema);
let merged_schema = Schema::try_merge(schemas)?;
Ok(Arc::new(merged_schema))
}
diff --git a/datafusion/datasource-parquet/src/file_format.rs
b/datafusion/datasource-parquet/src/file_format.rs
index 6358201c06..1b25a2c632 100644
--- a/datafusion/datasource-parquet/src/file_format.rs
+++ b/datafusion/datasource-parquet/src/file_format.rs
@@ -17,6 +17,7 @@
//! [`ParquetFormat`]: Parquet [`FileFormat`] abstractions
+use std::collections::HashSet;
use std::fmt;
use std::fmt::Debug;
use std::ops::Range;
@@ -37,7 +38,9 @@ use datafusion_datasource::TableSchema;
use datafusion_datasource::file_compression_type::FileCompressionType;
use datafusion_datasource::file_sink_config::FileSinkConfig;
-use datafusion_datasource::file_format::{FileFormat, FileFormatFactory};
+use datafusion_datasource::file_format::{
+ FileFormat, FileFormatFactory, ensure_unique_field_names,
+};
use datafusion_common::Statistics;
use datafusion_common::config::{ConfigField, ConfigFileType,
TableParquetOptions};
@@ -388,6 +391,17 @@ impl FileFormat for ParquetFormat {
schemas
.sort_unstable_by(|(location1, _), (location2, _)|
location1.cmp(location2));
+ let mut seen = HashSet::new();
+ for (location, schema) in &schemas {
+ ensure_unique_field_names(schema, &mut seen).map_err(|err| {
+ DataFusionError::Context(
+ format!("Error when processing Parquet file {location}"),
+ Box::new(err),
+ )
+ })?;
+ }
+ drop(seen);
+
let schemas = schemas.into_iter().map(|(_, schema)| schema);
let schema = if self.skip_metadata() {
diff --git a/datafusion/datasource/src/file_format.rs
b/datafusion/datasource/src/file_format.rs
index dd30881610..2c2a94e8cc 100644
--- a/datafusion/datasource/src/file_format.rs
+++ b/datafusion/datasource/src/file_format.rs
@@ -19,7 +19,7 @@
//! See write.rs for write related helper methods
use std::any::Any;
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::fmt;
use std::sync::Arc;
@@ -28,9 +28,11 @@ use crate::file_compression_type::FileCompressionType;
use crate::file_scan_config::FileScanConfig;
use crate::file_sink_config::FileSinkConfig;
-use arrow::datatypes::SchemaRef;
+use arrow::datatypes::{Schema, SchemaRef};
use datafusion_common::file_options::file_type::FileType;
-use datafusion_common::{GetExt, Result, Statistics, internal_err,
not_impl_err};
+use datafusion_common::{
+ GetExt, Result, SchemaError, Statistics, internal_err, not_impl_err,
schema_err,
+};
use datafusion_physical_expr::LexRequirement;
use datafusion_physical_expr_common::sort_expr::LexOrdering;
use datafusion_physical_plan::ExecutionPlan;
@@ -42,6 +44,27 @@ use object_store::{ObjectMeta, ObjectStore};
/// Default max records to scan to infer the schema
pub const DEFAULT_SCHEMA_INFER_MAX_RECORD: usize = 1000;
+/// Rejects an inferred schema that names the same field more than once.
+///
+/// [`Schema::try_merge`] coalesces fields by name, so callers validate each
+/// inferred file schema before merging. `seen` is cleared before use so
callers
+/// can reuse its allocation across schemas.
+pub fn ensure_unique_field_names<'a>(
+ schema: &'a Schema,
+ seen: &mut HashSet<&'a str>,
+) -> Result<()> {
+ seen.clear();
+ seen.reserve(schema.fields().len());
+ for field in schema.fields() {
+ if !seen.insert(field.name()) {
+ return schema_err!(SchemaError::DuplicateUnqualifiedField {
+ name: field.name().clone(),
+ });
+ }
+ }
+ Ok(())
+}
+
/// Metadata fetched from a file, including statistics and ordering.
///
/// This struct is returned by [`FileFormat::infer_stats_and_ordering`] to
diff --git a/datafusion/sqllogictest/test_files/csv_files.slt
b/datafusion/sqllogictest/test_files/csv_files.slt
index af2c6d41af..a64f1df6e4 100644
--- a/datafusion/sqllogictest/test_files/csv_files.slt
+++ b/datafusion/sqllogictest/test_files/csv_files.slt
@@ -15,6 +15,13 @@
# specific language governing permissions and limitations
# under the License.
+# duplicate_header_is_rejected
+statement error Schema contains duplicate unqualified field name value
+CREATE EXTERNAL TABLE csv_with_duplicate_header
+STORED AS CSV
+LOCATION '../core/tests/data/duplicate_header.csv'
+OPTIONS ('format.has_header' 'true');
+
# create_external_table_with_quote_escape
statement ok
CREATE EXTERNAL TABLE csv_with_quote (
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]