From 048455e4cb344cfad6016d06195d160fbe282a74 Mon Sep 17 00:00:00 2001
From: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Date: Sat, 24 Oct 2026 09:38:46 +0000
Subject: [PATCH v5 8/8] Report buffer access strategies for index scans in
 EXPLAIN

When ANALYZE, BUFFERS, and VERBOSE are enabled, report every buffer access
strategy selected by the index access method for index, index-only, and
bitmap index scans.

Extend index scan instrumentation with an optional strategy mask and request
it only when buffer instrumentation is enabled. Have each in-core index access
method record its selected strategy in the scan instrumentation. The standard
access methods report normal, while Bloom reports bulkread.

Preserve strategies across rescans and propagate them through parallel worker
instrumentation. Report them only for the leader and workers that execute the
scan. An empty mask allows another index access method to leave the information
unavailable.

Add documentation and TAP coverage for normal and bulkread strategies,
parallel workers, scans that exit before searching the index, and omission
when either BUFFERS or VERBOSE is disabled.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reviewed-by: Sami Imseih <samimseih.pg@gmail.com>
Reviewed-by: Lukas Fittl <lukas@fittl.com>
Discussion: https://postgr.es/m/auCYYbDyrQEwIsQq@bdtpg
---
 contrib/bloom/blscan.c                        |   1 +
 contrib/bloom/meson.build                     |   1 +
 contrib/bloom/t/002_explain.pl                | 141 ++++++++++++++++++
 doc/src/sgml/ref/explain.sgml                 |   8 +-
 src/backend/access/brin/brin.c                |   1 +
 src/backend/access/gin/ginget.c               |   3 +
 src/backend/access/gist/gistget.c             |   2 +
 src/backend/access/hash/hashsearch.c          |   1 +
 src/backend/access/nbtree/nbtsearch.c         |   2 +
 src/backend/access/spgist/spgscan.c           |   6 +
 src/backend/commands/explain.c                |  53 +++++--
 src/backend/executor/nodeBitmapIndexscan.c    |   6 +-
 src/backend/executor/nodeIndexonlyscan.c      |   7 +-
 src/backend/executor/nodeIndexscan.c          |   6 +-
 src/include/executor/instrument_node.h        |  37 +++++
 .../t/016_explain_buffer_strategy.pl          |  39 +++--
 16 files changed, 289 insertions(+), 25 deletions(-)
  38.7% contrib/bloom/t/
   4.8% doc/src/sgml/ref/
   5.3% src/backend/access/
  19.3% src/backend/commands/
   7.3% src/backend/executor/
   9.2% src/include/executor/
  14.2% src/test/modules/test_misc/t/

diff --git a/contrib/bloom/blscan.c b/contrib/bloom/blscan.c
index 1a0e42021ec..c203702a376 100644
--- a/contrib/bloom/blscan.c
+++ b/contrib/bloom/blscan.c
@@ -117,6 +117,7 @@ blgetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 	 * We're going to read the whole index. This is why we use appropriate
 	 * buffer access strategy.
 	 */
+	RecordIndexScanStrategy(scan->instrument, BAS_BULKREAD);
 	bas = GetAccessStrategy(BAS_BULKREAD);
 	npages = RelationGetNumberOfBlocks(scan->indexRelation);
 	pgstat_count_index_scan(scan->indexRelation);
diff --git a/contrib/bloom/meson.build b/contrib/bloom/meson.build
index fa4f4ea796b..3e6a5667ef1 100644
--- a/contrib/bloom/meson.build
+++ b/contrib/bloom/meson.build
@@ -40,6 +40,7 @@ tests += {
   'tap': {
     'tests': [
       't/001_wal.pl',
+      't/002_explain.pl',
     ],
   },
 }
