From 82a84654f12b5601e7757ab2317e19601b224824 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Wed, 7 Oct 2026 01:50:14 +0000
Subject: [PATCH v7 4/6] Don't charge an index-only scan for index expressions
 it returns

An index-only scan on an index with an expression column reads that
expression's value from the index: set_indexonlyscan_references()
replaces a target list entry equal() to a returnable index expression
with a reference to that column.  But the planner still charges the
scan the full cost of evaluating the expression for every row, as if it
computed it.  Heikki Linnakangas showed this in the 2023 junk-column
thread with an index on (i, expensive(i)) and a function of COST
1000000: the index-only scan was estimated at 25 million, like the
Seq Scan that really does evaluate expensive(i).

path_target_cost() now also handles index-only scan paths: a top-level
target expression equal() to a returnable index expression costs
nothing.  cost_index() already goes through path_target_cost(), so the
index-only scan's estimate drops to what it actually does.

This corrects the estimate of an index-only scan path that is already
in the running, e.g. one kept for its pathkeys.  It does not make such a
path win the base relation's add_path() comparison when its only merit
is the cheaper expression: at that point every path of the rel carries
rel->reltarget, which holds only Vars, and the expression is charged to
all of them alike afterwards.  Doing that would mean giving index-only
paths their own targets, as the previous commit does for ordering
Index Scans; that's left for later.

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/util/pathnode.c | 59 ++++++++++++++++++++++++++-
 src/test/regress/expected/gist.out    | 33 +++++++++++++++
 src/test/regress/sql/gist.sql         | 23 +++++++++++
 3 files changed, 113 insertions(+), 2 deletions(-)

diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 377491cf4f2..44d82fad603 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -2791,6 +2791,57 @@ orderby_tlist_match(Expr *expr, Expr *orderby)
 		equal(lsecond(a->args), linitial(b->args));
 }
 
