Repository: duckdb/duckdb (2026-07-24T05:00:28Z)

Issue:
Parquet read doesn't prune row group for IN operator
### What happens?

See repro SQL for details.

### To Reproduce

```sql
memory D COPY (
             SELECT
                 ((i % 100) * 2)::INTEGER AS r,
                 i::BIGINT AS payload
             FROM range(20480) AS t(i)
         )
         TO '/tmp/duckdb_bloom_pruning_gap.parquet'
         (
             FORMAT PARQUET,
             ROW_GROUP_SIZE 2048,
             WRITE_BLOOM_FILTER TRUE,
             OVERWRITE TRUE
         );
memory D EXPLAIN ANALYZE
         SELECT sum(payload)
         FROM read_parquet('/tmp/duckdb_bloom_pruning_gap.parquet')
         WHERE r IN (101, 103);
╭─ Summary ───────────╮
│ Total Time: 0.0010s │
│ Data Read: 107.0 kB │
╰─────────────────────╯
╭─ Ungrouped Aggregate ─────────────────╮
│ Aggregates: sum(#0)                   │
│ 1 row                             0µs │
╰───────────────────┬───────────────────╯
╭─ Projection ──────┴───────────────────╮
│ Projections: payload                  │
│ 0 rows                            0µs │
╰───────────────────┬───────────────────╯
╭─ Projection ──────┴───────────────────╮
│ Projections: #1                       │
│ 0 rows                            0µs │
╰───────────────────┬───────────────────╯
╭─ Filter ──────────┴───────────────────╮
│ Expression: (r = 101) OR (r = 103)    │
│ 0 rows                            0µs │
╰───────────────────┒┎──────────────────╯
╭─ Table Scan ──────┚┖──────────────────╮
│ Function: READ_PARQUET                │
│ Projections: r, payload               │
│ Filters: optional: (r IN (101, 103))  │
│ Total Files Read: 1                   │
│ Filename(s):                          │
│ /tmp/duckdb_bloom_pruning_gap.parquet │
│ Row Groups Scanned: 10 / 10           │
│ 20,480 rows                       0µs │
╰───────────────────────────────────────╯
```

### OS:

macos

### DuckDB Version:

9ebdd1ee5279885dd2a89d4ac8f37034c05de203

### DuckDB Client:

SQL

### Hardware:

_No response_

### Full Name:

dentiny

### Affiliation:

N/A

### Did you include all relevant configuration (e.g., CPU architecture, Linux distribution) to reproduce the issue?

- [x] Yes, I have

### Did you include all code required to reproduce the issue?

- [x] Yes, I have

### Did you include all relevant data sets for reproducing the issue?

Yes


Discussion:

Patch:
diff --git a/extension/parquet/parquet_statistics.cpp b/extension/parquet/parquet_statistics.cpp
index e3aaa9e72ac6..7dc831afa17a 100644
--- a/extension/parquet/parquet_statistics.cpp
+++ b/extension/parquet/parquet_statistics.cpp
@@ -21,9 +21,11 @@
 #include "duckdb/storage/statistics/list_stats.hpp"
 #include "duckdb/planner/expression_iterator.hpp"
 #include "duckdb/planner/filter/expression_filter.hpp"
+#include "duckdb/planner/filter/table_filter_functions.hpp"
 #include "duckdb/planner/expression/bound_comparison_expression.hpp"
 #include "duckdb/planner/expression/bound_conjunction_expression.hpp"
 #include "duckdb/planner/expression/bound_constant_expression.hpp"
+#include "duckdb/planner/expression/bound_operator_expression.hpp"
 #include "duckdb/planner/expression/bound_reference_expression.hpp"
 #include "reader/uuid_column_reader.hpp"
 #include "duckdb/common/type_visitor.hpp"
@@ -674,6 +676,51 @@ unique_ptr<BaseStatistics> ParquetStatisticsUtils::TransformColumnStatistics(con
 	return row_group_stats;
 }
 
