Repository: duckdb/duckdb (2026-07-05T23:34:31Z)

Issue:
Left function doesn't get rewritten to prefix query
### What happens?

Hi team, DuckDB supports a few substring functions, but they're correctly rewritten to prefix query, which leads to extra unnecessary scan. See repro SQL for example, should be straightforward.

### To Reproduce

```sql
memory D CREATE TABLE t AS SELECT printf('%08d', i) AS s, i::BIGINT AS x FROM range(10000000) tbl(i);

-- Correctly pruned.
memory D EXPLAIN ANALYZE SELECT count(*) FROM t WHERE s LIKE '09999%';
╭─ Summary ───────────╮
│ Total Time: 0.0018s │
╰─────────────────────╯
╭─ Ungrouped Aggregate ────────────╮
│ Aggregates: count_star()         │
│ 1 row                        0µs │
╰─────────────────┒┎───────────────╯
╭─ Table Scan ────┚┖───────────────╮
│ Table: memory.main.t             │
│ Type: Sequential Scan            │
│ Filters:                         │
│ (s >= '09999') AND (s < '0999:') │
│ Row Groups Scanned: 1 / 82       │
│ 1,000 rows                   0µs │
╰──────────────────────────────────╯

-- Should be prefix query and scan 1 row group.
memory D EXPLAIN ANALYZE SELECT count(*) FROM t WHERE left(s, 5) = '09999';
╭─ Summary ───────────╮
│ Total Time: 0.0248s │
╰─────────────────────╯
╭─ Ungrouped Aggregate ─────────────╮
│ 1 row                         0µs │
╰─────────────────┒┎────────────────╯
╭─ Table Scan ────┚┖────────────────╮
│ Table: memory.main.t              │
│ Type: Sequential Scan             │
│ Filters: "left"(s, 5) = '09999'   │
│ Row Groups Scanned: 82 / 82       │
│ 1,000 rows                320.0ms │
╰───────────────────────────────────╯
```


### OS:

macos

### DuckDB Version:

a81547b

### 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/include/duckdb/optimizer/rule/left_to_prefix.hpp b/src/include/duckdb/optimizer/rule/left_to_prefix.hpp
new file mode 100644
index 000000000000..9f288d6c3c11
--- /dev/null
+++ b/src/include/duckdb/optimizer/rule/left_to_prefix.hpp
@@ -0,0 +1,25 @@
+//===----------------------------------------------------------------------===//
+//                         DuckDB
+//
+// duckdb/optimizer/rule/left_to_prefix.hpp
+//
+//
+//===----------------------------------------------------------------------===//
+
+#pragma once
+
+#include "duckdb/optimizer/rule.hpp"
+
+namespace duckdb {
+
+// This rule rewrites equality comparisons on string prefixes, e.g. [left(s, 3) = 'abc'] into
+// [prefix(s, 'abc')], which can be pushed into the scan as a range filter.
+class LeftToPrefixRule : public Rule {
+public:
+	explicit LeftToPrefixRule(ExpressionRewriter &rewriter);
+
+	unique_ptr<Expression> Apply(LogicalOperator &op, vector<reference<Expression>> &bindings, bool &changes_made,
+	                             bool is_root) override;
+};
+
+} // namespace duckdb
diff --git a/src/include/duckdb/optimizer/rule/list.hpp b/src/include/duckdb/optimizer/rule/list.hpp
index ced1d94ff0c6..fa1c7c8830cf 100644
--- a/src/include/duckdb/optimizer/rule/list.hpp
+++ b/src/include/duckdb/optimizer/rule/list.hpp
@@ -8,6 +8,7 @@
 #include "duckdb/optimizer/rule/date_trunc_simplification.hpp"
 #include "duckdb/optimizer/rule/distributivity.hpp"
 #include "duckdb/optimizer/rule/empty_needle_removal.hpp"
