From bd225e7f1dba2ba446c177b7a9139bf325e9c70e Mon Sep 17 00:00:00 2001
From: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Date: Wed, 8 Oct 2025 16:47:43 +0000
Subject: [PATCH v2] Preserve index stats during ALTER TABLE ... TYPE ...

During ALTER TABLE ... TYPE ... on an indexed column, a new index is created and
the old one is dropped. Currently this causes the statistics such as idx_scan,
last_idx_scan, and related counters to be lost.

We can not use pgstat_copy_relation_stats() because the old index is dropped
before the new one is created, so this commit saves the old PgStat_StatTabEntry
and restores it in the new index. This is done by adding a pointer to a hash table
in AlteredTableInfo where the hash key is the partition table Oid. This hash table
stores a list of all the information needed to restore the stats in the right
index.

The stats are saved in ATPostAlterTypeParse() (before the old index is dropped)
and restored in ATExecAddIndex() once the new index is created.

Note that pending statistics (if any) are not preserved, only the
accumulated stats from previous transactions. This is acceptable since the
accumulated stats represent the historical usage patterns we want to maintain.
---
 src/backend/commands/tablecmds.c    | 277 ++++++++++++++++++++++++++++
 src/test/regress/expected/stats.out | 215 ++++++++++++++++++++-
 src/test/regress/sql/stats.sql      |  86 ++++++++-
 src/tools/pgindent/typedefs.list    |   2 +
 4 files changed, 576 insertions(+), 4 deletions(-)
  41.5% src/backend/commands/
  34.0% src/test/regress/expected/
  24.2% src/test/regress/sql/

diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 5fd8b51312c..96b467e544d 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -102,6 +102,7 @@
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/partcache.h"
+#include "utils/pgstat_internal.h"
 #include "utils/relcache.h"
 #include "utils/ruleutils.h"
 #include "utils/snapmgr.h"
@@ -208,8 +209,28 @@ typedef struct AlteredTableInfo
 	char	   *clusterOnIndex; /* index to use for CLUSTER */
 	List	   *changedStatisticsOids;	/* OIDs of statistics to rebuild */
 	List	   *changedStatisticsDefs;	/* string definitions of same */
+	HTAB	   *savedIndexStatsHash;	/* list of SavedIndexStatsEntry by
+										 * partitionTableOid */
 } AlteredTableInfo;
 
+/* Hash table entry for finding saved index stats by partition table OID */
+typedef struct SavedIndexStatsHashEntry
+{
+	Oid			partitionTableOid;	/* hash key */
+	List	   *statsList;		/* list of SavedIndexStatsEntry for this
+								 * partition table */
+} SavedIndexStatsHashEntry;
+
+/* Struct saving stats from an old index before it's dropped */
+typedef struct SavedIndexStatsEntry
+{
+	char	   *indexName;		/* name of the index (for matching) */
+	IndexInfo  *indexInfo;		/* index structure (for matching) */
+	Oid		   *indcollation;	/* collations (for matching) */
+	Oid		   *indopfamily;	/* operator families (for matching) */
+	PgStat_StatTabEntry stats;	/* saved statistics */
+} SavedIndexStatsEntry;
+
 /* Struct describing one new constraint to check in Phase 3 scan */
 /* Note: new not-null constraints are handled elsewhere */
 typedef struct NewConstraint
@@ -542,6 +563,8 @@ static void ATPrepAddPrimaryKey(List **wqueue, Relation rel, AlterTableCmd *cmd,
 								bool recurse, LOCKMODE lockmode,
 								AlterTableUtilityContext *context);
 static void verifyNotNullPKCompatible(HeapTuple tuple, const char *colname);
+static void ATExecAddIndex_RestoreStatsToIndex(Oid indexOid, PgStat_StatTabEntry *saved_stats);
+static void ATExecAddIndex_RestoreStats(AlteredTableInfo *tab, Oid new_index_oid);
 static ObjectAddress ATExecAddIndex(AlteredTableInfo *tab, Relation rel,
 									IndexStmt *stmt, bool is_rebuild, LOCKMODE lockmode);
 static ObjectAddress ATExecAddStatistics(AlteredTableInfo *tab, Relation rel,
@@ -653,6 +676,8 @@ static void RememberIndexForRebuilding(Oid indoid, AlteredTableInfo *tab);
 static void RememberStatisticsForRebuilding(Oid stxoid, AlteredTableInfo *tab);
 static void ATPostAlterTypeCleanup(List **wqueue, AlteredTableInfo *tab,
 								   LOCKMODE lockmode);
+static void EnsureSavedIndexStatsHashExists(AlteredTableInfo *tab);
+static void SaveIndexStatsForAlterType(AlteredTableInfo *tab, Relation rel, Oid indexOid);
 static void ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId,
 								 char *cmd, List **wqueue, LOCKMODE lockmode,
 								 bool rewrite);
