Repository: duckdb/duckdb (2026-07-04T07:09:45Z)

Issue:
Prefix query doesn't always prune row groups correctly
### What happens?

See repro SQL for details, should be straightforward.

### To Reproduce

```sql
memory D CREATE TABLE t AS SELECT printf('%010d', r) s FROM range(50_000_000) tbl(r) ORDER BY r;

-- starts_with doesn't prune correctly
memory D EXPLAIN ANALYZE SELECT count(*) FROM t WHERE starts_with(s, '004999990');
╭─ Summary ───────────╮
│ Total Time: 0.0289s │
╰─────────────────────╯
╭─ Ungrouped Aggregate ────────────────╮
│ 1 row                            0µs │
╰───────────────────┒┎─────────────────╯
╭─ Table Scan ──────┚┖─────────────────╮
│ Table: memory.main.t                 │
│ Type: Sequential Scan                │
│ Filters: starts_with(s, '004999990') │
│ Row Groups Scanned: 407 / 407        │
│ 10 rows                      440.0ms │
╰──────────────────────────────────────╯

-- LIKE prunes as expected
memory D EXPLAIN ANALYZE SELECT count(*) FROM t WHERE s LIKE '004999990%';
╭─ Summary ───────────╮
│ Total Time: 0.0035s │
╰─────────────────────╯
╭─ Ungrouped Aggregate ────────────────────╮
│ Aggregates: count_star()                 │
│ 1 row                                0µs │
╰─────────────────────┒┎───────────────────╯
╭─ Table Scan ────────┚┖───────────────────╮
│ Table: memory.main.t                     │
│ Type: Sequential Scan                    │
│ Filters:                                 │
│ (s >= '004999990') AND (s < '004999991') │
│ Row Groups Scanned: 1 / 407              │
│ 10 rows                              0µs │
╰──────────────────────────────────────────╯

-- prefix operator doesn't prune as well
memory D EXPLAIN ANALYZE SELECT count(*) FROM t WHERE s ^@ '004999990';
╭─ Summary ───────────╮
│ Total Time: 0.0618s │
╰─────────────────────╯
╭─ Ungrouped Aggregate ─────────────╮
│ 1 row                         0µs │
╰─────────────────┒┎────────────────╯
╭─ Table Scan ────┚┖────────────────╮
│ Table: memory.main.t              │
│ Type: Sequential Scan             │
│ Filters: s ^@ '004999990'         │
│ Row Groups Scanned: 407 / 407     │
│ 10 rows                   930.0ms │
╰───────────────────────────────────╯
```

### OS:

macos

### DuckDB Version:

a81547b43c56c671a7ad1a636f83886713efdd8a

### 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 be6f41c95ca8..9f8181c75c7f 100644
--- a/src/optimizer/filter_combiner.cpp
+++ b/src/optimizer/filter_combiner.cpp
@@ -421,7 +421,8 @@ FilterPushdownResult FilterCombiner::TryPushdownPrefixFilter(TableFilterSet &tab
 		return FilterPushdownResult::NO_PUSHDOWN;
 	}
 	auto &func = expr.Cast<BoundFunctionExpression>();
-	if (func.Function().GetName() != "prefix") {
+	auto &function_name = func.Function().GetName();
+	if (function_name != "prefix" && function_name != "starts_with" && function_name != "^@") {
 		return FilterPushdownResult::NO_PUSHDOWN;
 	}
 	if (func.GetChildren()[0]->GetExpressionClass() != ExpressionClass::BOUND_COLUMN_REF ||


Test patch:
diff --git a/test/optimizer/pushdown/starts_with_pushdown.test b/test/optimizer/pushdown/starts_with_pushdown.test
new file mode 100644
index 000000000000..3761f8d9132a
--- /dev/null
+++ b/test/optimizer/pushdown/starts_with_pushdown.test
@@ -0,0 +1,21 @@
+# name: test/optimizer/pushdown/starts_with_pushdown.test
+# description: Test that starts_with/^@/prefix filters are pushed down as range filters
+# group: [pushdown]
+
+statement ok
+CREATE TABLE t AS SELECT lpad(i::VARCHAR, 9, '0') AS s FROM range(500000) tbl(i);
+
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM t WHERE starts_with(s, '00049999')
+----
+analyzed_plan	<REGEX>:(?s).*s >= '00049999'.*s < '0004999:'.*Row Groups Scanned: 1 / 5.*
+
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM t WHERE s ^@ '00049999'
+----
+analyzed_plan	<REGEX>:(?s).*s >= '00049999'.*s < '0004999:'.*Row Groups Scanned: 1 / 5.*
+
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM t WHERE prefix(s, '00049999')
+----
+analyzed_plan	<REGEX>:(?s).*s >= '00049999'.*s < '0004999:'.*Row Groups Scanned: 1 / 5.*