From e159df2c0f1ccd12041019793e794899ebe38436 Mon Sep 17 00:00:00 2001
From: Anthonin Bonnefoy <anthonin.bonnefoy@datadoghq.com>
Date: Tue, 23 Jun 2026 11:05:33 +0200
Subject: Adjust row estimation and index selectivity with unique index

Currently, matching an unique index in btcostestimate will only adjust
numIndexTuples to 1. This is fine for single column indexes as the
initial row estimation does check if there's a matching unique index,
and adjust the selectivity accordingly.

However, for multi-column unique indexes, the planner doesn't have such
information, and genericcostestimate will go through the normal process
of combining the selectivity of each column.

This can lead to a row estimation > 1 despite matching an unique index,
and thus possibly avoiding using this unique index due to the higher
cost.

This patch fixes this behavior by adjusting index selectivity to match
the single tuple when matching an unique index. The path row estimation
is also adjusted in cost_index to use indexSelectivity if it is better
than the initial baserel rows estimation.
---
 src/backend/optimizer/path/costsize.c     |  3 +++
 src/backend/utils/adt/selfuncs.c          | 22 +++++++++++++++++-----
 src/test/regress/expected/planner_est.out | 17 +++++++++++++++++
 src/test/regress/sql/planner_est.sql      | 12 ++++++++++++
 4 files changed, 49 insertions(+), 5 deletions(-)

diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 1c575e56ff6..4d4bc15e869 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -634,6 +634,9 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 	/* estimate number of main-table tuples fetched */
 	tuples_fetched = clamp_row_est(indexSelectivity * baserel->tuples);
 
+	/* refine path row estimation if the index has better selectivity */
+	path->path.rows = Min(path->path.rows, tuples_fetched);
+
 	/* fetch estimated page costs for tablespace containing table */
 	get_tablespace_page_costs(baserel->reltablespace,
 							  &spc_random_page_cost,
diff --git a/src/backend/utils/adt/selfuncs.c b/src/backend/utils/adt/selfuncs.c
index d6efd07073a..222a218543b 100644
--- a/src/backend/utils/adt/selfuncs.c
+++ b/src/backend/utils/adt/selfuncs.c
@@ -7463,11 +7463,18 @@ genericcostestimate(PlannerInfo *root,
 		}
 	}
 
-	/* Estimate the fraction of main-table tuples that will be visited */
-	indexSelectivity = clauselist_selectivity(root, selectivityQuals,
-											  index->rel->relid,
-											  JOIN_INNER,
-											  NULL);
+	/*
+	 * If the caller provided an indexSelectivity value, use it
+	 */
+	indexSelectivity = costs->indexSelectivity;
+	if (costs->indexSelectivity <= 0.0)
+	{
+		/* Estimate the fraction of main-table tuples that will be visited */
+		indexSelectivity = clauselist_selectivity(root, selectivityQuals,
+												  index->rel->relid,
+												  JOIN_INNER,
+												  NULL);
+	}
 
 	/*
 	 * If caller didn't give us an estimate, estimate the number of index
@@ -8036,7 +8043,12 @@ btcostestimate(PlannerInfo *root, IndexPath *path, double loop_count,
 		eqQualHere &&
 		!found_array &&
 		!found_is_null_op)
+	{
 		numIndexTuples = 1.0;
+		/* Also update index selectivity to match. */
+		if (index->rel->tuples >= 1.0)
+			costs.indexSelectivity = 1.0 / index->rel->tuples;
+	}
 	else
 	{
 		List	   *selectivityQuals;
diff --git a/src/test/regress/expected/planner_est.out b/src/test/regress/expected/planner_est.out
index b62a47552fa..489f58bcba7 100644
--- a/src/test/regress/expected/planner_est.out
+++ b/src/test/regress/expected/planner_est.out
@@ -210,4 +210,21 @@ false, true, false, true);
      ->  Result  (cost=N..N rows=1 width=N)
 (4 rows)
 
+-- Check that row estimation for multi-column unique index yields 1
+set enable_seqscan to false;
+CREATE TABLE multi_column_unique_test_table (a int, b int) WITH (autovacuum_enabled=false);
+CREATE UNIQUE INDEX multi_column_unique ON multi_column_unique_test_table (a, b);
+INSERT INTO multi_column_unique_test_table(a, b) SELECT 1, i FROM generate_series(1,10) as g(i);
+INSERT INTO multi_column_unique_test_table(a, b) SELECT i, 1 FROM generate_series(2,10) as g(i);
+ANALYZE multi_column_unique_test_table;
+SELECT explain_mask_costs($$
+SELECT * FROM multi_column_unique_test_table WHERE a=1 AND b=1;$$,
+false, true, false, true);
+                                           explain_mask_costs                                            
+---------------------------------------------------------------------------------------------------------
+ Index Only Scan using multi_column_unique on multi_column_unique_test_table  (cost=N..N rows=1 width=N)
+   Index Cond: ((a = 1) AND (b = 1))
+(2 rows)
+
+reset enable_seqscan;
 DROP FUNCTION explain_mask_costs(text, bool, bool, bool, bool);
diff --git a/src/test/regress/sql/planner_est.sql b/src/test/regress/sql/planner_est.sql
index 53210d5baad..a3d16b3132a 100644
--- a/src/test/regress/sql/planner_est.sql
+++ b/src/test/regress/sql/planner_est.sql
@@ -147,4 +147,16 @@ SELECT explain_mask_costs($$
 SELECT * FROM tenk1 WHERE unique1 <> ALL (ARRAY[1, 2, 98, (SELECT 99), NULL]);$$,
 false, true, false, true);
 
+-- Check that row estimation for multi-column unique index yields 1
+set enable_seqscan to false;
+CREATE TABLE multi_column_unique_test_table (a int, b int) WITH (autovacuum_enabled=false);
+CREATE UNIQUE INDEX multi_column_unique ON multi_column_unique_test_table (a, b);
+INSERT INTO multi_column_unique_test_table(a, b) SELECT 1, i FROM generate_series(1,10) as g(i);
+INSERT INTO multi_column_unique_test_table(a, b) SELECT i, 1 FROM generate_series(2,10) as g(i);
+ANALYZE multi_column_unique_test_table;
+SELECT explain_mask_costs($$
+SELECT * FROM multi_column_unique_test_table WHERE a=1 AND b=1;$$,
+false, true, false, true);
+reset enable_seqscan;
+
 DROP FUNCTION explain_mask_costs(text, bool, bool, bool, bool);
-- 
2.54.0

