Repository navigation
Description
Problem
After upgrading from DataFusion 51 to 52, we hit several hard errors reading Parquet files that worked fine in DF 51. The root cause is that DF 52 removed SchemaAdapter (PR #18998), which previously handled schema mismatches transparently via map_batch().
In real-world data lakes, Parquet files accumulated over years often have schema inconsistencies that are unavoidable:
- Nullability mismatch: Files declare columns as
REQUIRED(non-nullable) but actual data contains nulls — common with schema evolution or different writer implementations - List/Struct inner field differences: e.g.
List(Field("conditions", Int32, false))vsList(Field("element", Int32, true))— different Parquet writers use different field naming and nullability conventions - Type evolution: Older files store columns as
Utf8, newer table schema expectsDate32
These are not bugs — they are a natural reality of long-lived data lakes with multiple writers. DF 51 handled all of these gracefully and silently.
How DF 51 handled this (worked perfectly)
SchemaAdapter::map_batch() ran after the Parquet reader produced each RecordBatch:
- Rebuilt batches using the table schema, bypassing nullability validation entirely
- Called
arrow::compute::cast_with_options()on each column for type coercion (e.g.Utf8 → Date32) - Transparently handled missing columns, renamed fields, and nullability differences
This was a safe, battle-tested approach that made DataFusion robust for real-world Parquet files.
What breaks in DF 52
DF 52 replaced SchemaAdapter with PhysicalExprAdapterFactory, which only adapts filter/projection expressions — not batch data. Now:
-
RecordBatch::try_new_with_options(arrow-rs) strictly validates!field.is_nullable() && null_count > 0and rejects batches. This happens inside the Parquet reader stream, before any user code can intercept. -
ArrowReaderMetadata::try_new(parquet-rs) rejects schema overrides where nullability differs — even for safe widening (non-nullable → nullable):Incompatible supplied Arrow schema: nullability mismatch for field X: expected true but found false -
No escape hatch: There's no option to skip validation in
RecordBatch,ArrowReaderMetadata, orParquetRecordBatchStreamBuilder. We cannot work around this from the outside.
Concrete errors we hit
# Non-nullable column with null data
Arrow error: Invalid argument error: Column 'conditions' is declared as
non-nullable but contains null values
# Schema override rejected when trying to force nullable
Parquet error: Arrow: Incompatible supplied Arrow schema:
nullability mismatch for field location: expected true but found false
Workarounds attempted (all failed or insufficient)
| Approach | Result |
|---|---|
Force nullable in TableSchema passed to ParquetSource |
Parquet reader still uses file's physical schema internally → RecordBatch validation fails before our code runs |
Override schema via ArrowReaderOptions::with_schema() |
ArrowReaderMetadata::try_new rejects nullable widening |
| Wrap stream to fix batches post-read | Error occurs inside stream, batch never emitted |
Port SchemaAdapter::map_batch() logic into the opener |
Partially works, but breaks on multiple layers — ProjectionExprs, ArrowReaderMetadata, RecordBatch all validate independently |
Proposal: restore DF 51's automatic schema adaptation as an opt-in feature
We believe we are not the only downstream affected by this breaking change. Any data lake with historical Parquet files from different writers or schema evolution will hit similar issues.
Could DataFusion provide an opt-in option to restore DF 51's SchemaAdapter behavior? Specifically:
-
A
ParquetSourceorParquetOpeneroption (e.g.with_lenient_schema(true)orwith_schema_adapter(true)) that, when enabled:- Allows nullable widening in
ArrowReaderMetadata::try_new(non-nullable file → nullable table schema is always safe) - Applies
arrow::compute::cast()for type mismatches (like the oldmap_batch) - Rebuilds
RecordBatchwith the table schema after reading, skipping strict nullability validation
- Allows nullable widening in
-
This should be opt-in (default off) to preserve the current strict behavior for users who want it, while giving data lake users a migration path.
-
Alternatively, relaxing the
ArrowReaderMetadata::try_newcheck in arrow-rs to allow nullable widening (non-nullable → nullable) would solve the most common case. This is always safe — non-nullable data is trivially valid in a nullable column. The current check treats widening and narrowing as equally invalid, which seems overly strict.
Impact on complex downstream systems
Our system is a large-scale data platform built on DataFusion with extensive customizations:
- Custom physical operators (sort-preserving joins, cooperative execution, reverse scan, OneOf routing)
- Custom
ParquetFileReaderFactorywith caching and instrumented I/O - Custom
ExecutionPlanFactorythat wrapsParquetSource/ReverseParquetSourcewith adapted schemas - Materialized Views with their own table schemas that propagate through
ProjectionExecoutput
The removal of SchemaAdapter affects every layer that touches Parquet schema — not just the reader, but also projections, partition column injection, expression evaluation, and output schema validation. Because validation happens independently at multiple levels (ArrowReaderMetadata, RecordBatch, ProjectionExprs), fixing it in one place often exposes failures in another. This made the DF 51 → 52 upgrade extremely difficult and error-prone for us.
An upstream-supported compatibility strategy would be far more reliable than downstream workarounds, since the schema adaptation needs to be integrated at the core reader level where arrow-rs and DataFusion interact.
Without this, upgrading from DF 51 to DF 52 requires either:
- Rewriting all historical Parquet files (impractical for petabyte-scale lakes)
- Forking both DataFusion and arrow-rs to patch validation at multiple layers (what we're currently doing — fragile and hard to maintain)
- Staying on DF 51 indefinitely
None of these are sustainable. We'd love to hear if others have encountered similar issues and what the community's recommended approach is.
Environment
- DataFusion 51 → 52 upgrade
- arrow-rs / parquet 57.x
- Production data lake with ~5 years of Parquet files from multiple writers/pipelines
Activity
zhuqi-lucas commentedon Apr 1, 2026
cc @adriangb @alamb @xudong963
adriangb commentedon Apr 1, 2026
The new system handles all of the cases the old system does. This is evidenced by all tests passing. We also have parquet files with null columns that are marked as non-nullable at the table level.
Are you able to give a MRE that worked with DF 51 but does not work with DF 52 (or main)?
zhuqi-lucas commentedon Apr 1, 2026
Thanks @adriangb for reply:
In DF 51,
SchemaAdapterFactorywas built intoParquetOpeneritself — meaning all users ofParquetSourcegot batch-level schema adaptation automatically, regardless of whether they went throughListingTableor createdParquetSourcedirectly via a customTableProvider/ExecutionPlanFactory.In DF 52,
SchemaAdapterFactorywas removed fromParquetOpenerand replaced withPhysicalExprAdapterFactory. The new adapter handles coercion at the expression level (e.g. inserting CAST in projections). However, this may not be fully wired up for all code paths — specifically whenParquetSourceis created directly (not throughListingTable).Our custom
ParquetExecFactorycreatesParquetSource::new(table_schema)directly. This worked in DF 51 because the opener's built-inSchemaAdapterhandled everything. In DF 52, the same code path fails because:SchemaAdapter::map_batch()for batch-level coercionPhysicalExprAdapterFactorymay not be correctly initialized for the directParquetSourcepathSo this does appear to be a regression for anyone who uses
ParquetSourcedirectly rather than throughListingTable. TheSchemaAdapterFactorywas an implicit contract of theParquetOpenerthat downstream code relied on.Could you confirm whether
PhysicalExprAdapterFactoryis intended to cover all the same paths thatSchemaAdapterFactorydid? Or is there additional setup needed when creatingParquetSourcedirectly?adriangb commentedon Apr 1, 2026
Yes
PhysicalExprAdapterFactorydoes cover all of the same cases thatSchemaAdapterFactorydid. There may be some differences in wiring when creating aParquetSourcedirectly (mostly due to refactorings not specifically this change), however I can confirm that the defaultPhysicalExprAdapterFactoryis bound even if you don't specify a custom one:datafusion/datafusion/datasource-parquet/src/source.rs
Lines 519 to 522 in 1416ed4
I think this can be one of two things:
zhuqi-lucas commentedon Apr 1, 2026
Thanks @adriangb for looking into this. After extensive testing, here's what I found:
DF 51 works, DF 52 fails — confirmed by reverting our codebase to the pre-DF-52-upgrade commit and running the same queries against the same data. All queries succeed on DF 51, several fail on DF 52.
However, I was unable to create a standalone MRE using vanilla DataFusion APIs that reproduces the difference. The scenarios I tested (missing non-nullable column, List inner field mismatch, nullable file data + non-nullable table schema) either fail on both versions or succeed on both.
Our system uses a custom
ExecutionPlanFactorythat createsParquetSourcedirectly and wires it throughFileScanConfigBuilder::from(base_config).with_source(new_source). The interaction betweenSchemaAdapter::map_batch()(DF 51) and our custom path appears to be what handled the edge cases — but I can't isolate it into a minimal example because the behavior depends on our fullFileScanTable→ParquetExecFactory→ParquetSourcepipeline.Given that
PhysicalExprAdapterFactorydoes cover the same cases in vanilla DF, I think the issue is likely in how we're wiring things up after the migration. I'll continue debugging on our side.One concrete thing that might help other DF 52 migrators: the
replace_schemablock inParquetOpener(L600-617 inopener.rs) doesRecordBatch::try_new_with_options(output_schema, arrays)without any column casting — it just swaps the schema. In DF 51,SchemaAdapter::map_batch()calledarrow::compute::cast()on each column before creating the RecordBatch, which handled subtle differences like List inner field metadata. If anyone has a custom setup where the output schema doesn't exactly match the physical file schema (including inner field names/nullability), thereplace_schemablock will reject it.Thanks again for the help. I'll close this if we determine it's fully on our side.
adriangb commentedon Apr 1, 2026
Makes sense, I'm sorry for the extra work this has caused on your end. Obviously I can't help debug that much, but maybe could you check that
DefaultPhysicalExprAdapterFactoryis getting called? You could try wrapping it with your own implementation with some debug statements and wire that up and then you can see what the input / output schemas and expressions are.zhuqi-lucas commentedon Apr 2, 2026
Thanks, i found the workaround in our custom system now, and working well.
adriangb commentedon Apr 4, 2026
Shall we close this issue then? Or is there something you think DataFusion could improve?
zhuqi-lucas commentedon Apr 4, 2026
Closed it now @adriangb .