diff --git a/contrib/bloom/t/002_explain.pl b/contrib/bloom/t/002_explain.pl
new file mode 100644
index 00000000000..32b7679fef4
--- /dev/null
+++ b/contrib/bloom/t/002_explain.pl
@@ -0,0 +1,141 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test EXPLAIN reporting of buffer access strategies for Bloom index scans,
+# including early exits and parallel execution.
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP qw(decode_json);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+
+# Keep the test table below the bulk-read threshold
+$node->append_conf('postgresql.conf', 'shared_buffers = 1MB');
+$node->start;
+
+$node->safe_psql(
+	'postgres', qq{
+CREATE EXTENSION bloom;
+CREATE TABLE tst AS SELECT i % 10 AS i FROM generate_series(1, 2000) AS i;
+CREATE INDEX bloomidx ON tst USING bloom (i);
+});
+
+my $plan = explain_json('BUFFERS, VERBOSE');
+my $index_scan = find_plan_node($plan, 'Bitmap Index Scan');
+
+ok(defined $index_scan, 'plan contains a bitmap index scan');
+is_deeply($index_scan->{'Buffer Access Strategies'},
+	['bulkread'], 'bloom index scan uses the bulkread strategy');
+
+my $null_plan = explain_bloom_null_json();
+my $null_index_scan = find_plan_node($null_plan, 'Bitmap Index Scan');
+
+ok(defined $null_index_scan,
+	'plan contains a Bloom bitmap index scan for a NULL parameter');
+is($null_index_scan->{'Index Searches'},
+	0, 'Bloom exits before searching the index for a NULL parameter');
+ok( !exists $null_index_scan->{'Buffer Access Strategies'},
+	'strategy is not shown when Bloom does not search the index');
+
+my $parallel_plan = explain_parallel_json();
+my $gather = find_plan_node($parallel_plan, 'Gather');
+my $parallel_index_scan = find_plan_node($parallel_plan, 'Bitmap Index Scan');
+
+ok(defined $gather, 'parallel plan contains a Gather');
+cmp_ok(defined $gather ? $gather->{'Workers Launched'} : 0,
+	'>', 0, 'parallel worker launched');
+ok(defined $parallel_index_scan,
+	'parallel plan contains a bitmap index scan');
+ok( !exists $parallel_index_scan->{'Buffer Access Strategies'},
+	'strategy is not shown for the non-participating leader');
+my $workers = $parallel_index_scan->{'Workers'};
+ok(ref $workers eq 'ARRAY' && @$workers > 0,
+	'parallel bitmap index scan reports worker information');
+
+foreach my $worker (@$workers)
+{
+	is_deeply($worker->{'Buffer Access Strategies'},
+		['bulkread'], 'parallel worker uses the bulkread strategy');
+}
+
+my $without_buffers = explain_json('BUFFERS OFF, VERBOSE');
+$index_scan = find_plan_node($without_buffers, 'Bitmap Index Scan');
+
+ok( defined $index_scan && !exists $index_scan->{'Buffer Access Strategies'},
+	'strategy is not shown when BUFFERS is disabled');
+
+my $without_verbose = explain_json('BUFFERS');
+$index_scan = find_plan_node($without_verbose, 'Bitmap Index Scan');
+
+ok( defined $index_scan && !exists $index_scan->{'Buffer Access Strategies'},
+	'strategy is not shown when VERBOSE is disabled');
+
+$node->stop;
+
+done_testing();
+
+# Run EXPLAIN and return the top-level plan.
+sub explain_json
+{
+	my ($options) = @_;
+	my $output = $node->safe_psql(
+		'postgres', qq{
+SET enable_seqscan = off;
+EXPLAIN (ANALYZE, $options, FORMAT JSON)
+  SELECT * FROM tst WHERE i = 7;
+});
+
+	return decode_json($output)->[0]->{'Plan'};
+}
+
+# Run EXPLAIN for a Bloom scan that exits before reading the index.
+sub explain_bloom_null_json
+{
+	my $output = $node->safe_psql(
+		'postgres', qq{
+SET enable_seqscan = off;
+SET plan_cache_mode = force_generic_plan;
+PREPARE bloom_null_query(int) AS SELECT * FROM tst WHERE i = \$1;
+EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON)
+  EXECUTE bloom_null_query(NULL);
+});
+
+	return decode_json($output)->[0]->{'Plan'};
+}
+
+# Run EXPLAIN in a parallel worker and return the top-level plan.
+sub explain_parallel_json
+{
+	my $output = $node->safe_psql(
+		'postgres', qq{
+SET max_parallel_workers_per_gather = 1;
+SET debug_parallel_query = on;
+SET enable_seqscan = off;
+EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON)
+  SELECT * FROM tst WHERE i = 7;
+});
+
+	return decode_json($output)->[0]->{'Plan'};
+}
+
+# Find the first plan node of the requested type.
+sub find_plan_node
+{
+	my ($plan, $type) = @_;
+
+	return $plan if $plan->{'Node Type'} eq $type;
+
+	foreach my $child (@{ $plan->{'Plans'} // [] })
+	{
+		my $result = find_plan_node($child, $type);
+
+		return $result if defined $result;
+	}
+
+	return;
+}
diff --git a/doc/src/sgml/ref/explain.sgml b/doc/src/sgml/ref/explain.sgml
index 54ac2eb4167..94aa77bafef 100644
--- a/doc/src/sgml/ref/explain.sgml
+++ b/doc/src/sgml/ref/explain.sgml
@@ -208,8 +208,12 @@ ROLLBACK;
       nodes report the <glossterm
       linkend="glossary-buffer-access-strategy">buffer access
       strategies</glossterm> used by the table scan when the table access
-      method provides them.  The reported list includes every strategy
-      selected while executing the scan, including across rescans.
+      method provides them.  Executed
+      <literal>Index Scan</literal>, <literal>Index Only Scan</literal>, and
+      <literal>Bitmap Index Scan</literal> plan nodes likewise report the
+      strategies used by the index scan when the index access method provides
+      them.  The reported list includes every strategy selected while
+      executing the scan, including across rescans.
       Only strategies used by scan nodes are reported; write-side strategies
       such as <literal>bulkwrite</literal> are not shown, even when a command
       uses them internally.
diff --git a/src/backend/access/brin/brin.c b/src/backend/access/brin/brin.c
index 5059da8dce7..4db586a9c90 100644
--- a/src/backend/access/brin/brin.c
+++ b/src/backend/access/brin/brin.c
@@ -598,6 +598,7 @@ bringetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 	pgstat_count_index_scan(idxRel);
 	if (scan->instrument)
 		scan->instrument->nsearches++;
+	RecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
 
 	/*
 	 * We need to know the size of the table so that we know how long to
diff --git a/src/backend/access/gin/ginget.c b/src/backend/access/gin/ginget.c
index 2bcb32ca3d0..962d7f69d66 100644
--- a/src/backend/access/gin/ginget.c
+++ b/src/backend/access/gin/ginget.c
@@ -17,6 +17,7 @@
 #include "access/gin_private.h"
 #include "access/relscan.h"
 #include "common/pg_prng.h"
+#include "executor/instrument_node.h"
 #include "miscadmin.h"
 #include "storage/predicate.h"
 #include "utils/datum.h"
@@ -1942,6 +1943,8 @@ gingetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 	if (GinIsVoidRes(scan))
 		return 0;
 
+	RecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
+
 	ntids = 0;
 
 	/*
diff --git a/src/backend/access/gist/gistget.c b/src/backend/access/gist/gistget.c
index 111c310f9df..fe8f30f5e20 100644
--- a/src/backend/access/gist/gistget.c
+++ b/src/backend/access/gist/gistget.c
@@ -641,6 +641,7 @@ gistgettuple(IndexScanDesc scan, ScanDirection dir)
 		/* Begin the scan by processing the root page */
 		GISTSearchItem fakeItem;
 
+		RecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
 		pgstat_count_index_scan(scan->indexRelation);
 		if (scan->instrument)
 			scan->instrument->nsearches++;
@@ -761,6 +762,7 @@ gistgetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 	if (!so->qual_ok)
 		return 0;
 
+	RecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
 	pgstat_count_index_scan(scan->indexRelation);
 	if (scan->instrument)
 		scan->instrument->nsearches++;
diff --git a/src/backend/access/hash/hashsearch.c b/src/backend/access/hash/hashsearch.c
index 46eb12cc8b2..68685fc8be8 100644
--- a/src/backend/access/hash/hashsearch.c
+++ b/src/backend/access/hash/hashsearch.c
@@ -407,6 +407,7 @@ _hash_first(IndexScanDesc scan, ScanDirection dir)
 		hashkey = _hash_datum2hashkey_type(rel, cur->sk_argument,
 										   cur->sk_subtype);
 
+	RecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
 	so->hashso_sk_hash = hashkey;
 
 	buf = _hash_getbucketbuf_from_hashkey(rel, hashkey, HASH_READ, NULL);
diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index 61f63251c6f..abef791c5c4 100644
--- a/src/backend/access/nbtree/nbtsearch.c
+++ b/src/backend/access/nbtree/nbtsearch.c
@@ -922,6 +922,8 @@ _bt_first(IndexScanDesc scan, ScanDirection dir)
 		!_bt_parallel_seize(scan, &blkno, &lastcurrblkno, true))
 		return false;
 
+	RecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
+
 	/*
 	 * Initialize the scan's arrays (if any) for the current scan direction
 	 * (except when they were already set to later values as part of
diff --git a/src/backend/access/spgist/spgscan.c b/src/backend/access/spgist/spgscan.c
index 2cc5f06f5d7..7dbe8c86286 100644
--- a/src/backend/access/spgist/spgscan.c
+++ b/src/backend/access/spgist/spgscan.c
@@ -944,6 +944,9 @@ spggetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 	so->tbm = tbm;
 	so->ntids = 0;
 
+	if (so->searchNulls || so->searchNonNulls)
+		RecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
+
 	spgWalk(scan->indexRelation, so, true, storeBitmap);
 
 	return so->ntids;
@@ -1065,6 +1068,9 @@ spggettuple(IndexScanDesc scan, ScanDirection dir)
 		}
 		so->iPtr = so->nPtrs = 0;
 
+		if (so->searchNulls || so->searchNonNulls)
+			RecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
+
 		spgWalk(scan->indexRelation, so, false, storeGettuple);
 
 		if (so->nPtrs == 0)
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 3558410dcdd..732aa5eede9 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -142,6 +142,8 @@ static void show_hashagg_info(AggState *aggstate, ExplainState *es);
 static void show_indexscan_info(PlanState *planstate, ExplainState *es);
 static void show_tidbitmap_info(BitmapHeapScanState *planstate,
 								ExplainState *es);
+static void print_buffer_access_strategies(ExplainState *es,
+										   uint32 strategy_mask);
 static void show_scan_buffer_access_strategies(ScanState *planstate, ExplainState *es);
 static void show_scan_io_usage(ScanState *planstate,
 							   ExplainState *es);
@@ -3981,8 +3983,10 @@ show_indexscan_info(PlanState *planstate, ExplainState *es)
 {
 	Plan	   *plan = planstate->plan;
 	SharedIndexScanInstrumentation *SharedInfo = NULL;
-	uint64		nsearches = 0,
-				ntabletuplefetches = 0;
+	WorkerNodeInstrumentation *worker_instrument = planstate->worker_instrument;
+	IndexScanInstrumentation stats = {0};
+	double		leader_nloops = planstate->instrument ?
+		planstate->instrument->nloops : 0;
 
 	if (!es->analyze)
 		return;
@@ -3994,7 +3998,7 @@ show_indexscan_info(PlanState *planstate, ExplainState *es)
 			{
 				IndexScanState *indexstate = ((IndexScanState *) planstate);
 
-				nsearches = indexstate->iss_Instrument->nsearches;
+				stats = *indexstate->iss_Instrument;
 				SharedInfo = indexstate->iss_SharedInfo;
 				break;
 			}
@@ -4002,8 +4006,7 @@ show_indexscan_info(PlanState *planstate, ExplainState *es)
 			{
 				IndexOnlyScanState *indexstate = ((IndexOnlyScanState *) planstate);
 
-				nsearches = indexstate->ioss_Instrument->nsearches;
-				ntabletuplefetches = indexstate->ioss_Instrument->ntabletuplefetches;
+				stats = *indexstate->ioss_Instrument;
 				SharedInfo = indexstate->ioss_SharedInfo;
 				break;
 			}
@@ -4011,7 +4014,7 @@ show_indexscan_info(PlanState *planstate, ExplainState *es)
 			{
 				BitmapIndexScanState *indexstate = ((BitmapIndexScanState *) planstate);
 
-				nsearches = indexstate->biss_Instrument->nsearches;
+				stats = *indexstate->biss_Instrument;
 				SharedInfo = indexstate->biss_SharedInfo;
 				break;
 			}
@@ -4019,22 +4022,52 @@ show_indexscan_info(PlanState *planstate, ExplainState *es)
 			break;
 	}
 
+	if (worker_instrument)
+	{
+		/*
+		 * After parallel cleanup, the node's loop count includes worker
+		 * loops. Remove them to determine whether the leader executed the
+		 * scan.
+		 */
+		for (int i = 0; i < worker_instrument->num_workers; ++i)
+			leader_nloops -= worker_instrument->instrument[i].nloops;
+	}
+
 	/* Next get the sum of the counters set within each and every process */
 	if (SharedInfo)
 	{
 		for (int i = 0; i < SharedInfo->num_workers; ++i)
 		{
 			IndexScanInstrumentation *winstrument = &SharedInfo->winstrument[i];
+			double		worker_nloops =
+				worker_instrument && i < worker_instrument->num_workers ?
+				worker_instrument->instrument[i].nloops : 0;
+
+			stats.nsearches += winstrument->nsearches;
+			stats.ntabletuplefetches += winstrument->ntabletuplefetches;
 
-			nsearches += winstrument->nsearches;
-			ntabletuplefetches += winstrument->ntabletuplefetches;
+			if (!es->buffers || !es->verbose || worker_nloops <= 0 ||
+				winstrument->strategy_mask == 0)
+				continue;
+
+			if (es->workers_state)
+				ExplainOpenWorker(i, es);
+
+			print_buffer_access_strategies(es, winstrument->strategy_mask);
+
+			if (es->workers_state)
+				ExplainCloseWorker(i, es);
 		}
 	}
 
