You are viewing a plain text version of this content. The canonical link for it is here.
Posted to github@datafusion.apache.org by "pepijnve (via GitHub)" <gi...@apache.org> on 2025/11/11 15:30:19 UTC
[PR] Add blog post regarding CASE work [datafusion-site]
pepijnve opened a new pull request, #122:
URL: https://github.com/apache/datafusion-site/pull/122
Covers the work done as part of https://github.com/apache/datafusion/issues/18075
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3517464123
Current state is a very crude first draft written by Claude AI
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2726657227
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Problem 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed operations that are very well-optimized but still not cost free to execute:
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` (all columns) for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these steps allocates new arrays and shuffles a lot of data around.
+
+### Problem 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all columns 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Improvements
+
+### Optimization 1: Short-Circuit Early Exit (commit 7c215ed)
+
+The first optimization added early exit logic to the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+**Impact**: For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+### Optimization 2: Optimized Result Merging (commit e9431fc)
+
+The second optimization fundamentally restructured how partial results are merged.
+Instead of using `zip()` after each branch to merge results into an output array, we now:
+
+1. Maintain the subset of rows still needing evaluation across loop iterations
+2. Filter the batch progressively as rows are matched
+3. Build an index structure that tracks which branch produced each row's result
+4. Perform a single merge operation at the end
+
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+When a branch matches a number of rows, instead of immediately merging with `zip()`, we:
+1. Store the partial result array
+2. Mark the cells corresponding to each row in an indices array as needing to take one value from the partial result array
+
+In the example below, three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+The final result array is obtained by running the arrays through the merge operation.
+
+```aiignore
+┌───────────┐ ┌─────────┐ ┌─────────┐
+│┌─────────┐│ │ None │ │ NULL │
+││ A ││ ├─────────┤ ├─────────┤
+│└─────────┘│ │ 1 │ │ B │
+│┌─────────┐│ ├─────────┤ ├─────────┤
+││ B ││ │ 0 │ merge_n(values, indices) │ A │
+│└─────────┘│ ├─────────┤ ─────────────────────────▶ ├─────────┤
+│┌─────────┐│ │ None │ │ NULL │
+││ C ││ ├─────────┤ ├─────────┤
+│├─────────┤│ │ 2 │ │ C │
+││ D ││ ├─────────┤ ├─────────┤
+│└─────────┘│ │ 2 │ │ D │
+└───────────┘ └─────────┘ └─────────┘
+ arrays indices result
+```
+
+The main benefits of this merge operation are that the `scatter` step is eleminated enitrely, and instead of requiring
+a `zip` per branch only a single `merge_n` is done.
+
+Besides more efficient merging, we also maintain a progressively filtered `remainder_batch`.
+
+```rust
+let mut remainder_batch = Cow::Borrowed(batch);
+let mut remainder_rows = UInt32Array::from_iter_values(0..batch.num_rows());
+
+for branch in branches {
+ // Evaluate on progressively smaller batch
+ let when_value = evaluate_on(&remainder_batch)?;
+
+ // Filter remainder_batch for next iteration
+ let next_filter = create_filter(¬(&when_value)?);
+ remainder_batch = Cow::Owned(filter_record_batch(&remainder_batch, &next_filter)?);
+ remainder_rows = filter_array(&remainder_rows, &next_filter)?;
+}
+```
+
+As rows are matched, the `remainder_batch` shrinks, making subsequent filter operations a bit faster.
+
+**Impact**: This eliminates N-1 merge operations and makes filtering progressively cheaper as branches match rows.
+
+This new operation was initially developed specifically for DataFusion's `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+### Optimization 3: Column Projection (commit c3e49fb)
+
+The third optimization addresses the "filtering unused columns" problem through projection.
+Before evaluating a CASE expression, we:
+
+1. Analyze all WHEN/THEN/ELSE expressions to find referenced columns
+2. Build a projection vector containing only those column indices
+3. Derive new versions of the expressions with updated column references
+
+For example, if the original CASE references columns at indices `[1, 5, 8]` in a 20-column batch:
+- Project the batch to a 3-column batch with those columns
+- Rewrite expressions from `col@1, col@5, col@8` to `col@0, col@1, col@2`
+- Evaluate using the projected batch
+
+This is encapsulated in a `ProjectedCaseBody` structure:
+
+```rust
+struct ProjectedCaseBody {
+ projection: Vec<usize>, // [1, 5, 8]
+ body: CaseBody, // Expressions with rewritten column indices
+}
+```
+
+The projection logic is only applied when it would be beneficial (i.e., when the number of used columns is lower than the total number of columns in the batch).
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case (commit 32d2618)
+
+The final optimization targets a common pattern: `CASE WHEN condition THEN expr1 ELSE expr2 END`.
+
+Previously, this used the specialized `ExpressionOrExpression` fast path, but still used `evaluate_selection()` which produces scattered results:
+
+```rust
+// Old approach: produces scattered array with batch.num_rows() elements
+let then_value = then_expr.evaluate_selection(batch, &when_value)?
+ .into_array(batch.num_rows())?;
+```
+
+The new approach filters the batch first, then evaluates:
+
+```rust
+// New approach: filter to only matching rows
+let when_filter = create_filter(&when_value);
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?; // Produces compact array
+
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with a custom merge function that doesn't require pre-scattered inputs:
+
+```rust
+fn merge(mask: &BooleanArray, truthy: ColumnarValue, falsy: ColumnarValue) -> ArrayRef {
+ // Interleave truthy and falsy values directly using mask
+ // without requiring pre-alignment
+}
+```
+
+**Impact**: This eliminates unnecessary scatter operations and memory allocations for one of the most common CASE expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+## Summary
+
+Through four targeted optimizations, we've transformed CASE expression evaluation from a simple but inefficient implementation to a highly optimized one that:
+
+1. **Exits early** when all rows are matched
+2. **Defers merging** until the end with a single interleave operation
+3. **Projects columns** to avoid filtering unused data
+4. **Eliminates scatter** operations in common patterns
+
+These improvements compound: a CASE expression on a wide table with multiple branches and early matches benefits from all four optimizations simultaneously.
Review Comment:
Agreed, this is AI generated filler content. I don't like it either.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3677952025
After reading the excellent consecutive repartitioning post I think there might be some more polishing work to do on this one.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3800016742
@rluvaton I've added a section on your hash table work in here as well. Any feedback on that would also be welcome. Let me know if you would like me to add something to the acknowledgments section for this part (see https://datafusion.apache.org/blog/2025/12/15/avoid-consecutive-repartitions/#acknowledgements for an example).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2736115577
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
Ok. I wrote that based on the fact that hash table lookup is `O(1)` on average. The looping alternative is `O(n)` in the number of branches.
Do you agree with that? Happy to reword the paragraph if you disagree. Suggestions are welcome.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3835271087
And the blog is live: https://datafusion.apache.org/blog/2026/02/02/datafusion_case/
Thank you @pepijnve and @rluvaton ✍️ ❤️
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3801075776
> I would appreciate if each optimization would mention the author name, the GitHub handle and the PRs that added that optimization.
Acknowledgments added. Any employer reference that needs to be added as well?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "rluvaton (via GitHub)" <gi...@apache.org>.
rluvaton commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3801011458
> @rluvaton I've added a section on your hash table work in here as well. Any feedback on that would also be welcome. Let me know if you would like me to add something to the acknowledgments section for this part (see [datafusion.apache.org/blog/2025/12/15/avoid-consecutive-repartitions#acknowledgements](https://datafusion.apache.org/blog/2025/12/15/avoid-consecutive-repartitions/#acknowledgements) for an example).
I would appreciate if each optimization would mention the author name, the GitHub handle and the PRs that added that optimization.
for example:
```
Added by Raz Luvaton ([`@rluvaton`](https://github.com/rluvaton)) in [#18183](https://github.com/apache/datafusion/pull/18183)
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3835260242
I updated the blog date to today, 2026-02-02
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "rluvaton (via GitHub)" <gi...@apache.org>.
rluvaton commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2734090739
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
The computation is dependent on the number of when expression, having 1K when will be slower than 3.
What is independent is the values that are being matched, so there is no need to order the case when based on most likely to match first, which will also mean that it is independent of the data and provide stable performance.
Unlike before, if all the data matched the last when, it would be slower than all data matched the first when. With this implementation both cases will take the same time which is good for changing data and when no prior knowledge exist
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3836699618
Logged in #142
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2640113027
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
Review Comment:
I'll see if I can polish the quick sketches I made when I was working on this. Copying them here as reference.
Is something along these lines what you have in mind?
<img width="2510" height="2256" alt="image" src="https://github.com/user-attachments/assets/312d1264-0abc-426f-ae8d-819835d2632f" />
<img width="2092" height="2374" alt="image" src="https://github.com/user-attachments/assets/aa1e5f5f-0877-4617-8fa4-81d9bbdc5f17" />
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "rluvaton (via GitHub)" <gi...@apache.org>.
rluvaton commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2728685836
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,456 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Observation 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Observation 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Observation 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation in after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrated how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *, CASE WHEN country = 'USA' THEN state ELSE country END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value input to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions that use non-constant expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+During evaluation, rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+
+## Results
+
+The degree to which the performance optimizations described in this post will benefit your queries is highly dependent on both your data and your queries.
+To give some idea of the impact we ran the following query on the TPC_H `orders` table with a scale factory of 100:
+
+```sql
+SELECT
+ *,
+ case o_orderstatus
+ when 'O' then 'ordered'
+ when 'F' then 'filled'
+ when 'P' then 'pending'
+ else 'other'
+ end
Review Comment:
I think you should divide the results to 2, one for the lookup case and one for the other optimizations, the other optimizations should have a query that will go through 1 to 4 optimizations to better show the impact.
as optimizations 1 to 4 are not being used if I recall correctly for lookup based case when.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2746220792
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
> Still seems quite reasonable to me then to state that for all practical purposes
I agree that the differences in practice are not likely to be noticeable. Thank you 🙏
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "rluvaton (via GitHub)" <gi...@apache.org>.
rluvaton commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2734090739
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
The computation **is** dependent on the number of WHEN expressions, having 1K WHEN will be slower than 3.
What is independent is the values that are being matched, so there is no need to order the CASE WHEN based on most likely to match first, which will also mean that it is independent of the data and provide stable performance.
Unlike before, if all the data matched the last WHEN, it would be slower than all data matched the first WHEN. With this implementation both cases will take the same time which is good for changing data and when no prior knowledge exist
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "rluvaton (via GitHub)" <gi...@apache.org>.
rluvaton commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2734090739
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
The computation **is** dependent on the number of WHEN expressions, having 1K WHEN will be slower than 3.
What is independent are the input values that are being matched, so there is no need to order the CASE WHEN based on most likely to match first, which will also mean that it is independent of the data and provide stable performance.
Unlike before, if all the data matched the last WHEN, it would be slower than all data matched the first WHEN. With this implementation both cases will take the same time which is good for changing data and when no prior knowledge exist
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2732441232
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,468 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Observation 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Observation 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Observation 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
Review Comment:
Thanks for spotting that. Fixed.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3677737551
@alamb I finally found some time to get through what the bot produced. I think this is now in a good enough shape for a first review.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3733442187
With the impending release of DataFusion 52.0.0
- https://github.com/apache/datafusion/issues/18566
I am hoping we can publish blog early next week (Jan 12, 13) so that we can then refer to it in the DataFusion 52 release blog
- https://github.com/apache/datafusion/issues/19691
I think this one is looking pretty good, though I had been dreaming about more diagrams, I don't think they are requred.
Do you think this is ready to go from your perspective @pepijnve ? Would you mind if I took a pass trhough to clean up some formatting (like the title)?
<img width="1510" height="483" alt="Screenshot 2026-01-10 at 2 57 44 PM" src="https://github.com/user-attachments/assets/4be3bfa7-49fc-4564-ab0e-bb1a2ba54bb3" />
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2746100100
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
I was curious to know what the actual difference is so I ran some queries.
First one was
```
select *, case o_orderstatus when 'O' then 'ordered' when 'F' then 'filled' when 'P' then 'pending' else 'other' end from order;
```
and then
```
select *, case o_orderstatus when 'O' then 'ordered' when 'F' then 'filled' when 'P' then 'pending' <more whens> else 'other' end from order;
```
where `more whens` is `WHEN 'AA' THEN 'aa'` to WHEN 'ZZ' THEN 'zz'. So 26^2 extra whens. The runtime for those two queries is basically identical. The longer query ended up being faster than the shorter one a couple of times. Still seems quite reasonable to me then to state that for all practical purposes the number of `when` branches has no impact on query performance especially in contrast to the alternative that needs to iterate over the branches.
Not going to die on this hill, so I'll go with @alamb's suggestion, but stating 'does not grow significantly with' is in a way just a confusing. It suggests that more `when` branches will be slower but not by much, but in reality there's no growth at all so you really don't need to care about this.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2731489110
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,468 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
Review Comment:
```suggestion
DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
```
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,468 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
Review Comment:
this is a great diagram
<img width="1170" height="775" alt="Image" src="https://github.com/user-attachments/assets/a0d80d64-e336-4ab7-83dd-1d74c7f24ba7" />
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,468 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
Review Comment:
```suggestion
So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
```
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,468 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Observation 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Observation 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Observation 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
Review Comment:
I had a question about this diagram:
<img width="1129" height="683" alt="Image" src="https://github.com/user-attachments/assets/d6bcefd3-1561-4f3a-bb8e-7b8f60c82832" />
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,468 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Observation 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Observation 2: Repeated Filtering, Scattering, and Merging
Review Comment:
```suggestion
### Observation 2: Optimize Repeated Filtering, Scattering, and Merging
```
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,468 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Observation 1: No Early Exit
Review Comment:
```suggestion
### Observation 1: Early Exit
```
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,468 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
Review Comment:
While this is accurate, I suggest titling this section something more general (so it is easier for people to see this information is general rather than DataFusion specific). I also have a few more wording suggestions below to frame this in terms of "how to optimize case" rather than "what was bad about the old version"
For example:
```suggestion
## Basic `CASE` Evaluation
```
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,468 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Observation 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Observation 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Observation 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation in after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrated how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *, CASE WHEN country = 'USA' THEN state ELSE country END AS region
+FROM mailing_address
+```
Review Comment:
It might be more consistent to format the CASE vertically as in other examples
```suggestion
Suppose we have a query like:
```sql
SELECT *,
CASE
WHEN country = 'USA' THEN state
ELSE country
END AS region
FROM mailing_address
```
```
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,468 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Observation 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Observation 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Observation 3: Filtering Unused Columns
Review Comment:
```suggestion
### Observation 3: Filter only Necessary Columns
```
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,468 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Observation 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Observation 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Observation 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation in after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrated how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *, CASE WHEN country = 'USA' THEN state ELSE country END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value input to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions that use non-constant expressions for both `WHEN` and `THEN`.
Review Comment:
```suggestion
Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
```
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,468 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Observation 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Observation 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Observation 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation in after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrated how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *, CASE WHEN country = 'USA' THEN state ELSE country END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value input to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions that use non-constant expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE expression.
+
+This optimization was implemented by Raz Luvaton ([`@rluvaton`](https://github.com/rluvaton)) in [PR #18183](https://github.com/apache/datafusion/pull/18183)
+
+## Results
+
+The degree to which the performance optimizations described in this post will benefit your queries is highly dependent on both your data and your queries.
+To give some idea of the impact we ran the following query on the TPC_H `orders` table with a scale factory of 100:
+
+```sql
+SELECT
+ *,
+ case o_orderstatus
+ when 'O' then 'ordered'
+ when 'F' then 'filled'
+ when 'P' then 'pending'
+ else 'other'
+ end
+from orders
+```
+
+This query was first run with DataFusion 50.0.0 to get a baseline measurement.
+The same query was then run with each optimization applied in turn.
+The recorded times are presented as the blue series in the chart below.
+The green series shows the time measurement for the `SELECT * from orders` to give an idea of the cost the addition of a `CASE` expression in a query incurs.
+All measurements were made with a target partition count of `1`.
+
+<figure>
+<img src="/blog/images/case/results.png" alt="Performance measurements chart" width="100%" class="img-responsive">
+<figcaption>Performance measurements</figcaption>
+</figure>
+
+What can be seen in the chart is that the effect of the various optimizations compounds up to the `project` measurement.
+Up to that point these results are applicable to any `CASE` expression.
+The final improvement in the `hash` measurement is only applicable to simple `CASE` expressions with constant `WHEN` and `THEN` expressions.
+
+The cumulative effect of these optimizations is a 63-71% reduction in CPU time spent evaluating `CASE` expressions compared to the baseline.
+
+## Summary
+
+Through a number of targeted optimizations, we've transformed `CASE` expression evaluation from a simple, but unoptimized implementation to a highly optimized one.
+The optimizations described in this post compound: a `CASE` expression on a wide table with multiple branches and early matches benefits from all four optimizations simultaneously.
+The result is significantly reduced CPU time and memory allocation in a SQL constructs that are essential for ETL-like queries.
Review Comment:
I always suggest ending the posts with the "come join us in DataFusion" blurb.
For example, perhaps:
```suggestion
The result is significantly reduced CPU time and memory allocation in a SQL constructs that are essential for ETL-like queries.
## About DataFusion
[Apache DataFusion] is an extensible query engine toolkit, written
in Rust, that uses [Apache Arrow] as its in-memory format. DataFusion and
similar technology are part of the next generation “Deconstructed Database”
architectures, where new systems are built on a foundation of fast, modular
components, rather than as a single tightly integrated system.
The [DataFusion community] is always looking for new contributors to help
improve the project. If you are interested in learning more about how query
execution works, help document or improve the DataFusion codebase, or just try
it out, we would love for you to join us.
[Apache Arrow]: https://arrow.apache.org/
[Apache DataFusion]: https://datafusion.apache.org/
[DataFusion community]: https://datafusion.apache.org/contributor-guide/communication.html
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3805722303
@alamb all comments processed. TYVM for the editorial work. After staring at the same paragraph for too long I start to miss these kinds of details.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "rluvaton (via GitHub)" <gi...@apache.org>.
rluvaton commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2728700357
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,456 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Observation 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Observation 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Observation 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation in after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrated how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *, CASE WHEN country = 'USA' THEN state ELSE country END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value input to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions that use non-constant expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+During evaluation, rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
Review Comment:
Another added benefit is that the performance of the lookup based CASE WHEN does not depend on the data.
This means that if most of the rows match the last expression the performance will be the same as if they matched the first (which makes the query time more stable and predictable)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2736434794
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
🤔 I think for the most part you can think of it as being independent of the number of branches, can’t you? I mean is there a meaningful (measurable) time delta between 3 and 1k branches for the hash table evaluation strategy?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "rluvaton (via GitHub)" <gi...@apache.org>.
rluvaton commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2737187965
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
no, but I would update it that to be negligible instead of independent for being more accurate
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "rluvaton (via GitHub)" <gi...@apache.org>.
rluvaton commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2736373247
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
yes I agree with that. but I still think that we should not write that as it is confusing and instead write about the matched value like I wrote before
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3823835636
> Is that ok with you @pepijnve ?
All good for me
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2640012521
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
Review Comment:
That's what I get for letting AI draft stuff. I think I can fill that in with a description of @rluvaton's upcoming work.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2639606416
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
Review Comment:
I would personally phrase this like "tests that value against expressions (typically constants)" as the SQL doesn't say anything specific about constants
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
Review Comment:
I think it would be good to start this post off with some sort of quantification / visual of "how much faster is CASE after these optimizations"
Maybe either a chart like on https://datafusion.apache.org/blog/output/2025/09/29/datafusion-50.0.0/
<img width="858" height="506" alt="Image" src="https://github.com/user-attachments/assets/26968a55-32d3-4926-a4cc-6956d24d9f03" />
Or a table
It would also be cool to have an "ablation" version (aka measure the performance after each additional optimization was added -- like a chart that shows progressive improvement).
Maybe we could use the "average runtime of the case benchmark"? I can look into generating this if you like
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
Review Comment:
I think it might also make sense to mention that DataFusion (now) also rewrites all other conditional expressions to CASE (like `COALESCE`, `IF`, etc) so that these optimizations are now widely used
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
Review Comment:
FWIW this is a really nice clear and succinct CASE introduction ❤️
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
Review Comment:
```suggestion
While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
```
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Problem 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed operations that are very well-optimized but still not cost free to execute:
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` (all columns) for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these steps allocates new arrays and shuffles a lot of data around.
+
+### Problem 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all columns 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Improvements
+
+### Optimization 1: Short-Circuit Early Exit (commit 7c215ed)
+
+The first optimization added early exit logic to the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+**Impact**: For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+### Optimization 2: Optimized Result Merging (commit e9431fc)
+
+The second optimization fundamentally restructured how partial results are merged.
+Instead of using `zip()` after each branch to merge results into an output array, we now:
+
+1. Maintain the subset of rows still needing evaluation across loop iterations
+2. Filter the batch progressively as rows are matched
+3. Build an index structure that tracks which branch produced each row's result
+4. Perform a single merge operation at the end
+
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+When a branch matches a number of rows, instead of immediately merging with `zip()`, we:
+1. Store the partial result array
+2. Mark the cells corresponding to each row in an indices array as needing to take one value from the partial result array
+
+In the example below, three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+The final result array is obtained by running the arrays through the merge operation.
+
+```aiignore
+┌───────────┐ ┌─────────┐ ┌─────────┐
+│┌─────────┐│ │ None │ │ NULL │
+││ A ││ ├─────────┤ ├─────────┤
+│└─────────┘│ │ 1 │ │ B │
+│┌─────────┐│ ├─────────┤ ├─────────┤
+││ B ││ │ 0 │ merge_n(values, indices) │ A │
+│└─────────┘│ ├─────────┤ ─────────────────────────▶ ├─────────┤
+│┌─────────┐│ │ None │ │ NULL │
+││ C ││ ├─────────┤ ├─────────┤
+│├─────────┤│ │ 2 │ │ C │
+││ D ││ ├─────────┤ ├─────────┤
+│└─────────┘│ │ 2 │ │ D │
+└───────────┘ └─────────┘ └─────────┘
+ arrays indices result
+```
+
+The main benefits of this merge operation are that the `scatter` step is eleminated enitrely, and instead of requiring
+a `zip` per branch only a single `merge_n` is done.
+
+Besides more efficient merging, we also maintain a progressively filtered `remainder_batch`.
+
+```rust
+let mut remainder_batch = Cow::Borrowed(batch);
+let mut remainder_rows = UInt32Array::from_iter_values(0..batch.num_rows());
+
+for branch in branches {
+ // Evaluate on progressively smaller batch
+ let when_value = evaluate_on(&remainder_batch)?;
+
+ // Filter remainder_batch for next iteration
+ let next_filter = create_filter(¬(&when_value)?);
+ remainder_batch = Cow::Owned(filter_record_batch(&remainder_batch, &next_filter)?);
+ remainder_rows = filter_array(&remainder_rows, &next_filter)?;
+}
+```
+
+As rows are matched, the `remainder_batch` shrinks, making subsequent filter operations a bit faster.
+
+**Impact**: This eliminates N-1 merge operations and makes filtering progressively cheaper as branches match rows.
+
+This new operation was initially developed specifically for DataFusion's `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+### Optimization 3: Column Projection (commit c3e49fb)
+
+The third optimization addresses the "filtering unused columns" problem through projection.
+Before evaluating a CASE expression, we:
+
+1. Analyze all WHEN/THEN/ELSE expressions to find referenced columns
+2. Build a projection vector containing only those column indices
+3. Derive new versions of the expressions with updated column references
+
+For example, if the original CASE references columns at indices `[1, 5, 8]` in a 20-column batch:
Review Comment:
I can imagine a nice diagram for this section too where it shows rewriting the expression to only refer to the three columns, and then rewriting the input to a new three column form. Not necessary I am just thinking
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Problem 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed operations that are very well-optimized but still not cost free to execute:
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` (all columns) for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these steps allocates new arrays and shuffles a lot of data around.
+
+### Problem 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all columns 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Improvements
+
+### Optimization 1: Short-Circuit Early Exit (commit 7c215ed)
+
+The first optimization added early exit logic to the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+**Impact**: For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+### Optimization 2: Optimized Result Merging (commit e9431fc)
+
+The second optimization fundamentally restructured how partial results are merged.
+Instead of using `zip()` after each branch to merge results into an output array, we now:
+
+1. Maintain the subset of rows still needing evaluation across loop iterations
+2. Filter the batch progressively as rows are matched
+3. Build an index structure that tracks which branch produced each row's result
+4. Perform a single merge operation at the end
+
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+When a branch matches a number of rows, instead of immediately merging with `zip()`, we:
+1. Store the partial result array
+2. Mark the cells corresponding to each row in an indices array as needing to take one value from the partial result array
+
+In the example below, three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+The final result array is obtained by running the arrays through the merge operation.
+
+```aiignore
+┌───────────┐ ┌─────────┐ ┌─────────┐
+│┌─────────┐│ │ None │ │ NULL │
+││ A ││ ├─────────┤ ├─────────┤
+│└─────────┘│ │ 1 │ │ B │
+│┌─────────┐│ ├─────────┤ ├─────────┤
+││ B ││ │ 0 │ merge_n(values, indices) │ A │
+│└─────────┘│ ├─────────┤ ─────────────────────────▶ ├─────────┤
+│┌─────────┐│ │ None │ │ NULL │
+││ C ││ ├─────────┤ ├─────────┤
+│├─────────┤│ │ 2 │ │ C │
+││ D ││ ├─────────┤ ├─────────┤
+│└─────────┘│ │ 2 │ │ D │
+└───────────┘ └─────────┘ └─────────┘
+ arrays indices result
+```
+
+The main benefits of this merge operation are that the `scatter` step is eleminated enitrely, and instead of requiring
+a `zip` per branch only a single `merge_n` is done.
+
+Besides more efficient merging, we also maintain a progressively filtered `remainder_batch`.
+
+```rust
+let mut remainder_batch = Cow::Borrowed(batch);
+let mut remainder_rows = UInt32Array::from_iter_values(0..batch.num_rows());
+
+for branch in branches {
+ // Evaluate on progressively smaller batch
+ let when_value = evaluate_on(&remainder_batch)?;
+
+ // Filter remainder_batch for next iteration
+ let next_filter = create_filter(¬(&when_value)?);
+ remainder_batch = Cow::Owned(filter_record_batch(&remainder_batch, &next_filter)?);
+ remainder_rows = filter_array(&remainder_rows, &next_filter)?;
+}
+```
+
+As rows are matched, the `remainder_batch` shrinks, making subsequent filter operations a bit faster.
+
+**Impact**: This eliminates N-1 merge operations and makes filtering progressively cheaper as branches match rows.
+
+This new operation was initially developed specifically for DataFusion's `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+### Optimization 3: Column Projection (commit c3e49fb)
+
+The third optimization addresses the "filtering unused columns" problem through projection.
+Before evaluating a CASE expression, we:
+
+1. Analyze all WHEN/THEN/ELSE expressions to find referenced columns
+2. Build a projection vector containing only those column indices
+3. Derive new versions of the expressions with updated column references
+
+For example, if the original CASE references columns at indices `[1, 5, 8]` in a 20-column batch:
+- Project the batch to a 3-column batch with those columns
+- Rewrite expressions from `col@1, col@5, col@8` to `col@0, col@1, col@2`
+- Evaluate using the projected batch
+
+This is encapsulated in a `ProjectedCaseBody` structure:
+
+```rust
+struct ProjectedCaseBody {
+ projection: Vec<usize>, // [1, 5, 8]
+ body: CaseBody, // Expressions with rewritten column indices
+}
+```
+
+The projection logic is only applied when it would be beneficial (i.e., when the number of used columns is lower than the total number of columns in the batch).
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case (commit 32d2618)
+
+The final optimization targets a common pattern: `CASE WHEN condition THEN expr1 ELSE expr2 END`.
+
+Previously, this used the specialized `ExpressionOrExpression` fast path, but still used `evaluate_selection()` which produces scattered results:
+
+```rust
+// Old approach: produces scattered array with batch.num_rows() elements
+let then_value = then_expr.evaluate_selection(batch, &when_value)?
+ .into_array(batch.num_rows())?;
+```
+
+The new approach filters the batch first, then evaluates:
+
+```rust
+// New approach: filter to only matching rows
+let when_filter = create_filter(&when_value);
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?; // Produces compact array
+
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with a custom merge function that doesn't require pre-scattered inputs:
+
+```rust
+fn merge(mask: &BooleanArray, truthy: ColumnarValue, falsy: ColumnarValue) -> ArrayRef {
+ // Interleave truthy and falsy values directly using mask
+ // without requiring pre-alignment
+}
+```
+
+**Impact**: This eliminates unnecessary scatter operations and memory allocations for one of the most common CASE expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+## Summary
+
+Through four targeted optimizations, we've transformed CASE expression evaluation from a simple but inefficient implementation to a highly optimized one that:
+
+1. **Exits early** when all rows are matched
+2. **Defers merging** until the end with a single interleave operation
+3. **Projects columns** to avoid filtering unused data
+4. **Eliminates scatter** operations in common patterns
+
+These improvements compound: a CASE expression on a wide table with multiple branches and early matches benefits from all four optimizations simultaneously.
Review Comment:
I think it would be stronger here to refer to the actual performance numbers (see suggestion on introduction) rather than generalizations.
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Problem 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed operations that are very well-optimized but still not cost free to execute:
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` (all columns) for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these steps allocates new arrays and shuffles a lot of data around.
+
+### Problem 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all columns 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Improvements
+
+### Optimization 1: Short-Circuit Early Exit (commit 7c215ed)
Review Comment:
The same suggestion applies to the next few sections too
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Problem 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed operations that are very well-optimized but still not cost free to execute:
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` (all columns) for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these steps allocates new arrays and shuffles a lot of data around.
+
+### Problem 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all columns 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Improvements
+
+### Optimization 1: Short-Circuit Early Exit (commit 7c215ed)
Review Comment:
Having the commit in the title was a little strange (as it wasn't a link)
If you want to leave the commit, I recommend removing it from the title and adding a sentence w/ a link
So instead of
> The first optimization added early exit logic to the evaluation loop:
Maybe something like
> The first optimization (commit [7c215ed]) added early exit logic to the evaluation loop:
[7c215ed]: https://github.com/apache/datafusion/commit/7c215ed8dc850bcc2632fa5183ccdc58dd6491e7
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
Review Comment:
I recommend also adding the term SQL to make the title more specific
```suggestion
title: Optimizing SQL `CASE` Expression Evaluation
```
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
Review Comment:
I didn't see any section about future improvements 🤔
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
Review Comment:
I suggest referring to DataFusion with its full name on first reference (and a link so people can go back there when needed):
```suggestion
Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
```
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
Review Comment:
putting on my "emphasize the positive" hats, I suggest using the term "Opportunity" (so "Opportunity 1: No Early Exit" rather than problem :)
Likewise for the steps below
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Problem 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed operations that are very well-optimized but still not cost free to execute:
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` (all columns) for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
Review Comment:
I think we need whitespace between these items so that the pelican blog software renders them on different lines:
<img width="1130" height="312" alt="Image" src="https://github.com/user-attachments/assets/758df255-c5d9-4fa6-aaac-b9009ca56357" />
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
Review Comment:
The headings seemed strange to me -- I suggest demote them all one level:
<img width="435" height="914" alt="Image" src="https://github.com/user-attachments/assets/285d3e1d-995a-48ca-b098-376781b1b8a0" />
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
Review Comment:
I recommend adding links to these functions,
* PhysicalExpr::evaluate_selection: https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection
* `zip`: https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
Review Comment:
I think this section would be easier to follow if it had a diagram showing the steps -- I am not sure if you have a visual in your mind but we might be able to come up with something that visually shows the improvements the blogs describes
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
Review Comment:
I recommend also adding this context to the rust code itself, as comments on the appropriate line
A link / description of what `scatter` does would also be helpful
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Problem 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed operations that are very well-optimized but still not cost free to execute:
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` (all columns) for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these steps allocates new arrays and shuffles a lot of data around.
+
+### Problem 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all columns 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Improvements
+
+### Optimization 1: Short-Circuit Early Exit (commit 7c215ed)
+
+The first optimization added early exit logic to the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+**Impact**: For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+### Optimization 2: Optimized Result Merging (commit e9431fc)
+
+The second optimization fundamentally restructured how partial results are merged.
+Instead of using `zip()` after each branch to merge results into an output array, we now:
+
+1. Maintain the subset of rows still needing evaluation across loop iterations
+2. Filter the batch progressively as rows are matched
+3. Build an index structure that tracks which branch produced each row's result
+4. Perform a single merge operation at the end
+
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+When a branch matches a number of rows, instead of immediately merging with `zip()`, we:
+1. Store the partial result array
+2. Mark the cells corresponding to each row in an indices array as needing to take one value from the partial result array
+
+In the example below, three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+The final result array is obtained by running the arrays through the merge operation.
+
+```aiignore
+┌───────────┐ ┌─────────┐ ┌─────────┐
+│┌─────────┐│ │ None │ │ NULL │
+││ A ││ ├─────────┤ ├─────────┤
+│└─────────┘│ │ 1 │ │ B │
+│┌─────────┐│ ├─────────┤ ├─────────┤
+││ B ││ │ 0 │ merge_n(values, indices) │ A │
+│└─────────┘│ ├─────────┤ ─────────────────────────▶ ├─────────┤
+│┌─────────┐│ │ None │ │ NULL │
+││ C ││ ├─────────┤ ├─────────┤
+│├─────────┤│ │ 2 │ │ C │
+││ D ││ ├─────────┤ ├─────────┤
+│└─────────┘│ │ 2 │ │ D │
+└───────────┘ └─────────┘ └─────────┘
+ arrays indices result
+```
+
+The main benefits of this merge operation are that the `scatter` step is eleminated enitrely, and instead of requiring
+a `zip` per branch only a single `merge_n` is done.
+
+Besides more efficient merging, we also maintain a progressively filtered `remainder_batch`.
+
+```rust
+let mut remainder_batch = Cow::Borrowed(batch);
+let mut remainder_rows = UInt32Array::from_iter_values(0..batch.num_rows());
+
+for branch in branches {
+ // Evaluate on progressively smaller batch
+ let when_value = evaluate_on(&remainder_batch)?;
+
+ // Filter remainder_batch for next iteration
+ let next_filter = create_filter(¬(&when_value)?);
+ remainder_batch = Cow::Owned(filter_record_batch(&remainder_batch, &next_filter)?);
+ remainder_rows = filter_array(&remainder_rows, &next_filter)?;
+}
+```
+
+As rows are matched, the `remainder_batch` shrinks, making subsequent filter operations a bit faster.
+
+**Impact**: This eliminates N-1 merge operations and makes filtering progressively cheaper as branches match rows.
+
+This new operation was initially developed specifically for DataFusion's `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+### Optimization 3: Column Projection (commit c3e49fb)
+
+The third optimization addresses the "filtering unused columns" problem through projection.
+Before evaluating a CASE expression, we:
+
+1. Analyze all WHEN/THEN/ELSE expressions to find referenced columns
+2. Build a projection vector containing only those column indices
+3. Derive new versions of the expressions with updated column references
+
+For example, if the original CASE references columns at indices `[1, 5, 8]` in a 20-column batch:
+- Project the batch to a 3-column batch with those columns
+- Rewrite expressions from `col@1, col@5, col@8` to `col@0, col@1, col@2`
+- Evaluate using the projected batch
+
+This is encapsulated in a `ProjectedCaseBody` structure:
+
+```rust
+struct ProjectedCaseBody {
+ projection: Vec<usize>, // [1, 5, 8]
+ body: CaseBody, // Expressions with rewritten column indices
+}
+```
+
+The projection logic is only applied when it would be beneficial (i.e., when the number of used columns is lower than the total number of columns in the batch).
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case (commit 32d2618)
+
+The final optimization targets a common pattern: `CASE WHEN condition THEN expr1 ELSE expr2 END`.
+
+Previously, this used the specialized `ExpressionOrExpression` fast path, but still used `evaluate_selection()` which produces scattered results:
+
+```rust
+// Old approach: produces scattered array with batch.num_rows() elements
+let then_value = then_expr.evaluate_selection(batch, &when_value)?
+ .into_array(batch.num_rows())?;
+```
+
+The new approach filters the batch first, then evaluates:
+
+```rust
+// New approach: filter to only matching rows
+let when_filter = create_filter(&when_value);
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?; // Produces compact array
+
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with a custom merge function that doesn't require pre-scattered inputs:
+
+```rust
+fn merge(mask: &BooleanArray, truthy: ColumnarValue, falsy: ColumnarValue) -> ArrayRef {
+ // Interleave truthy and falsy values directly using mask
+ // without requiring pre-alignment
+}
+```
+
+**Impact**: This eliminates unnecessary scatter operations and memory allocations for one of the most common CASE expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+## Summary
+
+Through four targeted optimizations, we've transformed CASE expression evaluation from a simple but inefficient implementation to a highly optimized one that:
+
+1. **Exits early** when all rows are matched
+2. **Defers merging** until the end with a single interleave operation
+3. **Projects columns** to avoid filtering unused data
+4. **Eliminates scatter** operations in common patterns
+
+These improvements compound: a CASE expression on a wide table with multiple branches and early matches benefits from all four optimizations simultaneously.
+The result is significantly reduced CPU time and memory allocation in one of SQL's most frequently used constructs.
+
+All of these changes maintain full compatibility with DataFusion's existing semantics and are covered by the existing test suite, demonstrating that performance optimization doesn't have to compromise correctness or require API changes.
Review Comment:
I think this is a fairly obvious conclusion so I think we could remove it without much loss of content
Maybe we could also throw a shortened version of the standard footer / how to get involved sections?
https://datafusion.apache.org/blog/output/2025/11/25/datafusion-51.0.0/#about-datafusion
https://datafusion.apache.org/blog/output/2025/11/25/datafusion-51.0.0/#how-to-get-involved
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Problem 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed operations that are very well-optimized but still not cost free to execute:
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` (all columns) for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these steps allocates new arrays and shuffles a lot of data around.
+
+### Problem 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all columns 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Improvements
+
+### Optimization 1: Short-Circuit Early Exit (commit 7c215ed)
+
+The first optimization added early exit logic to the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+**Impact**: For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+### Optimization 2: Optimized Result Merging (commit e9431fc)
+
+The second optimization fundamentally restructured how partial results are merged.
+Instead of using `zip()` after each branch to merge results into an output array, we now:
+
+1. Maintain the subset of rows still needing evaluation across loop iterations
+2. Filter the batch progressively as rows are matched
+3. Build an index structure that tracks which branch produced each row's result
+4. Perform a single merge operation at the end
+
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+When a branch matches a number of rows, instead of immediately merging with `zip()`, we:
+1. Store the partial result array
+2. Mark the cells corresponding to each row in an indices array as needing to take one value from the partial result array
+
+In the example below, three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+The final result array is obtained by running the arrays through the merge operation.
+
+```aiignore
Review Comment:
what does 'aiignore' mean ?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2735758912
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
What makes it significantly slower for 1k elements? Isn’t that just the size of the hash table that grows?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "rluvaton (via GitHub)" <gi...@apache.org>.
rluvaton commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2736059271
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
yes, so saying that it is independent on the number of when expression is not true
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3807603852
I think this blog is great. I'll maybe do a call for some more review and then we can plan to publish it later this week or early next week
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3835307918
🎉
@alamb in the meantime I got some feedback from our tech writer. Still need to process those. Are we ok with making edits to the blog post still or is it set in stone? I don't think there are any substantial contents changes, some small stylistic things.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2639587916
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Problem 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed operations that are very well-optimized but still not cost free to execute:
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` (all columns) for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these steps allocates new arrays and shuffles a lot of data around.
+
+### Problem 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all columns 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Improvements
+
+### Optimization 1: Short-Circuit Early Exit (commit 7c215ed)
+
+The first optimization added early exit logic to the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+**Impact**: For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+### Optimization 2: Optimized Result Merging (commit e9431fc)
+
+The second optimization fundamentally restructured how partial results are merged.
+Instead of using `zip()` after each branch to merge results into an output array, we now:
+
+1. Maintain the subset of rows still needing evaluation across loop iterations
+2. Filter the batch progressively as rows are matched
+3. Build an index structure that tracks which branch produced each row's result
+4. Perform a single merge operation at the end
+
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+When a branch matches a number of rows, instead of immediately merging with `zip()`, we:
+1. Store the partial result array
+2. Mark the cells corresponding to each row in an indices array as needing to take one value from the partial result array
+
+In the example below, three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+The final result array is obtained by running the arrays through the merge operation.
+
+```aiignore
+┌───────────┐ ┌─────────┐ ┌─────────┐
+│┌─────────┐│ │ None │ │ NULL │
+││ A ││ ├─────────┤ ├─────────┤
+│└─────────┘│ │ 1 │ │ B │
+│┌─────────┐│ ├─────────┤ ├─────────┤
+││ B ││ │ 0 │ merge_n(values, indices) │ A │
+│└─────────┘│ ├─────────┤ ─────────────────────────▶ ├─────────┤
+│┌─────────┐│ │ None │ │ NULL │
+││ C ││ ├─────────┤ ├─────────┤
+│├─────────┤│ │ 2 │ │ C │
+││ D ││ ├─────────┤ ├─────────┤
+│└─────────┘│ │ 2 │ │ D │
+└───────────┘ └─────────┘ └─────────┘
+ arrays indices result
+```
+
+The main benefits of this merge operation are that the `scatter` step is eleminated enitrely, and instead of requiring
Review Comment:
```suggestion
The main benefits of this merge operation are that the `scatter` step is eliminated entirely, and instead of requiring
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2640038067
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
Review Comment:
You're absolutely right, the current wording is too negative. I'll revise this.
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
Review Comment:
Fixed
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2640039685
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
Review Comment:
Text updated
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
Review Comment:
Done
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2640034561
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
Review Comment:
Done
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb merged PR #122:
URL: https://github.com/apache/datafusion-site/pull/122
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3835421214
> 🎉
>
> @alamb in the meantime I got some feedback from our tech writer. Still need to process those. Are we ok with making edits to the blog post still or is it set in stone? I don't think there are any substantial contents changes, some small stylistic things.
I think updating the blog with stylistic changes is a great idea -- just make a new PR and we can merge it / update the post
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3734487497
> I've been meaning to add those diagrams and do another round of editing. Not much progress due to the holiday break and other priorities.
Me too! No worries. I'll check back in a few days -- anything I can do to help?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3733443451
I've been meaning to add those diagrams and do another round of editing. Not much progress due to the holiday break and other priorities.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "rluvaton (via GitHub)" <gi...@apache.org>.
rluvaton commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3801078414
Not on my side, thanks
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2728702671
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,456 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Observation 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Observation 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Observation 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation in after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrated how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *, CASE WHEN country = 'USA' THEN state ELSE country END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value input to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions that use non-constant expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+During evaluation, rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+
+## Results
+
+The degree to which the performance optimizations described in this post will benefit your queries is highly dependent on both your data and your queries.
+To give some idea of the impact we ran the following query on the TPC_H `orders` table with a scale factory of 100:
+
+```sql
+SELECT
+ *,
+ case o_orderstatus
+ when 'O' then 'ordered'
+ when 'F' then 'filled'
+ when 'P' then 'pending'
+ else 'other'
+ end
Review Comment:
They were in DF51, not anymore in DF52. The first result is with the equivalent of DF50. Each subsequent result is one additional optimisation applied on top of the previous one. Finally the hash table version, which is the DF52 result, does even better than DF51 still.
I wanted to keep the result section concise. And also not have to spend too much time compiling all the results.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "rluvaton (via GitHub)" <gi...@apache.org>.
rluvaton commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2736059271
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
yes, so saying that it is independent on the number of WHEN expressionד is not true
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2746100100
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
I was curious to know what the actual difference is so I ran some queries.
First one was
```
select *, case o_orderstatus when 'O' then 'ordered' when 'F' then 'filled' when 'P' then 'pending' else 'other' end from order;
```
and then
```
select *, case o_orderstatus when 'O' then 'ordered' when 'F' then 'filled' when 'P' then 'pending' <more whens> else 'other' end from order;
```
where `more whens` is `WHEN 'AA' THEN 'aa'` to WHEN 'ZZ' THEN 'zz'. So 26^2 extra whens. The runtime for those two queries is basically identical. The longer query ended up being faster than the shorter one a couple of times. Still seems quite reasonable to me then to state that for all practical purposes the number of `when` branches has no impact on query performance especially in contrast to the alternative that needs to iterate over the branches.
Not going to die on this hill, so I'll go with @alamb's suggestion, but stating 'does not grow significantly with' is a way just a confusing, because it suggests that more `when` branches will be slower but not much, but in reality there's no growth at all so you really don't need to care about this.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2640049358
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Problem 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed operations that are very well-optimized but still not cost free to execute:
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` (all columns) for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these steps allocates new arrays and shuffles a lot of data around.
+
+### Problem 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all columns 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Improvements
+
+### Optimization 1: Short-Circuit Early Exit (commit 7c215ed)
+
+The first optimization added early exit logic to the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+**Impact**: For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+### Optimization 2: Optimized Result Merging (commit e9431fc)
+
+The second optimization fundamentally restructured how partial results are merged.
+Instead of using `zip()` after each branch to merge results into an output array, we now:
+
+1. Maintain the subset of rows still needing evaluation across loop iterations
+2. Filter the batch progressively as rows are matched
+3. Build an index structure that tracks which branch produced each row's result
+4. Perform a single merge operation at the end
+
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+When a branch matches a number of rows, instead of immediately merging with `zip()`, we:
+1. Store the partial result array
+2. Mark the cells corresponding to each row in an indices array as needing to take one value from the partial result array
+
+In the example below, three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+The final result array is obtained by running the arrays through the merge operation.
+
+```aiignore
+┌───────────┐ ┌─────────┐ ┌─────────┐
+│┌─────────┐│ │ None │ │ NULL │
+││ A ││ ├─────────┤ ├─────────┤
+│└─────────┘│ │ 1 │ │ B │
+│┌─────────┐│ ├─────────┤ ├─────────┤
+││ B ││ │ 0 │ merge_n(values, indices) │ A │
+│└─────────┘│ ├─────────┤ ─────────────────────────▶ ├─────────┤
+│┌─────────┐│ │ None │ │ NULL │
+││ C ││ ├─────────┤ ├─────────┤
+│├─────────┤│ │ 2 │ │ C │
+││ D ││ ├─────────┤ ├─────────┤
+│└─────────┘│ │ 2 │ │ D │
+└───────────┘ └─────────┘ └─────────┘
+ arrays indices result
+```
+
+The main benefits of this merge operation are that the `scatter` step is eleminated enitrely, and instead of requiring
+a `zip` per branch only a single `merge_n` is done.
+
+Besides more efficient merging, we also maintain a progressively filtered `remainder_batch`.
+
+```rust
+let mut remainder_batch = Cow::Borrowed(batch);
+let mut remainder_rows = UInt32Array::from_iter_values(0..batch.num_rows());
+
+for branch in branches {
+ // Evaluate on progressively smaller batch
+ let when_value = evaluate_on(&remainder_batch)?;
+
+ // Filter remainder_batch for next iteration
+ let next_filter = create_filter(¬(&when_value)?);
+ remainder_batch = Cow::Owned(filter_record_batch(&remainder_batch, &next_filter)?);
+ remainder_rows = filter_array(&remainder_rows, &next_filter)?;
+}
+```
+
+As rows are matched, the `remainder_batch` shrinks, making subsequent filter operations a bit faster.
+
+**Impact**: This eliminates N-1 merge operations and makes filtering progressively cheaper as branches match rows.
+
+This new operation was initially developed specifically for DataFusion's `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+### Optimization 3: Column Projection (commit c3e49fb)
+
+The third optimization addresses the "filtering unused columns" problem through projection.
+Before evaluating a CASE expression, we:
+
+1. Analyze all WHEN/THEN/ELSE expressions to find referenced columns
+2. Build a projection vector containing only those column indices
+3. Derive new versions of the expressions with updated column references
+
+For example, if the original CASE references columns at indices `[1, 5, 8]` in a 20-column batch:
+- Project the batch to a 3-column batch with those columns
+- Rewrite expressions from `col@1, col@5, col@8` to `col@0, col@1, col@2`
+- Evaluate using the projected batch
+
+This is encapsulated in a `ProjectedCaseBody` structure:
+
+```rust
+struct ProjectedCaseBody {
+ projection: Vec<usize>, // [1, 5, 8]
+ body: CaseBody, // Expressions with rewritten column indices
+}
+```
+
+The projection logic is only applied when it would be beneficial (i.e., when the number of used columns is lower than the total number of columns in the batch).
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case (commit 32d2618)
+
+The final optimization targets a common pattern: `CASE WHEN condition THEN expr1 ELSE expr2 END`.
+
+Previously, this used the specialized `ExpressionOrExpression` fast path, but still used `evaluate_selection()` which produces scattered results:
+
+```rust
+// Old approach: produces scattered array with batch.num_rows() elements
+let then_value = then_expr.evaluate_selection(batch, &when_value)?
+ .into_array(batch.num_rows())?;
+```
+
+The new approach filters the batch first, then evaluates:
+
+```rust
+// New approach: filter to only matching rows
+let when_filter = create_filter(&when_value);
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?; // Produces compact array
+
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with a custom merge function that doesn't require pre-scattered inputs:
+
+```rust
+fn merge(mask: &BooleanArray, truthy: ColumnarValue, falsy: ColumnarValue) -> ArrayRef {
+ // Interleave truthy and falsy values directly using mask
+ // without requiring pre-alignment
+}
+```
+
+**Impact**: This eliminates unnecessary scatter operations and memory allocations for one of the most common CASE expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+## Summary
+
+Through four targeted optimizations, we've transformed CASE expression evaluation from a simple but inefficient implementation to a highly optimized one that:
+
+1. **Exits early** when all rows are matched
+2. **Defers merging** until the end with a single interleave operation
+3. **Projects columns** to avoid filtering unused data
+4. **Eliminates scatter** operations in common patterns
+
+These improvements compound: a CASE expression on a wide table with multiple branches and early matches benefits from all four optimizations simultaneously.
+The result is significantly reduced CPU time and memory allocation in one of SQL's most frequently used constructs.
+
+All of these changes maintain full compatibility with DataFusion's existing semantics and are covered by the existing test suite, demonstrating that performance optimization doesn't have to compromise correctness or require API changes.
Review Comment:
I thought I had stripped all the AI generated filler. Missed this one. You're right that it's just fluff that should be removed.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3799441326
@alamb I've gone through the article entirely again. Would be good to get a fresh pair of eyes to review it again; I've been looking at this thing for too long.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2728757475
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,456 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Observation 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Observation 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Observation 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation in after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrated how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *, CASE WHEN country = 'USA' THEN state ELSE country END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value input to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions that use non-constant expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+During evaluation, rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
Review Comment:
I've added some text that tries to explain this. Did I cover what you had in mind?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2732442032
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,468 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+[Apache DataFusion]: https://datafusion.apache.org/
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the remainder of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Observation 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Observation 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Observation 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation in after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrated how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *, CASE WHEN country = 'USA' THEN state ELSE country END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value input to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions that use non-constant expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE expression.
+
+This optimization was implemented by Raz Luvaton ([`@rluvaton`](https://github.com/rluvaton)) in [PR #18183](https://github.com/apache/datafusion/pull/18183)
+
+## Results
+
+The degree to which the performance optimizations described in this post will benefit your queries is highly dependent on both your data and your queries.
+To give some idea of the impact we ran the following query on the TPC_H `orders` table with a scale factory of 100:
+
+```sql
+SELECT
+ *,
+ case o_orderstatus
+ when 'O' then 'ordered'
+ when 'F' then 'filled'
+ when 'P' then 'pending'
+ else 'other'
+ end
+from orders
+```
+
+This query was first run with DataFusion 50.0.0 to get a baseline measurement.
+The same query was then run with each optimization applied in turn.
+The recorded times are presented as the blue series in the chart below.
+The green series shows the time measurement for the `SELECT * from orders` to give an idea of the cost the addition of a `CASE` expression in a query incurs.
+All measurements were made with a target partition count of `1`.
+
+<figure>
+<img src="/blog/images/case/results.png" alt="Performance measurements chart" width="100%" class="img-responsive">
+<figcaption>Performance measurements</figcaption>
+</figure>
+
+What can be seen in the chart is that the effect of the various optimizations compounds up to the `project` measurement.
+Up to that point these results are applicable to any `CASE` expression.
+The final improvement in the `hash` measurement is only applicable to simple `CASE` expressions with constant `WHEN` and `THEN` expressions.
+
+The cumulative effect of these optimizations is a 63-71% reduction in CPU time spent evaluating `CASE` expressions compared to the baseline.
+
+## Summary
+
+Through a number of targeted optimizations, we've transformed `CASE` expression evaluation from a simple, but unoptimized implementation to a highly optimized one.
+The optimizations described in this post compound: a `CASE` expression on a wide table with multiple branches and early matches benefits from all four optimizations simultaneously.
+The result is significantly reduced CPU time and memory allocation in a SQL constructs that are essential for ETL-like queries.
Review Comment:
Added
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3677873324
> @alamb I finally found some time to get through what the bot produced. I think this is now in a good enough shape for a first review.
Thank you -- I will try and review it over the next few days
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2640025137
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Problem 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed operations that are very well-optimized but still not cost free to execute:
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` (all columns) for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these steps allocates new arrays and shuffles a lot of data around.
+
+### Problem 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all columns 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Improvements
+
+### Optimization 1: Short-Circuit Early Exit (commit 7c215ed)
+
+The first optimization added early exit logic to the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+**Impact**: For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+### Optimization 2: Optimized Result Merging (commit e9431fc)
+
+The second optimization fundamentally restructured how partial results are merged.
+Instead of using `zip()` after each branch to merge results into an output array, we now:
+
+1. Maintain the subset of rows still needing evaluation across loop iterations
+2. Filter the batch progressively as rows are matched
+3. Build an index structure that tracks which branch produced each row's result
+4. Perform a single merge operation at the end
+
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+When a branch matches a number of rows, instead of immediately merging with `zip()`, we:
+1. Store the partial result array
+2. Mark the cells corresponding to each row in an indices array as needing to take one value from the partial result array
+
+In the example below, three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+The final result array is obtained by running the arrays through the merge operation.
+
+```aiignore
Review Comment:
I think that's "AI ignore". Jetbrains is getting a bit too AI happy and keeps on trying to inject stuff you didn't ask for. I've removed it.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "pepijnve (via GitHub)" <gi...@apache.org>.
pepijnve commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2640033647
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+The baseline implementation in DataFusion 50.0.0 evaluated `CASE` using a straightforward approach:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using `PhysicalExpr::evaluate_selection`, passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the `zip` kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using `zip`
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut current_value = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ let when_value = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ let when_value = and(&when_value, &remainder)?;
+
+ if when_value.true_count() == 0 {
+ continue;
+ }
+
+ let then_value = then_expr.evaluate_selection(batch, &when_value)?;
+ current_value = zip(&when_value, &then_value, ¤t_value)?;
+ remainder = and_not(&remainder, &when_value)?;
+}
+```
+
+While correct, this implementation had several performance issues mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how does the simple evaluation strategy and use of `evaluate_selection` cause performance overhead?
+
+### Problem 1: No Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Problem 2: Repeated Filtering, Scattering, and Merging
+
+Each iteration performed operations that are very well-optimized but still not cost free to execute:
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` (all columns) for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these steps allocates new arrays and shuffles a lot of data around.
+
+### Problem 3: Filtering Unused Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all columns 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Improvements
+
+### Optimization 1: Short-Circuit Early Exit (commit 7c215ed)
Review Comment:
I got rid of them. I had them in there for myself originally just to keep track of what was what, but I agree they're not useful.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Add blog post regarding CASE work [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2645638493
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
Review Comment:
Yes, exactly -- showing how the data flows from input to output, with any important intermediate results along the way 🙉
##########
content/blog/2025-11-11-datafusion_case.md:
##########
@@ -0,0 +1,377 @@
+---
+layout: post
+title: Optimizing CASE Expression Evaluation
+date: 2025-11-11
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+# Optimizing CASE Expression Evaluation in DataFusion
+
+SQL's `CASE` expression is one of the few constructs the language provides to perform conditional logic.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few weeks, we've landed a series of improvements to DataFusion's `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+Finally we'll also take a look at some future improvements to `CASE` that are in the works.
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the constants (or expressions) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's a simple example:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `THEN` expression value for the first matching `WHEN` expression is returned per row.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+
+This allows you to test different columns with different operators per branch as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated. Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It let's you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE nominator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+## `CASE` Evaluation in DataFusion 50.0.0
+
+For the rest of this post we'll be looking at 'searched case' evaluation.
+'Simple case' uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
Review Comment:
Yes, exactly -- showing how the data flows from input to output, with any important intermediate results along the way ❤️
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on code in PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#discussion_r2737435065
##########
content/blog/2026-01-26-datafusion_case.md:
##########
@@ -0,0 +1,499 @@
+---
+layout: post
+title: Optimizing SQL CASE Expression Evaluation
+date: 2026-01-26
+author: Pepijn Van Eeckhoudt
+categories: [features]
+---
+<!--
+{% comment %}
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+-->
+
+[TOC]
+
+<style>
+figure {
+ margin: 20px 0;
+}
+
+figure img {
+ display: block;
+ max-width: 80%;
+ margin: auto;
+}
+
+figcaption {
+ font-style: italic;
+ color: #555;
+ font-size: 0.9em;
+ max-width: 80%;
+ margin: auto;
+ text-align: center;
+}
+</style>
+
+SQL's `CASE` expression is one of the few explicit conditional evaluation constructs the language provides.
+It lets you control which expression from a set of expressions is evaluated for each row based on arbitrary boolean expressions.
+Its deceptively simple syntax hides significant implementation complexity.
+Over the past few releases, we've landed a series of improvements to [Apache DataFusion]'s `CASE` expression evaluator that reduce both CPU time and memory allocations.
+This post walks through the original implementation, its performance bottlenecks, and how we addressed them step by step.
+
+
+## Background: CASE Expression Evaluation
+
+SQL supports two forms of CASE expressions:
+
+1. **Simple**: `CASE expr WHEN value1 THEN result1 WHEN value2 THEN result2 ... END`
+2. **Searched**: `CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ... END`
+
+The simple form evaluates an expression once for each input row and then tests that value against the expressions (typically constants) in each `WHEN` clause using equality comparisons.
+Think of it as a limited Rust `match` expression.
+
+Here's an example of the simple form:
+
+```sql
+CASE status
+ WHEN 'pending' THEN 1
+ WHEN 'active' THEN 2
+ WHEN 'complete' THEN 3
+ ELSE 0
+END
+```
+
+In this `CASE` expression, `status` is evaluated once per row, and then its value is tested for equality with the values `'pending'`, `'active'`, and `'complete'` in that order.
+The `CASE` expression evaluates to the value of the `THEN` expression corresponding to the first matching `WHEN` expression.
+
+The searched `CASE` form is a more flexible variant.
+It evaluates completely independent boolean expressions for each branch.
+This allows you to test different columns with different operators per branch, as can be seen in the following example:
+
+```sql
+CASE
+ WHEN age > 65 THEN 'senior'
+ WHEN childCount != 0 THEN 'parent'
+ WHEN age < 21 THEN 'minor'
+ ELSE 'adult'
+END
+```
+
+In both forms, branches are evaluated sequentially with short-circuit semantics: for each row, once a `WHEN` condition matches, the corresponding `THEN` expression is evaluated.
+Any further branches are not evaluated for that row.
+This lazy evaluation model is critical for correctness.
+It lets you safely write `CASE` expressions like
+
+```sql
+CASE
+ WHEN denominator == 0 THEN NULL
+ ELSE numerator / denominator
+END
+```
+
+that are guaranteed to not trigger divide-by-zero errors.
+
+Besides `CASE`, there are a few [conditional scalar functions](https://datafusion.apache.org/user-guide/sql/scalar_functions.html#conditional-functions) that provide similar, more restricted capabilities.
+These include `COALESCE`, `IFNULL`, and `NVL2`.
+
+Each of these functions can be seen as the equivalent of a macro for `CASE`.
+`COALESCE(expr1, expr2, expr3)` for instance, would expand to:
+
+```sql
+CASE
+ WHEN expr1 IS NOT NULL THEN expr1
+ WHEN expr2 IS NOT NULL THEN expr2
+ ELSE expr3
+END
+```
+
+[Apache DataFusion] implements these conditional functions by rewriting them to their equivalent `CASE` expression.
+As a consequence, any optimizations related to `CASE` described in this post also apply to conditional function evaluation.
+
+## Basic `CASE` Evaluation
+
+For the remainder of this post, we'll be looking at `searched CASE` evaluation.
+`Simple CASE` uses a distinct, but very similar implementation.
+The same set of improvements has been applied to both.
+
+DataFusion 50.0.0 uses a common, straightforward approach to evaluate `CASE`:
+
+1. Start with an output array `out` with the same length as the input batch, filled with nulls. Additionally, create a bit vector `remainder` with the same length and each value set to `true`.
+2. For each `WHEN`/`THEN` branch:
+ - Evaluate the `WHEN` condition for remaining unmatched rows using [`PhysicalExpr::evaluate_selection`](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html#method.evaluate_selection), passing in the input batch and the `remainder` mask
+ - If any rows matched, evaluate the `THEN` expression for those rows using `PhysicalExpr::evaluate_selection`
+ - Merge the results into the `out` using the [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html) kernel
+ - Update the `remainder` mask to exclude matched rows
+3. If there's an `ELSE` clause, evaluate it for any remaining unmatched rows and merge using [`zip`](https://docs.rs/arrow/latest/arrow/compute/kernels/zip/fn.zip.html)
+
+Here's a simplified version of the original loop:
+
+```rust
+let mut out = new_null_array(&return_type, batch.num_rows());
+let mut remainder = BooleanArray::from(vec![true; batch.num_rows()]);
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ // Determine for which remaining rows the WHEN condition matches
+ let when = when_expr.evaluate_selection(batch, &remainder)?
+ .into_array(batch.num_rows())?;
+ // Ensure any `NULL` values are treated as false
+ let when_and_rem = and(&when, &remainder)?;
+
+ if when_and_rem.true_count() == 0 {
+ continue;
+ }
+
+ // Evaluate the THEN expression for matching rows
+ let then = then_expr.evaluate_selection(batch, &when_and_rem)?;
+ // Merge results into output array
+ out = zip(&when_and_rem, &then_value, &out)?;
+ // Update remainder mask to exclude matched rows
+ remainder = and_not(&remainder, &when_and_rem)?;
+}
+```
+
+Schematically, one iteration of this loop for the case expression
+
+```sql
+CASE
+ WHEN col = 'b' THEN 100
+ ELSE 200
+END
+```
+
+looks like this:
+
+<figure>
+<img src="/blog/images/case/original_loop.svg" alt="Schematic representation of data flow in the original CASE implementation" width="100%" class="img-responsive">
+<figcaption>One iteration of the `CASE` evaluation loop</figcaption>
+</figure>
+
+While correct, this implementation has significant room for optimization, mostly related to the usage of `evaluate_selection`.
+To understand why, we need to dig a little deeper into the implementation of that function.
+Here's a simplified version of it that captures the relevant parts:
+
+```rust
+pub trait PhysicalExpr {
+ fn evaluate_selection(
+ &self,
+ batch: &RecordBatch,
+ selection: &BooleanArray,
+ ) -> Result<ColumnarValue> {
+ // Reduce record batch to only include rows that match selection
+ let filtered_batch = filter_record_batch(batch, selection)?;
+ // Perform regular evaluation on filtered batch
+ let filtered_result = self.evaluate(&filtered_batch)?;
+ // Expand result array to match original batch length
+ scatter(selection, filtered_result)
+ }
+}
+```
+
+Going back to the same example as before, the data flow looks like this:
+
+<figure>
+<img src="/blog/images/case/evaluate_selection.svg" alt="Schematic representation of `evaluate_selection` evaluation" width="100%" class="img-responsive">
+<figcaption>evaluate_selection data flow</figcaption>
+</figure>
+
+The `evaluate_selection` method first filters the input batch to only include rows that match the `selection` mask.
+It then calls the regular `evaluate` method using the filtered batch as input.
+Finally, to return a result array with the same number of rows as `batch`, the `scatter` function is called.
+This function produces a new array padded with `null` values for any rows that didn't match the `selection` mask.
+
+So how can we improve the performance of the simple evaluation strategy and use of `evaluate_selection`?
+
+### Opportunity 1: Early Exit
+
+The case evaluation loop always iterated through all branches, even when every row had already been matched.
+In queries where early branches match many rows, this meant unnecessary work was done for remaining rows.
+
+### Opportunity 2: Optimize Repeated Filtering, Scattering, and Merging
+
+Each iteration performed a number of operations that are very well-optimized, but still take up a significant amount of CPU time:
+
+- **Filtering**: `PhysicalExpr::evaluate_selection` filters the entire `RecordBatch` for each branch. For the `WHEN` expression, this was done even if the selection mask was entirely empty.
+- **Scattering**: `PhysicalExpr::evaluate_selection` scatters the filtered result back to the original `RecordBatch` length.
+- **Merging**: The `zip` kernel is called once per branch to merge partial results into the output array
+
+Each of these operations needs to allocate memory for new arrays and shuffle quite a bit of data around.
+
+### Opportunity 3: Filter only Necessary Columns
+
+The `PhysicalExpr::evaluate_selection` method filters the entire record batch, including columns that the current branch's `WHEN` and `THEN` expressions don't reference.
+For wide tables (many columns) with narrow expressions (few column references), this is wasteful.
+
+Suppose we have a table with 26 columns named `a` through `z`.
+For a simple CASE expression like:
+
+```sql
+CASE
+ WHEN a > 1000 THEN 'large'
+ WHEN a >= 0 THEN 'positive'
+ ELSE 'negative'
+END
+```
+
+the implementation would filter all 26 columns even though only a single column is needed for the entire `CASE` expression evaluation.
+Again this involves a non-negligible amount of allocation and data copying.
+
+## Performance Optimizations
+
+### Optimization 1: Short-Circuit Early Exit
+
+The first optimization is an easy one.
+As soon as we can detect that all rows of the batch have been matched we break out of the evaluation loop:
+
+```rust
+let mut remainder_count = batch.num_rows();
+
+for (when_expr, then_expr) in &self.when_then_expr {
+ if remainder_count == 0 {
+ break; // All rows matched, exit early
+ }
+
+ // ... evaluate branch ...
+
+ let when_match_count = when_value.true_count();
+ remainder_count -= when_match_count;
+}
+```
+
+Additionally, we avoid evaluating the `ELSE` clause when no rows remain:
+
+```rust
+if let Some(else_expr) = &self.else_expr {
+ remainder = or(&base_nulls, &remainder)?;
+ if remainder.true_count() > 0 {
+ // ... evaluate else ...
+ }
+}
+```
+
+For queries where early branches match all rows, this eliminates unnecessary branch evaluations and `ELSE` clause processing.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #17898](https://github.com/apache/datafusion/pull/17898)
+
+### Optimization 2: Optimized Result Merging
+
+The second optimization fundamentally restructured how the results of each loop iteration are merged.
+The diagram below illustrates the optimized data flow when evaluating the `CASE WHEN col = 'b' THEN 100 ELSE 200 END` from before:
+
+<figure>
+<img src="/blog/images/case/merging.svg" alt="Schematic representation of optimized evaluation loop" width="100%" class="img-responsive">
+<figcaption>optimized evaluation loop</figcaption>
+</figure>
+
+In the reworked implementation, `evaluate_selection` is no longer used.
+The key insight is that we can defer all merging until the end of the evaluation loop by tracking result provenance.
+This was implemented with the following changes:
+
+1. Augment the input batch with a column containing row indices
+2. Reduce the augmented batch after each loop iteration to only contain the remaining rows
+3. Use the row index column to track which partial result array contains the value for each row
+4. Perform a single merge operation at the end instead of a `zip` operation after each loop iteration
+
+With these changes it is no longer necessary to `scatter` and `zip` results in each loop iteration.
+Instead, when all rows have been matched, we can then merge the partial results using [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+The diagram below illustrates how `merge_n` works for an example where three `WHEN/THEN` branches produced results.
+The first branch produced the result `A` for 2, the second produced `B` for row 1, and the third produced `C` and `D` for rows 4 and 5.
+
+<figure>
+<img src="/blog/images/case/merge_n.svg" alt="Schematic illustration of the merge_n algorithm" width="100%" class="img-responsive">
+<figcaption>merge_n example</figcaption>
+</figure>
+
+The `merge_n` algorithm scans through the indices array.
+For each non-empty cell, it takes one value from the corresponding values array.
+In the example above, we first encounter `1`.
+This takes the first element from the values array with index `1`, resulting in `B`.
+The next cell contains `0` which takes `A`, from the first array.
+Finally, we encounter `2` twice.
+This takes the first and second element from the last values array respectively.
+
+This algorithm was initially implemented in DataFusion for `CASE` evaluation, but in the meantime has been generalized and moved into the `arrow-rs` crate as [`arrow_select::merge::merge_n`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge_n.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18152](https://github.com/apache/datafusion/pull/18152)
+
+### Optimization 3: Column Projection
+
+The third optimization addresses the "filtering unused columns" overhead through projection.
+
+Suppose we have a query like:
+
+```sql
+SELECT *,
+ CASE
+ WHEN country = 'USA' THEN state
+ ELSE country
+ END AS region
+FROM mailing_address
+```
+
+where the `mailing_address` table has columns `name`, `surname`, `street`, `number`, `city`, `state`, `country`.
+We can see that the `CASE` expression only references columns `country` and `state`, but because all columns are being queried, projection pushdown cannot reduce the number of columns being fed in to the projection operator.
+
+<figure>
+<img src="/blog/images/case/no_projection.svg" alt="Schematic illustration of CASE evaluation without projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation without projection</figcaption>
+</figure>
+
+During `CASE` evaluation, the batch needs to be filtered using the `WHEN` expression in order to evaluate the `THEN` expression values.
+As the diagram above shows, this filtering creates a reduced copy of all columns.
+
+This unnecessary copying can be avoided by first narrowing the batch to only include the columns that are actually needed.
+
+<figure>
+<img src="/blog/images/case/projection.svg" alt="Schematic illustration of CASE evaluation with projection" width="100%" class="img-responsive">
+<figcaption>CASE evaluation with projection</figcaption>
+</figure>
+
+At first glance this might not seem beneficial, since we're introducing an additional processing step.
+Luckily projection of a record batch only requires a shallow copy of the record batch.
+The column arrays themselves are not copied, and the only work that is actually done is incrementing the reference counts of the columns.
+
+**Impact**: For wide tables with narrow CASE expressions, this dramatically reduces filtering overhead by removing copying of unused columns.
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18329](https://github.com/apache/datafusion/pull/18329)
+
+### Optimization 4: Eliminating Scatter in Two-Branch Case
+
+Some of the earlier examples in this post used an expression of the form `CASE WHEN condition THEN expr1 ELSE expr2 END` to explain how the general evaluation loop works.
+For this kind of two-branch `CASE` expression, [Apache DataFusion] has a more optimized implementation that unrolls the loop.
+This specialized `ExpressionOrExpression` fast path still used `evaluate_selection()` for both branches which uses `scatter` and `zip` to combine the results incurring the same performance overhead as the general implementation.
+
+The revised implementation eliminates the use of `evaluate_selection` as follows:
+
+```rust
+// Compute the `WHEN` condition for the entire batch
+let when_filter = create_filter(&when_value);
+
+// Compute a compact array of `THEN` values for the matching rows
+let then_batch = filter_record_batch(batch, &when_filter)?;
+let then_value = then_expr.evaluate(&then_batch)?;
+
+// Compute a compact array of `ELSE` values for the non-matching rows
+let else_filter = create_filter(¬(&when_value)?);
+let else_batch = filter_record_batch(batch, &else_filter)?;
+let else_value = else_expr.evaluate(&else_batch)?;
+```
+
+This produces two compact arrays (one for THEN values, one for ELSE values) which are then merged with the `merge` function.
+In contrast to `zip`, `merge` does not require both of its value inputs to have the same length.
+Instead it requires that the sum of the length of the value inputs matches the length of the mask array.
+
+<figure>
+<img src="/blog/images/case/merge.svg" alt="Schematic illustration of the merge algorithm" width="100%" class="img-responsive">
+<figcaption>merge example</figcaption>
+</figure>
+
+This eliminates unnecessary scatter operations and memory allocations for one of the most common `CASE` expression patterns.
+
+Just like `merge_n` this operation has been moved into `arrow-rs` as [`arrow_select::merge::merge`](https://docs.rs/arrow-select/57.1.0/arrow_select/merge/fn.merge.html).
+
+This optimization was implemented by Pepijn Van Eeckhoudt ([`@pepijnve`](https://github.com/pepijnve)) in [PR #18444](https://github.com/apache/datafusion/pull/18444)
+
+### Optimization 5: Table Lookup of Constants
+
+Up until now we've been discussing the implementations for generic `CASE` expressions with arbitrary expressions for both `WHEN` and `THEN`.
+Another common use of `CASE` though is to perform a mapping from one set of constants to another.
+For instance, expanding numeric constants to human-readable strings can be done using
+
+```sql
+CASE status
+ WHEN 0 THEN 'idle'
+ WHEN 1 THEN 'running'
+ WHEN 2 THEN 'paused'
+ WHEN 3 THEN 'stopped'
+ ELSE 'unknown'
+END
+```
+
+A final `CASE` optimization recognizes this pattern and compiles the `CASE` expression into a hash table.
+Rather than evaluating the `WHEN` and `THEN` expressions, the input expression is evaluated once, and the result array is computed using a vectorized hash table lookup.
+This approach avoids the need to filter the input batch and combine partial results entirely.
+Instead the result array is computed in a single pass over the input values and the computation time is independent of the number of `WHEN` branches in the `CASE` expression.
Review Comment:
How about something like
```suggestion
Instead the result array is computed in a single pass over the input values and the computation time does not grow significantly with the number of `WHEN` branches in the `CASE` expression.
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org
Re: [PR] Blog post about CASE optimization [datafusion-site]
Posted by "alamb (via GitHub)" <gi...@apache.org>.
alamb commented on PR #122:
URL: https://github.com/apache/datafusion-site/pull/122#issuecomment-3823701552
I am thinking we should plan to publish this next Monday, Feb 2. Is that ok with you @pepijnve ?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: github-unsubscribe@datafusion.apache.org
For additional commands, e-mail: github-help@datafusion.apache.org