Repository: duckdb/duckdb (2026-07-05T08:51:46Z)

Issue:
`least`/`greatest` function doesn't propagate stats
### What happens?

Hi team, I found these functions don't propagate stats correctly, which leads to unnecessary reads; see repro SQL for details, should be straightforward.

### To Reproduce

```sql
memory D CREATE TABLE integers AS
         SELECT i::BIGINT AS id
         FROM range(0, 100000) tbl(i);

-- The possible min is 5, so no scan and IO should happen
memory D EXPLAIN SELECT count(*) FROM integers WHERE greatest(id, 5) < 3;
╭─ Ungrouped Aggregate ────────╮
│ Aggregates: count_star()     │
│ ~1 row                       │
╰───────────────┬──────────────╯
╭─ Seq Scan ────┴──────────────╮
│ Table: memory.main.integers  │
│ Type: Sequential Scan        │
│ Filters: greatest(id, 5) < 3 │
│ ~20,000 rows                 │
╰──────────────────────────────╯
```

### 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/extension/core_functions/scalar/generic/least.cpp b/extension/core_functions/scalar/generic/least.cpp
index ee3307a0853a..e749d98c7fb8 100644
--- a/extension/core_functions/scalar/generic/least.cpp
+++ b/extension/core_functions/scalar/generic/least.cpp
@@ -3,6 +3,8 @@
 #include "duckdb/function/create_sort_key.hpp"
 #include "duckdb/planner/expression/bound_function_expression.hpp"
 #include "duckdb/planner/expression_binder.hpp"