+	if (es->buffers && es->verbose && stats.strategy_mask != 0 &&
+		leader_nloops > 0)
+		print_buffer_access_strategies(es, stats.strategy_mask);
+
 	if (nodeTag(plan) == T_IndexOnlyScan)
-		ExplainPropertyUInteger("Heap Fetches", NULL, ntabletuplefetches, es);
+		ExplainPropertyUInteger("Heap Fetches", NULL, stats.ntabletuplefetches, es);
 
-	ExplainPropertyUInteger("Index Searches", NULL, nsearches, es);
+	ExplainPropertyUInteger("Index Searches", NULL, stats.nsearches, es);
 }
 
 /*
diff --git a/src/backend/executor/nodeBitmapIndexscan.c b/src/backend/executor/nodeBitmapIndexscan.c
index 90b010f9b71..dfea3fdd84c 100644
--- a/src/backend/executor/nodeBitmapIndexscan.c
+++ b/src/backend/executor/nodeBitmapIndexscan.c
@@ -203,7 +203,7 @@ ExecEndBitmapIndexScan(BitmapIndexScanState *node)
 		 * shutdown on the workers.  On rescan it will spin up new workers
 		 * which will have a new BitmapIndexScanState and zeroed stats.
 		 */
-		winstrument->nsearches += node->biss_Instrument->nsearches;
+		CombineIndexScanInstrumentation(winstrument, node->biss_Instrument);
 		Assert(node->biss_Instrument->ntabletuplefetches == 0);
 	}
 
