The Contract Every DataFusion Operator Signs: A Tour of ExecutionPlan's Declarative Methods

DataFusion's physical optimizer does not special-case operators; it asks them a dozen questions, and the answers are how shuffles, sorts and filters get optimised.

TL;DR: DataFusion’s physical optimizer has no if plan is HashJoinExec branches. Instead, every operator answers a dozen declarative questions — “what distribution do your inputs need?”, “are your inputs already sorted?”, “can you absorb a LIMIT?”, “does a filter on your output reduce your output?” — and the optimizer combines the answers to decide where to put shuffles, which sorts to delete and which filters to push. It is a nice piece of API design, and it is the reason DataFusion can add a new operator without touching the optimizer.

Most query optimizers are a pile of special cases. if the plan is a HashJoin, do X; if it is a Sort, do Y. It works, and it is why those optimizers are hard to extend.

DataFusion’s physical optimizer takes the other approach. The ExecutionPlan trait has about a dozen declarative methods, and the optimizer is a small amount of logic that combines their answers.

The questions

Here is the full list of what an operator can be asked, from datafusion/physical-plan/src/execution_plan.rs:

rust
fn required_input_distribution(&self) -> Vec<Distribution> { ... }
fn input_distribution_requirements(&self) -> InputDistributionRequirements { ... }
fn required_input_ordering(&self) -> Vec<Option<OrderingRequirements>> { ... }
fn maintains_input_order(&self) -> Vec<bool> { ... }
fn benefits_from_input_partitioning(&self) -> Vec<bool> { ... }
fn supports_limit_pushdown(&self) -> bool { ... }
fn with_fetch(&self, _limit: Option<usize>) -> Option<Arc<dyn ExecutionPlan>> { ... }
fn fetch(&self) -> Option<usize> { ... }
fn cardinality_effect(&self) -> CardinalityEffect { ... }
fn try_swapping_with_projection(&self, ...) -> Result<Option<Arc<dyn ExecutionPlan>>> { ... }
fn gather_filters_for_pushdown(&self, ...) -> Result<FilterDescription> { ... }
fn try_pushdown_sort(&self, ...) -> Result<Option<Arc<dyn ExecutionPlan>>> { ... }
fn with_preserve_order(&self, ...) -> Result<Arc<dyn ExecutionPlan>> { ... }

Each one is a question the optimizer asks. Here is what they mean.

“What distribution do your inputs need?”

rust
fn required_input_distribution(&self) -> Vec<Distribution> { ... }

Distribution::SinglePartition, Distribution::HashPartitioned(exprs, n), Distribution::RangePartitioned(..). A hash join says “I need both my inputs hash-partitioned on the join key, in the same way”. The optimizer then inserts a RepartitionExec where that is not already true.

The newer, more expressive version is input_distribution_requirements, which can express co-partitioning:

rust
/// `InputDistributionRequirements` describes what distribution an operator
/// requires from each child.
///
/// - [`Self::new`] describes independent per-child requirements.
/// - [`Self::co_partitioned`] additionally requires child partitions with the
/// same index to cover compatible key ranges.

The difference matters: “both sides hash-partitioned on the key” is satisfied by two independent repartitions. “Both sides range-partitioned on the key with the same split points” is not — the second side has to be cut at the same boundaries, which means it needs the first side’s boundaries.

“Are your inputs already sorted?”

rust
fn required_input_ordering(&self) -> Vec<Option<OrderingRequirements>> { ... }

A SortExec says “my input must be sorted by these columns”. The optimizer compares that against the input’s declared output_ordering, and if the requirement is already satisfied, the sort is deleted.

And maintains_input_order answers the complementary question: “does this operator preserve the order of its input?” A FilterExec does; a HashJoinExec does not. Without that, the optimizer could not know whether it is safe to remove a sort.

“Can you absorb a LIMIT?”

rust
fn supports_limit_pushdown(&self) -> bool { ... }
fn with_fetch(&self, _limit: Option<usize>) -> Option<Arc<dyn ExecutionPlan>> { ... }

A SortExec says yes, and with_fetch returns a copy of itself that will stop after N rows. An operator that cannot says no, and the optimizer leaves a LimitExec in place.

The default for with_fetch is None — an operator has to opt in to absorbing a limit. And an operator that says it supports limit pushdown but returns None from with_fetch is handled gracefully.

“Does a filter on your output reduce your output?”

rust
fn cardinality_effect(&self) -> CardinalityEffect { ... }

CardinalityEffect::Equal means “the output has the same number of rows as the input” — so a filter pushed above this operator cannot reduce it. CardinalityEffect::Lower (or Unknown) means it might.

This is what stops the optimizer from pushing a filter through an operator where the filter would be a no-op. It is a small method that prevents a whole class of wrong optimisations.

“Can I swap you with a projection?”

rust
fn try_swapping_with_projection(&self, ...) -> Result<Option<Arc<dyn ExecutionPlan>>> { ... }

This is the projection pushdown mechanism, and it is operator-specific in a good way: a ProjectionExec can say “yes, and here is the projection I would rather have” — which is how a projection gets folded into a scan, or a ColumnRewriter gets applied.

“What filters can I take?”

rust
fn gather_filters_for_pushdown(&self, ...) -> Result<FilterDescription> { ... }
fn handle_child_pushdown_result(&self, ...) -> Result<FilterPushdownResult> { ... }

The two-phase filter pushdown negotiation. The parent offers its filters to the child; the child says which ones it can handle entirely, which ones it can handle partially, and which ones it cannot. The result is a FilterPushdownResult with the residual filters that must stay above.

The “partially” case is the interesting one: a Parquet scan can push a predicate into the reader and keep a residual filter to guarantee correctness. That is only possible because the negotiation returns “partially handled”, not just “yes” or “no”.

“Can I push a sort below you?”

rust
fn try_pushdown_sort(&self, ...) -> Result<Option<Arc<dyn ExecutionPlan>>> { ... }

A FilterExec says “yes, put the sort below me and I will filter the sorted stream”. The optimizer then has to know that the filter does not change the order — which is what maintains_input_order is for.

The properties object

All of the declarative answers are collected once, at construction time, into a PlanProperties:

rust
fn properties(&self) -> &Arc<PlanProperties>;

which holds the output partitioning, the output ordering, the boundedness and the equivalence properties. The reason is caching: these values never change for a given operator instance, so computing them once at construction beats recomputing them on every optimizer question.

Why it matters

The alternative — special-casing operators in the optimizer — has a familiar failure mode. Every new operator needs a branch in the optimizer, and every branch is a place where the special case and the general case can disagree. DataFusion’s approach means a new operator implements a trait and is immediately optimisable.

It also means the knowledge lives with the operator. A hash join knows what distribution it needs; a sort knows what ordering it needs; a filter knows what it can push down. The optimizer is a policy engine, not a catalogue of operator-specific hacks.

And the method list is a good map of what a modern query engine thinks matters: distribution, ordering, cardinality, limits, projections, filters, and the ability to preserve order. If you were writing an execution engine from scratch, that list is a good place to start.

DatafusionOptimizerDesignPerformanceInternals