diff --git a/content/blog/2026-07-20-sort-pushdown.md b/content/blog/2026-07-20-sort-pushdown.md
new file mode 100644
index 00000000..3fdebe41
--- /dev/null
+++ b/content/blog/2026-07-20-sort-pushdown.md
@@ -0,0 +1,528 @@
+---
+layout: post
+title: Optimizing for Almost Sorted Data: Sort Pushdown in Apache DataFusion
+date: 2026-07-20
+author: Qi Zhu and Andrew Lamb
+categories: [performance]
+---
+
+
+
+[TOC]
+
+*[Qi Zhu](https://github.com/zhuqi-lucas) ([Massive](https://www.massive.com/)); [Andrew Lamb](https://github.com/alamb) ([InfluxData](https://www.influxdata.com/))*
+
+[Apache DataFusion] uses sortedness automatically — even when data is only
+partially sorted or when no ordering was declared. This post explains how
+plan-time sort pushdown, runtime scan reordering, and row-group pruning driven
+by [dynamic filters][dyn-filters-blog] make that possible.
+
+[Apache DataFusion]: https://datafusion.apache.org/
+[dyn-filters-blog]: https://datafusion.apache.org/blog/2025/09/10/dynamic-filters/
+
+## Are Real Datasets Sorted?
+
+Sorting data is prohibitively expensive for many workloads, so maintaining it in fully
+sorted order is often not practical.
+However, many real datasets are at least partly sorted when stored: time-series files by
+ingestion time, event logs by event id, partitioned tables by partition key, and
+data lakes based on [Apache Iceberg] and similar formats in write order.
+
+Sortedness only helps if the query engine can detect and use it. Two common
+cases make that hard:
+
+1. The ordering is undeclared in the file. For example, the writer did not set Parquet
+ [sorting_columns](https://github.com/apache/parquet-format/blob/8a5e04bdecf100e8e981daacfa117e8b5aadacb9/src/main/thrift/parquet.thrift#L1044),
+ or the table was not created with DataFusion's
+ [WITH ORDER](https://datafusion.apache.org/user-guide/sql/ddl.html#create-external-table) clause.
+2. Files are individually sorted, but the engine is scanning multiple files
+ and does not know a global ordering at plan time.
+
+In both cases, queries with `ORDER BY` or `ORDER BY ... LIMIT N` pay for a full
+scan and sometimes a blocking full sort, which buffers every row and can
+dominate latency and peak memory on large scans.
+
+Using min/max statistics for *predicate* pushdown is well-known and widely
+implemented across databases, as covered in [@XiangpengHao]'s
+post on [Parquet pruning][parquet-pruning-blog]. Using the same
+statistics to *reason about sort order* and to prune `ORDER BY ... LIMIT`
+(top-k) queries is also increasingly common, for example in the
+[Pruning in Snowflake: Working Smarter, Not Harder] paper (see [Related Work](#related-work) for more).
+This post describes several such techniques for a general audience,
+including the less-common case of *discovering* a
+global sort order from per-file statistics,
+deleting redundant sorts, and biasing scan order toward the
+most-promising data.
+
+[Apache Iceberg]: https://iceberg.apache.org/
+[@XiangpengHao]: https://github.com/XiangpengHao
+[parquet-pruning-blog]: https://datafusion.apache.org/blog/2025/03/20/parquet-pruning
+[Pruning in Snowflake: Working Smarter, Not Harder]: https://dl.acm.org/doi/10.1145/3722212.3724447
+
+## Exact vs. Inexact Ordering
+
+DataFusion has long skipped sorts when it knows the data is **exactly** sorted, as covered in
+[@akurmustafa's earlier post][ordering-analysis]:
+if the table declares an ordering (via `WITH ORDER` or Parquet
+`sorting_columns`) and the file listing matches it, the redundant sort is removed.
+
+This post is about the messier real-world cases
+where sortedness exists but is *inexact* or not provable up front:
+
+- Files listed in the "wrong" order on disk (each file is internally
+ sorted, but the files are not globally ordered).
+- Ordering is known, but the sort key ranges **overlap** across files.
+- **No** declared ordering at all.
+- `ORDER BY ... DESC` on ASC-sorted data.
+
+This post describes two primary techniques:
+
+1. **Statistics-based sort elimination** (`Exact`): avoids sorts entirely by
+ reordering files by min/max statistics to create a global ordering. This
+ extends DataFusion's existing statistics-based file-group ordering
+ ([#9593]) and is similar to prior work on concatenating disjoint ranges.
+2. **Runtime reorder and dynamic pruning** (`Inexact`): reorders the scan to read
+ the most-promising data first, then re-checks the `TopK` dynamic filter at file
+ and row-group boundaries.
+
+These techniques are implemented in DataFusion and together result in:
+
+- **Sort elimination**: 2×–49× faster on `ORDER BY` and `ORDER BY ... LIMIT`
+ queries where the file list was in the wrong disk order.
+- **Runtime row-group pruning**: 5 of 11 benchmark queries run ≥2× faster
+ (up to ~4×) with zero regressions; total runtime drops by 44%.
+
+The rest of this post walks through each technique in detail.
+
+[#22450]: https://github.com/apache/datafusion/pull/22450
+[#9593]: https://github.com/apache/datafusion/pull/9593
+[ordering-analysis]: https://datafusion.apache.org/blog/2025/03/11/ordering-analysis/
+
+## Tracking Ordering
+
+
+The [PushdownSort](https://github.com/apache/datafusion/blob/main/datafusion/physical-optimizer/src/pushdown_sort.rs)
+optimizer rule classifies each scan below a sort as either `Exact`,
+`Inexact`, or `Unsupported`, and records this information on DataFusion's
+[FileScanConfig](https://docs.rs/datafusion-datasource/latest/datafusion_datasource/file_scan_config/struct.FileScanConfig.html):
+
+- **`Exact`** — the optimizer is *certain* of the output order,
+ and removes redundant [SortExec](https://docs.rs/datafusion-physical-plan/latest/datafusion_physical_plan/sorts/sort/struct.SortExec.html) operators entirely.
+- **`Inexact`** — the optimizer believes the output is probably ordered
+ but cannot prove it, so the sort is kept but the scan is biased to read the most-promising data first.
+- **`Unsupported`** — the optimizer cannot determine the ordering, so no sort is removed.
+
+
+*Figure: the `PushdownSort` rule asks each scan whether it can satisfy the
+required ordering, returning `Exact` (drop the sort), `Inexact` (bias the scan
+and keep the sort), or `Unsupported` (the sort stays).*
+
+For example, given a query that returns the 10 most recent trades:
+
+```sql
+SELECT ts, symbol, amount FROM trades ORDER BY ts DESC LIMIT 10;
+```
+
+- With **`Exact`** ordering knowledge, DataFusion drops the sort entirely and
+ stops reading after emitting 10 rows.
+- With **`Inexact`** ordering, the `SortExec` stays but scans start
+ from the most-promising data, so the `TopK` threshold is more likely to
+ tighten quickly and prune the rest with statistics.
+- With no ordering knowledge, DataFusion scans everything and uses a
+ `TopK` heap to keep the running best 10.
+
+## Exact: Sort Elimination via Statistics
+
+DataFusion can eliminate sorts entirely when it can use Parquet min/max statistics to reorder files and prove a global ordering
+(relevant PRs: [#19064] and [#21182]) as shown below.
+
+
+*Figure: EXPLAIN output before and after `PushdownSort` eliminates the sort. The `SortExec` is removed and the scan's ordering claim is upgraded to `Exact`.*
+
+
+
+[#19064]: https://github.com/apache/datafusion/pull/19064
+[#21182]: https://github.com/apache/datafusion/pull/21182
+
+For example, consider three files `a.parquet`, `b.parquet`, `c.parquet`. Each is
+internally sorted by `ts` and the time ranges do not overlap, but the files were
+written by different jobs. Without this optimization, a query engine is forced to
+use a full sort for `ORDER BY ts` queries, even though it could simply read the
+files one after another and produce the correct result.
+
+The `Exact` path requires each file to be
+*individually* sorted on the key, and DataFusion knows this from the declared
+ordering (`WITH ORDER` or Parquet `sorting_columns`).
+Concatenating the files in min/max range order yields a globally sorted stream
+only when that per-file precondition holds. When no per-file ordering is declared, the
+`Inexact` path (described next) applies instead.
+
+`PushdownSort` fixes this in three steps at the file-scan node:
+
+1. **Sort the file list by per-file `min`** on the sort column.
+2. **Check overlap**: does `file[i].max ≤ file[i+1].min` hold for
+ every adjacent pair? If yes, the sorted file list produces a globally
+ sorted stream.
+3. **Upgrade the source's ordering claim to `Exact`** and remove the
+ surrounding `Sort`. Note that this requires some additional performance optimizations
+ described in [appendix on buffering without sorting](#appendix-buffering-without-sorting).
+
+This is an instance of what Graefe calls *virtual concatenation*, and is similar to
+LSM "trivial move" compaction (see [Related Work](#related-work)). It is less common
+to discover the disjoint ranges from per-file
+min/max statistics rather than from a declared partitioning scheme.
+
+[graefe2006]: https://dl.acm.org/doi/10.1145/1132960.1132964
+[graefe1993]: https://dl.acm.org/doi/10.1145/152610.152611
+
+
+*Figure: reordering by per-file `min/max` puts the file list in range order.*
+
+
+
+*Figure: `PushdownSort` sorts files by `min` and checks adjacency,
+upgrading to `Exact` only when the ranges don't overlap. Left: non-overlapping
+ranges are safe to upgrade to `Exact` and the sort is removed; right:
+overlapping ranges keep the sort and fall through to the `Inexact` path described next.*
+
+We measured the single-core performance of statistics-based sort elimination with DataFusion's
+[sort_pushdown](https://github.com/apache/datafusion/tree/main/benchmarks/queries/sort_pushdown) benchmark suite
+by setting `--partitions 1`. The results are as follows:
+
+
+
+| Query | Before | After | Speedup |
+| ------------------------------------------- | -------:| -------:| -------: |
+| Q1 — `ORDER BY key` (full scan) | 259 ms | 122 ms | **2.1×** |
+| Q2 — `ORDER BY key LIMIT 100` | 80 ms | 3 ms | **27×** |
+| Q3 — `SELECT * ORDER BY key` | 700 ms | 313 ms | **2.2×** |
+| Q4 — `SELECT * ORDER BY key LIMIT 100` | 342 ms | 7 ms | **49×** |
+
+*Figure: `sort_pushdown` results. All four queries are `ASC` `ORDER BY` where the
+file list on disk is in the reverse of sort-key order; a naive engine sorts even
+though the files are already individually sorted.*
+
+While DataFusion can avoid sorting for all four queries, the benefit is most
+dramatic for `LIMIT` queries:
+
+- **Full-scan queries (Q1, Q3)** result in a ~2× speedup as the scan is now a single-pass streaming read.
+- **`LIMIT` queries (Q2, Q4)** result in a 27×–49× speedup because `LIMIT N` turns into a streaming read with early stopping.
+
+## Inexact: Scan Reordering Makes Dynamic Filters More Effective
+
+It is not possible to eliminate the sort when the files have
+overlapping sort key ranges. However, it is still possible to use sorted or
+partly sorted data to optimize `LIMIT` queries. By reordering the read,
+data that is most likely to improve the `TopK` dynamic filter is seen first and
+the filter is more effective at pruning files, row groups, and rows that cannot
+possibly contribute to the final result.
+
+This `Inexact` path is used when Parquet min/max statistics are available
+and the sort expression is supported by DataFusion's
+[equivalence-properties][ordering-analysis], including plain columns, monotonic
+functions[1](#footnote1), constants inferred from filters,
+and multi-column orderings. The same dynamic filter is used to drive three layers of pruning:
+
+
+*Figure: the Parquet reader applies three pruning layers, all driven by the same `TopK` dynamic filter.*
+
+* **File-level pruning** ([file_pruner] + [EarlyStoppingStream]) — skips whole files before they are opened.
+* **Row-group-level pruning** ([#22450]) — skips whole row groups at each boundary, before any pages are fetched.
+* **Row-level filtering** ([RowFilter]) — skips decoding the remaining columns in a surviving row group.
+
+[file_pruner]: https://docs.rs/datafusion-pruning/latest/datafusion_pruning/struct.FilePruner.html
+[EarlyStoppingStream]: https://github.com/apache/datafusion/blob/e104138b4d45d3acfb76223cd968385f6764477b/datafusion/datasource-parquet/src/opener/early_stop.rs
+[RowFilter]: https://docs.rs/parquet/latest/parquet/arrow/arrow_reader/struct.RowFilter.html
+
+When `Inexact` applies, the Parquet opener reorders
+the scan before and during execution using three steps:
+
+
+
+*Figure: the Parquet opener applies file-level reordering, row-group-level reordering, and an optional iteration reverse.*
+
+1. **File-level reordering** — the file list is sorted by `min(col)`,
+ so the most-promising file is picked first across all partitions.
+2. **Row-group-level reordering** — once a file is opened, its row groups
+ are sorted by `min(col)`.
+3. **Iteration reverse** — for `DESC` queries, the file and row-group order is reversed.
+
+**File-Level Pruning**: once files are ordered "most-promising first", the
+`TopK`'s heap fills quickly and its dynamic filter threshold tightens. The
+[FilePruner] then cuts low-value files before opening them, as shown in the
+following example.
+
+[FilePruner]: https://github.com/apache/datafusion/blob/e104138b4d45d3acfb76223cd968385f6764477b/datafusion/pruning/src/file_pruner.rs
+
+
+
+*Figure: after reordering files by their sort key, the low sort-key value files (`file_d` and `file_c`,
+whose sort-key values are least promising for this `DESC LIMIT` query)
+end up at the tail of the read queue and are pruned by the file-level pruner before they
+are ever opened.*
+
+**Row-Group-Level Pruning**: When a file is first opened, DataFusion prunes
+row groups based on predicates and statistics, and then determines
+the order to scan the row groups. During the scan, immediately before DataFusion
+reads the next row group, it checks whether the `TopK` dynamic filter has changed. If so,
+it re-evaluates the remaining row groups against the new threshold and
+prunes any that cannot contribute to the final result (added in [#22450]). See the
+[Appendix: Decoder Loop and Decision Point](#appendix-decoder-loop-and-decision-point) for more details.
+
+
+
+*Figure: for `ORDER BY x DESC LIMIT 10`, after reading the first row group, DataFusion prunes the rest of the row groups in a single pass (walkthrough below).*
+
+For the example shown above, the file has 10 row groups, each containing rows with values
+`[0..10)`, `[10..20)`, ..., `[90..100)` and the query is `ORDER BY x DESC LIMIT 10`.
+
+1. The row groups are first reordered by their maximum values, so the highest-value row group is read first.
+2. Row group 9 (values `[90..100)`) is opened and read, filling
+ the heap with 10 values (the minimum heap value is `90`). The `TopK` dynamic filter threshold is updated to `90`.
+3. At the next row-group boundary, the pruner sees the dynamic filter has been updated
+ and re-evaluates the filter on all remaining row groups. Since row group 8
+ through row group 0 have `max < 90`, they are all pruned and skipped in a single pass.
+4. The scan ends early, having read only one row group instead of all 10.
+
+Note that for the row groups that were skipped, no data is fetched or decoded.
+This example shows the core benefit provided by runtime row-group dynamic
+pruning: reading a single row group can cascade-eliminate every remaining row
+group.
+
+**Row-Level Early Stopping**: Even if the row group cannot be pruned with statistics,
+the tightened dynamic filter often rules out all rows after evaluating just the
+filter columns. When it does, arrow-rs's `RowFilter` skips fetching the projection
+columns for that row group entirely. [#22450] makes this fire more often
+by keeping the threshold up-to-date at every row-group boundary.
+
+
+
+*Figure: inside a file, the first row group tightens the threshold —
+subsequent row groups have their projection columns short-circuited,
+but the filter column still has to be read to discover that no rows
+qualify.*
+
+
+## Benchmark: sort_tpch
+
+To test the overall benefit of this work, we used DataFusion's
+[sort_tpch](https://github.com/apache/datafusion/blob/main/benchmarks/src/sort_tpch.rs)
+benchmark that runs 11 queries over the TPC-H SF1 dataset, each with
+`ORDER BY ... LIMIT 100`. The data is stored in Parquet files, sorted by
+`l_orderkey`. For the largest table, `lineitem`, `l_orderkey` is a `BIGINT` with ~1.5M
+distinct values, so per-row-group `min/max` ranges are cleanly disjoint.
+We compared DataFusion with the sort optimizations enabled (the default) and disabled.
+
+
+
+| Metric | Value |
+| ----------------------------------- | ---------------------------------- |
+| Total wall-clock (sum of 11 queries) | 248.8 ms → 139.1 ms (**−44%**) |
+| Queries with ≥2× speedup | **5 of 11** (Q2, Q4, Q8, Q9, Q10) |
+| Queries with regression | **0** |
+| Best single-query speedup | **~4×** |
+
+*Per-query raw numbers are available in the [#22450 benchmark run][topk-tpch-raw].*
+
+[topk-tpch-raw]: https://github.com/apache/datafusion/pull/22450#issuecomment-4765161078
+
+The five queries that improved use `l_orderkey` as the **first** sort key column, so
+row-group-level pruning can cascade-prune aggressively. The other queries have multi-column
+sorts with
+low-cardinality or unsorted columns (`l_linenumber`, `l_comment`,
+`l_shipmode`), whose per-row-group ranges overlap heavily. Even when `l_orderkey`
+appears later as a tie-breaker, the leading key controls row-group-level disjointness,
+so row-level filtering is still partially effective.
+
+
+## Conclusion and Future Work
+
+Use cases where the query sort key (e.g. `ORDER BY time DESC LIMIT 10`) is
+aligned with the physical layout (e.g. the data is ordered by `time`) are common
+in time-series, partitioned tables, and ingestion-ordered event logs. Over the
+last few DataFusion releases, we implemented several optimizations that make
+these workloads up to ~4× faster without slowing down queries for which they
+don't apply. We hope you enjoy using them and welcome your feedback.
+
+Two follow-ups are open: page-level `Exact` reverse would let `DESC` queries drop the sort
+but needs lower-level support in the Parquet reader ([arrow-rs#9937](https://github.com/apache/arrow-rs/pull/9937))
+for reading pages in reverse order.
+We are also considering implementing page-level dynamic pruning at
+row-group boundaries ([#23216](https://github.com/apache/datafusion/issues/23216))
+using the same pattern one level deeper in Parquet.
+
+## Acknowledgements
+
+Thank you to [@adriangb], [@xudong963], [@2010YOUY01], and
+[@Dandandan] for reviewing the design and the patches across many
+iterations. The DataFusion community's willingness to engage deeply
+with optimizer changes, including ones that touch foundational
+code like the Parquet inner decode loop, is what made this work
+possible.
+
+We also thank [Massive](https://www.massive.com/) and [InfluxData](https://www.influxdata.com/) for sponsoring this
+work.
+
+[1](#fn1) For example, if the source declares
+`ts DESC`, reversing that ordering gives `ts ASC`, which can satisfy
+`date_trunc('day', ts) ASC`.
+
+[@adriangb]: https://github.com/adriangb
+[@xudong963]: https://github.com/xudong963
+[@2010YOUY01]: https://github.com/2010YOUY01
+[@Dandandan]: https://github.com/Dandandan
+
+## Get Involved
+
+- **Try it out**: Run your `ORDER BY` / `ORDER BY ... LIMIT N` queries on your own data and share what you see.
+- **Work on a [good first issue](https://github.com/apache/datafusion/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)**, or pick up one of the [follow-ups](https://github.com/apache/datafusion/issues/23036) listed in the umbrella issue.
+- **File issues or join the conversation**: [GitHub](https://github.com/apache/datafusion/) for bugs and feature requests, [Slack or Discord](https://datafusion.apache.org/contributor-guide/communication.html) for discussion.
+- Learn more by visiting the [DataFusion](https://datafusion.apache.org/index.html) project page.
+
+## Related Work
+
+The ideas in this blog build on a long line of research, and several other
+systems implement closely related techniques.
+
+**Exploiting and proving sort order.** Reasoning about orderings to
+eliminate redundant sorts goes back to "interesting orders" in System R
+([Selinger et al., SIGMOD 1979][selinger1979]) and was formalized by
+[Simmen, Shekita, and Malkemus (SIGMOD 1996)][simmen1996]. Those techniques
+derive orderings from *schema* — declared keys, indexes, and functional
+dependencies — whereas we derive them from per-file min/max *statistics*.
+Concatenating disjoint key ranges rather than merging them is Graefe's
+*virtual concatenation* ([ACM Computing Surveys 2006][graefe2006]) — an idea
+rooted further back in range-partitioned parallel sort
+([Graefe, ACM Computing Surveys 1993][graefe1993]). It also appears as
+"trivial move" compaction in LSM trees ([RocksDB][rocksdb-trivial]). For
+*declared* partition bounds, the "concatenate, don't merge" optimization
+appears in [TimescaleDB's OrderedAppend][timescale-append] and
+[PostgreSQL's ordered partition scans][pg-append].
+
+**Top-k early termination and threshold pushdown.** Using a running
+threshold to stop early is described in [Fagin's Threshold Algorithm
+(PODS 2001)][fagin2001], with index choice for faster convergence
+explored in [IO-Top-k (VLDB 2006)][iotopk2006]; see
+[Ilyas et al. (ACM Computing Surveys 2008)][ilyas2008] for a survey.
+Pushing a *live* top-k boundary value into a columnar scan to skip blocks
+via min/max is described for Snowflake in
+[Pruning in Snowflake: Working Smarter, Not Harder] and ships in
+[ClickHouse][ch-topn] (granule-level top-N skipping), [DuckDB][duckdb-topn]
+(dynamic Top-N table filters), [PolarDB-IMCI][polardb-topk] (self-sharpening
+runtime filters), and InfluxDB IOx ([ProgressiveEvalExec][iox-progressive]).
+
+**Reverse scans and data skipping.** Reading physically-ordered data in
+reverse to satisfy `DESC ... LIMIT` is a well-known technique in traditional
+databases (backward/descending index scans in [PostgreSQL][pg-backward] and [Oracle][oracle-backward]).
+Min/max block skipping itself originates with
+[Small Materialized Aggregates (Moerkotte, VLDB 1998)][sma1998] and is
+now ubiquitous across columnar storage formats and query engines.
+
+[selinger1979]: https://dl.acm.org/doi/10.1145/582095.582099
+[simmen1996]: https://dl.acm.org/doi/10.1145/233269.233320
+[fagin2001]: https://arxiv.org/abs/cs/0204046
+[iotopk2006]: https://www.vldb.org/conf/2006/p475-bast.pdf
+[ilyas2008]: https://dl.acm.org/doi/10.1145/1391729.1391730
+[sma1998]: https://www.vldb.org/conf/1998/p476.pdf
+[rocksdb-trivial]: https://github.com/facebook/rocksdb/wiki/Compaction-Trivial-Move
+[timescale-append]: https://www.tigerdata.com/blog/ordered-append-postgresql-optimization
+[pg-append]: https://www.postgresql.org/message-id/2401607.SfZhPQhbS4@ronan_laptop
+[pg-backward]: https://www.postgresql.org/docs/current/indexes-ordering.html
+[oracle-backward]: https://docs.oracle.com/en/database/oracle/oracle-database/19/tgsql/optimizer-access-paths.html
+[ch-topn]: https://clickhouse.com/blog/clickhouse-top-n-queries-granule-level-data-skipping
+[duckdb-topn]: https://duckdb.org/2024/10/25/topn
+[polardb-topk]: https://www.alibabacloud.com/blog/how-does-the-imci-of-polardb-for-mysql-achieve-ultimate-topk-query-performance_600006
+[iox-progressive]: https://www.influxdata.com/blog/query-optimization-progressive-evaluation-influxdb/
+
+
+## References
+
+Umbrella issue tracking the entire effort:
+
+* **[EPIC] Sort Pushdown · skip sorts and skip IO for ORDER BY / TopK queries: [#23036](https://github.com/apache/datafusion/issues/23036)** — phase-by-phase status of all the PRs and follow-ups.
+
+Major PRs:
+
+* `MinMaxStatistics` foundation: [#9593](https://github.com/apache/datafusion/pull/9593)
+* `PushdownSort` rule + row-group reverse: [#19064](https://github.com/apache/datafusion/pull/19064)
+* Reverse-output redesign: [#19446](https://github.com/apache/datafusion/pull/19446), [#19557](https://github.com/apache/datafusion/pull/19557)
+* Sort elimination via statistics: [#21182](https://github.com/apache/datafusion/pull/21182)
+* `BufferExec` capacity for sort elimination: [#21426](https://github.com/apache/datafusion/pull/21426)
+* Push-based Parquet decoder (DataFusion owns the loop): [#20839](https://github.com/apache/datafusion/pull/20839)
+* Morsel-style work scheduling: [#21351](https://github.com/apache/datafusion/pull/21351)
+* Runtime reorder for `TopK` convergence: [#21956](https://github.com/apache/datafusion/pull/21956)
+* **Runtime row-group dynamic pruning ([#22450])** — the centerpiece of this post.
+* `peek_next_row_group` API (arrow-rs): [arrow-rs#10158](https://github.com/apache/arrow-rs/pull/10158) — enables per-RG `fully_matched` RowFilter skip.
+
+In flight / open:
+
+* Page-level reverse (arrow-rs): [arrow-rs#9937](https://github.com/apache/arrow-rs/pull/9937), discussion in [arrow-rs#9934](https://github.com/apache/arrow-rs/issues/9934)
+* Per-RG `fully_matched` RowFilter skip on top of [#22450] (uses arrow-rs#10158): [#23067](https://github.com/apache/datafusion/issues/23067)
+* Page-level dynamic prune at RG boundary: [#23216](https://github.com/apache/datafusion/issues/23216)
+* Multi-column / function-wrapped stats reorder follow-ups: [#22198](https://github.com/apache/datafusion/issues/22198)
+
+Benchmark suites: [sort_pushdown](https://github.com/apache/datafusion/tree/main/benchmarks/queries/sort_pushdown), [sort_tpch](https://github.com/apache/datafusion/blob/main/benchmarks/src/sort_tpch.rs).
+
+
+## Appendix: Buffering Without Sorting
+
+
+*Figure: removing the per-partition `SortExec` leaves the top-of-plan
+merge (`SortPreservingMergeExec`) directly consuming raw I/O; a stall
+on any partition stalls the whole plan.*
+
+Removing `SortExec` was not always faster in multi-partition plans: the deleted
+sort had also acted as an implicit buffer. The fix was explicit buffering in
+some plans; see
+[#21426](https://github.com/apache/datafusion/pull/21426)
+for details, as shown in the following figure.
+
+
+*Figure: `BufferExec` is inserted where the `SortExec` used to live —
+same greedy per-partition prefill, but no blocking sort.*
+
+The fix is [BufferExec](https://github.com/apache/datafusion/blob/main/datafusion/physical-plan/src/buffer.rs):
+a bounded per-partition prefill buffer that restores the greedy parallel I/O
+driver role without sorting.
+
+
+
+## Appendix: Decoder Loop and Decision Point
+
+
+*Figure: the decoder loop has three steps. Step 2 (DECIDE) is what
+[#22450] adds — it only fires at row-group boundaries.*
+
+The loop body reads: **drain** the current row group's batches until
+it's exhausted; **decide** at the boundary whether any of the
+remaining row groups can be dropped based on the live threshold; then
+**drive** the decoder into the next row group and repeat. Inside a
+row group, only drain and drive run — no decision point.
+
+
+*Figure: the pruner has a cheap "check if the filter changed" step, a
+moderately expensive "rebuild the predicate if so" step, and a cheap
+"apply the predicate to remaining row groups" step.*
+
+The pruner does expensive work only when it can help: a cheap epoch check
+detects dynamic-filter changes, and only then rebuilds the pruning predicate.
+The predicate is then applied to remaining row groups' min/max statistics using
+metadata only.
+
diff --git a/content/images/sort-pushdown/arch_one_glance.png b/content/images/sort-pushdown/arch_one_glance.png
new file mode 100644
index 00000000..7e8dafc3
Binary files /dev/null and b/content/images/sort-pushdown/arch_one_glance.png differ
diff --git a/content/images/sort-pushdown/benchmark.svg b/content/images/sort-pushdown/benchmark.svg
new file mode 100644
index 00000000..4eed0a7c
--- /dev/null
+++ b/content/images/sort-pushdown/benchmark.svg
@@ -0,0 +1,78 @@
+
diff --git a/content/images/sort-pushdown/buffer-exec-stall.svg b/content/images/sort-pushdown/buffer-exec-stall.svg
new file mode 100644
index 00000000..bbcc9a24
--- /dev/null
+++ b/content/images/sort-pushdown/buffer-exec-stall.svg
@@ -0,0 +1,160 @@
+
diff --git a/content/images/sort-pushdown/buffer-exec.svg b/content/images/sort-pushdown/buffer-exec.svg
new file mode 100644
index 00000000..fe8a744d
--- /dev/null
+++ b/content/images/sort-pushdown/buffer-exec.svg
@@ -0,0 +1,89 @@
+
diff --git a/content/images/sort-pushdown/desc_walk_file.svg b/content/images/sort-pushdown/desc_walk_file.svg
new file mode 100644
index 00000000..7e411774
--- /dev/null
+++ b/content/images/sort-pushdown/desc_walk_file.svg
@@ -0,0 +1,80 @@
+
diff --git a/content/images/sort-pushdown/desc_walk_rg.svg b/content/images/sort-pushdown/desc_walk_rg.svg
new file mode 100644
index 00000000..945ce29c
--- /dev/null
+++ b/content/images/sort-pushdown/desc_walk_rg.svg
@@ -0,0 +1,61 @@
+
diff --git a/content/images/sort-pushdown/future_page_level.png b/content/images/sort-pushdown/future_page_level.png
new file mode 100644
index 00000000..e41f781b
Binary files /dev/null and b/content/images/sort-pushdown/future_page_level.png differ
diff --git a/content/images/sort-pushdown/phase1-file-reorder.svg b/content/images/sort-pushdown/phase1-file-reorder.svg
new file mode 100644
index 00000000..00d8e24a
--- /dev/null
+++ b/content/images/sort-pushdown/phase1-file-reorder.svg
@@ -0,0 +1,88 @@
+
diff --git a/content/images/sort-pushdown/phase2-stats-overlap.svg b/content/images/sort-pushdown/phase2-stats-overlap.svg
new file mode 100644
index 00000000..8580c210
--- /dev/null
+++ b/content/images/sort-pushdown/phase2-stats-overlap.svg
@@ -0,0 +1,89 @@
+
diff --git a/content/images/sort-pushdown/plan-diff.svg b/content/images/sort-pushdown/plan-diff.svg
new file mode 100644
index 00000000..8fdd8bc4
--- /dev/null
+++ b/content/images/sort-pushdown/plan-diff.svg
@@ -0,0 +1,53 @@
+
diff --git a/content/images/sort-pushdown/pr21956-decision.svg b/content/images/sort-pushdown/pr21956-decision.svg
new file mode 100644
index 00000000..f03aa853
--- /dev/null
+++ b/content/images/sort-pushdown/pr21956-decision.svg
@@ -0,0 +1,87 @@
+
diff --git a/content/images/sort-pushdown/pr21956-runtime-pipeline.svg b/content/images/sort-pushdown/pr21956-runtime-pipeline.svg
new file mode 100644
index 00000000..1218af12
--- /dev/null
+++ b/content/images/sort-pushdown/pr21956-runtime-pipeline.svg
@@ -0,0 +1,71 @@
+
diff --git a/content/images/sort-pushdown/pruner_loop.png b/content/images/sort-pushdown/pruner_loop.png
new file mode 100644
index 00000000..3852b0c3
Binary files /dev/null and b/content/images/sort-pushdown/pruner_loop.png differ
diff --git a/content/images/sort-pushdown/pruning_stack.svg b/content/images/sort-pushdown/pruning_stack.svg
new file mode 100644
index 00000000..f59decda
--- /dev/null
+++ b/content/images/sort-pushdown/pruning_stack.svg
@@ -0,0 +1,84 @@
+
diff --git a/content/images/sort-pushdown/reverse-scan.svg b/content/images/sort-pushdown/reverse-scan.svg
new file mode 100644
index 00000000..443a0a1c
--- /dev/null
+++ b/content/images/sort-pushdown/reverse-scan.svg
@@ -0,0 +1,100 @@
+
diff --git a/content/images/sort-pushdown/rg_cascade.svg b/content/images/sort-pushdown/rg_cascade.svg
new file mode 100644
index 00000000..dc13616b
--- /dev/null
+++ b/content/images/sort-pushdown/rg_cascade.svg
@@ -0,0 +1,113 @@
+
diff --git a/content/images/sort-pushdown/topk_tpch_bench.svg b/content/images/sort-pushdown/topk_tpch_bench.svg
new file mode 100644
index 00000000..da65fd17
--- /dev/null
+++ b/content/images/sort-pushdown/topk_tpch_bench.svg
@@ -0,0 +1,121 @@
+
diff --git a/content/images/sort-pushdown/transition_anatomy.png b/content/images/sort-pushdown/transition_anatomy.png
new file mode 100644
index 00000000..37a38b52
Binary files /dev/null and b/content/images/sort-pushdown/transition_anatomy.png differ