@@ -277,7 +277,11 @@ ExecInitBitmapIndexScan(BitmapIndexScan *node, EState *estate, int eflags)
 
 	/* Set up instrumentation of bitmap index scans if requested */
 	if (estate->es_instrument)
+	{
 		indexstate->biss_Instrument = palloc0_object(IndexScanInstrumentation);
+		indexstate->biss_Instrument->need_strategy =
+			(estate->es_instrument & INSTRUMENT_BUFFERS) != 0;
+	}
 
 	/* Open the index relation. */
 	lockmode = exec_rt_fetch(node->scan.scanrelid, estate)->rellockmode;
diff --git a/src/backend/executor/nodeIndexonlyscan.c b/src/backend/executor/nodeIndexonlyscan.c
index b1009027ac8..14ce758c00f 100644
--- a/src/backend/executor/nodeIndexonlyscan.c
+++ b/src/backend/executor/nodeIndexonlyscan.c
@@ -260,8 +260,7 @@ ExecEndIndexOnlyScan(IndexOnlyScanState *node)
 		 * shutdown on the workers.  On rescan it will spin up new workers
 		 * which will have a new IndexOnlyScanState and zeroed stats.
 		 */
-		winstrument->nsearches += node->ioss_Instrument->nsearches;
-		winstrument->ntabletuplefetches += node->ioss_Instrument->ntabletuplefetches;
+		CombineIndexScanInstrumentation(winstrument, node->ioss_Instrument);
 	}
 
 	/*
@@ -426,7 +425,11 @@ ExecInitIndexOnlyScan(IndexOnlyScan *node, EState *estate, int eflags)
 
 	/* Set up instrumentation of index-only scans if requested */
 	if (estate->es_instrument)