+#include "duckdb/optimizer/rule/left_to_prefix.hpp"
 #include "duckdb/optimizer/rule/like_optimizations.hpp"
 #include "duckdb/optimizer/rule/list_comprehension_rewrite.hpp"
 #include "duckdb/optimizer/rule/move_constants.hpp"
diff --git a/src/optimizer/optimizer.cpp b/src/optimizer/optimizer.cpp
index f43e0fe94eb5..5a7d0982cd9b 100644
--- a/src/optimizer/optimizer.cpp
+++ b/src/optimizer/optimizer.cpp
@@ -73,6 +73,7 @@ Optimizer::Optimizer(Binder &binder, ClientContext &context) : context(context),
 	rewriter.rules.push_back(make_uniq<EqualOrNullSimplification>(rewriter));
 	rewriter.rules.push_back(make_uniq<MoveConstantsRule>(rewriter));
 	rewriter.rules.push_back(make_uniq<LikeOptimizationRule>(rewriter));
+	rewriter.rules.push_back(make_uniq<LeftToPrefixRule>(rewriter));
 	rewriter.rules.push_back(make_uniq<OrderedAggregateOptimizer>(rewriter));
 	rewriter.rules.push_back(make_uniq<DistinctAggregateOptimizer>(rewriter));
 	rewriter.rules.push_back(make_uniq<DistinctWindowedOptimizer>(rewriter));