@@ -9574,6 +9599,148 @@ verifyNotNullPKCompatible(HeapTuple tuple, const char *colname)
 						"ALTER TABLE ... VALIDATE CONSTRAINT"));
 }
 
+/*
+ * Helper to restore stats from saved entry to a specific index OID.
+ */
+static void
+ATExecAddIndex_RestoreStatsToIndex(Oid indexOid, PgStat_StatTabEntry *saved_stats)
+{
+	PgStatShared_Relation *shstats;
+	PgStat_EntryRef *entry_ref;
+	Relation	irel = index_open(indexOid, AccessShareLock);
+
+	/* get or create the stats entry for the new index */
+	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_RELATION,
+											irel->rd_rel->relisshared ?
+											InvalidOid : MyDatabaseId,
+											indexOid,
+											false);
+
+	if (entry_ref != NULL)
+	{
+		shstats = (PgStatShared_Relation *) entry_ref->shared_stats;
+
+		/* restore the saved statistics */
+		shstats->stats = *saved_stats;
+
+		pgstat_unlock_entry(entry_ref);
+	}
+
+	index_close(irel, AccessShareLock);
+}
+
+/*
+ * Restore index statistics that were saved before ALTER TABLE ... TYPE ...
+ *
+ * During ALTER TABLE ... TYPE ... on an indexed column, indexes are rebuilt by
+ * dropping the old index and creating a new one. This function restores the
+ * statistics (such as idx_scan, last_idx_scan, etc.) from the old index to
+ * the new index.
+ *
+ * For partitioned tables, this handles restoring stats for all partition
+ * indexes by matching them based on:
+ *   1. The partition table OID (used as hash key for efficient lookup)
+ *   2. The index name (primary matching criterion)
+ *   3. Index structure verification (sanity check via CompareIndexInfo)
+ *
+ * The matching by name is essential when multiple indexes exist on the same
+ * column(s), ensuring each index gets its own historical statistics.
+ */
+static void
+ATExecAddIndex_RestoreStats(AlteredTableInfo *tab, Oid new_index_oid)
+{
+	Relation	new_idx_rel;
+	List	   *all_parts;
+	ListCell   *lc_new;
+
+	if (tab->savedIndexStatsHash == NULL)
+		return;
+
+	new_idx_rel = index_open(new_index_oid, AccessShareLock);
+
+	/*
+	 * This check is not strictly needed, it's done to avoid calling
+	 * find_all_inheritors().
+	 */
+	if (new_idx_rel->rd_rel->relkind == RELKIND_PARTITIONED_INDEX)
+		all_parts = find_all_inheritors(new_index_oid, NoLock, NULL);
+	else
+		all_parts = list_make1_oid(new_index_oid);
+
+	foreach(lc_new, all_parts)
+	{
+		Oid			new_part_idx_oid;
+		Oid			new_part_table_oid;
+		Relation	new_part_idx_rel;
+		SavedIndexStatsHashEntry *hash_entry;
+
+		new_part_idx_oid = lfirst_oid(lc_new);
+		new_part_idx_rel = index_open(new_part_idx_oid, AccessShareLock);
+		new_part_table_oid = new_part_idx_rel->rd_index->indrelid;
+
+		hash_entry = (SavedIndexStatsHashEntry *) hash_search(tab->savedIndexStatsHash,
+															  &new_part_table_oid,
+															  HASH_FIND,
+															  NULL);
+		if (hash_entry != NULL)
+		{
+			ListCell   *lc_old;
+			Relation	part_table_rel;
+			AttrMap    *attmap;
+			IndexInfo  *new_info;
+			const char *new_idx_name;
+
+			new_idx_name = RelationGetRelationName(new_part_idx_rel);
+
+			/* open the partition table for attribute mapping */
+			new_info = BuildIndexInfo(new_part_idx_rel);
+			part_table_rel = table_open(new_part_table_oid, NoLock);
+
+			/*
+			 * Build an attribute mapping (same table to itself). We use this
+			 * to verify the old and new indexes have the same structure
+			 * relative to the current table layout.
+			 */
+			attmap = build_attrmap_by_name(RelationGetDescr(part_table_rel),
+										   RelationGetDescr(part_table_rel),
+										   false);
+
+			/* iterate through the list of indexes for this partition table */
+			foreach(lc_old, hash_entry->statsList)
+			{
+				SavedIndexStatsEntry *old_entry;
+
+				old_entry = (SavedIndexStatsEntry *) lfirst(lc_old);
+
+				/* match by index name */
+				if (strcmp(old_entry->indexName, new_idx_name) != 0)
+					continue;
+
+				/* match by index structure using CompareIndexInfo */
+				if (CompareIndexInfo(old_entry->indexInfo, new_info,
+									 old_entry->indcollation,
+									 new_part_idx_rel->rd_indcollation,
+									 old_entry->indopfamily,
+									 new_part_idx_rel->rd_opfamily,
+									 attmap))
+				{
+					/* restore the saved statistics */
+					ATExecAddIndex_RestoreStatsToIndex(new_part_idx_oid, &old_entry->stats);
+					break;
+				}
+			}
+
+			table_close(part_table_rel, NoLock);
+			free_attrmap(attmap);
+		}
+
+		index_close(new_part_idx_rel, AccessShareLock);
+	}
+
+	index_close(new_idx_rel, AccessShareLock);
+}
+
+
 /*
  * ALTER TABLE ADD INDEX
  *
@@ -9636,6 +9803,9 @@ ATExecAddIndex(AlteredTableInfo *tab, Relation rel,
 		index_close(irel, NoLock);
 	}
 
+	if (tab->savedIndexStatsHash != NULL)
+		ATExecAddIndex_RestoreStats(tab, address.objectId);
+
 	return address;
 }
 
@@ -15583,6 +15753,105 @@ ATPostAlterTypeCleanup(List **wqueue, AlteredTableInfo *tab, LOCKMODE lockmode)
 	 */
 }
 