+	{
 		indexstate->ioss_Instrument = palloc0_object(IndexScanInstrumentation);
+		indexstate->ioss_Instrument->need_strategy =
+			(estate->es_instrument & INSTRUMENT_BUFFERS) != 0;
+	}
 
 	/* Open the index relation. */
 	lockmode = exec_rt_fetch(node->scan.scanrelid, estate)->rellockmode;
diff --git a/src/backend/executor/nodeIndexscan.c b/src/backend/executor/nodeIndexscan.c
index 129d005f187..83262d4ea6e 100644
--- a/src/backend/executor/nodeIndexscan.c
+++ b/src/backend/executor/nodeIndexscan.c
@@ -819,7 +819,7 @@ ExecEndIndexScan(IndexScanState *node)
 		 * shutdown on the workers.  On rescan it will spin up new workers
 		 * which will have a new IndexOnlyScanState and zeroed stats.
 		 */
-		winstrument->nsearches += node->iss_Instrument->nsearches;
+		CombineIndexScanInstrumentation(winstrument, node->iss_Instrument);
 		Assert(node->iss_Instrument->ntabletuplefetches == 0);
 	}
 
@@ -983,7 +983,11 @@ ExecInitIndexScan(IndexScan *node, EState *estate, int eflags)
 
 	/* Set up instrumentation of index scans if requested */
 	if (estate->es_instrument)
