This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new f38096a0 [datafusion] Support runtime filters for Paimon scans (#549)
f38096a0 is described below

commit f38096a09c696cd2b5c9933ba44690244df9f23e
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Jul 20 14:28:35 2026 +0800

    [datafusion] Support runtime filters for Paimon scans (#549)
---
 benchmarks/tpcds/README.md                         |   5 +
 benchmarks/tpcds/src/cli.rs                        |   4 +
 benchmarks/tpcds/src/context.rs                    |  10 +-
 benchmarks/tpcds/src/lib.rs                        |   9 +
 benchmarks/tpcds/tests/smoke.rs                    |   1 +
 crates/integrations/datafusion/src/config.rs       |  61 ++
 .../integrations/datafusion/src/filter_pushdown.rs |   2 +-
 crates/integrations/datafusion/src/lib.rs          |   1 +
 .../datafusion/src/physical_plan/scan.rs           | 823 ++++++++++++++++++++-
 crates/integrations/datafusion/src/sql_context.rs  |  55 +-
 crates/integrations/datafusion/tests/blob_tests.rs |   3 +-
 crates/paimon/src/arrow/format/avro.rs             |   4 +
 crates/paimon/src/arrow/format/mod.rs              |   8 +-
 crates/paimon/src/arrow/format/mosaic.rs           |   2 +
 crates/paimon/src/arrow/format/orc.rs              |   2 +
 crates/paimon/src/arrow/format/parquet.rs          |  40 +-
 crates/paimon/src/arrow/format/row.rs              |   3 +
 crates/paimon/src/arrow/format/vortex.rs           |  11 +
 crates/paimon/src/arrow/residual.rs                |   7 +-
 crates/paimon/src/table/data_evolution_reader.rs   |  83 ++-
 crates/paimon/src/table/data_file_reader.rs        | 134 +++-
 crates/paimon/src/table/format_table_read.rs       |  10 +-
 crates/paimon/src/table/kv_file_reader.rs          |  65 +-
 crates/paimon/src/table/table_read.rs              |  48 +-
 crates/paimon/src/table/vector_search_builder.rs   |   2 +
 docs/src/sql.md                                    |   4 +-
 26 files changed, 1324 insertions(+), 73 deletions(-)

diff --git a/benchmarks/tpcds/README.md b/benchmarks/tpcds/README.md
index f5872214..606f0909 100644
--- a/benchmarks/tpcds/README.md
+++ b/benchmarks/tpcds/README.md
@@ -164,6 +164,7 @@ target/release/paimon-tpcds-bench run \
   --warmup 1 \
   --iterations 3 \
   --target-partitions 64 \
+  --parquet-pushdown-filters \
   --memory-limit-gib 192 \
   --spill-dir /nvme/datafusion-spill \
   --max-spill-gib 1024
@@ -173,6 +174,10 @@ This is an end-to-end source comparison. Loading the data 
into Paimon rewrites
 the physical files, so it is not a pure measurement of catalog or manifest
 overhead.
 
+`--parquet-pushdown-filters` only controls DataFusion's Parquet reader. Paimon
+always receives supported predicates for conservative pruning, while exact row
+filtering stays in the parent DataFusion operator for benchmark runs.
+
 ## Cache Protocol
 
 Run and label cold and warm experiments separately:
diff --git a/benchmarks/tpcds/src/cli.rs b/benchmarks/tpcds/src/cli.rs
index 878819f2..ece7f9de 100644
--- a/benchmarks/tpcds/src/cli.rs
+++ b/benchmarks/tpcds/src/cli.rs
@@ -113,6 +113,9 @@ pub struct RuntimeArgs {
     /// DataFusion execution partitions. Defaults to available CPUs.
     #[arg(long)]
     pub target_partitions: Option<usize>,
+    /// Evaluate pushed filters during Parquet scans, in addition to 
statistics pruning.
+    #[arg(long)]
+    pub parquet_pushdown_filters: bool,
     /// DataFusion memory limit in GiB. Omit for an unbounded pool.
     #[arg(long)]
     pub memory_limit_gib: Option<u64>,
@@ -132,6 +135,7 @@ impl RuntimeArgs {
                 .target_partitions
                 .unwrap_or(defaults.target_partitions)
                 .max(1),
+            parquet_pushdown_filters: self.parquet_pushdown_filters,
             memory_limit_bytes: 
self.memory_limit_gib.map(gib_to_usize).transpose()?,
             spill_dir: self.spill_dir.clone(),
             max_spill_bytes: self.max_spill_gib.map(gib_to_u64).transpose()?,
diff --git a/benchmarks/tpcds/src/context.rs b/benchmarks/tpcds/src/context.rs
index a4a4cd4e..c9b0d703 100644
--- a/benchmarks/tpcds/src/context.rs
+++ b/benchmarks/tpcds/src/context.rs
@@ -28,6 +28,8 @@ use serde::{Deserialize, Serialize};
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct BenchmarkRuntimeConfig {
     pub target_partitions: usize,
+    #[serde(default)]
+    pub parquet_pushdown_filters: bool,
     pub memory_limit_bytes: Option<usize>,
     pub spill_dir: Option<PathBuf>,
     pub max_spill_bytes: Option<u64>,
@@ -39,6 +41,7 @@ impl Default for BenchmarkRuntimeConfig {
             target_partitions: std::thread::available_parallelism()
                 .map(usize::from)
                 .unwrap_or(1),
+            parquet_pushdown_filters: false,
             memory_limit_bytes: None,
             spill_dir: None,
             max_spill_bytes: None,
@@ -60,10 +63,15 @@ pub fn build_sql_context(config: &BenchmarkRuntimeConfig) 
-> DataFusionResult<SQ
     let sql = SQLContext::new();
     let state_ref = sql.ctx().state_ref();
     let current_state = state_ref.read().clone();
-    let session_config = current_state
+    let mut session_config = current_state
         .config()
         .clone()
         .with_target_partitions(config.target_partitions.max(1));
+    session_config
+        .options_mut()
+        .execution
+        .parquet
+        .pushdown_filters = config.parquet_pushdown_filters;
     let state = SessionStateBuilder::from(current_state)
         .with_config(session_config)
         .with_runtime_env(Arc::new(runtime.build()?))
diff --git a/benchmarks/tpcds/src/lib.rs b/benchmarks/tpcds/src/lib.rs
index 6229b550..39442f86 100644
--- a/benchmarks/tpcds/src/lib.rs
+++ b/benchmarks/tpcds/src/lib.rs
@@ -183,6 +183,7 @@ mod tests {
         let spill_dir = TempDir::new().unwrap();
         let ctx = build_sql_context(&BenchmarkRuntimeConfig {
             target_partitions: 3,
+            parquet_pushdown_filters: true,
             memory_limit_bytes: Some(32 * 1024 * 1024),
             spill_dir: Some(spill_dir.path().to_path_buf()),
             max_spill_bytes: Some(64 * 1024 * 1024),
@@ -197,6 +198,14 @@ mod tests {
                 .target_partitions,
             3
         );
+        assert!(
+            ctx.ctx()
+                .state()
+                .config_options()
+                .execution
+                .parquet
+                .pushdown_filters
+        );
         assert!(matches!(
             ctx.ctx().runtime_env().memory_pool.memory_limit(),
             MemoryLimit::Finite(size) if size == 32 * 1024 * 1024
diff --git a/benchmarks/tpcds/tests/smoke.rs b/benchmarks/tpcds/tests/smoke.rs
index c86d73ef..2974a3d8 100644
--- a/benchmarks/tpcds/tests/smoke.rs
+++ b/benchmarks/tpcds/tests/smoke.rs
@@ -225,6 +225,7 @@ async fn 
command_orchestration_loads_and_writes_a_run_report() {
     .unwrap();
     let runtime = RuntimeArgs {
         target_partitions: Some(2),
+        parquet_pushdown_filters: false,
         memory_limit_gib: None,
         spill_dir: None,
         max_spill_gib: None,
diff --git a/crates/integrations/datafusion/src/config.rs 
b/crates/integrations/datafusion/src/config.rs
new file mode 100644
index 00000000..7903a4fa
--- /dev/null
+++ b/crates/integrations/datafusion/src/config.rs
@@ -0,0 +1,61 @@
+// 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.
+
+use datafusion::common::config::ConfigExtension;
+use datafusion::common::{config_namespace, extensions_options};
+
+pub const PAIMON_ROW_FILTER: &str = "paimon.read.row_filter";
+
+config_namespace! {
+    /// Paimon read options.
+    pub struct PaimonReadOptions {
+        /// Apply pushed predicates as row filters inside Paimon readers.
+        pub row_filter: bool, default = false
+    }
+}
+
+extensions_options! {
+    /// Paimon-specific DataFusion session options.
+    pub struct PaimonConfig {
+        /// Options that control Paimon reads.
+        pub read: PaimonReadOptions, default = PaimonReadOptions::default()
+    }
+}
+
+impl ConfigExtension for PaimonConfig {
+    const PREFIX: &'static str = "paimon";
+}
+
+#[cfg(test)]
+mod tests {
+    use datafusion::config::ConfigOptions;
+
+    use super::*;
+
+    #[test]
+    fn paimon_row_filter_defaults_to_false_and_can_be_set() {
+        let mut options = ConfigOptions::default();
+        options.extensions.insert(PaimonConfig::default());
+
+        let config = options.extensions.get::<PaimonConfig>().unwrap();
+        assert!(!config.read.row_filter);
+
+        options.set("paimon.read.row_filter", "true").unwrap();
+        let config = options.extensions.get::<PaimonConfig>().unwrap();
+        assert!(config.read.row_filter);
+    }
+}
diff --git a/crates/integrations/datafusion/src/filter_pushdown.rs 
b/crates/integrations/datafusion/src/filter_pushdown.rs
index cef12aa6..33fd2aca 100644
--- a/crates/integrations/datafusion/src/filter_pushdown.rs
+++ b/crates/integrations/datafusion/src/filter_pushdown.rs
@@ -410,7 +410,7 @@ fn reverse_comparison_operator(op: Operator) -> 
Option<Operator> {
     }
 }
 
-fn scalar_to_datum(scalar: &ScalarValue, data_type: &DataType) -> 
Option<Datum> {
+pub(crate) fn scalar_to_datum(scalar: &ScalarValue, data_type: &DataType) -> 
Option<Datum> {
     match data_type {
         DataType::Boolean(_) => match scalar {
             ScalarValue::Boolean(Some(value)) => Some(Datum::Bool(*value)),
diff --git a/crates/integrations/datafusion/src/lib.rs 
b/crates/integrations/datafusion/src/lib.rs
index 05e4037a..6f24ff92 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -40,6 +40,7 @@ mod blob_descriptor_functions;
 mod blob_reader;
 mod blob_view;
 mod catalog;
+pub mod config;
 mod delete;
 mod error;
 mod filter_pushdown;
diff --git a/crates/integrations/datafusion/src/physical_plan/scan.rs 
b/crates/integrations/datafusion/src/physical_plan/scan.rs
index f374c034..2aa72536 100644
--- a/crates/integrations/datafusion/src/physical_plan/scan.rs
+++ b/crates/integrations/datafusion/src/physical_plan/scan.rs
@@ -15,27 +15,53 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use std::collections::HashSet;
 use std::sync::Arc;
+use std::time::Duration;
 
-use datafusion::arrow::compute::cast;
+use datafusion::arrow::array::BooleanArray;
+use datafusion::arrow::compute::{cast, filter_record_batch};
 use datafusion::arrow::datatypes::{
     DataType as ArrowDataType, SchemaRef as ArrowSchemaRef, TimeUnit,
 };
 use datafusion::arrow::record_batch::{RecordBatch, RecordBatchOptions};
 use datafusion::common::stats::Precision;
 use datafusion::common::{ColumnStatistics, ScalarValue, Statistics};
+use datafusion::config::ConfigOptions;
+use 
datafusion::datasource::physical_plan::parquet::can_expr_be_pushed_down_with_schemas;
 use datafusion::error::Result as DFResult;
 use datafusion::execution::{SendableRecordBatchStream, TaskContext};
+use datafusion::logical_expr::Operator;
+use datafusion::physical_expr::expressions::{
+    BinaryExpr, Column, DynamicFilterPhysicalExpr, InListExpr, Literal,
+};
 use datafusion::physical_expr::EquivalenceProperties;
+use datafusion::physical_expr::PhysicalExpr;
 use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
+use datafusion::physical_plan::filter_pushdown::{
+    ChildPushdownResult, FilterPushdownPhase, FilterPushdownPropagation, 
PushedDown,
+};
 use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
 use datafusion::physical_plan::{DisplayAs, ExecutionPlan, Partitioning, 
PlanProperties};
+use futures::stream::FuturesUnordered;
 use futures::{StreamExt, TryStreamExt};
-use paimon::spec::{DataField, Datum, MergeEngine, Predicate};
+use paimon::spec::{DataField, Datum, MergeEngine, Predicate, PredicateBuilder};
 use paimon::table::{ScanTrace, Table};
 use paimon::DataSplit;
 
+use crate::config::PaimonConfig;
 use crate::error::to_datafusion_error;
+use crate::filter_pushdown::scalar_to_datum;
+
+const RUNTIME_FILTER_WAIT_MIN_ROWS: usize = 250_000;
+const RUNTIME_FILTER_WAIT_TIMEOUT: Duration = Duration::from_secs(1);
+
+fn scan_applies_row_filter(config: &ConfigOptions) -> bool {
+    config
+        .extensions
+        .get::<PaimonConfig>()
+        .is_some_and(|config| config.read.row_filter)
+}
 
 fn to_datafusion_batch(batch: RecordBatch, schema: &ArrowSchemaRef) -> 
DFResult<RecordBatch> {
     if batch.num_columns() != schema.fields().len() {
@@ -64,6 +90,250 @@ fn to_datafusion_batch(batch: RecordBatch, schema: 
&ArrowSchemaRef) -> DFResult<
     RecordBatch::try_new_with_options(Arc::clone(schema), columns, 
&options).map_err(Into::into)
 }
 
+async fn runtime_pruning_predicate(
+    filters: &[Arc<dyn PhysicalExpr>],
+    fields: &[DataField],
+    case_sensitive: bool,
+    wait_timeout: Duration,
+) -> Option<Predicate> {
+    let mut pending = filters.to_vec();
+    let mut dynamic_filters = Vec::new();
+    while let Some(expr) = pending.pop() {
+        pending.extend(expr.children().into_iter().cloned());
+        if expr.downcast_ref::<DynamicFilterPhysicalExpr>().is_some() {
+            dynamic_filters.push(expr);
+        }
+    }
+
+    let mut waiters = FuturesUnordered::new();
+    for expr in dynamic_filters {
+        waiters.push(async move {
+            let dynamic = expr
+                .downcast_ref::<DynamicFilterPhysicalExpr>()
+                .expect("only dynamic filters are queued");
+            dynamic.wait_complete().await;
+            dynamic.expression_id()
+        });
+    }
+
+    let deadline = tokio::time::Instant::now() + wait_timeout;
+    let mut completed_dynamic_filters = HashSet::new();
+    while !waiters.is_empty() {
+        match tokio::time::timeout_at(deadline, waiters.next()).await {
+            Ok(Some(Some(expression_id))) => {
+                completed_dynamic_filters.insert(expression_id);
+            }
+            Ok(Some(None)) => {}
+            Ok(None) | Err(_) => break,
+        }
+    }
+
+    let predicate_builder = PredicateBuilder::new_with_case_sensitive(fields, 
case_sensitive);
+    let mut predicates = Vec::new();
+    for filter in filters {
+        collect_runtime_pruning_predicates(
+            filter.as_ref(),
+            fields,
+            &predicate_builder,
+            case_sensitive,
+            &completed_dynamic_filters,
+            &mut predicates,
+        );
+    }
+    (!predicates.is_empty()).then(|| Predicate::and(predicates))
+}
+
+fn runtime_filter_wait_timeout(estimated_rows: usize) -> Duration {
+    if estimated_rows > RUNTIME_FILTER_WAIT_MIN_ROWS {
+        RUNTIME_FILTER_WAIT_TIMEOUT
+    } else {
+        Duration::ZERO
+    }
+}
+
+fn collect_runtime_pruning_predicates(
+    expr: &dyn PhysicalExpr,
+    fields: &[DataField],
+    predicate_builder: &PredicateBuilder,
+    case_sensitive: bool,
+    completed_dynamic_filters: &HashSet<u64>,
+    predicates: &mut Vec<Predicate>,
+) {
+    if let Some(dynamic) = expr.downcast_ref::<DynamicFilterPhysicalExpr>() {
+        if dynamic
+            .expression_id()
+            .is_none_or(|id| !completed_dynamic_filters.contains(&id))
+        {
+            return;
+        }
+        if let Ok(current) = dynamic.current() {
+            collect_runtime_pruning_predicates(
+                current.as_ref(),
+                fields,
+                predicate_builder,
+                case_sensitive,
+                completed_dynamic_filters,
+                predicates,
+            );
+        }
+        return;
+    }
+
+    if let Some(binary) = expr.downcast_ref::<BinaryExpr>() {
+        if binary.op() == &Operator::And {
+            collect_runtime_pruning_predicates(
+                binary.left().as_ref(),
+                fields,
+                predicate_builder,
+                case_sensitive,
+                completed_dynamic_filters,
+                predicates,
+            );
+            collect_runtime_pruning_predicates(
+                binary.right().as_ref(),
+                fields,
+                predicate_builder,
+                case_sensitive,
+                completed_dynamic_filters,
+                predicates,
+            );
+        } else if let Some(predicate) =
+            translate_runtime_comparison(binary, fields, predicate_builder, 
case_sensitive)
+        {
+            predicates.push(predicate);
+        }
+        return;
+    }
+
+    if let Some(in_list) = expr.downcast_ref::<InListExpr>() {
+        if let Some(predicate) =
+            translate_runtime_in_list(in_list, fields, predicate_builder, 
case_sensitive)
+        {
+            predicates.push(predicate);
+        }
+    }
+}
+
+fn translate_runtime_comparison(
+    binary: &BinaryExpr,
+    fields: &[DataField],
+    predicate_builder: &PredicateBuilder,
+    case_sensitive: bool,
+) -> Option<Predicate> {
+    let direct = runtime_column_literal(
+        binary.left().as_ref(),
+        binary.right().as_ref(),
+        fields,
+        case_sensitive,
+    )
+    .map(|(field, datum)| (*binary.op(), field, datum));
+    let comparison = direct.or_else(|| {
+        runtime_column_literal(
+            binary.right().as_ref(),
+            binary.left().as_ref(),
+            fields,
+            case_sensitive,
+        )
+        .and_then(|(field, datum)| {
+            reverse_runtime_comparison(*binary.op()).map(|op| (op, field, 
datum))
+        })
+    })?;
+
+    let (op, field, datum) = comparison;
+    if matches!(
+        field.data_type(),
+        paimon::spec::DataType::Binary(_) | 
paimon::spec::DataType::VarBinary(_)
+    ) && matches!(
+        op,
+        Operator::Lt | Operator::LtEq | Operator::Gt | Operator::GtEq
+    ) {
+        // Arrow compares binary values as unsigned bytes, while Paimon follows
+        // Java's signed-byte ordering. Range predicates could therefore prune
+        // rows that DataFusion would keep; equality predicates remain safe.
+        return None;
+    }
+    match op {
+        Operator::Eq => predicate_builder.equal(field.name(), datum).ok(),
+        Operator::NotEq => predicate_builder.not_equal(field.name(), 
datum).ok(),
+        Operator::Lt => predicate_builder.less_than(field.name(), datum).ok(),
+        Operator::LtEq => predicate_builder.less_or_equal(field.name(), 
datum).ok(),
+        Operator::Gt => predicate_builder.greater_than(field.name(), 
datum).ok(),
+        Operator::GtEq => predicate_builder.greater_or_equal(field.name(), 
datum).ok(),
+        _ => None,
+    }
+}
+
+fn translate_runtime_in_list(
+    in_list: &InListExpr,
+    fields: &[DataField],
+    predicate_builder: &PredicateBuilder,
+    case_sensitive: bool,
+) -> Option<Predicate> {
+    let column = in_list.expr().downcast_ref::<Column>()?;
+    let field = resolve_runtime_field(column.name(), fields, case_sensitive)?;
+    let literals = in_list
+        .list()
+        .iter()
+        .map(|expr| {
+            let literal = expr.downcast_ref::<Literal>()?;
+            if literal.value().is_null() {
+                return None;
+            }
+            scalar_to_datum(literal.value(), field.data_type())
+        })
+        .collect::<Option<Vec<_>>>()?;
+
+    if in_list.negated() {
+        predicate_builder.is_not_in(field.name(), literals).ok()
+    } else {
+        predicate_builder.is_in(field.name(), literals).ok()
+    }
+}
+
+fn runtime_column_literal<'a>(
+    column: &dyn PhysicalExpr,
+    literal: &dyn PhysicalExpr,
+    fields: &'a [DataField],
+    case_sensitive: bool,
+) -> Option<(&'a DataField, Datum)> {
+    let column = column.downcast_ref::<Column>()?;
+    let literal = literal.downcast_ref::<Literal>()?;
+    if literal.value().is_null() {
+        return None;
+    }
+    let field = resolve_runtime_field(column.name(), fields, case_sensitive)?;
+    let datum = scalar_to_datum(literal.value(), field.data_type())?;
+    Some((field, datum))
+}
+
+fn resolve_runtime_field<'a>(
+    name: &str,
+    fields: &'a [DataField],
+    case_sensitive: bool,
+) -> Option<&'a DataField> {
+    if case_sensitive {
+        fields.iter().find(|field| field.name() == name)
+    } else {
+        let mut matches = fields
+            .iter()
+            .filter(|field| field.name().eq_ignore_ascii_case(name));
+        let field = matches.next()?;
+        matches.next().is_none().then_some(field)
+    }
+}
+
+fn reverse_runtime_comparison(op: Operator) -> Option<Operator> {
+    match op {
+        Operator::Eq => Some(Operator::Eq),
+        Operator::NotEq => Some(Operator::NotEq),
+        Operator::Lt => Some(Operator::Gt),
+        Operator::LtEq => Some(Operator::GtEq),
+        Operator::Gt => Some(Operator::Lt),
+        Operator::GtEq => Some(Operator::LtEq),
+        _ => None,
+    }
+}
+
 #[derive(Debug)]
 struct ColumnStatsAccumulator {
     min_value: Option<Datum>,
@@ -249,7 +519,7 @@ fn datum_to_scalar(value: Datum, data_type: &ArrowDataType) 
-> Option<ScalarValu
 /// Planning is performed eagerly in 
[`super::super::table::PaimonTableProvider::scan`],
 /// and the resulting splits are distributed across DataFusion execution 
partitions
 /// so that DataFusion can schedule them in parallel.
-#[derive(Debug)]
+#[derive(Debug, Clone)]
 pub struct PaimonTableScan {
     table: Table,
     /// Full Paimon read type for nested or connector-defined projections.
@@ -274,6 +544,14 @@ pub struct PaimonTableScan {
     /// Column-name case sensitivity carried from planning to execution so the
     /// read path resolves names the same way the scan was planned.
     case_sensitive: bool,
+    /// Physical filters retained from DataFusion's runtime filter-pushdown 
pass.
+    /// They are always available for conservative reader pruning and are
+    /// evaluated exactly only when Paimon row filtering is enabled.
+    runtime_filters: Vec<Arc<dyn PhysicalExpr>>,
+    /// Planning-time decision that this scan evaluates `runtime_filters`
+    /// exactly. Stored on the plan so execution cannot observe a different
+    /// session setting after the parent FilterExec has been removed.
+    apply_row_filter: bool,
 }
 
 impl PaimonTableScan {
@@ -307,6 +585,8 @@ impl PaimonTableScan {
             scan_trace,
             pushed_variants,
             case_sensitive,
+            runtime_filters: Vec::new(),
+            apply_row_filter: false,
         }
     }
 
@@ -347,8 +627,9 @@ impl PaimonTableScan {
             return Statistics::unknown_column(&self.schema());
         }
 
-        let exact_null_counts = (self.table.schema().primary_keys().is_empty()
-            || merge_engine == MergeEngine::Deduplicate)
+        let exact_null_counts = self.runtime_filters.is_empty()
+            && (self.table.schema().primary_keys().is_empty()
+                || merge_engine == MergeEngine::Deduplicate)
             && self.pushed_predicate.is_none()
             && self.limit.is_none()
             && partitions
@@ -403,6 +684,59 @@ impl ExecutionPlan for PaimonTableScan {
         Ok(self)
     }
 
+    fn handle_child_pushdown_result(
+        &self,
+        _phase: FilterPushdownPhase,
+        child_pushdown_result: ChildPushdownResult,
+        config: &ConfigOptions,
+    ) -> DFResult<FilterPushdownPropagation<Arc<dyn ExecutionPlan>>> {
+        let filters = child_pushdown_result
+            .parent_filters
+            .into_iter()
+            .map(|result| result.filter)
+            .collect::<Vec<_>>();
+        if filters.is_empty() {
+            return Ok(FilterPushdownPropagation::with_parent_pushdown_result(
+                Vec::new(),
+            ));
+        }
+
+        let schema = self.schema();
+        let apply_row_filter = scan_applies_row_filter(config);
+        let mut accepted = Vec::new();
+        let parent_filter_handled = filters
+            .into_iter()
+            .map(|filter| {
+                if can_expr_be_pushed_down_with_schemas(&filter, 
schema.as_ref()) {
+                    accepted.push(filter);
+                    // `PushedDown` reports whether this scan evaluates the 
predicate
+                    // exactly so the parent FilterExec can be removed. The 
predicate is
+                    // retained above for pruning regardless of this result.
+                    if apply_row_filter {
+                        PushedDown::Yes
+                    } else {
+                        PushedDown::No
+                    }
+                } else {
+                    PushedDown::No
+                }
+            })
+            .collect::<Vec<_>>();
+        if accepted.is_empty() {
+            return Ok(FilterPushdownPropagation::with_parent_pushdown_result(
+                parent_filter_handled,
+            ));
+        }
+
+        let mut scan = self.clone();
+        scan.runtime_filters.extend(accepted);
+        scan.apply_row_filter = apply_row_filter;
+        Ok(
+            
FilterPushdownPropagation::with_parent_pushdown_result(parent_filter_handled)
+                .with_updated_node(Arc::new(scan)),
+        )
+    }
+
     fn execute(
         &self,
         partition: usize,
@@ -420,23 +754,60 @@ impl ExecutionPlan for PaimonTableScan {
         let read_type = self.read_type.clone();
         let pushed_predicate = self.pushed_predicate.clone();
         let case_sensitive = self.case_sensitive;
+        let runtime_filters = self.runtime_filters.clone();
+        let apply_row_filter = self.apply_row_filter;
 
         let fut = async move {
             let mut read_builder = table.new_read_builder();
 
             read_builder.with_case_sensitive(case_sensitive);
             read_builder.with_read_type(read_type);
-            if let Some(filter) = pushed_predicate {
+            let estimated_rows = splits
+                .iter()
+                .filter_map(|split| usize::try_from(split.row_count()).ok())
+                .fold(0usize, usize::saturating_add);
+            let runtime_pruning = runtime_pruning_predicate(
+                &runtime_filters,
+                table.schema().fields(),
+                case_sensitive,
+                runtime_filter_wait_timeout(estimated_rows),
+            )
+            .await;
+            let read_predicate = match (pushed_predicate, runtime_pruning) {
+                (Some(pushed), Some(runtime)) => 
Some(Predicate::and(vec![pushed, runtime])),
+                (Some(predicate), None) | (None, Some(predicate)) => 
Some(predicate),
+                (None, None) => None,
+            };
+            if let Some(filter) = read_predicate {
                 read_builder.with_filter(filter);
             }
 
-            let read = read_builder.new_read().map_err(to_datafusion_error)?;
+            let read = read_builder
+                .new_read()
+                .map_err(to_datafusion_error)?
+                .with_row_filter(apply_row_filter);
             let stream = read.to_arrow(&splits).map_err(to_datafusion_error)?;
             let batch_schema = Arc::clone(&schema);
             let stream = stream.map(move |result| {
-                result
+                let mut batch = result
                     .map_err(to_datafusion_error)
-                    .and_then(|batch| to_datafusion_batch(batch, 
&batch_schema))
+                    .and_then(|batch| to_datafusion_batch(batch, 
&batch_schema))?;
+                if apply_row_filter {
+                    for filter in &runtime_filters {
+                        let predicate = 
filter.evaluate(&batch)?.into_array(batch.num_rows())?;
+                        let predicate = predicate
+                            .as_any()
+                            .downcast_ref::<BooleanArray>()
+                            .ok_or_else(|| {
+                                
datafusion::error::DataFusionError::Execution(format!(
+                                    "Paimon runtime filter must return 
Boolean, got {}",
+                                    predicate.data_type()
+                                ))
+                            })?;
+                        batch = filter_record_batch(&batch, predicate)?;
+                    }
+                }
+                Ok(batch)
             });
 
             Ok::<_, 
datafusion::error::DataFusionError>(RecordBatchStreamAdapter::new(
@@ -474,12 +845,15 @@ impl ExecutionPlan for PaimonTableScan {
         // 1. All splits have known merged_row_count (no deletion files with 
unknown cardinality)
         // 2. No limit is applied (limit would make row count inexact)
         // 3. Filter is exact (no residual filtering needed above the scan)
-        let num_rows_precision =
-            if all_row_counts_known && self.limit.is_none() && 
self.filter_exact {
-                Precision::Exact(total_rows)
-            } else {
-                Precision::Inexact(total_rows)
-            };
+        let num_rows_precision = if all_row_counts_known
+            && self.limit.is_none()
+            && self.filter_exact
+            && self.runtime_filters.is_empty()
+        {
+            Precision::Exact(total_rows)
+        } else {
+            Precision::Inexact(total_rows)
+        };
 
         Ok(Arc::new(Statistics {
             num_rows: num_rows_precision,
@@ -528,6 +902,14 @@ impl DisplayAs for PaimonTableScan {
         if let Some(ref pushed_variants) = self.pushed_variants {
             write!(f, ", PushedVariants=[{pushed_variants}]")?;
         }
+        if !self.runtime_filters.is_empty() {
+            let filters = self
+                .runtime_filters
+                .iter()
+                .map(ToString::to_string)
+                .collect::<Vec<_>>();
+            write!(f, ", runtime_filters=[{}]", filters.join(" AND "))?;
+        }
         Ok(())
     }
 }
@@ -535,20 +917,30 @@ impl DisplayAs for PaimonTableScan {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::config::PaimonConfig;
     mod test_utils {
         include!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test_utils.rs"));
     }
 
     use datafusion::arrow::array::Int32Array;
     use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, 
Schema as ArrowSchema};
+    use datafusion::config::ConfigOptions;
+    use datafusion::logical_expr::Operator;
+    use datafusion::physical_expr::expressions::{
+        lit, BinaryExpr, Column, DynamicFilterPhysicalExpr, InListExpr,
+    };
+    use datafusion::physical_expr::PhysicalExpr;
+    use datafusion::physical_plan::filter_pushdown::{
+        ChildFilterPushdownResult, ChildPushdownResult,
+    };
     use datafusion::physical_plan::ExecutionPlan;
     use datafusion::prelude::SessionContext;
     use futures::TryStreamExt;
     use paimon::catalog::Identifier;
     use paimon::io::FileIOBuilder;
     use paimon::spec::{
-        BinaryRow, DataFileMeta, DataType, Datum, IntType, PredicateBuilder,
-        Schema as PaimonSchema, TableSchema,
+        BinaryRow, BinaryType, DataFileMeta, DataType, Datum, IntType, 
PredicateBuilder,
+        Schema as PaimonSchema, TableSchema, VarBinaryType,
     };
     use paimon::table::{DeletionFile, RowRange, Table};
     use std::fs;
@@ -585,6 +977,41 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_binary_runtime_ranges_are_not_translated() {
+        for data_type in [
+            DataType::Binary(BinaryType::new(1).unwrap()),
+            DataType::VarBinary(VarBinaryType::new(1).unwrap()),
+        ] {
+            let fields = vec![DataField::new(0, "bytes".to_string(), 
data_type)];
+            let predicate_builder = PredicateBuilder::new(&fields);
+            for op in [Operator::Lt, Operator::LtEq, Operator::Gt, 
Operator::GtEq] {
+                let expression = BinaryExpr::new(
+                    Arc::new(Column::new("bytes", 0)),
+                    op,
+                    
Arc::new(Literal::new(ScalarValue::Binary(Some(vec![0xff])))),
+                );
+
+                assert!(
+                    translate_runtime_comparison(&expression, &fields, 
&predicate_builder, true,)
+                        .is_none(),
+                    "binary {op} must remain with DataFusion"
+                );
+            }
+
+            let equality = BinaryExpr::new(
+                Arc::new(Column::new("bytes", 0)),
+                Operator::Eq,
+                Arc::new(Literal::new(ScalarValue::Binary(Some(vec![0xff])))),
+            );
+            assert!(
+                translate_runtime_comparison(&equality, &fields, 
&predicate_builder, true)
+                    .is_some(),
+                "binary equality is ordering-independent"
+            );
+        }
+    }
+
     #[test]
     fn test_partition_count_empty_plan() {
         let schema = test_schema();
@@ -856,8 +1283,366 @@ mod tests {
         assert_eq!(value_stats.null_count, Precision::Absent);
     }
 
+    #[test]
+    fn test_scan_retains_dynamic_filter_for_runtime_evaluation() {
+        let scan = PaimonTableScan::new(
+            test_schema(),
+            dummy_table(),
+            test_read_type(),
+            None,
+            vec![Arc::from(Vec::<DataSplit>::new())],
+            None,
+            true,
+            None,
+            None,
+            true,
+        );
+        let dynamic_filter: Arc<dyn PhysicalExpr> =
+            Arc::new(DynamicFilterPhysicalExpr::new(Vec::new(), lit(true)));
+        let result = scan
+            .handle_child_pushdown_result(
+                FilterPushdownPhase::Post,
+                ChildPushdownResult {
+                    parent_filters: vec![ChildFilterPushdownResult {
+                        filter: dynamic_filter,
+                        child_results: Vec::new(),
+                    }],
+                    self_filters: Vec::new(),
+                },
+                &ConfigOptions::default(),
+            )
+            .unwrap();
+
+        let updated = result
+            .updated_node
+            .expect("the scan must retain physical filters so dynamic join 
filters remain active");
+        let statistics = updated.partition_statistics(None).unwrap();
+        assert_eq!(statistics.num_rows, Precision::Inexact(0));
+        assert_eq!(
+            statistics.column_statistics[0].null_count,
+            Precision::Absent,
+            "runtime-filtered scans must not expose unfiltered exact null 
counts"
+        );
+    }
+
+    #[test]
+    fn test_scan_reports_filter_handled_when_row_filter_is_enabled() {
+        let scan = PaimonTableScan::new(
+            test_schema(),
+            dummy_table(),
+            test_read_type(),
+            None,
+            vec![Arc::from(Vec::<DataSplit>::new())],
+            None,
+            true,
+            None,
+            None,
+            true,
+        );
+        let filter: Arc<dyn PhysicalExpr> = Arc::new(BinaryExpr::new(
+            Arc::new(Column::new("id", 0)),
+            Operator::Gt,
+            lit(1_i32),
+        ));
+        let mut paimon_config = PaimonConfig::default();
+        paimon_config.read.row_filter = true;
+        let mut config = ConfigOptions::default();
+        config.extensions.insert(paimon_config);
+
+        let result = scan
+            .handle_child_pushdown_result(
+                FilterPushdownPhase::Post,
+                ChildPushdownResult {
+                    parent_filters: vec![ChildFilterPushdownResult {
+                        filter,
+                        child_results: Vec::new(),
+                    }],
+                    self_filters: Vec::new(),
+                },
+                &config,
+            )
+            .unwrap();
+
+        assert!(matches!(result.filters.as_slice(), [PushedDown::Yes]));
+        assert!(result.updated_node.is_some());
+    }
+
+    #[test]
+    fn test_scan_rejects_filter_outside_output_schema() {
+        let scan = PaimonTableScan::new(
+            test_schema(),
+            dummy_table(),
+            test_read_type(),
+            None,
+            vec![Arc::from(Vec::<DataSplit>::new())],
+            None,
+            true,
+            None,
+            None,
+            true,
+        );
+        let filter: Arc<dyn PhysicalExpr> = Arc::new(BinaryExpr::new(
+            Arc::new(Column::new("missing", 1)),
+            Operator::Gt,
+            lit(1_i32),
+        ));
+        let mut paimon_config = PaimonConfig::default();
+        paimon_config.read.row_filter = true;
+        let mut config = ConfigOptions::default();
+        config.extensions.insert(paimon_config);
+
+        let result = scan
+            .handle_child_pushdown_result(
+                FilterPushdownPhase::Post,
+                ChildPushdownResult {
+                    parent_filters: vec![ChildFilterPushdownResult {
+                        filter,
+                        child_results: Vec::new(),
+                    }],
+                    self_filters: Vec::new(),
+                },
+                &config,
+            )
+            .unwrap();
+
+        assert!(matches!(result.filters.as_slice(), [PushedDown::No]));
+        assert!(result.updated_node.is_none());
+    }
+
+    #[tokio::test]
+    async fn test_scan_uses_retained_runtime_filter_for_pruning_only() {
+        let tempdir = tempdir().unwrap();
+        let table_path = local_file_path(tempdir.path());
+        let bucket_dir = tempdir.path().join("bucket-0");
+        fs::create_dir_all(&bucket_dir).unwrap();
+        write_int_parquet_file(
+            &bucket_dir.join("data.parquet"),
+            vec![("id", vec![1, 2, 3, 4, 5])],
+            None,
+        );
+        let file_size = 
fs::metadata(bucket_dir.join("data.parquet")).unwrap().len() as i64;
+        let file_io = FileIOBuilder::new("file").build().unwrap();
+        let table_schema = TableSchema::new(
+            0,
+            &PaimonSchema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .build()
+                .unwrap(),
+        );
+        let table = Table::new(
+            file_io,
+            Identifier::new("default", "t"),
+            table_path,
+            table_schema,
+            None,
+        );
+        let split = paimon::DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(local_file_path(&bucket_dir))
+            .with_total_buckets(1)
+            .with_data_files(vec![test_data_file("data.parquet", 5, 
file_size)])
+            .build()
+            .unwrap();
+        let scan = PaimonTableScan::new(
+            test_schema(),
+            table,
+            test_read_type(),
+            None,
+            vec![Arc::from(vec![split])],
+            None,
+            false,
+            None,
+            None,
+            true,
+        );
+        let filter: Arc<dyn PhysicalExpr> = Arc::new(BinaryExpr::new(
+            Arc::new(Column::new("id", 0)),
+            Operator::Gt,
+            lit(2_i32),
+        ));
+        let pruning_result = scan
+            .handle_child_pushdown_result(
+                FilterPushdownPhase::Post,
+                ChildPushdownResult {
+                    parent_filters: vec![ChildFilterPushdownResult {
+                        filter: Arc::clone(&filter),
+                        child_results: Vec::new(),
+                    }],
+                    self_filters: Vec::new(),
+                },
+                &ConfigOptions::default(),
+            )
+            .unwrap();
+        assert!(matches!(
+            pruning_result.filters.as_slice(),
+            [PushedDown::No]
+        ));
+        let pruning_scan = pruning_result.updated_node.unwrap();
+        let ctx = SessionContext::new();
+        let batches = pruning_scan
+            .execute(0, ctx.task_ctx())
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+        let ids = collect_ids(&batches);
+
+        assert_eq!(
+            ids,
+            vec![1, 2, 3, 4, 5],
+            "unsupported physical filters may prune row groups but must not 
remove rows"
+        );
+
+        let mut paimon_config = PaimonConfig::default();
+        paimon_config.read.row_filter = true;
+        let mut exact_config = ConfigOptions::default();
+        exact_config.extensions.insert(paimon_config);
+        let exact_result = scan
+            .handle_child_pushdown_result(
+                FilterPushdownPhase::Post,
+                ChildPushdownResult {
+                    parent_filters: vec![ChildFilterPushdownResult {
+                        filter,
+                        child_results: Vec::new(),
+                    }],
+                    self_filters: Vec::new(),
+                },
+                &exact_config,
+            )
+            .unwrap();
+        assert!(matches!(exact_result.filters.as_slice(), [PushedDown::Yes]));
+
+        // Execution deliberately uses the default context. The scan must honor
+        // the planning-time decision that allowed the parent FilterExec to be 
removed.
+        let exact_scan = exact_result.updated_node.unwrap();
+        let exact_batches = exact_scan
+            .execute(0, SessionContext::new().task_ctx())
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+        assert_eq!(collect_ids(&exact_batches), vec![3, 4, 5]);
+    }
+
+    fn collect_ids(batches: &[RecordBatch]) -> Vec<i32> {
+        batches
+            .iter()
+            .flat_map(|batch| {
+                batch
+                    .column(0)
+                    .as_any()
+                    .downcast_ref::<Int32Array>()
+                    .unwrap()
+                    .values()
+                    .iter()
+                    .copied()
+            })
+            .collect()
+    }
+
+    #[tokio::test]
+    async fn test_dynamic_filter_builds_reader_pruning_predicate() {
+        let column: Arc<dyn PhysicalExpr> = Arc::new(Column::new("id", 0));
+        let dynamic_filter = Arc::new(DynamicFilterPhysicalExpr::new(
+            vec![Arc::clone(&column)],
+            lit(true),
+        ));
+        dynamic_filter
+            .update(Arc::new(BinaryExpr::new(
+                column,
+                Operator::GtEq,
+                lit(3_i32),
+            )))
+            .unwrap();
+        dynamic_filter.mark_complete();
+        let filter: Arc<dyn PhysicalExpr> = dynamic_filter;
+
+        let predicate =
+            runtime_pruning_predicate(&[filter], &test_read_type(), true, 
Duration::ZERO)
+                .await
+                .expect("a completed column/literal dynamic filter should 
prune the reader");
+
+        assert_eq!(predicate.to_string(), "id >= 3");
+    }
+
+    #[tokio::test]
+    async fn test_runtime_in_list_builds_reader_pruning_predicate() {
+        let filter: Arc<dyn PhysicalExpr> = Arc::new(
+            InListExpr::try_new(
+                Arc::new(Column::new("id", 0)),
+                vec![lit(1_i32), lit(3_i32)],
+                false,
+                test_schema().as_ref(),
+            )
+            .unwrap(),
+        );
+
+        let predicate =
+            runtime_pruning_predicate(&[filter], &test_read_type(), true, 
Duration::ZERO)
+                .await
+                .expect("a literal IN list should prune the reader");
+
+        assert_eq!(predicate.to_string(), "id IN (1, 3)");
+    }
+
+    #[test]
+    fn test_runtime_filter_wait_is_cost_aware() {
+        assert_eq!(runtime_filter_wait_timeout(204_000), Duration::ZERO);
+        assert_eq!(runtime_filter_wait_timeout(250_000), Duration::ZERO);
+        assert_eq!(runtime_filter_wait_timeout(250_001), 
Duration::from_secs(1));
+    }
+
+    #[tokio::test]
+    async fn 
test_incomplete_dynamic_filter_does_not_materially_delay_scan_startup() {
+        let column: Arc<dyn PhysicalExpr> = Arc::new(Column::new("id", 0));
+        let dynamic_filter: Arc<dyn PhysicalExpr> =
+            Arc::new(DynamicFilterPhysicalExpr::new(vec![column], lit(true)));
+
+        let predicate = tokio::time::timeout(
+            Duration::from_millis(100),
+            runtime_pruning_predicate(&[dynamic_filter], &test_read_type(), 
true, Duration::ZERO),
+        )
+        .await
+        .expect("an incomplete dynamic filter must not delay scan startup");
+
+        assert!(predicate.is_none());
+    }
+
+    #[tokio::test]
+    async fn 
test_completed_dynamic_filter_still_prunes_when_another_times_out() {
+        let column: Arc<dyn PhysicalExpr> = Arc::new(Column::new("id", 0));
+        let completed = Arc::new(DynamicFilterPhysicalExpr::new(
+            vec![Arc::clone(&column)],
+            lit(true),
+        ));
+        completed
+            .update(Arc::new(BinaryExpr::new(
+                Arc::clone(&column),
+                Operator::GtEq,
+                lit(3_i32),
+            )))
+            .unwrap();
+        completed.mark_complete();
+        let incomplete: Arc<dyn PhysicalExpr> =
+            Arc::new(DynamicFilterPhysicalExpr::new(vec![column], lit(true)));
+        let completed: Arc<dyn PhysicalExpr> = completed;
+
+        let predicate = runtime_pruning_predicate(
+            &[completed, incomplete],
+            &test_read_type(),
+            true,
+            Duration::ZERO,
+        )
+        .await
+        .expect("completed filters should survive another filter timing out");
+
+        assert_eq!(predicate.to_string(), "id >= 3");
+    }
+
     #[tokio::test]
-    async fn test_execute_applies_pushed_filter_during_read() {
+    async fn test_execute_uses_pushed_filter_only_for_pruning_by_default() {
         let tempdir = tempdir().unwrap();
         let table_path = local_file_path(tempdir.path());
         let bucket_dir = tempdir.path().join("bucket-0");
@@ -941,7 +1726,7 @@ mod tests {
             })
             .collect();
 
-        assert_eq!(actual_ids, vec![2, 3, 4]);
+        assert_eq!(actual_ids, vec![1, 2, 3, 4]);
     }
 
     #[tokio::test]
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index 3190487b..910edc8a 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -106,7 +106,10 @@ impl SQLContext {
     /// Creates a new empty SQL context.
     pub fn new() -> Self {
         let state = SessionStateBuilder::new()
-            .with_config(crate::lateral_vector_search::session_config())
+            .with_config(
+                crate::lateral_vector_search::session_config()
+                    
.with_option_extension(crate::config::PaimonConfig::default()),
+            )
             .with_default_features()
             .with_relation_planners(vec![Arc::new(
                 crate::relation_planner::PaimonRelationPlanner::new(),
@@ -412,6 +415,9 @@ impl SQLContext {
             }) => {
                 let key = variable.to_string();
                 let key = key.trim_matches('\'').trim_matches('"');
+                if key == crate::config::PAIMON_ROW_FILTER {
+                    return self.ctx.sql(sql).await;
+                }
                 if let Some(paimon_key) = key.strip_prefix("paimon.") {
                     let value = values
                         .first()
@@ -435,6 +441,9 @@ impl SQLContext {
             }) => {
                 let key = name.to_string();
                 let key = key.trim_matches('\'').trim_matches('"');
+                if key == crate::config::PAIMON_ROW_FILTER {
+                    return self.ctx.sql("SET paimon.read.row_filter = 
false").await;
+                }
                 if let Some(paimon_key) = key.strip_prefix("paimon.") {
                     self.dynamic_options.write().unwrap().remove(paimon_key);
                     return ok_result(&self.ctx);
@@ -6572,6 +6581,50 @@ mod tests {
         assert!(opts.is_empty());
     }
 
+    #[tokio::test]
+    async fn test_set_paimon_read_option_delegates_to_session_config() {
+        let catalog = Arc::new(MockCatalog::new());
+        let sql_context = make_sql_context(catalog).await;
+
+        sql_context
+            .sql("SET paimon.read.row_filter = true")
+            .await
+            .unwrap();
+
+        let state = sql_context.ctx().state();
+        let config = state
+            .config_options()
+            .extensions
+            .get::<crate::config::PaimonConfig>()
+            .unwrap();
+        assert!(config.read.row_filter);
+        assert!(sql_context.dynamic_options().read().unwrap().is_empty());
+    }
+
+    #[tokio::test]
+    async fn test_reset_paimon_read_option_delegates_to_session_config() {
+        let catalog = Arc::new(MockCatalog::new());
+        let sql_context = make_sql_context(catalog).await;
+
+        sql_context
+            .sql("SET paimon.read.row_filter = true")
+            .await
+            .unwrap();
+        sql_context
+            .sql("RESET paimon.read.row_filter")
+            .await
+            .unwrap();
+
+        let state = sql_context.ctx().state();
+        let config = state
+            .config_options()
+            .extensions
+            .get::<crate::config::PaimonConfig>()
+            .unwrap();
+        assert!(!config.read.row_filter);
+        assert!(sql_context.dynamic_options().read().unwrap().is_empty());
+    }
+
     #[tokio::test]
     async fn test_set_multiple_paimon_options() {
         let catalog = Arc::new(MockCatalog::new());
diff --git a/crates/integrations/datafusion/tests/blob_tests.rs 
b/crates/integrations/datafusion/tests/blob_tests.rs
index 59058d9b..5df82d69 100644
--- a/crates/integrations/datafusion/tests/blob_tests.rs
+++ b/crates/integrations/datafusion/tests/blob_tests.rs
@@ -883,7 +883,7 @@ async fn 
test_blob_descriptor_field_short_read_returns_error() {
 }
 
 #[tokio::test]
-async fn 
test_blob_descriptor_filter_before_resolve_skips_filtered_bad_descriptor() {
+async fn test_blob_descriptor_filter_before_resolve_when_pushdown_enabled() {
     let (tmp, sql_context) = setup(
         "CREATE TABLE paimon.test_db.t (\
             id INT, \
@@ -906,6 +906,7 @@ async fn 
test_blob_descriptor_filter_before_resolve_skips_filtered_bad_descripto
          (2, 'Kept', X'4F4B')"
     );
     exec(&sql_context, &sql).await;
+    exec(&sql_context, "SET paimon.read.row_filter = true").await;
 
     let rows = query_id_name_picture(
         &sql_context,
diff --git a/crates/paimon/src/arrow/format/avro.rs 
b/crates/paimon/src/arrow/format/avro.rs
index 73dda9c6..d58f13b3 100644
--- a/crates/paimon/src/arrow/format/avro.rs
+++ b/crates/paimon/src/arrow/format/avro.rs
@@ -64,6 +64,7 @@ impl FormatFileReader for AvroFormatReader {
         // caller's `&FilePredicates` (FilePredicates is not `Clone`; rebuild 
it).
         let predicates = predicates.map(|fp| FilePredicates {
             predicates: fp.predicates.clone(),
+            apply_row_filter: fp.apply_row_filter,
             file_fields: fp.file_fields.clone(),
         });
 
@@ -1029,6 +1030,7 @@ mod tests {
                 PredicateOperator::Gt,
                 vec![Datum::Long(25)],
             )],
+            apply_row_filter: true,
             file_fields,
         };
 
@@ -1089,6 +1091,7 @@ mod tests {
                 PredicateOperator::Gt,
                 vec![Datum::Long(25)],
             )],
+            apply_row_filter: true,
             file_fields,
         };
 
@@ -1157,6 +1160,7 @@ mod tests {
                 PredicateOperator::Like,
                 vec![Datum::String("a%".to_string())],
             )],
+            apply_row_filter: true,
             file_fields: vec![age, name],
         };
 
diff --git a/crates/paimon/src/arrow/format/mod.rs 
b/crates/paimon/src/arrow/format/mod.rs
index ad63e0cc..b35f96f7 100644
--- a/crates/paimon/src/arrow/format/mod.rs
+++ b/crates/paimon/src/arrow/format/mod.rs
@@ -43,6 +43,8 @@ use std::collections::HashMap;
 pub(crate) struct FilePredicates {
     /// Predicates with indices already remapped to file-level fields.
     pub predicates: Vec<Predicate>,
+    /// Whether predicates may remove individual rows from emitted batches.
+    pub apply_row_filter: bool,
     /// File-level fields (full file schema), used for stats access and row 
filtering.
     pub file_fields: Vec<DataField>,
 }
@@ -62,13 +64,17 @@ pub(crate) trait FormatFileReader: Send + Sync {
     /// for residual filtering); the caller (`DataFileReader`) projects to the
     /// requested output by name, so extra columns are harmless.
     ///
-    /// Predicate exactness is per-format, NOT a blanket guarantee:
+    /// When `FilePredicates::apply_row_filter` is true, predicate exactness is
+    /// per-format, NOT a blanket guarantee:
     /// - Parquet, ORC, Avro, Row, and Vortex apply the predicate **exactly** —
     ///   each emitted batch contains only rows matching the pushed-down 
predicate
     ///   (native pushdown for pruning + a row-level residual pass for the 
rest).
     /// - Blob does not evaluate predicates at all; Mosaic applies only
     ///   stats-level (row-group) pruning. For those, non-matching rows may
     ///   survive and the caller must not assume exactness.
+    /// - When `apply_row_filter` is false, formats may still use predicates 
for
+    ///   conservative file, row-group, stripe, or page pruning, but must not
+    ///   remove individual rows from a retained unit.
     ///
     /// `row_selection` is a pre-merged list of 0-based inclusive row ranges
     /// (DV + row_ranges already combined by the caller).
diff --git a/crates/paimon/src/arrow/format/mosaic.rs 
b/crates/paimon/src/arrow/format/mosaic.rs
index 0242475d..fee2d22b 100644
--- a/crates/paimon/src/arrow/format/mosaic.rs
+++ b/crates/paimon/src/arrow/format/mosaic.rs
@@ -60,6 +60,7 @@ impl FormatFileReader for MosaicFormatReader {
         let read_fields = read_fields.to_vec();
         let predicates = predicates.map(|predicates| FilePredicates {
             predicates: predicates.predicates.clone(),
+            apply_row_filter: predicates.apply_row_filter,
             file_fields: predicates.file_fields.clone(),
         });
         let batch_size = batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
@@ -867,6 +868,7 @@ mod tests {
     ) -> FilePredicates {
         FilePredicates {
             predicates,
+            apply_row_filter: true,
             file_fields: fields,
         }
     }
diff --git a/crates/paimon/src/arrow/format/orc.rs 
b/crates/paimon/src/arrow/format/orc.rs
index 81556b14..69f3edb7 100644
--- a/crates/paimon/src/arrow/format/orc.rs
+++ b/crates/paimon/src/arrow/format/orc.rs
@@ -97,6 +97,7 @@ impl FormatFileReader for OrcFormatReader {
             (
                 FilePredicates {
                     predicates: fp.predicates.clone(),
+                    apply_row_filter: fp.apply_row_filter,
                     file_fields: fp.file_fields.clone(),
                 },
                 scan_fields,
@@ -424,6 +425,7 @@ mod tests {
     fn file_predicates(predicates: Vec<Predicate>, file_fields: 
Vec<DataField>) -> FilePredicates {
         FilePredicates {
             predicates,
+            apply_row_filter: true,
             file_fields,
         }
     }
diff --git a/crates/paimon/src/arrow/format/parquet.rs 
b/crates/paimon/src/arrow/format/parquet.rs
index c15ead0d..bb30b879 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -270,10 +270,12 @@ impl FormatFileReader for ParquetFormatReader {
         let arrow_file_reader = ArrowFileReader::new(file_size, reader);
 
         let empty_predicates = Vec::new();
-        let (preds, file_fields): (&[Predicate], &[DataField]) = match 
predicates {
-            Some(fp) => (&fp.predicates, &fp.file_fields),
-            None => (&empty_predicates, &[]),
-        };
+        let (preds, apply_row_filter, file_fields): (&[Predicate], bool, 
&[DataField]) =
+            match predicates {
+                Some(fp) => (&fp.predicates, fp.apply_row_filter, 
&fp.file_fields),
+                None => (&empty_predicates, true, &[]),
+            };
+        let pruning_preds = preds;
 
         // Only load the Parquet page index (ColumnIndex + OffsetIndex) when a
         // predicate can use it for page-level pruning — matching Java Paimon,
@@ -284,7 +286,7 @@ impl FormatFileReader for ParquetFormatReader {
         // skip it. `Optional` lets files without a page index fall through to
         // row-group-level pruning instead of erroring.
         let mut arrow_options = ArrowReaderOptions::new();
-        if !preds.is_empty() {
+        if !pruning_preds.is_empty() {
             arrow_options = 
arrow_options.with_page_index_policy(PageIndexPolicy::Optional);
         }
         let mut batch_stream_builder =
@@ -303,9 +305,10 @@ impl FormatFileReader for ParquetFormatReader {
         // predicate is fully enforced, so we decode exactly `read_fields`, 
skip
         // the residual pass entirely, and return the stream as before — zero
         // added overhead.
-        let all_enforced = preds
-            .iter()
-            .all(|p| predicate_fully_enforced_by_row_filter(&parquet_schema, 
p, file_fields));
+        let all_enforced = !apply_row_filter
+            || preds
+                .iter()
+                .all(|p| 
predicate_fully_enforced_by_row_filter(&parquet_schema, p, file_fields));
 
         // Residual branch must decode the predicate columns too, or the 
residual
         // pass could not see a predicate on a non-projected column (Gap A). 
The
@@ -331,14 +334,16 @@ impl FormatFileReader for ParquetFormatReader {
         let mask = ProjectionMask::roots(&parquet_schema, root_indices);
         batch_stream_builder = batch_stream_builder.with_projection(mask);
 
-        let parquet_row_filter = build_parquet_row_filter(&parquet_schema, 
preds, file_fields)?;
-        if let Some(f) = parquet_row_filter {
-            batch_stream_builder = batch_stream_builder.with_row_filter(f);
+        if apply_row_filter {
+            let parquet_row_filter = build_parquet_row_filter(&parquet_schema, 
preds, file_fields)?;
+            if let Some(f) = parquet_row_filter {
+                batch_stream_builder = batch_stream_builder.with_row_filter(f);
+            }
         }
 
         let predicate_row_selection = build_predicate_row_selection(
             batch_stream_builder.metadata().row_groups(),
-            preds,
+            pruning_preds,
             file_fields,
         )?;
         let mut combined_selection = predicate_row_selection;
@@ -346,8 +351,11 @@ impl FormatFileReader for ParquetFormatReader {
         // Page-level selection. Returns `None` when ColumnIndex / OffsetIndex 
are
         // absent (page index not loaded, older files, writer without page 
index)
         // or when no page could be skipped, so intersecting is a no-op then.
-        let page_selection =
-            build_predicate_page_selection(batch_stream_builder.metadata(), 
preds, file_fields)?;
+        let page_selection = build_predicate_page_selection(
+            batch_stream_builder.metadata(),
+            pruning_preds,
+            file_fields,
+        )?;
         combined_selection = 
intersect_optional_row_selections(combined_selection, page_selection);
 
         if let Some(ref ranges) = row_selection {
@@ -396,6 +404,7 @@ impl FormatFileReader for ParquetFormatReader {
         // projects the filtered batch to `read_fields` by name.
         let residual_predicates = FilePredicates {
             predicates: preds.to_vec(),
+            apply_row_filter: true,
             file_fields: file_fields.to_vec(),
         };
         let stream = batch_stream.map(move |result| {
@@ -3009,6 +3018,7 @@ mod tests {
 
         let predicates = FilePredicates {
             predicates: vec![predicate],
+            apply_row_filter: true,
             file_fields: id_name_age_file_fields(),
         };
 
@@ -3163,6 +3173,7 @@ mod tests {
         let reader_input = input.reader().await.unwrap();
         let predicates = FilePredicates {
             predicates: vec![leaf_gt, leaf_lt],
+            apply_row_filter: true,
             file_fields,
         };
         let reader = ParquetFormatReader;
@@ -3480,6 +3491,7 @@ mod tests {
         ])];
         let file_predicates = FilePredicates {
             predicates,
+            apply_row_filter: true,
             file_fields: fields.clone(),
         };
 
diff --git a/crates/paimon/src/arrow/format/row.rs 
b/crates/paimon/src/arrow/format/row.rs
index 94b740f4..a3fbd9d9 100644
--- a/crates/paimon/src/arrow/format/row.rs
+++ b/crates/paimon/src/arrow/format/row.rs
@@ -300,6 +300,7 @@ impl FormatFileReader for RowFormatReader {
         let blocks_to_read = blocks_to_read(&index, total_rows, 
row_selection.as_deref());
         let predicates = predicates.map(|fp| FilePredicates {
             predicates: fp.predicates.clone(),
+            apply_row_filter: fp.apply_row_filter,
             file_fields: fp.file_fields.clone(),
         });
         Ok(try_stream! {
@@ -2417,6 +2418,7 @@ mod tests {
                 op: PredicateOperator::Gt,
                 literals: vec![Datum::Int(25)],
             }],
+            apply_row_filter: true,
             file_fields: fields.clone(),
         };
 
@@ -2493,6 +2495,7 @@ mod tests {
                 op: PredicateOperator::Gt,
                 literals: vec![Datum::Int(25)],
             }],
+            apply_row_filter: true,
             file_fields: fields.clone(),
         };
 
diff --git a/crates/paimon/src/arrow/format/vortex.rs 
b/crates/paimon/src/arrow/format/vortex.rs
index 631f8add..e0d15cb1 100644
--- a/crates/paimon/src/arrow/format/vortex.rs
+++ b/crates/paimon/src/arrow/format/vortex.rs
@@ -76,6 +76,7 @@ impl FormatFileReader for VortexFormatReader {
         let read_fields = read_fields.to_vec();
         let predicates = predicates.map(|fp| FilePredicates {
             predicates: fp.predicates.clone(),
+            apply_row_filter: fp.apply_row_filter,
             file_fields: fp.file_fields.clone(),
         });
         let scan_fields = widen_scan_fields(&read_fields, predicates.as_ref());
@@ -1139,6 +1140,7 @@ mod tests {
         let pred = builder.equal("id", Datum::Int(3)).unwrap();
         let fp = FilePredicates {
             predicates: vec![pred],
+            apply_row_filter: true,
             file_fields: fields,
         };
         let ids =
@@ -1153,6 +1155,7 @@ mod tests {
         let pred = builder.greater_than("id", Datum::Int(3)).unwrap();
         let fp = FilePredicates {
             predicates: vec![pred],
+            apply_row_filter: true,
             file_fields: fields,
         };
         let ids =
@@ -1169,6 +1172,7 @@ mod tests {
             .unwrap();
         let fp = FilePredicates {
             predicates: vec![pred],
+            apply_row_filter: true,
             file_fields: fields,
         };
         let ids =
@@ -1185,6 +1189,7 @@ mod tests {
         let pred2 = builder.less_than("value", Datum::Int(50)).unwrap();
         let fp = FilePredicates {
             predicates: vec![pred1, pred2],
+            apply_row_filter: true,
             file_fields: fields,
         };
         let ids =
@@ -1201,6 +1206,7 @@ mod tests {
         let pred = builder.equal("id", Datum::Int(99)).unwrap();
         let fp = FilePredicates {
             predicates: vec![pred],
+            apply_row_filter: true,
             file_fields: fields,
         };
         let ids =
@@ -1236,6 +1242,7 @@ mod tests {
         let pred = builder.greater_than("value", Datum::Int(30)).unwrap();
         let fp = FilePredicates {
             predicates: vec![pred],
+            apply_row_filter: true,
             file_fields: fields.clone(),
         };
         let read_fields = vec![fields[0].clone()];
@@ -1277,6 +1284,7 @@ mod tests {
         let pred = builder.greater_than("id", Datum::Int(3)).unwrap();
         let fp = FilePredicates {
             predicates: vec![pred],
+            apply_row_filter: true,
             file_fields: fields,
         };
 
@@ -1294,10 +1302,12 @@ mod tests {
         let builder = PredicateBuilder::new(&fields);
         let eq = FilePredicates {
             predicates: vec![builder.equal("id", Datum::Int(3)).unwrap()],
+            apply_row_filter: true,
             file_fields: fields.clone(),
         };
         let gt = FilePredicates {
             predicates: vec![builder.greater_than("id", 
Datum::Int(3)).unwrap()],
+            apply_row_filter: true,
             file_fields: fields.clone(),
         };
         let combined = FilePredicates {
@@ -1305,6 +1315,7 @@ mod tests {
                 builder.greater_or_equal("id", Datum::Int(2)).unwrap(),
                 builder.less_than("value", Datum::Int(50)).unwrap(),
             ],
+            apply_row_filter: true,
             file_fields: fields,
         };
 
diff --git a/crates/paimon/src/arrow/residual.rs 
b/crates/paimon/src/arrow/residual.rs
index 1a6ff5a1..b2259963 100644
--- a/crates/paimon/src/arrow/residual.rs
+++ b/crates/paimon/src/arrow/residual.rs
@@ -76,6 +76,10 @@ pub(crate) fn filter_record_batch_by_predicates(
     predicates: &FilePredicates,
     scan_fields: &[DataField],
 ) -> crate::Result<RecordBatch> {
+    if !predicates.apply_row_filter {
+        return Ok(batch);
+    }
+
     let Some(mask) = evaluate_predicates_mask(
         &batch,
         &predicates.predicates,
@@ -250,7 +254,7 @@ pub(crate) fn widen_scan_fields(
 ) -> Vec<DataField> {
     let mut fields = read_fields.to_vec();
 
-    if let Some(fp) = predicates {
+    if let Some(fp) = predicates.filter(|fp| fp.apply_row_filter) {
         let mut predicate_indices = Vec::new();
         for predicate in &fp.predicates {
             collect_predicate_field_indices(predicate, &mut predicate_indices);
@@ -943,6 +947,7 @@ mod tests {
     fn file_predicates(predicates: Vec<Predicate>, file_fields: 
Vec<DataField>) -> FilePredicates {
         FilePredicates {
             predicates,
+            apply_row_filter: true,
             file_fields,
         }
     }
diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index 14871adf..ae99a5fc 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -103,9 +103,11 @@ pub(crate) struct DataEvolutionReader {
     /// Arrow schema of wide batches at the _ROW_ID attach point: the original
     /// read_type columns in caller order, then the extra predicate columns.
     wide_output_schema: Arc<arrow_schema::Schema>,
-    /// Data predicates (table-schema leaf indices). Applied exactly to every
-    /// batch after _ROW_ID attachment, before yielding.
+    /// Data predicates (table-schema leaf indices). Available for pruning and,
+    /// when `apply_row_filter` is enabled, applied exactly after `_ROW_ID`
+    /// attachment before yielding.
     predicates: Vec<Predicate>,
+    apply_row_filter: bool,
     blob_as_descriptor: bool,
     blob_descriptor_fields: HashSet<String>,
     blob_view_fields: HashSet<String>,
@@ -143,8 +145,10 @@ impl DataEvolutionReader {
         // at the END: `project_output` relies on that to project the
         // final batch back to `read_type` by prefix. Predicate leaf indices
         // point into the table schema, so `file_fields` = `table_fields`.
+        let apply_row_filter = true;
         let file_predicates = (!predicates.is_empty()).then(|| FilePredicates {
             predicates: predicates.clone(),
+            apply_row_filter,
             file_fields: table_fields.clone(),
         });
         let wide_file_read_type =
@@ -167,6 +171,7 @@ impl DataEvolutionReader {
             output_schema,
             wide_output_schema,
             predicates,
+            apply_row_filter,
             blob_as_descriptor,
             blob_descriptor_fields,
             blob_view_fields,
@@ -177,6 +182,11 @@ impl DataEvolutionReader {
         })
     }
 
+    pub(crate) fn with_row_filter(mut self, apply_row_filter: bool) -> Self {
+        self.apply_row_filter = apply_row_filter;
+        self
+    }
+
     pub(crate) fn with_batch_size(mut self, batch_size: Option<usize>) -> Self 
{
         self.batch_size = batch_size;
         self
@@ -198,14 +208,26 @@ impl DataEvolutionReader {
             let filter_before_blob_resolution =
                 
self.can_filter_before_blob_resolution(blob_view_lookup.is_some(), 
&descriptor_fields);
 
+            // Raw-convertible files can safely use predicates for conservative
+            // row-group/page pruning. Keep row filtering disabled here because
+            // the exact residual runs after schema evolution and `_ROW_ID`
+            // attachment. Positional row IDs cannot currently be reconciled
+            // with pruned row groups, so that projection falls back to no
+            // predicate pushdown.
+            let pruning_predicates = if self.row_id_index.is_none() {
+                self.predicates.clone()
+            } else {
+                Vec::new()
+            };
             let file_reader = DataFileReader::new(
                 self.file_io.clone(),
                 self.schema_manager.clone(),
                 self.table_schema_id,
                 self.table_fields.clone(),
                 self.wide_file_read_type.clone(),
-                Vec::new(),
+                pruning_predicates,
             )
+            .with_row_filter(false)
             .with_batch_size(self.batch_size);
 
             for split in splits {
@@ -360,7 +382,7 @@ impl DataEvolutionReader {
     /// `_ROW_ID` correctness: ids are attached before this filter, so 
surviving
     /// rows keep their original ids.
     fn filter_wide_batch(&self, batch: RecordBatch) -> 
crate::Result<RecordBatch> {
-        if self.predicates.is_empty() {
+        if !self.apply_row_filter || self.predicates.is_empty() {
             return Ok(batch);
         }
 
@@ -448,6 +470,9 @@ impl DataEvolutionReader {
         resolve_blob_views: bool,
         descriptor_fields: &HashSet<String>,
     ) -> bool {
+        if !self.apply_row_filter {
+            return false;
+        }
         let mut transformed_fields = HashSet::new();
         if resolve_blob_views {
             transformed_fields.extend(self.blob_view_fields.iter().cloned());
@@ -504,6 +529,7 @@ impl DataEvolutionReader {
             false,
             None,
         )?
+        .with_row_filter(self.apply_row_filter)
         .with_batch_size(self.batch_size);
         let mut stream = prescan.read(splits)?;
         let mut view_structs = HashSet::new();
@@ -5084,6 +5110,55 @@ mod tests {
         assert_eq!(collect_int_values(&batches, "value"), vec![20, 30, 40]);
     }
 
+    #[tokio::test]
+    async fn test_evolution_read_row_filter_disabled_still_prunes_row_groups() 
{
+        let tempdir = tempdir().unwrap();
+        let table_path = local_file_path(tempdir.path());
+        let bucket_dir = tempdir.path().join("bucket-0");
+        fs::create_dir_all(&bucket_dir).unwrap();
+
+        let parquet_path = bucket_dir.join("data.parquet");
+        write_int_parquet_file(
+            &parquet_path,
+            vec![("id", vec![1, 2, 3, 4]), ("value", vec![5, 10, 30, 40])],
+            Some(2),
+        );
+
+        let table = two_col_evolution_table(table_path);
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(local_file_path(&bucket_dir))
+            .with_total_buckets(1)
+            .with_data_files(vec![data_file_meta_with_path(
+                "data.parquet",
+                0,
+                4,
+                1,
+                parquet_path.metadata().unwrap().len() as i64,
+                Some(vec!["id", "value"]),
+            )])
+            .build()
+            .unwrap();
+
+        let predicate = PredicateBuilder::new(table.schema().fields())
+            .greater_than("id", Datum::Int(3))
+            .unwrap();
+        let mut builder = table.new_read_builder();
+        builder.with_filter(predicate);
+        let read = builder.new_read().unwrap().with_row_filter(false);
+        let batches = read
+            .to_arrow(&[split])
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+
+        assert_eq!(collect_int_values(&batches, "id"), vec![3, 4]);
+        assert_eq!(collect_int_values(&batches, "value"), vec![30, 40]);
+    }
+
     /// Raw-convertible branch: a compound `Or` predicate referencing a
     /// NON-projected column filters exactly, and the widened predicate column
     /// does not leak into the output schema.
diff --git a/crates/paimon/src/table/data_file_reader.rs 
b/crates/paimon/src/table/data_file_reader.rs
index 0faff2e9..369833b0 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -43,6 +43,7 @@ pub(crate) struct DataFileReader {
     table_fields: Vec<DataField>,
     read_type: Vec<DataField>,
     predicates: Vec<Predicate>,
+    row_filter: bool,
     blob_as_descriptor: bool,
     batch_size: Option<usize>,
 }
@@ -63,6 +64,7 @@ impl DataFileReader {
             table_fields,
             read_type,
             predicates,
+            row_filter: true,
             blob_as_descriptor: false,
             batch_size: None,
         }
@@ -78,6 +80,11 @@ impl DataFileReader {
         self
     }
 
+    pub(crate) fn with_row_filter(mut self, row_filter: bool) -> Self {
+        self.row_filter = row_filter;
+        self
+    }
+
     /// Return a copy with a replaced read-type. Used by 
`pk_vector_position_read`
     /// to inject the internal `_ROW_ID` column for physical-position recovery.
     pub(super) fn with_read_type(mut self, read_type: Vec<DataField>) -> Self {
@@ -94,7 +101,7 @@ impl DataFileReader {
 
     /// True if any configured predicate can actually drop rows. A lone
     /// `Predicate::AlwaysTrue` keeps every row in order and is not 
row-filtering,
-    /// matching `reject_row_id_with_predicate`'s notion. Consumed by
+    /// matching `reject_row_id_with_predicates`'s notion. Consumed by
     /// `pk_vector_position_read` (materialization read path).
     pub(super) fn has_row_filtering_predicate(&self) -> bool {
         self.predicates
@@ -102,11 +109,11 @@ impl DataFileReader {
             .any(|p| !matches!(p, Predicate::AlwaysTrue))
     }
 
-    /// Reject projecting `_ROW_ID` alongside a data predicate. `_ROW_ID` is
-    /// assigned positionally from post-filter batch row counts, so a residual
-    /// filter that drops rows would desync it. (`_ROW_ID` predicates travel 
via
-    /// `row_ranges`, not `predicates`, so they do not trip this.)
-    fn reject_row_id_with_predicate(
+    /// Reject projecting `_ROW_ID` alongside an exact or pruning predicate.
+    /// `_ROW_ID` is assigned positionally from emitted batch row counts, so
+    /// residual filtering or row-group/page pruning would desync it. 
(`_ROW_ID`
+    /// predicates travel via `row_ranges`, so they do not trip this.)
+    fn reject_row_id_with_predicates(
         read_type: &[DataField],
         predicates: &[Predicate],
     ) -> crate::Result<()> {
@@ -121,8 +128,9 @@ impl DataFileReader {
             .any(|p| !matches!(p, Predicate::AlwaysTrue));
         if projects_row_id && has_row_filtering_predicate {
             return Err(crate::Error::Unsupported {
-                message: "reading _ROW_ID together with a data predicate is 
not supported yet"
-                    .to_string(),
+                message:
+                    "reading _ROW_ID together with a data or pruning predicate 
is not supported yet"
+                        .to_string(),
             });
         }
         Ok(())
@@ -238,19 +246,20 @@ impl DataFileReader {
     ) -> crate::Result<ArrowRecordBatchStream> {
         // Guard at the true risk site: `_ROW_ID` is materialized positionally 
from
         // each batch's row count (see `row_id_column_for_batch`), assuming the
-        // reader emits rows in original file order and count. The format 
readers
-        // apply an exact residual filter that drops non-matching rows *before*
-        // `_ROW_ID` is assigned here, which would desync the ids. So 
projecting
-        // `_ROW_ID` together with a data predicate is unsupported — fail 
loudly
+        // reader emits rows in original file order and count. Format readers 
may
+        // skip row groups/pages or apply an exact row filter *before* 
`_ROW_ID`
+        // is assigned here, which would desync the ids. So projecting 
`_ROW_ID`
+        // together with a data predicate is unsupported — fail loudly
         // rather than return wrong ids. Placed here (not only in `read()`) 
because
         // `read_single_file_stream` is also called directly by the KV and
         // data-evolution readers; both strip/omit `_ROW_ID` from the read_type
         // they pass, so this guard does not affect them.
-        Self::reject_row_id_with_predicate(&self.read_type, &self.predicates)?;
+        Self::reject_row_id_with_predicates(&self.read_type, 
&self.predicates)?;
 
         let read_type = self.read_type.clone();
         let table_fields = self.table_fields.clone();
         let predicates = self.predicates.clone();
+        let row_filter = self.row_filter;
         let file_io = self.file_io.clone();
         let split = split.clone();
         let blob_as_descriptor = self.blob_as_descriptor;
@@ -293,6 +302,7 @@ impl DataFileReader {
             } else {
                 Some(crate::arrow::format::FilePredicates {
                     predicates: remapped,
+                    apply_row_filter: row_filter,
                     file_fields: file_fields.clone(),
                 })
             }
@@ -457,6 +467,7 @@ impl DataFileReader {
         let read_type = self.read_type.clone();
         let table_fields = self.table_fields.clone();
         let predicates = self.predicates.clone();
+        let row_filter = self.row_filter;
         let file_io = self.file_io.clone();
         let split = split.clone();
         let blob_as_descriptor = self.blob_as_descriptor;
@@ -498,6 +509,7 @@ impl DataFileReader {
             } else {
                 Some(crate::arrow::format::FilePredicates {
                     predicates: remapped,
+                    apply_row_filter: row_filter,
                     file_fields: file_fields.clone(),
                 })
             }
@@ -1163,6 +1175,81 @@ mod row_tests {
         assert_eq!(ages, vec![30, 40, 50]);
     }
 
+    #[tokio::test]
+    async fn 
parquet_predicate_skips_row_groups_when_row_filtering_is_disabled() {
+        let fields = vec![field(0, "id", DataType::Int(IntType::new()))];
+        let schema = build_target_arrow_schema(&fields).unwrap();
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let table_path = "memory:/parquet_pruning_only";
+        let bucket_path = format!("{table_path}/bucket-0");
+        let file_name = "part-0.parquet";
+        let file_path = format!("{bucket_path}/{file_name}");
+        let output = file_io.new_output(&file_path).unwrap();
+        let mut writer = create_format_writer(&output, schema.clone(), "zstd", 
1, None, None, None)
+            .await
+            .unwrap();
+        writer
+            .write(
+                &RecordBatch::try_new(schema.clone(), 
vec![Arc::new(Int32Array::from(vec![1, 2]))])
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        writer.flush().await.unwrap();
+        writer
+            .write(
+                &RecordBatch::try_new(schema, 
vec![Arc::new(Int32Array::from(vec![100, 101]))])
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        let file_size = writer.close().await.unwrap().file_size as i64;
+
+        let schema_id = 1;
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(bucket_path)
+            .with_total_buckets(1)
+            .with_data_files(vec![data_file(file_name, file_size, 4, 
schema_id)])
+            .build()
+            .unwrap();
+        let predicate = PredicateBuilder::new(&fields)
+            .greater_than("id", Datum::Int(100))
+            .unwrap();
+        let reader = DataFileReader::new(
+            file_io.clone(),
+            SchemaManager::new(file_io, table_path.to_string()),
+            schema_id,
+            fields.clone(),
+            fields,
+            vec![predicate],
+        )
+        .with_row_filter(false);
+        let batches = reader
+            .read(&[split])
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+        let ids = batches
+            .iter()
+            .flat_map(|batch| {
+                batch
+                    .column(0)
+                    .as_any()
+                    .downcast_ref::<Int32Array>()
+                    .unwrap()
+                    .values()
+                    .iter()
+                    .copied()
+            })
+            .collect::<Vec<_>>();
+
+        assert_eq!(ids, vec![100, 101]);
+    }
+
     /// Guard: projecting `_ROW_ID` together with a data predicate must fail
     /// loudly rather than assign wrong row ids. `_ROW_ID` is materialized
     /// positionally from post-filter batch row counts, so the readers' 
residual
@@ -1253,7 +1340,7 @@ mod row_tests {
         let read_type = vec![row_id];
         // AlwaysTrue alone -> allowed.
         assert!(
-            DataFileReader::reject_row_id_with_predicate(&read_type, 
&[Predicate::AlwaysTrue])
+            DataFileReader::reject_row_id_with_predicates(&read_type, 
&[Predicate::AlwaysTrue])
                 .is_ok(),
             "AlwaysTrue must not trip the _ROW_ID guard"
         );
@@ -1262,7 +1349,7 @@ mod row_tests {
             .greater_than("age", Datum::Int(1))
             .unwrap();
         assert!(
-            DataFileReader::reject_row_id_with_predicate(&read_type, 
&[filtering]).is_err(),
+            DataFileReader::reject_row_id_with_predicates(&read_type, 
&[filtering]).is_err(),
             "a row-filtering predicate must trip the _ROW_ID guard"
         );
     }
@@ -1336,6 +1423,23 @@ mod tests {
             with_filter.has_row_filtering_predicate(),
             "a real (non-AlwaysTrue) predicate is row-filtering"
         );
+
+        let pruning = PredicateBuilder::new(&fields)
+            .equal("id", crate::spec::Datum::Int(10))
+            .unwrap();
+        let with_pruning = DataFileReader::new(
+            file_io,
+            schema_manager,
+            1,
+            fields.clone(),
+            fields,
+            vec![pruning],
+        )
+        .with_row_filter(false);
+        assert!(
+            with_pruning.has_row_filtering_predicate(),
+            "a pruning predicate can skip physical rows"
+        );
     }
 
     struct MemOutputFile {
diff --git a/crates/paimon/src/table/format_table_read.rs 
b/crates/paimon/src/table/format_table_read.rs
index 0d561ab1..0b5f72f2 100644
--- a/crates/paimon/src/table/format_table_read.rs
+++ b/crates/paimon/src/table/format_table_read.rs
@@ -38,6 +38,7 @@ pub(crate) struct FormatTableRead<'a> {
     table: &'a Table,
     read_type: Vec<DataField>,
     data_predicates: Vec<Predicate>,
+    row_filter: bool,
     limit: Option<usize>,
 }
 
@@ -52,6 +53,7 @@ impl<'a> FormatTableRead<'a> {
             table,
             read_type,
             data_predicates,
+            row_filter: true,
             limit,
         }
     }
@@ -73,6 +75,11 @@ impl<'a> FormatTableRead<'a> {
         self
     }
 
+    pub(crate) fn with_row_filter(mut self, row_filter: bool) -> Self {
+        self.row_filter = row_filter;
+        self
+    }
+
     pub(crate) fn to_arrow(
         &self,
         data_splits: &[DataSplit],
@@ -88,13 +95,13 @@ impl<'a> FormatTableRead<'a> {
         let table_fields = self.table.schema().fields().to_vec();
         let (data_table_fields, data_predicates) =
             split_format_table_fields(&table_fields, &partition_keys, 
&self.data_predicates);
-
         let splits = data_splits.to_vec();
         let file_io = self.table.file_io().clone();
         let schema_manager = self.table.schema_manager().clone();
         let schema_id = self.table.schema().id();
         let mut remaining = self.limit;
         let batch_size = Some(core_options.read_batch_size()?);
+        let row_filter = self.row_filter;
 
         Ok(try_stream! {
             for split in splits {
@@ -110,6 +117,7 @@ impl<'a> FormatTableRead<'a> {
                     data_read_type.clone(),
                     data_predicates.clone(),
                 )
+                .with_row_filter(row_filter)
                 .with_batch_size(batch_size)
                 .read(std::slice::from_ref(&split))?;
 
diff --git a/crates/paimon/src/table/kv_file_reader.rs 
b/crates/paimon/src/table/kv_file_reader.rs
index 4450b273..675f8ed4 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -55,6 +55,8 @@ pub(crate) struct KeyValueFileReader {
     /// of a key survives); they are enforced by the post-merge residual
     /// filter using the full `config.predicates` instead.
     pushdown_predicates: Vec<Predicate>,
+    /// Whether predicates may remove rows before or after the key-value merge.
+    apply_row_filter: bool,
     #[cfg(test)]
     input_batch_sizes: Option<std::sync::Arc<std::sync::Mutex<Vec<usize>>>>,
 }
@@ -118,11 +120,17 @@ impl KeyValueFileReader {
             file_io,
             config,
             pushdown_predicates,
+            apply_row_filter: true,
             #[cfg(test)]
             input_batch_sizes: None,
         }
     }
 
+    pub(crate) fn with_row_filter(mut self, apply_row_filter: bool) -> Self {
+        self.apply_row_filter = apply_row_filter;
+        self
+    }
+
     #[cfg(test)]
     fn with_input_batch_sizes(
         mut self,
@@ -235,6 +243,7 @@ impl KeyValueFileReader {
         let residual_file_predicates =
             (!self.config.predicates.is_empty()).then(|| 
crate::arrow::format::FilePredicates {
                 predicates: self.config.predicates.clone(),
+                apply_row_filter: self.apply_row_filter,
                 file_fields: self.config.table_fields.clone(),
             });
         let user_fields = crate::arrow::residual::widen_scan_fields(
@@ -325,6 +334,7 @@ impl KeyValueFileReader {
         let table_options = self.config.table_options;
         let pushdown_predicates = self.pushdown_predicates;
         let residual_predicates = self.config.predicates;
+        let apply_row_filter = self.apply_row_filter;
         let primary_keys = self.config.primary_keys;
         let sequence_fields = self.config.sequence_fields;
         let read_batch_size = self.config.read_batch_size;
@@ -368,6 +378,7 @@ impl KeyValueFileReader {
                         internal_read_type.clone(),
                         pushdown_predicates.clone(),
                     )
+                    .with_row_filter(apply_row_filter)
                     .with_batch_size(Some(read_batch_size));
 
                     let stream = reader.read_single_file_stream(
@@ -421,15 +432,15 @@ impl KeyValueFileReader {
 
                 while let Some(batch) = merge_stream.next().await {
                     let batch = batch?;
-                    // Post-merge residual: enforce the FULL data predicate on
-                    // merged rows. PK conjuncts are also in this set (they 
were
-                    // already pushed down pre-merge); re-evaluating them on
-                    // already-matching rows is a no-op and keeps one shared
-                    // evaluator instead of deriving a non-PK subset. Runs on
-                    // the merge-output batch (keys + values, including widened
-                    // predicate columns); the reorder below projects the
-                    // output back to read_type.
-                    let batch = if residual_predicates.is_empty() {
+                    // When row filtering is enabled, the post-merge residual
+                    // enforces the FULL data predicate on merged rows. PK
+                    // conjuncts are also in this set (they were already pushed
+                    // down pre-merge); re-evaluating them on already-matching
+                    // rows is a no-op and keeps one shared evaluator instead 
of
+                    // deriving a non-PK subset. Runs on the merge-output batch
+                    // (keys + values, including widened predicate columns); 
the
+                    // reorder below projects the output back to read_type.
+                    let batch = if !apply_row_filter || 
residual_predicates.is_empty() {
                         batch
                     } else {
                         match crate::arrow::residual::evaluate_predicates_mask(
@@ -774,6 +785,42 @@ mod tests {
         assert_eq!(int_column(&batches, "value"), vec![21]);
     }
 
+    #[tokio::test]
+    async fn kv_read_row_filter_disabled_keeps_merged_rows() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_pruning_only";
+        setup_dirs(&file_io, table_path).await;
+        let table = pk_table(&file_io, table_path, &[]);
+
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(10), Some(20), Some(30)]),
+        )
+        .await;
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(11), Some(21), Some(31)]),
+        )
+        .await;
+
+        let filter = PredicateBuilder::new(table.schema().fields())
+            .equal("value", Datum::Int(21))
+            .unwrap();
+        let mut builder = table.new_read_builder();
+        builder.with_filter(filter);
+        let plan = builder.new_scan().plan().await.unwrap();
+        let read = builder.new_read().unwrap().with_row_filter(false);
+        let batches = read
+            .to_arrow(plan.splits())
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+
+        assert_eq!(int_column(&batches, "id"), vec![1, 2, 3]);
+        assert_eq!(int_column(&batches, "value"), vec![11, 21, 31]);
+    }
+
     /// Gap-A: the predicate column is NOT in the projection. The merge read
     /// must widen internally, filter, then project back — output schema must
     /// contain only the projected column.
diff --git a/crates/paimon/src/table/table_read.rs 
b/crates/paimon/src/table/table_read.rs
index d95c0104..5e4839a2 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -110,6 +110,22 @@ impl<'a> TableRead<'a> {
         }
     }
 
+    /// Configure whether data predicates may remove rows from emitted batches.
+    ///
+    /// When disabled, predicates remain available for conservative scan and
+    /// file-format pruning, and the caller must enforce them exactly. Merge 
and
+    /// data-evolution readers do not push such predicates below 
materialization.
+    pub fn with_row_filter(self, row_filter: bool) -> Self {
+        match self.0 {
+            TableReadKind::Paimon(read) => {
+                Self(TableReadKind::Paimon(read.with_row_filter(row_filter)))
+            }
+            TableReadKind::Format(read) => {
+                Self(TableReadKind::Format(read.with_row_filter(row_filter)))
+            }
+        }
+    }
+
     /// Returns an [`ArrowRecordBatchStream`].
     pub fn to_arrow(&self, data_splits: &[DataSplit]) -> 
crate::Result<ArrowRecordBatchStream> {
         match &self.0 {
@@ -158,6 +174,7 @@ struct PaimonTableRead<'a> {
     table: &'a Table,
     read_type: Vec<DataField>,
     data_predicates: Vec<Predicate>,
+    row_filter: bool,
 }
 
 impl<'a> PaimonTableRead<'a> {
@@ -171,6 +188,7 @@ impl<'a> PaimonTableRead<'a> {
             table,
             read_type,
             data_predicates,
+            row_filter: true,
         }
     }
 
@@ -189,9 +207,9 @@ impl<'a> PaimonTableRead<'a> {
         self.table
     }
 
-    /// Set a filter predicate. Used conservatively for read-side pruning and
-    /// enforced exactly by residual filtering on append, data-evolution, and
-    /// primary-key merge read paths (see
+    /// Set a filter predicate. Used conservatively for read-side pruning and,
+    /// unless row filtering is disabled, enforced exactly by residual 
filtering
+    /// on append, data-evolution, and primary-key merge read paths (see
     /// [`ReadBuilder::with_filter`](crate::table::ReadBuilder::with_filter)
     /// for per-format exceptions).
     pub fn with_filter(mut self, filter: Predicate) -> Self {
@@ -204,6 +222,11 @@ impl<'a> PaimonTableRead<'a> {
         self
     }
 
+    fn with_row_filter(mut self, row_filter: bool) -> Self {
+        self.row_filter = row_filter;
+        self
+    }
+
     /// Returns an [`ArrowRecordBatchStream`] for an incremental scan plan.
     pub fn to_incremental_arrow(
         &self,
@@ -286,6 +309,7 @@ impl<'a> PaimonTableRead<'a> {
             read_type,
             self.data_predicates.clone(),
         )
+        .with_row_filter(self.row_filter)
         
.with_batch_size(Some(self.table.schema().core_options().read_batch_size()?));
         let raw_stream = reader.read(&data_splits)?;
 
@@ -443,7 +467,8 @@ impl<'a> PaimonTableRead<'a> {
                     .collect(),
                 read_batch_size: core_options.read_batch_size()?,
             },
-        );
+        )
+        .with_row_filter(self.row_filter);
         reader.read(splits)
     }
 
@@ -466,6 +491,7 @@ impl<'a> PaimonTableRead<'a> {
             core_options.blob_view_resolve_enabled(),
             self.table.rest_env().cloned(),
         )?
+        .with_row_filter(self.row_filter)
         .with_batch_size(Some(core_options.read_batch_size()?));
         reader.read(data_splits)
     }
@@ -484,6 +510,7 @@ impl<'a> PaimonTableRead<'a> {
             self.read_type().to_vec(),
             self.data_predicates.clone(),
         )
+        .with_row_filter(self.row_filter)
         
.with_batch_size(Some(self.table.schema().core_options().read_batch_size()?)))
     }
 }
@@ -641,6 +668,19 @@ mod tests {
         assert!(!pk_split_needs_merge(&dv_compacted, true));
     }
 
+    #[test]
+    fn test_row_filter_disabled_keeps_data_predicates() {
+        let table = query_auth_table();
+        let read = PaimonTableRead::new(
+            &table,
+            table.schema.fields().to_vec(),
+            vec![Predicate::AlwaysFalse],
+        )
+        .with_row_filter(false);
+
+        assert_eq!(read.data_predicates(), &[Predicate::AlwaysFalse]);
+    }
+
     #[test]
     fn test_rowkind_rejects_null_value_kind() {
         let values = arrow_array::Int8Array::from(vec![Some(0), None]);
diff --git a/crates/paimon/src/table/vector_search_builder.rs 
b/crates/paimon/src/table/vector_search_builder.rs
index 57cde2be..cbfdcfef 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -413,6 +413,7 @@ impl<'a> VectorSearchBuilder<'a> {
             Some(filter) => {
                 let file_predicates = FilePredicates {
                     predicates: vec![filter.clone()],
+                    apply_row_filter: true,
                     file_fields: self.table.schema().fields().to_vec(),
                 };
                 let residual_read_type = widen_scan_fields(&[], 
Some(&file_predicates));
@@ -3407,6 +3408,7 @@ mod residual_positions_tests {
             .unwrap();
         FilePredicates {
             predicates: vec![pred],
+            apply_row_filter: true,
             file_fields: vec![id_field()],
         }
     }
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 01635ba6..0cd5e255 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1524,7 +1524,9 @@ SET 'paimon.scan.version' = '1';
 RESET 'paimon.scan.version';
 ```
 
-Options prefixed with `paimon.` are handled by Paimon; all others are 
delegated to DataFusion. Dynamic options are applied at table load time via 
`table.copy_with_options()`.
+Quoted options prefixed with `paimon.` are handled as Paimon table options; all
+others are delegated to DataFusion. Dynamic table options are applied at table
+load time via `table.copy_with_options()`.
 
 Example — enable BLOB descriptor mode:
 


Reply via email to