+/*
+ * Initialize the hash table for storing old index stats if not already created.
+ */
+static void
+EnsureSavedIndexStatsHashExists(AlteredTableInfo *tab)
+{
+	if (tab->savedIndexStatsHash == NULL)
+	{
+		HASHCTL		ctl;
+
+		ctl.keysize = sizeof(Oid);
+		ctl.entrysize = sizeof(SavedIndexStatsHashEntry);
+		ctl.hcxt = CurrentMemoryContext;
+
+		tab->savedIndexStatsHash = hash_create("Saved Index Stats Hash",
+											   32,	/* start small and extend */
+											   &ctl,
+											   HASH_ELEM | HASH_BLOBS | HASH_CONTEXT);
+	}
+}
+
+/*
+ * Helper function to save index stats before it's dropped.
+ */
+static void
+SaveIndexStatsForAlterType(AlteredTableInfo *tab, Relation rel, Oid indexOid)
+{
+	List	   *all_parts;
+	ListCell   *lc;
+
+	/*
+	 * If the table is partitioned, the index will be partitioned too, and we
+	 * need to save stats for all partition indexes.
+	 *
+	 * This check is not strictly needed, it's done to avoid calling
+	 * find_all_inheritors().
+	 */
+	if (rel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
+		all_parts = find_all_inheritors(indexOid, NoLock, NULL);
+	else
+		all_parts = list_make1_oid(indexOid);
+
+	/* initialize the hash table if needed */
+	EnsureSavedIndexStatsHashExists(tab);
+
+	/* save the stats for each index */
+	foreach(lc, all_parts)
+	{
+		Oid			part_idx_oid;
+		PgStat_StatTabEntry *src_stats;
+
+		part_idx_oid = lfirst_oid(lc);
+		src_stats = pgstat_fetch_stat_tabentry(part_idx_oid);
+
+		if (src_stats)
+		{
+			SavedIndexStatsEntry *entry;
+			SavedIndexStatsHashEntry *hash_entry;
+			Oid			tableOid;
+			bool		found;
+			Relation	part_idx_rel;
+
+			part_idx_rel = index_open(part_idx_oid, AccessShareLock);
+			tableOid = part_idx_rel->rd_index->indrelid;
+
+			/* create the stats entry */
+			entry = palloc(sizeof(SavedIndexStatsEntry));
+			entry->indexInfo = BuildIndexInfo(part_idx_rel);
+			entry->indexName = pstrdup(RelationGetRelationName(part_idx_rel));
+
+			/* copy collation and opfamily arrays */
+			entry->indcollation = palloc(sizeof(Oid) * entry->indexInfo->ii_NumIndexKeyAttrs);
+			memcpy(entry->indcollation, part_idx_rel->rd_indcollation,
+				   sizeof(Oid) * entry->indexInfo->ii_NumIndexKeyAttrs);
+
+			entry->indopfamily = palloc(sizeof(Oid) * entry->indexInfo->ii_NumIndexKeyAttrs);
+			memcpy(entry->indopfamily, part_idx_rel->rd_opfamily,
+				   sizeof(Oid) * entry->indexInfo->ii_NumIndexKeyAttrs);
+
+			/* copy the stats */
+			memcpy(&entry->stats, src_stats, sizeof(PgStat_StatTabEntry));
+
+			/* find or create the hash entry for this partition table OID */
+			hash_entry = (SavedIndexStatsHashEntry *) hash_search(tab->savedIndexStatsHash,
+																  &tableOid,
+																  HASH_ENTER,
+																  &found);
+
+			if (!found)
+				hash_entry->statsList = NIL;
+
+			/* append this entry to the list for this table */
+			hash_entry->statsList = lappend(hash_entry->statsList, entry);
+
+			index_close(part_idx_rel, AccessShareLock);
+		}
+	}
+}
+
 /*
  * Parse the previously-saved definition string for a constraint, index or
  * statistics object against the newly-established column data type(s), and
@@ -15664,8 +15933,12 @@ ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId, char *cmd,
 			IndexStmt  *stmt = (IndexStmt *) stm;
 			AlterTableCmd *newcmd;
 
+			/* save the stats before dropping the old index */
+			SaveIndexStatsForAlterType(tab, rel, oldId);
+
 			if (!rewrite)
 				TryReuseIndex(oldId, stmt);
+
 			stmt->reset_default_tblspc = true;
 			/* keep the index's comment */
 			stmt->idxcomment = GetComment(oldId, RelationRelationId, 0);
@@ -15693,8 +15966,12 @@ ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId, char *cmd,
 					indstmt = castNode(IndexStmt, cmd->def);
 					indoid = get_constraint_index(oldId);
 
+					/* save the stats before dropping the old index */
+					SaveIndexStatsForAlterType(tab, rel, indoid);
+
 					if (!rewrite)
 						TryReuseIndex(indoid, indstmt);
+
 					/* keep any comment on the index */
 					indstmt->idxcomment = GetComment(indoid,
 													 RelationRelationId, 0);
diff --git a/src/test/regress/expected/stats.out b/src/test/regress/expected/stats.out
index 67e1860e984..1a701a53f8e 100644
--- a/src/test/regress/expected/stats.out
+++ b/src/test/regress/expected/stats.out
@@ -651,8 +651,9 @@ DROP TABLE prevstats;
 -- granularity.
 -----
 BEGIN;
-CREATE TEMPORARY TABLE test_last_scan(idx_col int primary key, noidx_col int);
-INSERT INTO test_last_scan(idx_col, noidx_col) VALUES(1, 1);
+CREATE TEMPORARY TABLE test_last_scan(idx_col int primary key, idx_col2 int, noidx_col int);
+CREATE index test_last_scan_idx2 on test_last_scan(idx_col2);
+INSERT INTO test_last_scan(idx_col, idx_col2, noidx_col) VALUES(1, 1, 1);
 SELECT pg_stat_force_next_flush();
  pg_stat_force_next_flush 
 --------------------------
@@ -867,6 +868,216 @@ SELECT idx_scan, :'test_last_idx' < last_idx_scan AS idx_ok,
         3 | t      | f
 (1 row)
 
+-- check that an index rebuild preserves the stats
+ALTER TABLE test_last_scan ALTER COLUMN idx_col TYPE int;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+ idx_scan 
+----------
+        3
+(1 row)
+
+-- same test but with a rewrite
+ALTER TABLE test_last_scan ALTER COLUMN idx_col TYPE bigint;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+ idx_scan 
+----------
+        3
+(1 row)
+
+-- do the same on an indexed column not part of a constraint
+-- cause one index scan
+BEGIN;
+SET LOCAL enable_seqscan TO off;
+SET LOCAL enable_indexscan TO on;
+SET LOCAL enable_bitmapscan TO off;
+EXPLAIN (COSTS off) SELECT count(*) FROM test_last_scan WHERE idx_col2 = 1;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Aggregate
+   ->  Index Scan using test_last_scan_idx2 on test_last_scan
+         Index Cond: (idx_col2 = 1)
+(3 rows)
+
+SELECT count(*) FROM test_last_scan WHERE idx_col2 = 1;
+ count 
+-------
+     1
+(1 row)
+
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+COMMIT;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+ idx_scan 
+----------
+        4
+(1 row)
+
+-- check that an index rebuild preserves the stats
+ALTER TABLE test_last_scan ALTER COLUMN idx_col2 TYPE int;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+ idx_scan 
+----------
+        4
+(1 row)
+
+-- same test but with a rewrite
+ALTER TABLE test_last_scan ALTER COLUMN idx_col2 TYPE bigint;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+ idx_scan 
+----------
+        4
+(1 row)
+
+-- check stats are preserved for partitions too
+CREATE TEMPORARY TABLE test_last_scan_part(idx_col int primary key, idx_col2 int, noidx_col int) partition by range (idx_col);
+CREATE TEMPORARY TABLE test_last_scan_part1 PARTITION OF test_last_scan_part FOR VALUES FROM (0) TO (1);
+CREATE TEMPORARY TABLE test_last_scan_part2 PARTITION OF test_last_scan_part FOR VALUES FROM (1) TO (2);
+CREATE index test_last_scan_part_idx2 on test_last_scan_part(idx_col2);
+INSERT INTO test_last_scan_part(idx_col, idx_col2, noidx_col) VALUES(0, 0, 0);
+INSERT INTO test_last_scan_part(idx_col, idx_col2, noidx_col) VALUES(1, 1, 1);
+-- on an indexed column not part of a constraint
+-- cause one index scan
+BEGIN;
+SET LOCAL enable_seqscan TO off;
+SET LOCAL enable_indexscan TO on;
+SET LOCAL enable_bitmapscan TO off;
+EXPLAIN (COSTS off) SELECT count(*) FROM test_last_scan_part WHERE idx_col2 = 1;
+                                                  QUERY PLAN                                                  
+--------------------------------------------------------------------------------------------------------------
+ Aggregate
+   ->  Append
+         ->  Index Scan using test_last_scan_part1_idx_col2_idx on test_last_scan_part1 test_last_scan_part_1
+               Index Cond: (idx_col2 = 1)
+         ->  Index Scan using test_last_scan_part2_idx_col2_idx on test_last_scan_part2 test_last_scan_part_2
+               Index Cond: (idx_col2 = 1)
+(6 rows)
+
+SELECT count(*) FROM test_last_scan_part WHERE idx_col2 = 1;
+ count 
+-------
+     1
+(1 row)
+
+EXPLAIN (COSTS off) SELECT count(*) FROM test_last_scan_part2 WHERE idx_col2 = 1;
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
+ Aggregate
+   ->  Index Scan using test_last_scan_part2_idx_col2_idx on test_last_scan_part2
+         Index Cond: (idx_col2 = 1)
+(3 rows)
+
+SELECT count(*) FROM test_last_scan_part2 WHERE idx_col2 = 1;
+ count 
+-------
+     1
+(1 row)
+
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+COMMIT;
+SELECT idx_scan from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx';
+ idx_scan 
+----------
+        1
+(1 row)
+
+SELECT idx_scan from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx';
+ idx_scan 
+----------
+        2
+(1 row)
+
+-- check that an index rebuild preserves the stats
+ALTER TABLE test_last_scan_part ALTER COLUMN idx_col2 TYPE int;
+SELECT idx_scan from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx';
+ idx_scan 
+----------
+        1
+(1 row)
+
+SELECT idx_scan from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx';
+ idx_scan 
+----------
+        2
+(1 row)
+
+-- same test but with a rewrite
+ALTER TABLE test_last_scan_part ALTER COLUMN idx_col2 TYPE bigint;
+SELECT idx_scan from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx';
+ idx_scan 
+----------
+        1
+(1 row)
+
+SELECT idx_scan from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx';
+ idx_scan 
+----------
+        2
+(1 row)
+
+-- check when multiple indexes on the same set of columns
+CREATE index test_last_scan_part_idx2_bis on test_last_scan_part(idx_col2);
+BEGIN;
+SET LOCAL enable_seqscan TO off;
+SET LOCAL enable_indexscan TO on;
+SET LOCAL enable_bitmapscan TO off;
+SELECT count(*) FROM test_last_scan_part WHERE idx_col2 = 1;
+ count 
+-------
+     1
+(1 row)
+
+SELECT count(*) FROM test_last_scan_part2 WHERE idx_col2 = 1;
+ count 
+-------
+     1
+(1 row)
+
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+COMMIT;
+SELECT idx_scan AS idx_scan_part1_idx_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx' \gset
+SELECT idx_scan AS idx_scan_part2_idx_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx' \gset
+SELECT idx_scan AS idx_scan_part1_idx1_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx1' \gset
+SELECT idx_scan AS idx_scan_part2_idx1_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx1' \gset
+ALTER TABLE test_last_scan_part ALTER COLUMN idx_col2 TYPE int;
+SELECT idx_scan = :idx_scan_part1_idx_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx';
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT idx_scan = :idx_scan_part2_idx_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx';
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT idx_scan = :idx_scan_part1_idx1_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx1';
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT idx_scan = :idx_scan_part2_idx1_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx1';
+ ?column? 
+----------
+ t
+(1 row)
+
 -- check that the stats in pg_stat_all_indexes are reset
 SELECT pg_stat_reset_single_table_counters('test_last_scan_pkey'::regclass);
  pg_stat_reset_single_table_counters 
diff --git a/src/test/regress/sql/stats.sql b/src/test/regress/sql/stats.sql
index 8768e0f27fd..82cabb1b071 100644
--- a/src/test/regress/sql/stats.sql
+++ b/src/test/regress/sql/stats.sql
@@ -306,8 +306,9 @@ DROP TABLE prevstats;
 -----
 
 BEGIN;
-CREATE TEMPORARY TABLE test_last_scan(idx_col int primary key, noidx_col int);
-INSERT INTO test_last_scan(idx_col, noidx_col) VALUES(1, 1);
+CREATE TEMPORARY TABLE test_last_scan(idx_col int primary key, idx_col2 int, noidx_col int);
+CREATE index test_last_scan_idx2 on test_last_scan(idx_col2);
+INSERT INTO test_last_scan(idx_col, idx_col2, noidx_col) VALUES(1, 1, 1);
 SELECT pg_stat_force_next_flush();
 SELECT last_seq_scan, last_idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
 COMMIT;
@@ -390,6 +391,87 @@ SELECT idx_scan, :'test_last_idx' < last_idx_scan AS idx_ok,
   stats_reset IS NOT NULL AS has_stats_reset
   FROM pg_stat_all_indexes WHERE indexrelid = 'test_last_scan_pkey'::regclass;
 
+-- check that an index rebuild preserves the stats
+ALTER TABLE test_last_scan ALTER COLUMN idx_col TYPE int;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+-- same test but with a rewrite
+ALTER TABLE test_last_scan ALTER COLUMN idx_col TYPE bigint;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+
+-- do the same on an indexed column not part of a constraint
+-- cause one index scan
+BEGIN;
+SET LOCAL enable_seqscan TO off;
+SET LOCAL enable_indexscan TO on;
+SET LOCAL enable_bitmapscan TO off;
+EXPLAIN (COSTS off) SELECT count(*) FROM test_last_scan WHERE idx_col2 = 1;
+SELECT count(*) FROM test_last_scan WHERE idx_col2 = 1;
+SELECT pg_stat_force_next_flush();
+COMMIT;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+
+-- check that an index rebuild preserves the stats
+ALTER TABLE test_last_scan ALTER COLUMN idx_col2 TYPE int;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+-- same test but with a rewrite
+ALTER TABLE test_last_scan ALTER COLUMN idx_col2 TYPE bigint;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+
+-- check stats are preserved for partitions too
+CREATE TEMPORARY TABLE test_last_scan_part(idx_col int primary key, idx_col2 int, noidx_col int) partition by range (idx_col);
+CREATE TEMPORARY TABLE test_last_scan_part1 PARTITION OF test_last_scan_part FOR VALUES FROM (0) TO (1);
+CREATE TEMPORARY TABLE test_last_scan_part2 PARTITION OF test_last_scan_part FOR VALUES FROM (1) TO (2);
+CREATE index test_last_scan_part_idx2 on test_last_scan_part(idx_col2);
+INSERT INTO test_last_scan_part(idx_col, idx_col2, noidx_col) VALUES(0, 0, 0);
+INSERT INTO test_last_scan_part(idx_col, idx_col2, noidx_col) VALUES(1, 1, 1);
+
+-- on an indexed column not part of a constraint
+-- cause one index scan
+BEGIN;
+SET LOCAL enable_seqscan TO off;
+SET LOCAL enable_indexscan TO on;
+SET LOCAL enable_bitmapscan TO off;
+EXPLAIN (COSTS off) SELECT count(*) FROM test_last_scan_part WHERE idx_col2 = 1;
+SELECT count(*) FROM test_last_scan_part WHERE idx_col2 = 1;
+EXPLAIN (COSTS off) SELECT count(*) FROM test_last_scan_part2 WHERE idx_col2 = 1;
+SELECT count(*) FROM test_last_scan_part2 WHERE idx_col2 = 1;
+SELECT pg_stat_force_next_flush();
+COMMIT;
+SELECT idx_scan from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx';
+SELECT idx_scan from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx';
+
+-- check that an index rebuild preserves the stats
+ALTER TABLE test_last_scan_part ALTER COLUMN idx_col2 TYPE int;
+SELECT idx_scan from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx';
+SELECT idx_scan from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx';
+-- same test but with a rewrite
+ALTER TABLE test_last_scan_part ALTER COLUMN idx_col2 TYPE bigint;
+SELECT idx_scan from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx';
+SELECT idx_scan from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx';
+
+-- check when multiple indexes on the same set of columns
+CREATE index test_last_scan_part_idx2_bis on test_last_scan_part(idx_col2);
+BEGIN;
+SET LOCAL enable_seqscan TO off;
+SET LOCAL enable_indexscan TO on;
+SET LOCAL enable_bitmapscan TO off;
+SELECT count(*) FROM test_last_scan_part WHERE idx_col2 = 1;
+SELECT count(*) FROM test_last_scan_part2 WHERE idx_col2 = 1;
+SELECT pg_stat_force_next_flush();
+COMMIT;
+
+SELECT idx_scan AS idx_scan_part1_idx_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx' \gset
+SELECT idx_scan AS idx_scan_part2_idx_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx' \gset
+SELECT idx_scan AS idx_scan_part1_idx1_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx1' \gset
+SELECT idx_scan AS idx_scan_part2_idx1_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx1' \gset
+
+ALTER TABLE test_last_scan_part ALTER COLUMN idx_col2 TYPE int;
+
+SELECT idx_scan = :idx_scan_part1_idx_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx';
+SELECT idx_scan = :idx_scan_part2_idx_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx';
+SELECT idx_scan = :idx_scan_part1_idx1_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part1_idx_col2_idx1';
+SELECT idx_scan = :idx_scan_part2_idx1_before from pg_stat_all_indexes WHERE indexrelname = 'test_last_scan_part2_idx_col2_idx1';
+
 -- check that the stats in pg_stat_all_indexes are reset
 SELECT pg_stat_reset_single_table_counters('test_last_scan_pkey'::regclass);
 
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 5290b91e83e..4967a5f515e 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2663,6 +2663,8 @@ SYSTEM_INFO
 SampleScan
 SampleScanGetSampleSize_function
 SampleScanState
+SavedIndexStatsEntry
+SavedIndexStatsHashEntry
 SavedTransactionCharacteristics
 ScalarArrayOpExpr
 ScalarArrayOpExprHashEntry
-- 
2.34.1

