From 05b22c4ef1608de0691d4de5865fc7c79c739d43 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Wed, 7 Oct 2026 14:34:42 -0400
Subject: [PATCH v7 5/6] Let an index-only scan win on the index expressions it
 returns

The previous commit stopped charging an index-only scan for target
expressions it reads from the index, but that only changes the plan
when the index-only scan path is already in the running for some other
reason, such as useful pathkeys.  Heikki Linnakangas's example, an index
on (i, expensive(i)) and

    SELECT expensive(i) FROM atab ORDER BY expensive(i);

still got a Seq Scan and evaluated expensive() for every row.  When the
rel's paths are compared, they all carry rel->reltarget, which holds
only Vars.  The index-only scan costs more than the seq scan, and the
expression it would have read for free is charged to the winner later.

Give an index-only scan path its own PathTarget: rel->reltarget plus
each returnable index expression that is a top-level entry of the
query's targetlist.  This is the same thing the earlier commit does for
an ordering Index Scan's ORDER BY values.  Teach add_path() and
add_partial_path() to treat base-rel index paths with different targets
as incomparable, the way they treat different pathkeys, so neither
dominates the other on cost alone.  When the scan/join target is
applied, the seq scan pays for evaluating the expression and the
index-only scan doesn't, so the index-only scan wins on cost when it
should.  A path with its own target also needs the projection even
when the scan/join target's exprs equal the reltarget's, since its
target has extra columns.

Heikki's query now plans as Sort over an Index Only Scan: estimated
930 instead of 25000809.  With a function that really costs something,
it ran about 20 times faster.

This is limited to the query's sole base relation, where the value goes
straight to the scan/join target.  Below a join, the join would have to
credit the input for the value and the joinrel's add_path() would have
to keep it; that isn't attempted here.

Author: Greg Burd <greg@burd.me>
Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me
Discussion: https://postgr.es/m/356d5811-d8f0-4c29-a601-b61334337b11@iki.fi
---
 src/backend/optimizer/plan/planner.c  | 10 ++-
 src/backend/optimizer/util/pathnode.c | 91 ++++++++++++++++++++++++++-
 src/test/regress/expected/gist.out    | 46 ++++++++++++++
 src/test/regress/sql/gist.sql         | 14 +++++
 4 files changed, 157 insertions(+), 4 deletions(-)

diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 3b70b8a378f..f9a56020f13 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -8263,6 +8263,12 @@ apply_scanjoin_target_to_paths(PlannerInfo *root,
 	 * to every path, but a plain IndexScan doesn't pay for target entries it
 	 * takes from its ORDER BY values, so it can become cheaper relative to
 	 * the others; restore the cost ordering afterwards.
+	 *
+	 * An index path may emit its own target, rel->reltarget plus a value it
+	 * reads from the index (see indexonly_path_target() and
+	 * index_path_orderby_target()).  Such a path needs a projection even when
+	 * the exprs are the same, since its target has more columns than
+	 * scanjoin_target's sortgrouprefs describe.
 	 */
 	foreach(lc, rel->pathlist)
 	{
@@ -8271,7 +8277,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root,
 		/* Shouldn't have any parameterized paths anymore */
 		Assert(subpath->param_info == NULL);
 
-		if (tlist_same_exprs)
+		if (tlist_same_exprs && subpath->pathtarget == rel->reltarget)
 			subpath->pathtarget->sortgrouprefs =
 				scanjoin_target->sortgrouprefs;
 		else
@@ -8294,7 +8300,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root,
 		/* Shouldn't have any parameterized paths anymore */
 		Assert(subpath->param_info == NULL);
 
-		if (tlist_same_exprs)
+		if (tlist_same_exprs && subpath->pathtarget == rel->reltarget)
 			subpath->pathtarget->sortgrouprefs =
 				scanjoin_target->sortgrouprefs;
 		else
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 44d82fad603..6afb5032252 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -386,6 +386,27 @@ set_cheapest(RelOptInfo *parent_rel)
 	parent_rel->cheapest_parameterized_paths = parameterized_paths;
 }
 
+/*
+ * pathtargets_differ
+ *	  Do two paths of base relation 'rel' emit different targets?
+ *
+ * A base relation's paths normally all emit rel->reltarget, but an index
+ * path may also emit a value the query needs above the scan, one it has
+ * for free and other paths must compute (see indexonly_path_target() and
+ * index_path_orderby_target()).  That cost isn't in either path's cost
+ * yet; it is charged when the expression is.  So add_path() treats paths
+ * with different targets like paths with different pathkeys, and keeps
+ * both.
+ */
+static inline bool
+pathtargets_differ(RelOptInfo *rel, Path *path1, Path *path2)
+{
+	return rel->reloptkind == RELOPT_BASEREL &&
+		(IsA(path1, IndexPath) || IsA(path2, IndexPath)) &&
+		path1->pathtarget != path2->pathtarget &&
+		!equal(path1->pathtarget->exprs, path2->pathtarget->exprs);
+}
+
 /*
  * add_path
  *	  Consider a potential implementation path for the specified parent rel,
@@ -513,6 +534,8 @@ add_path(RelOptInfo *parent_rel, Path *new_path)
 			old_path_pathkeys = old_path->param_info ? NIL : old_path->pathkeys;
 			keyscmp = compare_pathkeys(new_path_pathkeys,
 									   old_path_pathkeys);
+			if (pathtargets_differ(parent_rel, new_path, old_path))
+				keyscmp = PATHKEYS_DIFFERENT;
 			if (keyscmp != PATHKEYS_DIFFERENT)
 			{
 				switch (costcmp)
@@ -818,8 +841,10 @@ add_partial_path(RelOptInfo *parent_rel, Path *new_path)
 		bool		remove_old = false; /* unless new proves superior */
 		PathKeysComparison keyscmp;
 
-		/* Compare pathkeys. */
+		/* Compare pathkeys, and targets as in add_path(). */
 		keyscmp = compare_pathkeys(new_path->pathkeys, old_path->pathkeys);
+		if (pathtargets_differ(parent_rel, new_path, old_path))
+			keyscmp = PATHKEYS_DIFFERENT;
 
 		/*
 		 * Unless pathkeys are incompatible, see if one of the paths dominates
@@ -1148,6 +1173,67 @@ index_path_orderby_target(PlannerInfo *root, IndexOptInfo *index,
 	return set_pathtarget_cost_width(root, target);
 }
 
+/*
+ * indexonly_path_target
+ *	  Choose the PathTarget for an index-only scan path.
+ *
+ * An index-only scan reads a returnable index expression column instead of
+ * computing it (set_indexonlyscan_references()), and isn't charged for it
+ * (indexonly_target_cost()).  But that only helps if the path survives
+ * add_path(), and with rel->reltarget, which has only Vars, nothing
+ * distinguishes it from a seq scan that will have to compute the expression
+ * later; it loses on cost before the expression is charged to anyone.  So
+ * if the query's targetlist has a top-level entry equal() to a returnable
+ * index expression, give the path a copy of rel->reltarget with that
+ * expression appended.  add_path() keeps paths with different targets
+ * (see pathtargets_differ()), and once the scan/join target is applied, the
+ * paths that must compute the expression pay for it and this one doesn't.
+ *
+ * This is done only when the rel is the query's sole base relation, where
+ * the value goes straight to the scan/join target.  Below a join, the join
+ * would have to credit it (as join_target_cost() does for ORDER BY values)
+ * and the joinrel's add_path() would have to keep it; not attempted yet.
+ */
+static PathTarget *
+indexonly_path_target(PlannerInfo *root, IndexOptInfo *index)
+{
+	RelOptInfo *rel = index->rel;
+	PathTarget *target = NULL;
+	ListCell   *lt;
+	int			i = 0;
+
+	if (index->indexprs == NIL || rel->reloptkind != RELOPT_BASEREL ||
+		root->processed_tlist == NIL ||
+		!bms_equal(rel->relids, root->all_query_rels))
+		return rel->reltarget;
+
+	foreach(lt, index->indextlist)
+	{
+		TargetEntry *itle = lfirst_node(TargetEntry, lt);
+		ListCell   *lq;
+
+		if (!index->canreturn[i++] || IsA(itle->expr, Var))
+			continue;
+
+		foreach(lq, root->processed_tlist)
+		{
+			if (equal(lfirst_node(TargetEntry, lq)->expr, itle->expr))
+			{
+				if (target == NULL)
+					target = copy_pathtarget(rel->reltarget);
+				add_column_to_pathtarget(target, itle->expr, 0);
+				break;
+			}
+		}
+	}
+
+	if (target == NULL)
+		return rel->reltarget;
+
+	/* add_column_to_pathtarget doesn't maintain cost and width */
+	return set_pathtarget_cost_width(root, target);
+}
+
 /*
  * index_path_emits_orderby
  *	  Does this plain IndexScan path's target hold a value it takes from its
@@ -1223,7 +1309,8 @@ create_index_path(PlannerInfo *root,
 
 	pathnode->path.pathtype = indexonly ? T_IndexOnlyScan : T_IndexScan;
 	pathnode->path.parent = rel;
-	pathnode->path.pathtarget = indexonly ? rel->reltarget :
+	pathnode->path.pathtarget = indexonly ?
+		indexonly_path_target(root, index) :
 		index_path_orderby_target(root, index, indexorderbys,
 								  indexorderbycols);
 	pathnode->path.param_info = get_baserel_parampathinfo(root, rel,
diff --git a/src/test/regress/expected/gist.out b/src/test/regress/expected/gist.out
index 457d1c41ae4..085391685ea 100644
--- a/src/test/regress/expected/gist.out
+++ b/src/test/regress/expected/gist.out
@@ -752,6 +752,52 @@ select gist_ios_total('select gist_ios_costly(i) from gist_ios_cost order by i')
  t
 (1 row)
 
+-- With nothing else to recommend it (no qual, no useful order), an
+-- index-only scan that reads the expression still beats a seq scan that has
+-- to compute it: add_path() keeps both, since their targets differ.
+explain (verbose, costs off)
+select gist_ios_costly(i) from gist_ios_cost order by gist_ios_costly(i);
+                                         QUERY PLAN                                         
+--------------------------------------------------------------------------------------------
+ Sort
+   Output: (gist_ios_costly(i))
+   Sort Key: (gist_ios_costly(gist_ios_cost.i))
+   ->  Index Only Scan using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost
+         Output: (gist_ios_costly(i))
+(5 rows)
+
+explain (verbose, costs off)
+select gist_ios_costly(i) from gist_ios_cost;
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
+ Index Only Scan using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost
+   Output: (gist_ios_costly(i))
+(2 rows)
+
+-- Ordered by the index, the scan emits the final targetlist directly and
+-- still reads the expression from the index.
+explain (verbose, costs off)
+select gist_ios_costly(i) from gist_ios_cost order by i desc limit 3;
+                                             QUERY PLAN                                              
+-----------------------------------------------------------------------------------------------------
+ Limit
+   Output: (gist_ios_costly(i)), i
+   ->  Index Only Scan Backward using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost
+         Output: (gist_ios_costly(i)), i
+(4 rows)
+
+-- Below a join it isn't considered.
+explain (costs off)
+select gist_ios_costly(a.i) from gist_ios_cost a join gist_ios_cost b using (i);
+               QUERY PLAN                
+-----------------------------------------
+ Hash Join
+   Hash Cond: (a.i = b.i)
+   ->  Seq Scan on gist_ios_cost a
+   ->  Hash
+         ->  Seq Scan on gist_ios_cost b
+(5 rows)
+
 drop function gist_ios_total(text);
 drop table gist_ios_cost;
 drop function gist_ios_costly(int);
diff --git a/src/test/regress/sql/gist.sql b/src/test/regress/sql/gist.sql
index e84ef688212..d032926667b 100644
--- a/src/test/regress/sql/gist.sql
+++ b/src/test/regress/sql/gist.sql
@@ -373,6 +373,20 @@ explain (verbose, costs off)
 select gist_ios_costly(i) from gist_ios_cost order by i;
 select gist_ios_total('select gist_ios_costly(i) from gist_ios_cost order by i')
   < 10000 as ios_expr_is_free;
+-- With nothing else to recommend it (no qual, no useful order), an
+-- index-only scan that reads the expression still beats a seq scan that has
+-- to compute it: add_path() keeps both, since their targets differ.
+explain (verbose, costs off)
+select gist_ios_costly(i) from gist_ios_cost order by gist_ios_costly(i);
+explain (verbose, costs off)
+select gist_ios_costly(i) from gist_ios_cost;
+-- Ordered by the index, the scan emits the final targetlist directly and
+-- still reads the expression from the index.
+explain (verbose, costs off)
+select gist_ios_costly(i) from gist_ios_cost order by i desc limit 3;
+-- Below a join it isn't considered.
+explain (costs off)
+select gist_ios_costly(a.i) from gist_ios_cost a join gist_ios_cost b using (i);
 drop function gist_ios_total(text);
 drop table gist_ios_cost;
 drop function gist_ios_costly(int);
-- 
2.50.1