+// Optional filters store the expression used for pruning in their bind data.
+static optional_ptr<const Expression> GetOptionalFilterChild(const Expression &expr) {
+	if (expr.GetExpressionClass() != ExpressionClass::BOUND_FUNCTION) {
+		return nullptr;
+	}
+	auto &func = expr.Cast<BoundFunctionExpression>();
+	if (!func.BindInfo()) {
+		return nullptr;
+	}
+	if (func.Function().GetName() == OptionalFilterScalarFun::NAME) {
+		return func.BindInfo()->Cast<OptionalFilterFunctionData>().child_filter_expr.get();
+	}
+	if (func.Function().GetName() == SelectivityOptionalFilterScalarFun::NAME) {
+		return func.BindInfo()->Cast<SelectivityOptionalFilterFunctionData>().child_filter_expr.get();
+	}
+	return nullptr;
+}
+
+// Bloom filters can only probe IN lists over this column and constants of the same type.
+static optional_ptr<const BoundOperatorExpression> GetBloomFilterInExpression(const Expression &expr) {
+	if (expr.GetExpressionClass() != ExpressionClass::BOUND_OPERATOR ||
+	    expr.GetExpressionType() != ExpressionType::COMPARE_IN) {
+		return nullptr;
+	}
+	auto &in_expr = expr.Cast<BoundOperatorExpression>();
+	auto &children = in_expr.GetChildren();
+	if (children.size() <= 1 || children[0]->GetExpressionClass() != ExpressionClass::BOUND_REF) {
+		return nullptr;
+	}
+	auto &column = children[0]->Cast<BoundReferenceExpression>();
+	if (column.Index() != 0) {
+		return nullptr;
+	}
+	for (idx_t child_idx = 1; child_idx < children.size(); ++child_idx) {
+		if (children[child_idx]->GetExpressionClass() != ExpressionClass::BOUND_CONSTANT) {
+			return nullptr;
+		}
+		auto &constant = children[child_idx]->Cast<BoundConstantExpression>().GetValue();
+		if (constant.IsNull() || column.GetReturnType() != constant.type()) {
+			return nullptr;
+		}
+	}
+	return in_expr;
+}
+
 static optional_ptr<const BoundConstantExpression> GetBloomFilterConstant(const Expression &expr) {
 	if (!BoundComparisonExpression::IsComparison(expr)) {
 		return nullptr;
@@ -709,6 +756,13 @@ static bool HasFilterConstants(const Expression &expr) {
 	if (BoundComparisonExpression::IsComparison(expr)) {
 		return GetBloomFilterConstant(expr) != nullptr;
 	}
+	if (GetBloomFilterInExpression(expr)) {
+		return true;
+	}
+	auto optional_filter_child = GetOptionalFilterChild(expr);
+	if (optional_filter_child) {
+		return HasFilterConstants(*optional_filter_child);
+	}
 	if (expr.GetExpressionClass() != ExpressionClass::BOUND_CONJUNCTION) {
 		return false;
 	}
@@ -907,6 +961,22 @@ static bool ApplyBloomFilter(const Expression &expr, ParquetBloomFilter &bloom_f
 		}
 		return BloomFilterExcludes(constant->GetValue(), bloom_filter, schema);
 	}
+	auto in_expr = GetBloomFilterInExpression(expr);
+	if (in_expr) {
+		auto &children = in_expr->GetChildren();
+		// An IN filter is excluded only when every candidate is absent.
+		for (idx_t child_idx = 1; child_idx < children.size(); ++child_idx) {
+			auto &constant = children[child_idx]->Cast<BoundConstantExpression>().GetValue();
+			if (!BloomFilterExcludes(constant, bloom_filter, schema)) {
+				return false;
+			}
+		}
+		return true;
+	}
+	auto optional_filter_child = GetOptionalFilterChild(expr);
+	if (optional_filter_child) {
+		return ApplyBloomFilter(*optional_filter_child, bloom_filter, schema);
+	}
 	if (expr.GetExpressionClass() != ExpressionClass::BOUND_CONJUNCTION) {
 		return false;
 	}


Test patch:
diff --git a/test/sql/copy/parquet/bloom_filters.test b/test/sql/copy/parquet/bloom_filters.test
index aa6d44cda381..281289e14e58 100644
--- a/test/sql/copy/parquet/bloom_filters.test
+++ b/test/sql/copy/parquet/bloom_filters.test
@@ -54,6 +54,52 @@ EXPLAIN ANALYZE SELECT count(*) FROM '{TEST_DIR}/bloom1.parquet' WHERE r >= 201
 ----
 analyzed_plan	<REGEX>:(?s).*Row Groups Scanned: 0 / 10.*
 
+statement ok
+COPY (
+	SELECT
+		((i % 100) * 2)::INTEGER AS r,
+		i::BIGINT AS payload
+	FROM range(20480) t(i)
+) TO '{TEST_DIR}/bloom_in.parquet' (
+	FORMAT PARQUET,
+	ROW_GROUP_SIZE 2048,
+	WRITE_BLOOM_FILTER TRUE
+);
+
+# Both values are absent but within the min/max range of every row group
+query II
+EXPLAIN ANALYZE
+SELECT sum(payload)
+FROM read_parquet('{TEST_DIR}/bloom_in.parquet')
+WHERE r IN (101, 103);
+----
+analyzed_plan	<REGEX>:(?s).*Row Groups Scanned: 0 / 10.*
+
+# Optional OR filters should also use the bloom filter
+query II
+EXPLAIN ANALYZE
+SELECT sum(payload)
+FROM read_parquet('{TEST_DIR}/bloom_in.parquet')
+WHERE r = 101 OR r = 103;
+----
+analyzed_plan	<REGEX>:(?s).*Row Groups Scanned: 0 / 10.*
+
+# A row group cannot be pruned when any IN value might be present
+query II
+EXPLAIN ANALYZE
+SELECT count(*)
+FROM read_parquet('{TEST_DIR}/bloom_in.parquet')
+WHERE r IN (100, 103);
+----
+analyzed_plan	<REGEX>:(?s).*Row Groups Scanned: 10 / 10.*
+
+query I
+SELECT count(*)
+FROM read_parquet('{TEST_DIR}/bloom_in.parquet')
+WHERE r IN (100, 103);
+----
+205
+
 # this value is outside min/max
 query I
 SELECT BOOL_AND(bloom_filter_excludes) FROM parquet_bloom_probe('{TEST_DIR}/bloom1.parquet', 'r', '112121212');