+	{
 		indexstate->iss_Instrument = palloc0_object(IndexScanInstrumentation);
+		indexstate->iss_Instrument->need_strategy =
+			(estate->es_instrument & INSTRUMENT_BUFFERS) != 0;
+	}
 
 	/* Open the index relation. */
 	lockmode = exec_rt_fetch(node->scan.scanrelid, estate)->rellockmode;
diff --git a/src/include/executor/instrument_node.h b/src/include/executor/instrument_node.h
index aa45ffec57f..d4d3e891541 100644
--- a/src/include/executor/instrument_node.h
+++ b/src/include/executor/instrument_node.h
@@ -150,8 +150,45 @@ typedef struct IndexScanInstrumentation
 
 	/* Table tuples fetched count (incremented during index-only scans) */
 	uint64		ntabletuplefetches;
+
+	/* Whether the index AM should record its buffer access strategy */
+	bool		need_strategy;
+
+	/* Buffer access strategies reported by the index AM */
+	uint32		strategy_mask;
 } IndexScanInstrumentation;
 
+/*
+ * Record a buffer access strategy selected by an index AM.
+ *
+ * Record strategies only when requested by the executor and preserve every
+ * strategy observed across rescans.
+ */
+static inline void
+RecordIndexScanStrategy(IndexScanInstrumentation *instrument,
+						BufferAccessStrategyType strategy)
+{
+	if (!instrument)
+		return;
+
+	if (instrument->need_strategy)
+		instrument->strategy_mask |= BufferAccessStrategyBit(strategy);
+}
+
+/*
+ * Combine index scan instrumentation from 'src' into 'dst'.
+ *
+ * Counter values are added and observed strategies are preserved.
+ */
+static inline void
+CombineIndexScanInstrumentation(IndexScanInstrumentation *dst,
+								IndexScanInstrumentation *src)
+{
+	dst->nsearches += src->nsearches;
+	dst->ntabletuplefetches += src->ntabletuplefetches;
+	dst->strategy_mask |= src->strategy_mask;
+}
+
 /*
  * Shared memory container for per-worker information
  */