+#include "duckdb/storage/statistics/base_statistics.hpp"
+#include "duckdb/storage/statistics/numeric_stats.hpp"
 
 namespace duckdb {
 
@@ -168,6 +170,68 @@ void LeastGreatestFunction(DataChunk &args, ExpressionState &state, Vector &resu
 	result.SetVectorType(result_type);
 }
 
+template <class LEAST_GREATER_OP>
+unique_ptr<BaseStatistics> PropagateLeastGreatestStats(ClientContext &context, FunctionStatisticsInput &input) {
+	auto &child_stats = input.child_stats;
+	auto &expr = input.expr;
+	auto &return_type = expr.GetReturnType();
+	if (BaseStatistics::GetStatsType(return_type) != StatisticsType::NUMERIC_STATS ||
+	    return_type.InternalType() == PhysicalType::BOOL) {
+		return nullptr;
+	}
+	constexpr bool IS_LEAST = std::is_same_v<LEAST_GREATER_OP, LeastOp>;
+
+	auto merge = [](Value &bound, const Value &candidate, bool keep_smaller) {
+		if (bound.IsNull() || (keep_smaller ? candidate < bound : candidate > bound)) {
+			bound = candidate;
+		}
+	};
+
+	// least/greatest always returns one of its inputs, so the result range is built from the
+	// inputs' min/max values. A NULL input is skipped at runtime, so only inputs that can never
+	// be NULL may tighten the "anchored" side of the range:
+	//
+	//             | loose side (over all inputs) | anchored side (over non-null inputs)
+	//   least     | min of the mins  -> min      | min of the maxes -> max
+	//   greatest  | max of the maxes -> max      | max of the mins  -> min
+	//
+	// If every input can be NULL, a single input may end up deciding the result, so the anchored
+	// side falls back to the opposite aggregation over all inputs
+	Value loose;
+	Value anchored;          // over non-null inputs only
+	Value anchored_fallback; // over all inputs; used only when every input is nullable
+	bool has_nonnull_input = false;
+	for (auto &cs : child_stats) {
+		if (cs.GetStatsType() != StatisticsType::NUMERIC_STATS || !NumericStats::HasMinMax(cs)) {
+			return nullptr;
+		}
+		const Value cmin = NumericStats::Min(cs);
+		const Value cmax = NumericStats::Max(cs);
+		const Value &loose_val = IS_LEAST ? cmin : cmax;
+		const Value &anchored_val = IS_LEAST ? cmax : cmin;
+
+		merge(loose, loose_val, /*keep_smaller=*/IS_LEAST);
+		merge(anchored_fallback, anchored_val, /*keep_smaller=*/!IS_LEAST);
+		if (!cs.CanHaveNull()) {
+			has_nonnull_input = true;
+			merge(anchored, anchored_val, /*keep_smaller=*/IS_LEAST);
+		}
+	}
+	if (!has_nonnull_input) {
+		anchored = std::move(anchored_fallback);
+	}
+
+	auto result = NumericStats::CreateEmpty(return_type);
+	NumericStats::SetMin(result, IS_LEAST ? std::move(loose) : std::move(anchored));
+	NumericStats::SetMax(result, IS_LEAST ? std::move(anchored) : std::move(loose));
+	result.Set(StatsInfo::CAN_HAVE_VALID_VALUES);
+	if (!has_nonnull_input) {
+		// the result is NULL only if all inputs are NULL
+		result.Set(StatsInfo::CAN_HAVE_NULL_VALUES);
+	}
+	return result.ToUnique();
+}
+
 template <class LEAST_GREATER_OP>
 unique_ptr<FunctionData> BindLeastGreatest(BindScalarFunctionInput &input) {
 	auto &context = input.GetClientContext();
@@ -235,8 +299,9 @@ unique_ptr<FunctionData> BindLeastGreatest(BindScalarFunctionInput &input) {
 
 template <class OP>
 ScalarFunction GetLeastGreatestFunction() {
-	return ScalarFunction({LogicalType::ANY}, LogicalType::ANY, nullptr, BindLeastGreatest<OP>, nullptr, nullptr,
-	                      LogicalType::ANY, FunctionStability::CONSISTENT, FunctionNullHandling::SPECIAL_HANDLING);
+	return ScalarFunction({LogicalType::ANY}, LogicalType::ANY, nullptr, BindLeastGreatest<OP>,
+	                      PropagateLeastGreatestStats<OP>, nullptr, LogicalType::ANY, FunctionStability::CONSISTENT,
+	                      FunctionNullHandling::SPECIAL_HANDLING);
 }
 
 template <class OP>


Test patch:
diff --git a/test/optimizer/statistics/statistics_least_greatest.test b/test/optimizer/statistics/statistics_least_greatest.test
new file mode 100644
index 000000000000..8a1872ba3180
--- /dev/null
+++ b/test/optimizer/statistics/statistics_least_greatest.test
@@ -0,0 +1,92 @@
+# name: test/optimizer/statistics/statistics_least_greatest.test
+# description: Statistics propagation for least/greatest
+# group: [statistics]
+
+statement ok
+CREATE TABLE integers AS
+SELECT i::BIGINT AS id
+FROM range(0, 100000) tbl(i);
+
+query II
+EXPLAIN SELECT count(*) FROM integers WHERE greatest(id, 5) < 3;
+----
+physical_plan	<REGEX>:.*Empty Result.*
+
+query II
+EXPLAIN SELECT count(*) FROM integers WHERE least(id, 5) > 10;
+----
+physical_plan	<REGEX>:.*Empty Result.*
+
+# nullable input: the non-null constant still anchors the bound, so pruning applies
+statement ok
+CREATE TABLE nullable_integers AS
+SELECT (CASE WHEN i % 2 = 0 THEN NULL ELSE i END)::BIGINT AS id
+FROM range(0, 100000) tbl(i);
+
+query II
+EXPLAIN SELECT count(*) FROM nullable_integers WHERE greatest(id, 5) < 3;
+----
+physical_plan	<REGEX>:.*Empty Result.*
+
+query II
+EXPLAIN SELECT count(*) FROM nullable_integers WHERE least(id, 5) > 10;
+----
+physical_plan	<REGEX>:.*Empty Result.*
+
+# result range [5, 99999] overlaps the predicate: must NOT be pruned
+query II
+EXPLAIN SELECT count(*) FROM nullable_integers WHERE greatest(id, 5) < 100;
+----
+physical_plan	<!REGEX>:.*Empty Result.*
+
+# verify the actual results match the pruning decisions
+query I
+SELECT count(*) FROM nullable_integers WHERE greatest(id, 5) < 3;
+----
+0
+
+query I
+SELECT count(*) FROM nullable_integers WHERE least(id, 5) > 10;
+----
+0
+
+query I
+SELECT count(*) FROM nullable_integers WHERE greatest(id, 5) < 100;
+----
+50050
+
+# two nullable columns and no constant: exercises the all-nullable fallback bound
+statement ok
+CREATE TABLE nullable_pairs AS
+SELECT
+    (CASE WHEN i % 2 = 0 THEN NULL ELSE i END)::BIGINT AS a,
+    (CASE WHEN i % 3 = 0 THEN NULL ELSE i + 1000000 END)::BIGINT AS b
+FROM range(0, 100000) tbl(i);
+
+# greatest(a, b) ranges over [1, 1099998], so < 0 can never hold
+query II
+EXPLAIN SELECT count(*) FROM nullable_pairs WHERE greatest(a, b) < 0;
+----
+physical_plan	<REGEX>:.*Empty Result.*
+
+# least(a, b) ranges over [1, 1099998], so > 2000000 can never hold
+query II
+EXPLAIN SELECT count(*) FROM nullable_pairs WHERE least(a, b) > 2000000;
+----
+physical_plan	<REGEX>:.*Empty Result.*
+
+# predicate falls inside the range: must NOT be pruned
+query II
+EXPLAIN SELECT count(*) FROM nullable_pairs WHERE greatest(a, b) < 1050000;
+----
+physical_plan	<!REGEX>:.*Empty Result.*
+
+query I
+SELECT count(*) FROM nullable_pairs WHERE greatest(a, b) < 0;
+----
+0
+
+query I
+SELECT count(*) FROM nullable_pairs WHERE least(a, b) > 2000000;
+----
+0