Repository: duckdb/duckdb (2026-07-24T03:56:47Z)

Issue:
Row group pruning seems incorrect on rowid column
### What happens?

See repro for details.

### To Reproduce

```sql
memory D CREATE TABLE p AS SELECT i FROM range(0, 300000) tbl(i);

-- should only access one single row group
memory D EXPLAIN ANALYZE SELECT count(*) FROM p WHERE rowid = 122880;
╭─ Summary ───────────╮
│ Total Time: 0.0007s │
╰─────────────────────╯
╭─ Ungrouped Aggregate ─────╮
│ Aggregates: count_star()  │
│ 1 row                 0µs │
╰─────────────┒┎────────────╯
╭─ Table Scan ┚┖────────────╮
│ Table: memory.main.p      │
│ Type: Sequential Scan     │
│ Filters: rowid = 122880   │
│ Row Groups Scanned: 2 / 3 │
│ 1 row                 0µs │
╰───────────────────────────╯
```

### OS:

macos

### DuckDB Version:

71145c7b2726c29c46a2c8af71ebf7ea8051c22f

### 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/storage/table/row_group.cpp b/src/storage/table/row_group.cpp
index b81b0e018b46..a1863db6f41f 100644
--- a/src/storage/table/row_group.cpp
+++ b/src/storage/table/row_group.cpp
@@ -698,11 +698,14 @@ static idx_t IntersectSelections(const SelectionVector &left, idx_t left_count,
 }
 
 FilterPropagateResult RowGroup::CheckRowIdFilter(const TableFilter &filter, idx_t beg_row, idx_t end_row) {
+	if (end_row <= beg_row) {
+		return FilterPropagateResult::FILTER_ALWAYS_FALSE;
+	}
 	// RowId columns dont have a zonemap, but we can trivially create stats to check the filter against.
 	BaseStatistics dummy_stats = NumericStats::CreateEmpty(LogicalType::ROW_TYPE);
 	dummy_stats.SetHasNoNullFast();
 	NumericStats::SetMin(dummy_stats, UnsafeNumericCast<row_t>(beg_row));
-	NumericStats::SetMax(dummy_stats, UnsafeNumericCast<row_t>(end_row));
+	NumericStats::SetMax(dummy_stats, UnsafeNumericCast<row_t>(end_row - 1));
 
 	auto &expr_filter = ExpressionFilter::GetExpressionFilter(filter, "RowGroup::CheckRowIdFilter");
 	return expr_filter.CheckStatistics(dummy_stats);


Test patch:
diff --git a/test/sql/optimizer/test_rowid_pruning.test b/test/sql/optimizer/test_rowid_pruning.test
index d2f308c52c7b..b0fec2ce4659 100644
--- a/test/sql/optimizer/test_rowid_pruning.test
+++ b/test/sql/optimizer/test_rowid_pruning.test
@@ -15,3 +15,73 @@ query II
 EXPLAIN ANALYZE SELECT count(*) FROM rowid_prune WHERE rowid BETWEEN 0 AND 10;
 ----
 analyzed_plan	<REGEX>:.*Row Groups Scanned: 1 / 3.*
+
+# Row group ranges are half-open: [0, 122880), [122880, 245760), [245760, 300000).
+# Boundary row IDs should not keep the previous row group alive.
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM rowid_prune WHERE rowid = 122879;
+----
+analyzed_plan	<REGEX>:.*Row Groups Scanned: 1 / 3.*
+
+query I
+SELECT count(*) FROM rowid_prune WHERE rowid = 122879;
+----
+1
+
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM rowid_prune WHERE rowid = 122880;
+----
+analyzed_plan	<REGEX>:.*Row Groups Scanned: 1 / 3.*
+
+query I
+SELECT count(*) FROM rowid_prune WHERE rowid = 122880;
+----
+1
+
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM rowid_prune WHERE rowid = 245759;
+----
+analyzed_plan	<REGEX>:.*Row Groups Scanned: 1 / 3.*
+
+query I
+SELECT count(*) FROM rowid_prune WHERE rowid = 245759;
+----
+1
+
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM rowid_prune WHERE rowid = 245760;
+----
+analyzed_plan	<REGEX>:.*Row Groups Scanned: 1 / 3.*
+
+query I
+SELECT count(*) FROM rowid_prune WHERE rowid = 245760;
+----
+1
+
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM rowid_prune WHERE rowid = 300000;
+----
+analyzed_plan	<REGEX>:.*Row Groups Scanned: 0 / 3.*
+
+query I
+SELECT count(*) FROM rowid_prune WHERE rowid = 300000;
+----
+0
+
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM rowid_prune WHERE rowid >= 300000;
+----
+analyzed_plan	<REGEX>:.*Row Groups Scanned: 0 / 3.*
+
+query I
+SELECT count(*) FROM rowid_prune WHERE rowid >= 300000;
+----
+0
+
+statement ok
+CREATE TABLE empty_rowid_prune(i INTEGER);
+
+query I
+SELECT count(*) FROM empty_rowid_prune WHERE rowid = 0;
+----
+0