diff --git a/src/optimizer/rule/CMakeLists.txt b/src/optimizer/rule/CMakeLists.txt
index 905276b9f41a..e0ef84c0c9c6 100644
--- a/src/optimizer/rule/CMakeLists.txt
+++ b/src/optimizer/rule/CMakeLists.txt
@@ -15,6 +15,7 @@ add_library_unity(
   equal_or_null_simplification.cpp
   in_clause_simplification_rule.cpp
   join_dependent_filter.cpp
+  left_to_prefix.cpp
   like_optimizations.cpp
   list_comprehension_rewrite.cpp
   move_constants.cpp
diff --git a/src/optimizer/rule/left_to_prefix.cpp b/src/optimizer/rule/left_to_prefix.cpp
new file mode 100644
index 000000000000..c65fd2c5f801
--- /dev/null
+++ b/src/optimizer/rule/left_to_prefix.cpp
@@ -0,0 +1,80 @@
+#include "duckdb/optimizer/rule/left_to_prefix.hpp"
+
+#include "duckdb/common/limits.hpp"
+#include "duckdb/function/scalar/string_common.hpp"
+#include "duckdb/function/scalar/string_functions.hpp"
+#include "duckdb/optimizer/expression_rewriter.hpp"
+#include "duckdb/optimizer/matcher/expression_matcher.hpp"
+#include "duckdb/optimizer/matcher/type_matcher_id.hpp"
+#include "duckdb/planner/expression/bound_comparison_expression.hpp"
+#include "duckdb/planner/expression/bound_constant_expression.hpp"
+#include "duckdb/planner/expression/bound_function_expression.hpp"
+
+namespace duckdb {
+
+LeftToPrefixRule::LeftToPrefixRule(ExpressionRewriter &rewriter) : Rule(rewriter) {
+	// match [left(s, n) = 'constant'] (in any operand order)
+	auto op = make_uniq<ComparisonExpressionMatcher>();
+	op->expr_type = make_uniq<SpecificExpressionTypeMatcher>(ExpressionType::COMPARE_EQUAL);
+	op->policy = SetMatcher::Policy::UNORDERED;
+
+	auto func = make_uniq<FunctionExpressionMatcher>();
+	func->function = make_uniq<SpecificFunctionMatcher>("left");
+	func->type = make_uniq<TypeMatcherId>(LogicalTypeId::VARCHAR);
+	auto count_matcher = make_uniq<ConstantExpressionMatcher>();
+	count_matcher->type = make_uniq<IntegerTypeMatcher>();
+	func->matchers.push_back(make_uniq<ExpressionMatcher>());
+	func->matchers.push_back(std::move(count_matcher));
+	func->policy = SetMatcher::Policy::ORDERED;
+	op->matchers.push_back(std::move(func));
+
+	auto constant = make_uniq<ConstantExpressionMatcher>();
+	constant->type = make_uniq<TypeMatcherId>(LogicalTypeId::VARCHAR);
+	op->matchers.push_back(std::move(constant));
+
+	root = std::move(op);
+}
+
+unique_ptr<Expression> LeftToPrefixRule::Apply(LogicalOperator &op, vector<reference<Expression>> &bindings,
+                                               bool &changes_made, bool is_root) {
+	auto &comparison = bindings[0].get().Cast<BoundFunctionExpression>();
+	auto &func = bindings[1].get().Cast<BoundFunctionExpression>();
+	auto &count_constant = bindings[3].get().Cast<BoundConstantExpression>();
+	auto &constant = bindings[4].get().Cast<BoundConstantExpression>();
+
+	// [left(s, n) = NULL] and [left(s, NULL) = c] are always NULL
+	if (constant.GetValue().IsNull() || count_constant.GetValue().IsNull()) {
+		return make_uniq<BoundConstantExpression>(Value(comparison.GetReturnType()));
+	}
+
+	// Left counts from the end of the string for negative values: don't rewrite
+	auto num_characters = count_constant.GetValue().GetValue<int64_t>();
+	if (num_characters < 0 || num_characters > NumericLimits<uint32_t>::Maximum()) {
+		return nullptr;
+	}
+
+	auto &children = func.GetChildrenMutable();
+	auto constant_str = StringValue::Get(constant.GetValue());
+	auto constant_length = Length<string_t, int64_t>(string_t(constant_str.c_str(), constant_str.size()));
+
+	// The constant is longer than the extracted prefix, so the comparison can only be FALSE
+	if (constant_length > num_characters) {
+		return ExpressionRewriter::ConstantOrNull(std::move(children[0]), Value::BOOLEAN(false));
+	}
+
+	// left can only return fewer than n characters if it returns the entire string, so this is an equality comparison
+	// on the string itself
+	if (constant_length < num_characters) {
+		return BoundComparisonExpression::Create(ExpressionType::COMPARE_EQUAL, std::move(children[0]),
+		                                         make_uniq<BoundConstantExpression>(constant.GetValue()));
+	}
+
+	// Convert to prefix comparison
+	vector<unique_ptr<Expression>> prefix_children;
+	prefix_children.emplace_back(std::move(children[0]));
+	prefix_children.emplace_back(make_uniq<BoundConstantExpression>(constant.GetValue()));
+	auto prefix_expr = PrefixFun::GetFunction().Bind(GetContext(), std::move(prefix_children));
+	return std::move(prefix_expr);
+}
+
+} // namespace duckdb


Test patch:
diff --git a/test/optimizer/left_to_prefix.test b/test/optimizer/left_to_prefix.test
new file mode 100644
index 000000000000..1d2479f1da0c
--- /dev/null
+++ b/test/optimizer/left_to_prefix.test
@@ -0,0 +1,17 @@
+# name: test/optimizer/left_to_prefix.test
+# description: Test rewriting of left(s, n) = constant into prefix comparisons
+# group: [optimizer]
+
+statement ok
+CREATE TABLE t AS SELECT printf('%08d', i) AS s, i::BIGINT AS x FROM range(10000000) tbl(i);
+
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM t WHERE left(s, 5) = '09999';
+----
+analyzed_plan	<REGEX>:.*\(s >= '09999'\) AND \(s < '0999:'\).*Row Groups Scanned: 1 / 82.*
+
+# left rejects substring lengths above the supported range, so don't rewrite those
+statement error
+SELECT * FROM (VALUES ('a')) t(s) WHERE left(s, 4294967296) = 'a';
+----
+Out of Range Error: Substring length outside of supported range