Upgrade Guides#
DataFusion 55.0.0#
Note: DataFusion 55.0.0 has not been released yet. The information provided
in this section pertains to features and changes that have already been merged
to the main branch and are awaiting release in this version.
DataFrame::fill_null now borrows its arguments#
DataFrame::fill_null previously took its arguments by value:
// Before
pub fn fill_null(
&self,
value: ScalarValue,
columns: Vec<String>,
) -> Result<DataFrame>
It now borrows them, matching the signature of the newly added
DataFrame::fill_nan:
// After
pub fn fill_null(
&self,
value: &ScalarValue,
columns: &[&str],
) -> Result<DataFrame>
This lets callers pass a borrowed ScalarValue and slice literals (or
&str column names) without first allocating owned Strings.
Migration guide:
Borrow the value and pass a slice of &str instead of an owned Vec<String>:
// Before
let df = df.fill_null(ScalarValue::from(0), vec!["a".to_owned(), "c".to_owned()])?;
let df = df.fill_null(ScalarValue::from(0), vec![])?;
// After
let df = df.fill_null(&ScalarValue::from(0), &["a", "c"])?;
let df = df.fill_null(&ScalarValue::from(0), &[])?;
FileScanConfig::partitioned_by_file_group removed#
FileScanConfig::partitioned_by_file_group and
FileScanConfigBuilder::with_partitioned_by_file_group(...) have been removed.
Use FileScanConfig::output_partitioning and
FileScanConfigBuilder::with_output_partitioning(...) instead.
The corresponding
datafusion_proto::protobuf::FileScanExecConf::partitioned_by_file_group
field has also been removed.
Who is affected:
Users who accessed
FileScanConfig::partitioned_by_file_groupdirectly.Users who called
FileScanConfigBuilder::with_partitioned_by_file_group(true).Users who constructed or accessed
datafusion_proto::protobuf::FileScanExecConf::partitioned_by_file_group.
Migration guide:
If your file groups are organized by table partition column values, declare hash output partitioning over those partition columns:
use datafusion_datasource::file_scan_config::{
FileScanConfigBuilder, output_partitioning_from_partition_fields,
};
let output_partitioning = output_partitioning_from_partition_fields(
source.table_schema().table_schema(),
source.table_schema().table_partition_cols(),
file_groups.len(),
);
let config = FileScanConfigBuilder::new(object_store_url, source)
.with_file_groups(file_groups)
.with_output_partitioning(output_partitioning)
.build();
output_partitioning_from_partition_fields returns
Some(Partitioning::Hash(...)) when partition columns are present and None
otherwise. If you construct the partitioning manually, pass
Some(Partitioning::Hash(partition_exprs, partition_count)) to
with_output_partitioning(...).
When constructing FileScanExecConf, omit partitioned_by_file_group and set
output_partitioning instead.
User SpillFile traits instead of RefCountedTempFile#
Spill file APIs now use the datafusion_execution::SpillFile trait instead of
the concrete RefCountedTempFile type. DiskManager::create_tmp_file now
returns Arc<dyn SpillFile>.
This change was introduced in [PR #21882], which adds pluggable spill file
backends via SpillFile and TempFileFactory.
If your code matched on DiskManagerMode, add a DiskManagerMode::Custom(_)
arm.
If your code wrote directly to a RefCountedTempFile or called
RefCountedTempFile::update_disk_usage, open a spill writer instead:
- temp_file.inner().as_file().write_all(bytes)?;
- temp_file.update_disk_usage()?;
+ temp_file.open_writer()?.write_all(bytes)?;
Use temp_file.size() instead of RefCountedTempFile::current_disk_usage.
Dialect::AVAILABLE replaced by Dialect::available()#
datafusion_common::config::Dialect::AVAILABLE has been removed. Use
Dialect::available() instead.
spill_record_batch_by_size removed#
datafusion_physical_plan::spill::spill_record_batch_by_size has been removed.
This function was deprecated in DataFusion 46.0.0.
Use datafusion_physical_plan::spill::SpillManager::spill_record_batch_by_size
instead.
CreateExternalTable supports multiple locations#
CREATE EXTERNAL TABLE now accepts multiple paths in a single LOCATION
clause, which are read together as one table:
CREATE EXTERNAL TABLE hits
STORED AS PARQUET
LOCATION ('file_1.parquet', 'file_2.parquet');
To support this, the location field of both
datafusion_expr::CreateExternalTable and
datafusion_sql::parser::CreateExternalTable changed from a String to a
Vec<String> named locations:
// Before (54.0.0)
let location: String = create_external_table.location;
// After (55.0.0)
let locations: Vec<String> = create_external_table.locations;
The CreateExternalTable::builder(name, location, file_type, schema)
constructor is unchanged and still takes a single location; use the new
CreateExternalTableBuilder::with_locations(Vec<String>) to set more than one.
All listed locations must resolve to the same schema and reside on the same
object store. A plain string literal remains a single location, so paths that
contain commas continue to work, for example LOCATION 'path/with,comma.csv'.
Decimal scalar formatting uses human-readable values#
Decimal scalar literals in EXPLAIN output, expression display strings, and
auto-generated column names now format the decimal value using its scale while
still showing the precision and scale. For example, a Decimal128 literal with
stored value 1, precision 1, and scale 1 is now rendered as
Decimal128(0.1,1,1) instead of Decimal128(Some(1),1,1). When formatting a
ScalarValue directly, it now appears as 0.1 instead of Some(1),1,1.
NULL decimal literals were previously shown as Decimal128(None,10,2); they
will now appear as Decimal128(NULL,10,2).
Query result values already used human-readable decimal formatting and are unchanged.
Coercion supports dictionary encoding preservation#
datafusion_expr_common::signature::Coercion now supports optional dictionary
encoding preservation. Typed coercions materialize dictionary inputs by
default, including both TypeSignatureClass::Native(...) and broader classes
such as Integer, Numeric, and Binary. When preservation is enabled,
DataFusion instead coerces dictionary inputs to
Dictionary(original_key_type, coerced_value_type) instead of materializing them
to the coerced value type.
User-defined functions can opt in by setting dictionary encoding preservation on the relevant coercion:
Coercion::new_exact(TypeSignatureClass::Native(logical_string()))
.with_encoding_preservation(EncodingPreservation::dictionary())
This changes the coerced argument type passed to the function. If a function derives its return type from that coerced argument type, code that checks exact result types may need to update its expectations or add an explicit cast to materialize the result.
This changes the previous behavior of typed non-native classes such as
Integer and Binary, which retained the physical dictionary type by default.
UDFs relying on that behavior must now explicitly enable dictionary
preservation. TypeSignatureClass::Any is unaffected.
GroupsAccumulator::merge_batch no longer takes opt_filter#
The opt_filter argument has been removed from
datafusion_expr_common::groups_accumulator::GroupsAccumulator::merge_batch:
fn merge_batch(
&mut self,
values: &[ArrayRef],
group_indices: &[usize],
- opt_filter: Option<&BooleanArray>,
total_num_groups: usize,
) -> Result<()>;
Aggregate FILTER clauses only apply to raw input rows during the partial
(update) phase, so by the time intermediate states are merged there is nothing
left to filter per row. In practice opt_filter was always None here, so
removing it makes the API self-explanatory and impossible to misuse.
Who is affected:
Anyone with a custom
GroupsAccumulatorimplementation.Anyone calling
merge_batchdirectly.
Migration guide:
Drop the opt_filter argument from your merge_batch signature and from any
call sites:
fn merge_batch(
&mut self,
values: &[ArrayRef],
group_indices: &[usize],
- opt_filter: Option<&BooleanArray>,
total_num_groups: usize,
) -> Result<()> {
// ...
}
- acc.merge_batch(values, group_indices, None, total_num_groups)?;
+ acc.merge_batch(values, group_indices, total_num_groups)?;
If your implementation previously inspected opt_filter (for example asserting
it was None), that code can simply be deleted.
See issue #22775 for details.
GroupsAccumulator::convert_to_state is now required#
datafusion_expr_common::groups_accumulator::GroupsAccumulator::convert_to_state
no longer provides a default implementation, and the
GroupsAccumulator::supports_convert_to_state capability method has been
removed. All GroupsAccumulator implementations must now support converting
input batches directly to intermediate aggregate state.
Who is affected:
Users with custom
GroupsAccumulatorimplementations.FFI providers and consumers that use
FFI_GroupsAccumulator.
Migration guide:
Custom GroupsAccumulator implementations must now provide their own
convert_to_state implementation.
Delete supports_convert_to_state implementations because convert_to_state
is now required:
- fn supports_convert_to_state(&self) -> bool {
- true
- }
The supports_convert_to_state field has also been removed from
datafusion_ffi::udaf::groups_accumulator::FFI_GroupsAccumulator, changing its
ABI layout. Rebuild both FFI providers and consumers against DataFusion 55, and
do not exchange this struct with libraries built against older major versions.
See issue #23081 for details.
is_dynamic_physical_expr is deprecated#
datafusion_physical_expr_common::physical_expr::is_dynamic_physical_expr is
deprecated. It was a thin wrapper over snapshot_generation(expr) != 0 used to
ask “does this predicate contain a dynamic filter?”.
Prefer asking the question directly against the concrete type. For a one-off
check, downcast to DynamicFilterPhysicalExpr:
use datafusion_physical_expr::expressions::DynamicFilterPhysicalExpr;
use datafusion_common::tree_node::{TreeNode, TreeNodeRecursion};
let mut is_dynamic = false;
predicate.apply(|e| {
if e.downcast_ref::<DynamicFilterPhysicalExpr>().is_some() {
is_dynamic = true;
Ok(TreeNodeRecursion::Stop)
} else {
Ok(TreeNodeRecursion::Continue)
}
})?;
If you also need to know whether the dynamic filters can still change (and to be
notified when they do), use the new DynamicFilterTracking /
DynamicFilterTracker API in datafusion_physical_expr:
use datafusion_physical_expr::DynamicFilterTracking;
let tracking = DynamicFilterTracking::classify(&predicate);
if tracking.contains_dynamic_filter() {
// worth re-evaluating the predicate at runtime
}
PruningPredicate::try_new is deprecated#
datafusion_pruning::PruningPredicate::try_new is deprecated. Use
PruningPredicateBuilder instead. The deprecated constructor remains available
in DataFusion 55 and preserves its existing behavior.
// Before
let predicate = PruningPredicate::try_new(expr, schema)?;
// After
let predicate = PruningPredicateBuilder::new()
.with_file_schema(schema)
.try_build(expr)?;
FilePruner::try_new no longer builds a pruner for static predicates without statistics#
datafusion_pruning::FilePruner::try_new now returns None when the predicate
is purely static and the file carries no usable column statistics, because
such a pruner can never prune anything beyond what planning already did.
Previously it returned Some whenever a statistics struct was present (the
“is this worth pruning?” decision lived in the Parquet opener). Files with column
statistics, and predicates that carry a dynamic filter, are unaffected.
QueryPlanner adds Any as a supertrait#
To enable downcasting of dyn QueryPlanner to concrete query planner types (via
is::<T>() / downcast_ref::<T>()), the QueryPlanner trait now has Any
as a supertrait:
- pub trait QueryPlanner: Debug
+ pub trait QueryPlanner: Any + Debug
ExecutionPlan::partition_statistics deprecated in favor of statistics_from_inputs#
ExecutionPlan::partition_statistics is deprecated. Statistics computation is
now split into two parts:
StatisticsContextowns the bottom-up plan-tree traversal and a per-walk cache of memoized child statistics. CallStatisticsContext::computeto obtain statistics for a plan.ExecutionPlan::statistics_from_inputscomputes a node’s statistics from its children’s already-resolved statistics, which the context passes in. The node does not traverse the tree itself.
Existing implementations of partition_statistics continue to work unchanged.
The default statistics_from_inputs delegates to the deprecated method, so no
migration is required until the deprecated method is removed.
Warning: The delegation is one-way: the default
statistics_from_inputscallspartition_statistics, but the defaultpartition_statisticsdoes not callstatistics_from_inputs— it returnsStatistics::new_unknown. Nodes that override onlystatistics_from_inputswill silently returnStatistics::new_unknownto any caller still using the deprecatedpartition_statistics.
Who is affected:
Users who implement custom
ExecutionPlannodes (recommended to migrate)Users who call
partition_statisticsdirectly (recommended to switch toStatisticsContext::compute)
Migration guide:
For implementations, override statistics_from_inputs instead of
partition_statistics, plus child_stats_requests to declare which children to
resolve. Child statistics then arrive pre-computed in input_stats (one entry per
child, in children() order), so the node only expresses its local propagation
logic. Leaf nodes, and nodes that derive their statistics without reading children,
need neither override (the default child_stats_requests skips every child).
// Before:
fn partition_statistics(&self, partition: Option<usize>) -> Result<Arc<Statistics>> {
let child_stats = self.input.partition_statistics(partition)?;
// ... transform child_stats ...
}
// After: declare the child to resolve, then compute from its statistics.
fn child_stats_requests(&self, partition: Option<usize>) -> Vec<ChildStats> {
vec![ChildStats::At(partition)]
}
fn statistics_from_inputs(
&self,
input_stats: &[Arc<Statistics>],
args: &StatisticsArgs,
) -> Result<Arc<Statistics>> {
let child_stats = Arc::clone(&input_stats[0]);
// ... transform child_stats ...
}
Important: the default
child_stats_requestsskips every child, so a node that readsinput_statsmust override it to declare the children it uses, or those slots are filled withStatistics::new_unknownplaceholders. Request a child withChildStats::At(partition)(None= overall) and omit one withChildStats::Skip. For example, a partition-merging operator requestsChildStats::At(None), and a broadcast join requests its build side atNone.
For callers, walk a plan through StatisticsContext::compute. The cache is
created with the context:
use datafusion_physical_plan::{StatisticsArgs, StatisticsContext};
// Before:
let stats = plan.partition_statistics(None)?;
// After:
let stats = StatisticsContext::new().compute(plan.as_ref(), &StatisticsArgs::new())?;
DdlStatement::CreateExternalTable and CreateFunction are now boxed#
The two largest variants of datafusion_expr::DdlStatement are now
Boxed:
// Before
pub enum DdlStatement {
CreateExternalTable(CreateExternalTable),
// ...
CreateFunction(CreateFunction),
// ...
}
// After
pub enum DdlStatement {
CreateExternalTable(Box<CreateExternalTable>),
// ...
CreateFunction(Box<CreateFunction>),
// ...
}
CreateExternalTable is 312 bytes and CreateFunction is 288 bytes, so
without boxing they forced the entire LogicalPlan enum to 320 bytes
even on SELECT-only query paths that never instantiate them. Boxing
shrinks LogicalPlan from 320 → 176 bytes (−45%), making every
mem::take / mem::swap / Arc<LogicalPlan> store on the planning
hot path move a smaller payload.
Who is affected:
Users who construct
DdlStatement::CreateExternalTable(...)orDdlStatement::CreateFunction(...)from an owned struct.Users who pattern-match these variants and destructure the inner struct in the same pattern (e.g.
DdlStatement::CreateExternalTable(CreateExternalTable { name, .. })).Code that consumes the inner struct out of these variants (e.g. to pass
CreateExternalTableby value to another function).
Migration guide:
When constructing the variants, wrap the inner struct in Box::new:
// Before
let stmt = DdlStatement::CreateFunction(CreateFunction { name, args, .. });
// After
let stmt = DdlStatement::CreateFunction(Box::new(CreateFunction {
name,
args,
..
}));
When pattern-matching, bind the boxed value and either access fields
through it (Rust auto-derefs the Box) or destructure via .as_ref():
// Before
match ddl {
DdlStatement::CreateExternalTable(CreateExternalTable {
name, location, ..
}) => { /* use name, location */ }
}
// After — access fields through the box
match ddl {
DdlStatement::CreateExternalTable(ce) => {
let name = &ce.name;
let location = &ce.location;
/* ... */
}
}
// After — destructure the dereferenced struct
match ddl {
DdlStatement::CreateExternalTable(ce) => {
let CreateExternalTable { name, location, .. } = ce.as_ref();
/* ... */
}
}
When you need an owned CreateExternalTable / CreateFunction out of
the variant, dereference the box with *:
// Before
match plan {
LogicalPlan::Ddl(DdlStatement::CreateExternalTable(cmd)) => Ok(cmd),
_ => { /* ... */ }
}
// After
match plan {
LogicalPlan::Ddl(DdlStatement::CreateExternalTable(cmd)) => Ok(*cmd),
_ => { /* ... */ }
}
See PR #22733 for details, including the per-variant size breakdown and benchmark results.
ExecutionPlan::with_new_children and ExecutionPlan::with_new_children_and_same_properties deprecated#
with_new_children and with_new_children_and_same_properties have been
deprecated. These methods are used to replace the child plans of an
ExecutionPlan while leaving the plan otherwise identical.
with_new_children_if_necessary has also been deprecated in favor of
replace_children_if_necessary for consistency in naming.
As noted here,
while the addition of with_new_children_and_same_properties has the benefit
of skipping potentially expensive computation in the case that replacement children
have the same properties as the original children, it widens the API surface area
of ExecutionPlan in a way that could be confusing for users.
Thus, to rectify this, we unify these methods by introducing replace_children.
replace_children solves this problem by taking ReplaceChildrenOptions,
which includes a ChildrenPropertiesMode. The mode has two variants,
Keep and Recompute, which tell replace_children whether plan
properties can be reused or need to be recomputed.
This method is called from replace_children_if_necessary, which is the
standard entry point that should be used for replacing the children of a node.
Migration guide:
To migrate from with_new_children and with_new_children_and_same_properties
to replace_children, it is recommended to implement replace_children with
a match statement matching on the ChildrenPropertiesMode. In the case that
the properties match the children, ChildrenPropertiesMode::Keep,
follow the body of with_new_children_and_same_properties. In the case that
the properties do not match the children, ChildrenPropertiesMode::Recompute,
follow the body of with_new_children.
For example, take a look at the implementation for FilterExec:
fn replace_children(
self: Arc<Self>,
mut children: Vec<Arc<dyn ExecutionPlan>>,
options: ReplaceChildrenOptions,
) -> Result<Arc<dyn ExecutionPlan>> {
validate_child_count!(self, children);
match options.children_properties {
ChildrenPropertiesMode::Keep => Ok(Arc::new(Self {
input: children.swap_remove(0),
metrics: ExecutionPlanMetricsSet::new(),
..Self::clone(&*self)
})),
ChildrenPropertiesMode::Recompute => {
let new_input = children.swap_remove(0);
FilterExecBuilder::from(&*self)
.with_input(new_input)
.build()
.map(|e| Arc::new(e) as _)
}
}
}
In the case that the options indicate the properties are the same, we can simply swap the children without having to recompute the properties. In the other case, we create a new node from scratch.
To ensure that this works correctly, it is recommended that users also look
through their codebase and ensure that they use replace_children_if_necessary
for these changes — replace_children_if_necessary should be preferred over
manual use of replace_children, since replace_children_if_necessary will
call replace_children with the correct options filled in.
See PR #23903 for details.
ListingOptions::target_partitions and collect_stat removed#
The target_partitions and collect_stat fields on
datafusion_catalog_listing::ListingOptions, their builder methods
(with_target_partitions, with_collect_stat), and the
with_session_config_options helper have been removed.
ListingTable now reads both values directly from the active SessionConfig
at scan time instead of from a copy snapshotted onto the table at construction
time.
Who is affected:
Code that set
target_partitions/collect_statper table viaListingOptions, or read those public fields.Code that relied on a
ListingTablefreezing these values at construction time independently of the session config. The table now always reflects the currentSessionConfig.
Migration guide:
Configure these on the SessionConfig instead:
// Before
let options = ListingOptions::new(format)
.with_target_partitions(8)
.with_collect_stat(true);
// After
let config = SessionConfig::new()
.with_target_partitions(8)
.with_collect_statistics(true);
See PR #22969 for details.
Spark map functions now reject duplicate keys by default#
The Spark-compatibility map-construction functions (map_from_arrays,
map_from_entries, str_to_map) now raise [DUPLICATED_MAP_KEY] at runtime
when constructing a map that contains duplicate keys. This matches the default
of Spark’s spark.sql.mapKeyDedupPolicy.
A new config option, datafusion.spark.map_key_dedup_policy, controls the
behavior:
EXCEPTION(default): raise on any duplicate key.LAST_WIN: keep the last occurrence of each duplicate key. The key stays at its first-seen position with the value from its last occurrence (matching Spark’sArrayBasedMapBuilder).
Who is affected:
Queries calling
map_from_arraysorstr_to_mapon data that contains duplicate keys. Previously these functions either tolerated duplicates silently or raised a non-configurable error.
Migration guide:
To restore lenient duplicate-key handling, set the policy to LAST_WIN:
SET datafusion.spark.map_key_dedup_policy = 'LAST_WIN';
See PR #21720 for details.
Unify LRU memory-limiting caches into one generic cache#
The caches DefaultFileMetadataCache, DefaultListFilesCache and DefaultFileStatisticsCache
are merged into one generic implementation DefaultCache. The corresponding traits are now
type aliases:
- pub trait FileStatisticsCache: CacheAccessor<TableScopedPath, CachedFileMetadata>
- pub trait ListFilesCache: CacheAccessor<TableScopedPath, CachedFileList>
- pub trait FileMetadataCache: CacheAccessor<Path, CachedFileMetadataEntry>
+ pub type FileStatisticsCache = dyn Cache<TableScopedPath, CachedFileMetadata>;
+ pub type ListFilesCache = dyn Cache<TableScopedPath, CachedFileList>;
+ pub type FileMetadataCache = dyn Cache<Path, CachedFileMetadataEntry>;
Who is affected:
Users who introduced their own implementation of
FileMetadataCache,ListFilesCacheorFileStatisticsCache.
Migration guide:
Implement the newly introduced types for your custom cache implementation.
See PR #22613 for details.
CachedFileMetadata now validates file schema#
The file-statistics cache remains keyed by TableScopedPath, but
CachedFileMetadata now stores a SchemaFingerprint of the file_schema used
to compute the cached statistics. Cache hits are valid only when both the file
metadata and schema fingerprint match.
Who is affected:
Users constructing
CachedFileMetadatavalues directly.
Migration guide:
Pass
Arc::new(SchemaFingerprint::from_schema(file_schema))toCachedFileMetadata::new.Pass the current schema fingerprint to
CachedFileMetadata::is_valid_for.
See PR #23201 for details.
EmptyExecNode and PlaceholderRowExecNode gained a partitions field#
The generated protobuf structs EmptyExecNode and PlaceholderRowExecNode
encoded only a schema, so the partition count set by EmptyExec::with_partitions
was silently dropped when a physical plan was serialized and deserialized: a plan
that reported n partitions before encoding reported 1 after. Both messages now
carry a partitions field that round-trips the count.
Who is affected:
Users constructing
EmptyExecNodeorPlaceholderRowExecNodewith an exhaustive struct literal.
Migration guide:
Set the new field, or fill it from Default:
// Before
EmptyExecNode { schema: Some(schema) }
// After
EmptyExecNode { schema: Some(schema), partitions: 4 }
// or
EmptyExecNode { schema: Some(schema), ..Default::default() }
The wire format stays compatible in both directions. Plans encoded before this field existed decode as a single partition, the previous default, and plans encoded after it add a field that older readers ignore.
See PR #23643 for details.
time ± interval now returns a time instead of an interval#
Adding or subtracting an interval to/from a time value now returns a time
that wraps within the 24-hour clock, matching PostgreSQL and DuckDB. Previously
DataFusion returned an interval.
-- 55.0.0 onwards: returns a time
SELECT time '23:30:00' + interval '2 hours';
-- 01:30:00
Only the sub-day portion of the interval affects the result; whole days and
months are ignored, as in PostgreSQL. The result keeps the input time’s unit
(mirroring timestamp + interval), and any interval precision finer than that
unit is truncated – so time(s) + interval '1 nanosecond' is a no-op.
See PR #23279 for details.
Physical-planning state moved to an explicit PhysicalPlanningContext#
The subquery_indexes and subquery_results public fields on
datafusion_expr::execution_props::ExecutionProps have been removed. They were
added in 54.0.0 as the channel through which the physical planner passed
uncorrelated scalar-subquery state to functions that create physical
Arc<dyn PhysicalExpr> values from logical Expr values.
The lambda_variable_qualifier public field and the
with_qualified_lambda_variables method on ExecutionProps have been removed
for the same reason: they carried the qualifiers of the lambda variables in
scope while create_physical_expr descended into a lambda body.
That state is now carried by a dedicated
datafusion_expr::physical_planning_context::PhysicalPlanningContext passed explicitly
through functions and planner traits. Unlike ExecutionProps, which applies
throughout the planning of an entire query, this context is scoped to the
logical plan subtree currently being converted. This removes the need for the
physical planner to clone and mutate a SessionState, is a prerequisite for
letting the planner take &dyn Session, and lets ExtensionPlanner
implementations create physical
expressions containing scalar subqueries against the same subquery state as the
rest of the plan.
The following functions take a new trailing
planning_ctx: &PhysicalPlanningContext parameter:
datafusion_physical_expr::create_physical_expr/create_physical_exprsdatafusion_physical_expr::create_physical_sort_expr/create_physical_sort_exprs/create_physical_partitioningdatafusion::physical_planner::create_window_expr/create_window_expr_with_namedatafusion_physical_expr::aggregate::LoweredAggregateBuilder::new
The planner traits changed accordingly:
PhysicalPlanner::create_physical_exprtakesplanning_ctx: &PhysicalPlanningContextExtensionPlanner::plan_extensionandplan_table_scanreceiveplanning_ctx: &PhysicalPlanningContextand should forward it toPhysicalPlanner::create_physical_exprwhen creating physical expressions
Convenience methods such as SessionContext::create_physical_expr and
SessionState::create_physical_expr are unchanged.
Who is affected:
Code calling the functions above: pass
&PhysicalPlanningContext::default()unless you are creating physical expressions as part of a physical plan that contains uncorrelated scalar subqueries.Custom
PhysicalPlannerorExtensionPlannerimplementations: add the new parameter and forward it.Code that read or wrote
execution_props.subquery_indexes/execution_props.subquery_results: build aPhysicalPlanningContextinstead.Code that read
execution_props.lambda_variable_qualifieror calledExecutionProps::with_qualified_lambda_variables: remove that usage. Callers that only plan aHigherOrderFunctionare not affected –create_physical_exprpopulates the lambda qualifiers itself as it descends into lambda bodies. Code that needs to read or extend the lambda scope should use the equivalents onPhysicalPlanningContext:PhysicalPlanningContext::lambda_variable_qualifierandPhysicalPlanningContext::with_qualified_lambda_variables.
Migration guide:
When creating a physical expression outside of physical planning, pass an empty context:
use datafusion_expr::physical_planning_context::PhysicalPlanningContext;
use datafusion_physical_expr::create_physical_expr;
// Before
let phys = create_physical_expr(&expr, &schema, &props)?;
// After
let phys = create_physical_expr(
&expr,
&schema,
&props,
&PhysicalPlanningContext::default(),
)?;
For ExtensionPlanner implementations, accept and forward the context:
async fn plan_extension(
&self,
planner: &dyn PhysicalPlanner,
node: &dyn UserDefinedLogicalNode,
logical_inputs: &[&LogicalPlan],
physical_inputs: &[Arc<dyn ExecutionPlan>],
session: &dyn Session,
planning_ctx: &PhysicalPlanningContext, // new parameter
) -> Result<Option<Arc<dyn ExecutionPlan>>> {
for expr in node.expressions() {
// Forward the context so scalar subqueries in this node's
// expressions resolve against the plan's subquery state
planner.create_physical_expr(&expr, node.schema(), session, planning_ctx)?;
}
// ...
}
Catalog, planner, and optimizer contracts moved to datafusion-session#
The catalog, planner, and physical optimizer contract traits now live in the
datafusion-session crate. This makes them available through Session without
downcasting to SessionState, including across the FFI boundary.
The moved catalog traits are CatalogProviderList, CatalogProvider,
SchemaProvider, TableProvider, TableProviderFactory, and
TableFunctionImpl. The related TableFunction struct also moved. The
datafusion-catalog crate re-exports these items from their new location, so
paths such as datafusion::catalog::TableProvider and
datafusion_catalog::CatalogProvider continue to work unchanged.
The moved planning and optimization traits are QueryPlanner,
PhysicalPlanner, ExtensionPlanner, PhysicalOptimizerRule, and
PhysicalOptimizerContext. Their previous paths also continue to work through
re-exports:
datafusion::execution::context::QueryPlannerdatafusion::physical_planner::{PhysicalPlanner, ExtensionPlanner}datafusion_physical_optimizer::{PhysicalOptimizerRule, PhysicalOptimizerContext}
The session argument for methods on QueryPlanner, PhysicalPlanner, and
ExtensionPlanner changed from &SessionState to &dyn Session. Custom planner
implementations should update their signatures. Planner code should use methods
on Session instead of downcasting it to SessionState.
The Session trait now requires a catalog_list method that returns the
catalogs registered with the session:
fn catalog_list(&self) -> Arc<dyn CatalogProviderList>;
Custom Session implementations must add this method. Implementations that do
not expose a catalog can return the new EmptyCatalogProviderList:
use std::sync::Arc;
use datafusion_session::{CatalogProviderList, EmptyCatalogProviderList};
fn catalog_list(&self) -> Arc<dyn CatalogProviderList> {
Arc::new(EmptyCatalogProviderList)
}
Session gains a query_planner method alongside optimize,
physical_optimizers, and statistics_registry. All four have default
implementations, so existing Session implementations that do not perform
physical planning require no changes: query_planner defaults to the new
UnsupportedQueryPlanner, optimize returns the plan unchanged,
physical_optimizers returns no rules, and statistics_registry returns
None.
A custom session that drives planning through DefaultQueryPlanner or
DefaultPhysicalPlanner must override these methods to expose its planning and
optimization behavior; the defaults will otherwise produce unoptimized plans or
fail to plan at all. The simplest approach is to delegate to a SessionState:
use std::sync::Arc;
use datafusion_session::{PhysicalOptimizerRule, QueryPlanner};
fn query_planner(&self) -> Arc<dyn QueryPlanner + Send + Sync> {
self.inner.query_planner()
}
fn optimize(&self, plan: &LogicalPlan) -> Result<LogicalPlan> {
self.inner.optimize(plan)
}
fn physical_optimizers(&self) -> &[Arc<dyn PhysicalOptimizerRule + Send + Sync>] {
self.inner.physical_optimizers()
}
ForeignSession::create_physical_plan runs the complete planning pipeline in the
library that owns the session. ForeignSession::query_planner, optimize, and
physical_optimizers forward to the owning session across the FFI boundary. A
foreign query planner can also be installed on a session through the new
datafusion_ffi::query_planner::FFI_QueryPlanner; see that module’s
documentation for how plans and extension codecs cross the boundary.
See PR #23703 for details on the catalog changes.
FFI_LogicalExtensionCodec::task_ctx_provider is now private#
The task_ctx_provider field on
datafusion_ffi::proto::logical_extension_codec::FFI_LogicalExtensionCodec was
pub and is now crate-private, matching FFI_PhysicalExtensionCodec.
Who is affected:
Code that read or cloned
FFI_LogicalExtensionCodec::task_ctx_providerdirectly. Pass the task context provider toFFI_LogicalExtensionCodec::newinstead, and keep your own copy if you need it elsewhere.
Unused async removed from several public functions#
Public functions that were declared async but never awaited anything are now
synchronous:
CsvFormat::read_to_delimited_chunks_from_stream(indatafusion_datasource_csv, re-exported asdatafusion::datasource::file_format::csv::CsvFormat)datafusion_substrait::serializer::deserialize_bytes, which now also borrows its input as&[u8]instead of taking an ownedVec<u8>datafusion::test_util::parquet::TestParquetFile::create_scan
Migration guide:
Remove .await from call sites; the compiler flags each one, since .await
on a non-future value does not compile:
// Before
let stream = csv_format
.read_to_delimited_chunks_from_stream(input)
.await;
let plan = deserialize_bytes(proto_bytes).await?;
// After
let stream = csv_format.read_to_delimited_chunks_from_stream(input);
let plan = deserialize_bytes(&proto_bytes)?;
MovingMin and MovingMax changed to pub(crate)#
MovingMin and MovingMax in datafusion_functions_aggregate::min_max have been changed from pub to pub(crate) visibility as they are internal helper data structures for DataFusion’s sliding window aggregators.
Who is affected:
Code that directly imported
MovingMinorMovingMaxfromdatafusion_functions_aggregate. Standard SQL window functions (MIN(...) OVER (...)/MAX(...) OVER (...)) are unaffected.
See PR #23827 for details.
ExecutionPlan::apply_expressions is now a required method#
apply_expressions has been added as a required method on the ExecutionPlan, FileSource, and DataSource traits. Any custom implementation of
these traits must now implement apply_expressions. See docs on ExecutionPlan::apply_expressions for migration details.
WindowExpr::evaluate_stateful now takes a WindowEvalContext#
WindowExpr::evaluate_stateful (and the provided
AggregateWindowExpr::aggregate_evaluate_stateful method) take a new
WindowEvalContext argument carrying stream-level information that is shared
by all partitions:
// Before
fn evaluate_stateful(
&self,
partition_batches: &PartitionBatches,
window_agg_state: &mut PartitionWindowAggStates,
) -> Result<()>
// After
fn evaluate_stateful(
&self,
partition_batches: &PartitionBatches,
window_agg_state: &mut PartitionWindowAggStates,
eval_ctx: &WindowEvalContext<'_>,
) -> Result<()>
WindowEvalContext currently carries the most recent input row, which
previously lived in each partition’s PartitionBatchState (see the next
section). The struct is #[non_exhaustive] so that fields can be added
without further signature changes: construct it with
WindowEvalContext::default() and set fields through its builder methods.
Who is affected:
Implementations of the
WindowExprtrait that overrideevaluate_statefulmust add the new parameter.Callers of
evaluate_statefuloraggregate_evaluate_statefulmust pass a context.
Migration guide:
use datafusion_physical_expr::window::WindowEvalContext;
// Before
window_expr.evaluate_stateful(&partition_batches, &mut window_agg_state)?;
// After
let eval_ctx = WindowEvalContext::default()
.with_most_recent_row(most_recent_row.as_ref());
window_expr.evaluate_stateful(
&partition_batches,
&mut window_agg_state,
&eval_ctx,
)?;
Pass WindowEvalContext::default() when no most-recent-row watermark is
available (for example, when the input is sorted by the partition keys and
partition ends are detected directly).
PartitionBatchState::most_recent_row removed#
The most_recent_row field and the set_most_recent_row method have been
removed from datafusion_expr::window_state::PartitionBatchState. The most
recent input row is a property of the whole input stream rather than
per-partition state: every partition observed the same value. It is now
tracked once by the operator driving the evaluation and passed to window
expressions through the new WindowEvalContext argument of
WindowExpr::evaluate_stateful described above.
Who is affected:
Code that read
PartitionBatchState::most_recent_rowor calledset_most_recent_row, such as custom streaming window operators.
Migration guide:
Track the most recent input row once per stream (for example, a one-row
slice of the last non-empty input batch) and pass it to window expressions
via WindowEvalContext::with_most_recent_row instead of copying it into
each partition’s state.
MSRV updated to 1.94.0#
The Minimum Supported Rust Version (MSRV) has been updated to 1.94.0.
CachedParquetFileReader removed; ParquetFileReader fields are now private#
CachedParquetFileReader duplicated ParquetFileReader and has been removed;
ParquetFileReader’s fields are also now private, with
file_metrics() and partitioned_file() accessors added for the two that
were previously public.
Who is affected:
Code that names the
CachedParquetFileReadertype.Code that constructs a
ParquetFileReaderdirectly via a struct literal, or reads/writes its fields.
Migration guide:
ParquetFileReader::new is no longer public; build a reader through
ParquetFileReaderFactory::create_reader (via DefaultParquetFileReaderFactory
or CachedParquetFileReaderFactory) instead of constructing one directly:
// Before
let inner = ParquetObjectReader::new(Arc::clone(&store), location).with_file_size(size);
let reader = CachedParquetFileReader::new(
file_metrics,
store,
inner,
partitioned_file,
metadata_cache,
metadata_size_hint,
);
// After
let reader = CachedParquetFileReaderFactory::new(store, metadata_cache)
.create_reader(partition_index, partitioned_file, metadata_size_hint, &metrics)?;
Replace field access with the new accessor methods:
// Before
let bytes_scanned = reader.file_metrics.bytes_scanned.value();
let location = &reader.partitioned_file.object_meta.location;
// After
let bytes_scanned = reader.file_metrics().bytes_scanned.value();
let location = &reader.partitioned_file().object_meta.location;
array_distance scalar function now rejects multidimensional arrays#
array_distance only supports one-dimensional arrays. Previously, when given
multidimensional arrays, it computed the distance using only the first
subarray and ignored the remaining subarrays. For example:
SELECT array_distance(
[[1, 2], [100, 100]],
[[1, 4], [0, 0]]
);
Previously, this query returned 2.0, the distance between [1, 2] and
[1, 4]. It now returns a planning error stating that array_distance only
supports one-dimensional arrays.
ParquetObjectReader / ParquetObjectWriter deprecated upstream#
The parquet crate deprecated ParquetObjectReader
and ParquetObjectWriter in favor of implementing
AsyncFileReader directly (see the example on the AsyncFileReader trait and
parquet/examples/object_store.rs in arrow-rs) or passing an
BufWriter straight to AsyncArrowWriter.
Who is affected:
Custom
ParquetFileReaderFactoryimplementations that construct aParquetObjectReaderdirectly and now see a deprecation warning after upgrading theparquetdependency.
Migration guide:
If your AsyncFileReader implementation exists mainly to read from an
ObjectStore and track metrics, consider using DataFusion’s
ParquetFileReader instead of wrapping a ParquetObjectReader:
// Before
let inner = ParquetObjectReader::new(store, location).with_file_size(size);
Ok(Box::new(MyReader { inner, file_metrics, partitioned_file }))
// After
Ok(Box::new(ParquetFileReader {
file_metrics,
store,
metadata_size_hint,
partitioned_file,
}))
If you need custom behavior (I/O coalescing, byte caching, a dedicated I/O
runtime), implement AsyncFileReader directly against your ObjectStore,
following the pattern in parquet/examples/object_store.rs
See PR #24030 for details.
datafusion-proto: parquet options conversions are fallible#
protobuf::ParquetOptions and protobuf::TableParquetOptions validate
writer_version when converting into their datafusion-common counterparts, so
those conversions are TryFrom rather than From.
Every other From / TryFrom conversion between DataFusion types and
datafusion_proto::protobuf messages is unchanged. Several impls moved to the
crate that owns their DataFusion type, but trait impls are global, so
X::try_from(&proto) and proto.try_into() still resolve with no import
changes.
Migration guide:
// Before
let opts = ParquetOptions::from(&proto_opts);
let table_opts = TableParquetOptions::from(&proto_table_opts);
// After
let opts = ParquetOptions::try_from(&proto_opts)?;
let table_opts = TableParquetOptions::try_from(&proto_table_opts)?;
See issue #24019 for details.