Repository: duckdb/duckdb (2026-07-21T08:29:06Z)

Issue:
Parquet read doesn't prune row group with equal BETWEEN boundary
### What happens?

See repro 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
                  );

-- shouldn't read any row group
memory D EXPLAIN ANALYZE
         SELECT sum(payload)
         FROM read_parquet('/tmp/duckdb_bloom_pruning_gap.parquet')
         WHERE r BETWEEN 101 AND 101;
╭─ Summary ───────────╮
│ Total Time: 0.0014s │
│ Data Read: 107.0 kB │
╰─────────────────────╯
╭─ Ungrouped Aggregate ─────────────────╮
│ Aggregates: sum(#0)                   │
│ 1 row                             0µs │
╰───────────────────┬───────────────────╯
╭─ Projection ──────┴───────────────────╮
│ Projections: payload                  │
│ 0 rows                            0µs │
╰───────────────────┬───────────────────╯
╭─ Table Scan ──────┴───────────────────╮
│ Function: READ_PARQUET                │
│ Projections: payload                  │
│ Filters: (r >= 101) AND (r <= 101)    │
│ Total Files Read: 1                   │
│ Filename(s):                          │
│ /tmp/duckdb_bloom_pruning_gap.parquet │
│ Row Groups Scanned: 10 / 10           │
│ 0 rows                            0µs │
╰───────────────────────────────────────╯

-- shouldn't read any row group
memory D EXPLAIN ANALYZE
         SELECT sum(payload)
         FROM read_parquet('/tmp/duckdb_bloom_pruning_gap.parquet')
         WHERE r >= 101 AND r <= 101;
╭─ Summary ───────────╮
│ Total Time: 0.0012s │
│ Data Read: 107.0 kB │
╰─────────────────────╯
╭─ Ungrouped Aggregate ─────────────────╮
│ Aggregates: sum(#0)                   │
│ 1 row                             0µs │
╰───────────────────┬───────────────────╯
╭─ Projection ──────┴───────────────────╮
│ Projections: payload                  │
│ 0 rows                            0µs │
╰───────────────────┬───────────────────╯
╭─ Table Scan ──────┴───────────────────╮
│ Function: READ_PARQUET                │
│ Projections: payload                  │
│ Filters: (r >= 101) AND (r <= 101)    │
│ Total Files Read: 1                   │
│ Filename(s):                          │
│ /tmp/duckdb_bloom_pruning_gap.parquet │
│ Row Groups Scanned: 10 / 10           │
│ 0 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/src/optimizer/filter_combiner.cpp b/src/optimizer/filter_combiner.cpp
index f5911fabea0a..036a11523de5 100644
--- a/src/optimizer/filter_combiner.cpp
+++ b/src/optimizer/filter_combiner.cpp
@@ -193,15 +193,21 @@ void FilterCombiner::GenerateFilters(const std::function<void(unique_ptr<Express
 				}
 			}
 			if (lower_index.IsValid() && upper_index.IsValid()) {
-				// found both lower and upper index, create a BETWEEN expression
-				auto lower_constant =
-				    make_uniq<BoundConstantExpression>(constant_list[lower_index.GetIndex()].constant);
-				auto upper_constant =
-				    make_uniq<BoundConstantExpression>(constant_list[upper_index.GetIndex()].constant);
-				auto between =
-				    BoundBetweenExpression::Create(entries[i].get().Copy(), std::move(lower_constant),
-				                                   std::move(upper_constant), lower_inclusive, upper_inclusive);
-				callback(std::move(between));
+				auto &lower_value = constant_list[lower_index.GetIndex()].constant;
+				auto &upper_value = constant_list[upper_index.GetIndex()].constant;
+				if (lower_inclusive && upper_inclusive && lower_value == upper_value) {
+					auto constant = make_uniq<BoundConstantExpression>(lower_value);
+					auto comparison = BoundComparisonExpression::Create(ExpressionType::COMPARE_EQUAL,
+					                                                    entries[i].get().Copy(), std::move(constant));
+					callback(std::move(comparison));
+				} else {
+					auto lower_constant = make_uniq<BoundConstantExpression>(lower_value);
+					auto upper_constant = make_uniq<BoundConstantExpression>(upper_value);
+					auto between =
+					    BoundBetweenExpression::Create(entries[i].get().Copy(), std::move(lower_constant),
+					                                   std::move(upper_constant), lower_inclusive, upper_inclusive);
+					callback(std::move(between));
+				}
 			} else if (lower_index.IsValid()) {
 				// only lower index found, create simple comparison expression
 				auto constant = make_uniq<BoundConstantExpression>(constant_list[lower_index.GetIndex()].constant);


Test patch:
diff --git a/test/sql/copy/parquet/bloom_filters.test b/test/sql/copy/parquet/bloom_filters.test
index 8f80c3ce984e..d35d84b4f439 100644
--- a/test/sql/copy/parquet/bloom_filters.test
+++ b/test/sql/copy/parquet/bloom_filters.test
@@ -32,6 +32,17 @@ SELECT BOOL_AND(bloom_filter_excludes) FROM parquet_bloom_probe('{TEST_DIR}/bloo
 ----
 true
 
+# Equal inclusive bounds are reduced to equality, enabling Bloom filter pruning
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM '{TEST_DIR}/bloom1.parquet' WHERE r BETWEEN 201 AND 201;
+----
+analyzed_plan	<REGEX>:(?s).*Row Groups Scanned: 0 / 10.*
+
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM '{TEST_DIR}/bloom1.parquet' WHERE r >= 201 AND r <= 201;
+----
+analyzed_plan	<REGEX>:(?s).*Row Groups Scanned: 0 / 10.*
+
 # this value is outside min/max
 query I
 SELECT BOOL_AND(bloom_filter_excludes) FROM parquet_bloom_probe('{TEST_DIR}/bloom1.parquet', 'r', '112121212');