diff --git a/src/test/modules/test_misc/t/016_explain_buffer_strategy.pl b/src/test/modules/test_misc/t/016_explain_buffer_strategy.pl
index f7d20f2ec2b..aa7417037c7 100644
--- a/src/test/modules/test_misc/t/016_explain_buffer_strategy.pl
+++ b/src/test/modules/test_misc/t/016_explain_buffer_strategy.pl
@@ -1,6 +1,6 @@
 # Copyright (c) 2026, PostgreSQL Global Development Group
 
-# Test EXPLAIN reporting of buffer access strategies for table scans,
+# Test EXPLAIN reporting of buffer access strategies for table and index scans,
 # including rescans, parallel execution, and cases where no strategy is shown.
 
 use strict;
@@ -38,6 +38,25 @@ my $normal_scan = explain_json('SELECT * FROM test_normal');
 is($normal_scan->{'Node Type'}, 'Seq Scan', 'small sequential scan plan');
 check_strategy($normal_scan, 'normal', 'small sequential scan');
 
+my $index_scan_plan = explain_json(
+	'SELECT * FROM test_strategy WHERE g = 1', qq{
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+});
+my $index_scan = find_plan_node($index_scan_plan, 'Index Scan');
+ok(defined $index_scan, 'B-tree index scan plan');
+check_strategy($index_scan, 'normal', 'B-tree index scan');
+
+my $index_only_scan_plan = explain_json(
+	'SELECT g FROM test_strategy WHERE g = 1', qq{
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+});
+my $index_only_scan =
+  find_plan_node($index_only_scan_plan, 'Index Only Scan');
+ok(defined $index_only_scan, 'B-tree index-only scan plan');
+check_strategy($index_only_scan, 'normal', 'B-tree index-only scan');
+
 my $never_executed_plan = decode_json(
 	$node->safe_psql(
 		'postgres', qq{
@@ -81,17 +100,17 @@ is_deeply(
 	[ 'normal', 'bulkread' ],
 	'parameterized sample scan reports every strategy used');
 
-my $bitmap_plan = decode_json(
-	$node->safe_psql(
-		'postgres', qq{
+my $bitmap_plan = explain_json(
+	'SELECT * FROM test_strategy WHERE g BETWEEN 1 AND 1000', qq{
 SET enable_seqscan = off;
 SET enable_indexscan = off;
-EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON)
-  SELECT * FROM test_strategy WHERE g BETWEEN 1 AND 1000;
-}))->[0]->{'Plan'};
+});
 my $bitmap_scan = find_plan_node($bitmap_plan, 'Bitmap Heap Scan');
 ok(defined $bitmap_scan, 'bitmap heap scan plan');
 check_strategy($bitmap_scan, 'normal', 'bitmap heap scan');
+my $bitmap_index_scan = find_plan_node($bitmap_plan, 'Bitmap Index Scan');
+ok(defined $bitmap_index_scan, 'B-tree bitmap index scan plan');
+check_strategy($bitmap_index_scan, 'normal', 'B-tree bitmap index scan');
 
 my $tid_plan =
   explain_json("SELECT * FROM test_strategy WHERE ctid = '(0,1)'");
@@ -190,9 +209,11 @@ done_testing();
 # Run EXPLAIN for the query and return its top-level plan.
 sub explain_json
 {
-	my ($query) = @_;
+	my ($query, $settings) = @_;
+
+	$settings //= '';
 	my $output = $node->safe_psql('postgres',
-		"EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON) $query");
+		"$settings\nEXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON) $query");
 
 	return decode_json($output)->[0]->{'Plan'};
 }
-- 
2.34.1