+/*
+ * indexonly_target_cost
+ *	  path_target_cost() for an index-only scan path.
+ *
+ * setrefs.c's set_indexonlyscan_references() matches target expressions
+ * against the index's returnable columns, expression columns included, and
+ * replaces a match with a reference to that column: the scan reads the
+ * value the index stored instead of computing it.  So a top-level target
+ * expression equal() to a returnable index expression costs nothing.  Only
+ * top-level entries are considered, as elsewhere; that's what's normally in
+ * a scan's target.
+ */
+static QualCost
+indexonly_target_cost(PlannerInfo *root, IndexPath *ipath, PathTarget *target)
+{
+	QualCost	cost = target->cost;
+	IndexOptInfo *index = ipath->indexinfo;
+	ListCell   *lc;
+
+	if (index->indexprs == NIL)
+		return cost;			/* only Vars, which cost nothing anyway */
+
+	foreach(lc, target->exprs)
+	{
+		Node	   *expr = (Node *) lfirst(lc);
+		ListCell   *lt;
+		int			i = 0;
+
+		if (IsA(expr, Var))
+			continue;
+
+		foreach(lt, index->indextlist)
+		{
+			TargetEntry *tle = lfirst_node(TargetEntry, lt);
+
+			if (index->canreturn[i++] && !IsA(tle->expr, Var) &&
+				equal(expr, tle->expr))
+			{
+				QualCost	ecost;
+
+				cost_qual_eval_node(&ecost, expr, root);
+				cost.startup -= ecost.startup;
+				cost.per_tuple -= ecost.per_tuple;
+				break;
+			}
+		}
+	}
+
+	return cost;
+}
+
 /*
  * join_target_cost
  *	  path_target_cost() for a join path.
@@ -2851,8 +2902,9 @@ join_target_cost(PlannerInfo *root, JoinPath *jpath, PathTarget *target)
  *	  Return the cost 'path' pays to evaluate 'target'.
  *
  * Normally that's just target->cost.  A join path doesn't pay for entries
- * an input already emits (see join_target_cost()).  And a plain IndexScan
- * takes any
+ * an input already emits (see join_target_cost()), nor an index-only scan
+ * for entries it reads from the index (see indexonly_target_cost()).  And a
+ * plain IndexScan takes any
  * top-level target expression that is a returnable ORDER BY expression
  * (see orderby_tlist_match()) from its ORDER BY values instead of
  * evaluating it (setrefs.c rewrites it into a reference to them), so it
@@ -2869,6 +2921,9 @@ path_target_cost(PlannerInfo *root, Path *path, PathTarget *target)
 	if (IsA(path, NestPath) || IsA(path, MergePath) || IsA(path, HashPath))
 		return join_target_cost(root, (JoinPath *) path, target);
 
+	if (IsA(path, IndexPath) && path->pathtype == T_IndexOnlyScan)
+		return indexonly_target_cost(root, (IndexPath *) path, target);
+
 	if (!IsA(path, IndexPath) || path->pathtype != T_IndexScan)
 		return cost;
 	ipath = (IndexPath *) path;
diff --git a/src/test/regress/expected/gist.out b/src/test/regress/expected/gist.out
index 4fc2b084df1..457d1c41ae4 100644
--- a/src/test/regress/expected/gist.out
+++ b/src/test/regress/expected/gist.out
@@ -722,6 +722,39 @@ drop table gist_join_fact;
 drop table gist_join_dim;
 drop function gist_join_pt(point);
 drop sequence gist_join_cnt;
+-- An index-only scan isn't charged for evaluating a target expression it
+-- reads from a returnable index expression column: it reads the stored
+-- value instead.  (Not GiST-specific; btree returns expression columns.)
+create function gist_ios_costly(int) returns int
+  language plpgsql immutable strict cost 100000
+  as $$ begin return $1; end $$;
+create temp table gist_ios_cost (i int);
+insert into gist_ios_cost select g from generate_series(1, 10000) g;
+create index on gist_ios_cost (i, gist_ios_costly(i));
+vacuum analyze gist_ios_cost;
+create function gist_ios_total(q text) returns float8 language plpgsql as
+$$ declare j json; begin
+     execute 'explain (format json) ' || q into j;
+     return (j->0->'Plan'->>'Total Cost')::float8;
+   end $$;
+explain (verbose, costs off)
+select gist_ios_costly(i) from gist_ios_cost order by i;
+                                      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)), i
+(2 rows)
+
+select gist_ios_total('select gist_ios_costly(i) from gist_ios_cost order by i')
+  < 10000 as ios_expr_is_free;
+ ios_expr_is_free 
+------------------
+ t
+(1 row)
+
+drop function gist_ios_total(text);
+drop table gist_ios_cost;
+drop function gist_ios_costly(int);
 -- Test that an index-only scan deforms the tuple it reconstructs with the
 -- descriptor the AM formed it with, not the scan slot's descriptor.
 create temp table gist_ios_tupdesc (a inet, r numrange);
diff --git a/src/test/regress/sql/gist.sql b/src/test/regress/sql/gist.sql
index 9e7bc0fd176..e84ef688212 100644
--- a/src/test/regress/sql/gist.sql
+++ b/src/test/regress/sql/gist.sql
@@ -354,6 +354,29 @@ drop table gist_join_dim;
 drop function gist_join_pt(point);
 drop sequence gist_join_cnt;
 
+-- An index-only scan isn't charged for evaluating a target expression it
+-- reads from a returnable index expression column: it reads the stored
+-- value instead.  (Not GiST-specific; btree returns expression columns.)
+create function gist_ios_costly(int) returns int
+  language plpgsql immutable strict cost 100000
+  as $$ begin return $1; end $$;
+create temp table gist_ios_cost (i int);
+insert into gist_ios_cost select g from generate_series(1, 10000) g;
+create index on gist_ios_cost (i, gist_ios_costly(i));
+vacuum analyze gist_ios_cost;
+create function gist_ios_total(q text) returns float8 language plpgsql as
+$$ declare j json; begin
+     execute 'explain (format json) ' || q into j;
+     return (j->0->'Plan'->>'Total Cost')::float8;
+   end $$;
+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;
+drop function gist_ios_total(text);
+drop table gist_ios_cost;
+drop function gist_ios_costly(int);
+
 -- Test that an index-only scan deforms the tuple it reconstructs with the
 -- descriptor the AM formed it with, not the scan slot's descriptor.
 create temp table gist_ios_tupdesc (a inet, r numrange);
-- 
2.50.1

