Preserve index stats during ALTER TABLE ... TYPE ...

Started by Bertrand Drouvot3 months ago14 messages
#1Bertrand Drouvot
bertranddrouvot.pg@gmail.com
1 attachment(s)

Hi hackers,

while working on relfilenode statistics [1]/messages/by-id/ZlGYokUIlERemvpB@ip-10-97-1-34.eu-west-3.compute.internal, I observed that index stats
are not preserved during ALTER TABLE ... TYPE ....

Indeed, for example:

postgres=# CREATE TABLE test_tab(a int primary key, b int, c int);
CREATE INDEX test_b_idx ON test_tab(b);
-- Force an index scan on test_b_idx
SELECT * FROM test_tab WHERE b = 2;
CREATE TABLE
CREATE INDEX
a | b | c
---+---+---
(0 rows)

postgres=# select indexrelname, idx_scan from pg_stat_all_indexes where indexrelname in ('test_b_idx', 'test_tab_pkey');
indexrelname | idx_scan
---------------+----------
test_tab_pkey | 0
test_b_idx | 1
(2 rows)

postgres=# select idx_scan from pg_stat_all_tables where relname = 'test_tab';
idx_scan
----------
1
(1 row)

postgres=# ALTER TABLE test_tab ALTER COLUMN b TYPE int;
ALTER TABLE

postgres=# select indexrelname, idx_scan from pg_stat_all_indexes where indexrelname in ('test_b_idx', 'test_tab_pkey');
indexrelname | idx_scan
---------------+----------
test_tab_pkey | 0
test_b_idx | 0
(2 rows)

postgres=# select idx_scan from pg_stat_all_tables where relname = 'test_tab';
idx_scan
----------
0
(1 row)

During ALTER TABLE ... TYPE ... on an indexed column, a new index is created and
the old one is dropped.

As you can see, the index stats (linked to the column that has been altered) are
not preserved. I think that they should be preserved (like a REINDEX does).

Note that the issue is the same if a rewrite is involved (ALTER TABLE test_tab
ALTER COLUMN b TYPE bigint).

PFA, a patch to $SUBJECT.

A few remarks:

- We can not use pgstat_copy_relation_stats() because the old index is dropped
before the new one is created, so the patch adds a new PgStat_StatTabEntry
pointer in AlteredTableInfo.

- 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. I think this is
acceptable since the accumulated stats represent the historical usage patterns we
want to maintain.

- The patch adds a few tests to cover multiple scenarios (with and without
rewrites, and indexes with and without associated constraints).

- I'm not familiar with this area of the code, the patch is an attempt to fix
the issue, maybe there is a more elegant way to solve it.

- The issue exists back to v13, but I'm not sure that's serious enough for
back-patching.

Looking forward to your feedback,

Regards,

[1]: /messages/by-id/ZlGYokUIlERemvpB@ip-10-97-1-34.eu-west-3.compute.internal

--
Bertrand Drouvot
PostgreSQL Contributors Team
RDS Open Source Databases
Amazon Web Services: https://aws.amazon.com

Attachments:

v1-0001-Preserve-index-stats-during-ALTER-TABLE-.-TYPE.patchtext/x-diff; charset=us-asciiDownload
From b238d9f8660c360e920f98eb6be586d3a2dd15b3 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 v1] 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 adds a new PgStat_StatTabEntry
pointer in AlteredTableInfo.

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    | 60 +++++++++++++++++++++++++
 src/test/regress/expected/stats.out | 70 ++++++++++++++++++++++++++++-
 src/test/regress/sql/stats.sql      | 31 ++++++++++++-
 3 files changed, 157 insertions(+), 4 deletions(-)
  30.6% src/backend/commands/
  39.8% src/test/regress/expected/
  29.5% src/test/regress/sql/

diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fc89352b661..5353bbc3ec1 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,6 +209,7 @@ typedef struct AlteredTableInfo
 	char	   *clusterOnIndex; /* index to use for CLUSTER */
 	List	   *changedStatisticsOids;	/* OIDs of statistics to rebuild */
 	List	   *changedStatisticsDefs;	/* string definitions of same */
+	PgStat_StatTabEntry *savedIndexStats;	/* stats from old index, if any */
 } AlteredTableInfo;
 
 /* Struct describing one new constraint to check in Phase 3 scan */
@@ -9637,6 +9639,36 @@ ATExecAddIndex(AlteredTableInfo *tab, Relation rel,
 		index_close(irel, NoLock);
 	}
 
+	/* restore the statistics from the old index */
+	if (tab->savedIndexStats != NULL)
+	{
+		PgStatShared_Relation *shstats;
+		PgStat_EntryRef *entry_ref;
+		Relation	irel = index_open(address.objectId, NoLock);
+
+		/* 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,
+												irel->rd_id,
+												false);
+
+		if (entry_ref != NULL)
+		{
+			shstats = (PgStatShared_Relation *) entry_ref->shared_stats;
+
+			/* copy the saved statistics */
+			shstats->stats = *(tab->savedIndexStats);
+
+			pgstat_unlock_entry(entry_ref);
+		}
+
+		pfree(tab->savedIndexStats);
+		tab->savedIndexStats = NULL;
+
+		index_close(irel, NoLock);
+	}
+
 	return address;
 }
 
@@ -15664,9 +15696,22 @@ ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId, char *cmd,
 		{
 			IndexStmt  *stmt = (IndexStmt *) stm;
 			AlterTableCmd *newcmd;
+			PgStat_StatTabEntry *src_stats = pgstat_fetch_stat_tabentry(oldId);
+
+			/* keep the index's statistics */
+			if (src_stats)
+			{
+				tab->savedIndexStats = (PgStat_StatTabEntry *) palloc(sizeof(PgStat_StatTabEntry));
+				memcpy(tab->savedIndexStats, src_stats, sizeof(PgStat_StatTabEntry));
+			}
+			else
+			{
+				tab->savedIndexStats = NULL;
+			}
 
 			if (!rewrite)
 				TryReuseIndex(oldId, stmt);
+
 			stmt->reset_default_tblspc = true;
 			/* keep the index's comment */
 			stmt->idxcomment = GetComment(oldId, RelationRelationId, 0);
@@ -15690,12 +15735,27 @@ ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId, char *cmd,
 				{
 					IndexStmt  *indstmt;
 					Oid			indoid;
+					PgStat_StatTabEntry *src_stats;
 
 					indstmt = castNode(IndexStmt, cmd->def);
 					indoid = get_constraint_index(oldId);
 
+					/* keep the index's statistics */
+					src_stats = pgstat_fetch_stat_tabentry(indoid);
+
+					if (src_stats)
+					{
+						tab->savedIndexStats = (PgStat_StatTabEntry *) palloc(sizeof(PgStat_StatTabEntry));
+						memcpy(tab->savedIndexStats, src_stats, sizeof(PgStat_StatTabEntry));
+					}
+					else
+					{
+						tab->savedIndexStats = NULL;
+					}
+
 					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..7864a8f44b8 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,71 @@ 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 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..eaf359aeba1 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,32 @@ 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 that the stats in pg_stat_all_indexes are reset
 SELECT pg_stat_reset_single_table_counters('test_last_scan_pkey'::regclass);
 
-- 
2.34.1

#2Sami Imseih
samimseih@gmail.com
In reply to: Bertrand Drouvot (#1)
Re: Preserve index stats during ALTER TABLE ... TYPE ...

Hi,

Thanks for raising this issue and for the patch!

As you can see, the index stats (linked to the column that has been altered) are
not preserved. I think that they should be preserved (like a REINDEX does).

I agree.

- We can not use pgstat_copy_relation_stats() because the old index is dropped
before the new one is created, so the patch adds a new PgStat_StatTabEntry
pointer in AlteredTableInfo.

I wonder if it will be good to have a pgstat_save_relation_stats() routine that
gets called in all code paths that will need to restore the stats. This way
pgstat_copy_relation_stats can also be used. This will be cleaner than code
paths that need this having to deal with pgstat_fetch_stat_tabentry?

Have not thought this thoroughly, but it seems like it might be a more general
approach.

- The patch adds a few tests to cover multiple scenarios (with and without
rewrites, and indexes with and without associated constraints).

The current patch does not work for partitioned tables because
the "oldId" is that of the parent index which has no stats. So we
are just copying zeros to the new entry.

```
DROP TABLE test_tab;
CREATE TABLE test_tab(a int primary key, b int, c int) partition by range (a);
CREATE TABLE test_tab_p1 PARTITION OF test_tab
FOR VALUES FROM (0) TO (100);
CREATE TABLE test_tab_p2 PARTITION OF test_tab
FOR VALUES FROM (100) TO (200);
CREATE INDEX test_b_idx ON test_tab(b);
-- Force an index scan on test_b_idx
SELECT * FROM test_tab WHERE b = 2;

test=# select indexrelname, idx_scan from pg_stat_all_indexes where
indexrelname like '%test%';
indexrelname | idx_scan
-------------------+----------
test_tab_p1_pkey | 0
test_tab_p2_pkey | 0
test_tab_p1_b_idx | 1
test_tab_p2_b_idx | 1
(4 rows)

test=# ALTER TABLE test_tab ALTER COLUMN b TYPE int;
ALTER TABLE
test=# select indexrelname, idx_scan from pg_stat_all_indexes where
indexrelname like '%test%';
indexrelname | idx_scan
-------------------+----------
test_tab_p1_pkey | 0
test_tab_p2_pkey | 0
test_tab_p1_b_idx | 0
test_tab_p2_b_idx | 0
(4 rows)

```

Regards,

--
Sami Imseih
Amazon Web Services (AWS)

#3Bertrand Drouvot
bertranddrouvot.pg@gmail.com
In reply to: Sami Imseih (#2)
Re: Preserve index stats during ALTER TABLE ... TYPE ...

Hi,

On Fri, Oct 10, 2025 at 07:37:59AM -0500, Sami Imseih wrote:

Hi,

Thanks for raising this issue and for the patch!

Thanks for looking at it!

As you can see, the index stats (linked to the column that has been altered) are
not preserved. I think that they should be preserved (like a REINDEX does).

I agree.

- We can not use pgstat_copy_relation_stats() because the old index is dropped
before the new one is created, so the patch adds a new PgStat_StatTabEntry
pointer in AlteredTableInfo.

I wonder if it will be good to have a pgstat_save_relation_stats() routine that
gets called in all code paths that will need to restore the stats. This way
pgstat_copy_relation_stats can also be used. This will be cleaner than code
paths that need this having to deal with pgstat_fetch_stat_tabentry?

pgstat_copy_relation_stats() needs 2 Relation, I'm not sure how a new
pgstat_save_relation_stats() could help using pgstat_copy_relation_stats()
here.

The current patch does not work for partitioned tables because
the "oldId" is that of the parent index which has no stats. So we
are just copying zeros to the new entry.

Doh, of course. I've spend some time on it and now have something working.

The idea is to:

- store a List of savedIndexStats. The savedIndexStats struct would get the
PgStat_StatTabEntry + all the information needed to be able to use
CompareIndexInfo() when restoring the stats (so that we can restore each PgStat_StatTabEntry
in the right index).

- Iterate on all the indexes and populate this new list in AlteredTableInfo in
ATPostAlterTypeParse().

- Iterate on all the indexes and use the list above and CompareIndexInfo() to
restore the stats in ATExecAddIndex().

Will polish and share next week.

Regards,

--
Bertrand Drouvot
PostgreSQL Contributors Team
RDS Open Source Databases
Amazon Web Services: https://aws.amazon.com

#4Bertrand Drouvot
bertranddrouvot.pg@gmail.com
In reply to: Bertrand Drouvot (#3)
1 attachment(s)
Re: Preserve index stats during ALTER TABLE ... TYPE ...

Hi,

On Fri, Oct 10, 2025 at 03:52:58PM +0000, Bertrand Drouvot wrote:

The idea is to:

- store a List of savedIndexStats. The savedIndexStats struct would get the
PgStat_StatTabEntry + all the information needed to be able to use
CompareIndexInfo() when restoring the stats (so that we can restore each PgStat_StatTabEntry
in the right index).

- Iterate on all the indexes and populate this new list in AlteredTableInfo in
ATPostAlterTypeParse().

- Iterate on all the indexes and use the list above and CompareIndexInfo() to
restore the stats in ATExecAddIndex().

Will polish and share next week.

PFA v2 that handles partitioned tables/indexes.

A few words about its design:

I started by just creating a list of of PgStat_StatTabEntry + all the information
needed to be able to use CompareIndexInfo() when restoring the stats.

But that lead to O(P^2) when restoring the stats (for each new partition index
(P), it was scanning through all saved ones (P)), and could be non negligible.

For example, with 20K partitions and no rewrite:

-   89.64%     0.00%  postgres  postgres           [.] ATController
   - ATController
      - 79.23% ATRewriteCatalogs
         - 64.43% ATExecCmd
            - 56.53% ATExecAddIndex
               + 46.34% DefineIndex 
               + 10.19% ATExecAddIndex_RestoreStats
            + 5.29% ATExecAlterColumnType
            + 2.60% CommandCounterIncrement
         + 11.91% ATPostAlterTypeCleanup
         + 2.77% relation_open
      + 8.79% ATPrepCmd
      + 1.62% ATRewriteTables

We can see ATExecAddIndex_RestoreStats was not negligible at that time. That was
less of an issue when rewrite was involved:

-   89.35%     0.00%  postgres  postgres           [.] ATController
   - ATController
      + 51.24% ATRewriteTables
      - 33.89% ATRewriteCatalogs
         - 26.98% ATExecCmd
            - 22.16% ATExecAddIndex
               + 17.44% DefineIndex
                 4.71% ATExecAddIndex_RestoreStats
            + 3.58% ATExecAlterColumnType
            + 1.24% CommandCounterIncrement
         + 5.53% ATPostAlterTypeCleanup
         + 1.32% relation_open
      + 4.22% ATPrepCmd

So I added a hash table keyed by partition table OID, with each entry containing
a list of saved index stats for that partition. This way, restoration is now O(P)
instead of O(P�).

With the attached, the perf profile (again 20K partitions and no rewrite) is:

-   89.06%     0.00%  postgres  postgres           [.] ATController
   - ATController
      - 77.65% ATRewriteCatalogs
         - 61.57% ATExecCmd
            - 52.63% ATExecAddIndex
               + 51.16% DefineIndex
               + 1.47% ATExecAddIndex_RestoreStats
            + 5.96% ATExecAlterColumnType
            + 2.98% CommandCounterIncrement
         + 13.26% ATPostAlterTypeCleanup
         + 2.73% relation_open
      + 9.59% ATPrepCmd
      + 1.82% ATRewriteTables

As we can see, the ATExecAddIndex_RestoreStats impact is now around 1.5%, which
I think is acceptable given the benefit of preserving historical statistics.

Additional remarks:

- I initially tried using only CompareIndexInfo() for matching, but this fails
when multiple indexes exist on the same column(s). So I added the index name as
the primary matching check with CompareIndexInfo() kept as a sanity check (I think
that it could be removed).

- The new resources are allocated in the PortalContext, it's a short lived one so
the patch does not free them explicitly.

- Much more tests have been added as compared to v1.

Regards,

--
Bertrand Drouvot
PostgreSQL Contributors Team
RDS Open Source Databases
Amazon Web Services: https://aws.amazon.com

Attachments:

v2-0001-Preserve-index-stats-during-ALTER-TABLE-.-TYPE.patchtext/x-diff; charset=us-asciiDownload
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

#5Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#2)
Re: Preserve index stats during ALTER TABLE ... TYPE ...

On Fri, Oct 10, 2025 at 07:37:59AM -0500, Sami Imseih wrote:

As you can see, the index stats (linked to the column that has been altered) are
not preserved. I think that they should be preserved (like a REINDEX does).

I agree.

Hmm. Why should it be always OK to preserve the stats of an index
when one of its attributes is changed so as a relation is rewritten?
A REINDEX (including CONCURRENTLY), while it initiates a rewrite of
the index, does not change the definition of the underlying index. A
type alteration, on the contrary, does. Hence, the planner may decide
to treat a given index differently (doesn't it? Tuple width or
whole-row references come into mind). Keeping the past stats may
actually lead to confusing conclusions when overlapping them with some
of the new number generated under the new type? Could there be more
benefits in always resetting them as we do now?

Any thoughts from others?
--
Michael

#6Tom Lane
tgl@sss.pgh.pa.us
In reply to: Michael Paquier (#5)
Re: Preserve index stats during ALTER TABLE ... TYPE ...

Michael Paquier <michael@paquier.xyz> writes:

On Fri, Oct 10, 2025 at 07:37:59AM -0500, Sami Imseih wrote:
As you can see, the index stats (linked to the column that has been altered) are
not preserved. I think that they should be preserved (like a REINDEX does).

Hmm. Why should it be always OK to preserve the stats of an index
when one of its attributes is changed so as a relation is rewritten?

Right offhand, this proposal seems utterly unsafe, to the point of
maybe introducing security-grade bugs. I see that the patch compares
opfamilies but that seems insufficient, since "same opfamily" does not
mean "binary compatible". We could easily be restoring stats whose
binary content is incompatible with the new column type.

regards, tom lane

#7Michael Paquier
michael@paquier.xyz
In reply to: Tom Lane (#6)
Re: Preserve index stats during ALTER TABLE ... TYPE ...

On Thu, Oct 16, 2025 at 01:38:19AM -0400, Tom Lane wrote:

Michael Paquier <michael@paquier.xyz> writes:

Hmm. Why should it be always OK to preserve the stats of an index
when one of its attributes is changed so as a relation is rewritten?

Right offhand, this proposal seems utterly unsafe, to the point of
maybe introducing security-grade bugs. I see that the patch compares
opfamilies but that seems insufficient, since "same opfamily" does not
mean "binary compatible". We could easily be restoring stats whose
binary content is incompatible with the new column type.

The point of the thread is about copying the aggregated numbers stored
in pgstats. These numbers have a fixed size, for contents in
PgStat_StatTabEntry. The point of the patch is about copying these
entries in the pgstats hash table across rewrites, so I am not sure to
follow your argument.

My point was slightly different: I am questioning if a reset does not
make more sense in most cases as an attribute type change may cause
the planner to choose a different Path, making the new stats generated
leading to decisions that are inconsistent when aggregated with the
numbers copied across the rewrites.
--
Michael

#8Bertrand Drouvot
bertranddrouvot.pg@gmail.com
In reply to: Michael Paquier (#5)
Re: Preserve index stats during ALTER TABLE ... TYPE ...

Hi,

On Thu, Oct 16, 2025 at 02:06:01PM +0900, Michael Paquier wrote:

On Fri, Oct 10, 2025 at 07:37:59AM -0500, Sami Imseih wrote:

As you can see, the index stats (linked to the column that has been altered) are
not preserved. I think that they should be preserved (like a REINDEX does).

I agree.

Hmm. Why should it be always OK to preserve the stats of an index
when one of its attributes is changed so as a relation is rewritten?

I agree that in this case the stats (namely idx_scan, idx_tup_read and
idx_tup_fetch) would represent a mixture of two different index structures.

Hence, the planner may decide
to treat a given index differently (doesn't it? Tuple width or
whole-row references come into mind).

I do think so, yes.

Keeping the past stats may
actually lead to confusing conclusions when overlapping them with some
of the new number generated under the new type? Could there be more
benefits in always resetting them as we do now?

The issue is that these stats are also exposed at the table level
(idx_scan, last_idx_scan, idx_tup_fetch in pg_stat_all_tables).
That's valuable information for understanding table access patterns
that is currently lost.

It would make more sense to reset the index stats if table level
stats were tracked independently from the underlying index stats.

Also, users already have pg_stat_reset_single_table_counters() if
they want to reset the index stats. This patch gives users the choice to preserve
stats or reset them. Currently, they have no choice: the stats are
always lost.

Also, when the rewrite also occurs on the table (type changes) a stat like
seq_scan is preserved (because the table Oid does not change, only the
relfilenode does). Why would it be ok to preserve seq_scan and not idx_scan?

Regards,

--
Bertrand Drouvot
PostgreSQL Contributors Team
RDS Open Source Databases
Amazon Web Services: https://aws.amazon.com

#9Bertrand Drouvot
bertranddrouvot.pg@gmail.com
In reply to: Michael Paquier (#7)
Re: Preserve index stats during ALTER TABLE ... TYPE ...

Hi,

On Thu, Oct 16, 2025 at 03:09:24PM +0900, Michael Paquier wrote:

On Thu, Oct 16, 2025 at 01:38:19AM -0400, Tom Lane wrote:

Michael Paquier <michael@paquier.xyz> writes:

Hmm. Why should it be always OK to preserve the stats of an index
when one of its attributes is changed so as a relation is rewritten?

Right offhand, this proposal seems utterly unsafe, to the point of
maybe introducing security-grade bugs. I see that the patch compares
opfamilies but that seems insufficient, since "same opfamily" does not
mean "binary compatible". We could easily be restoring stats whose
binary content is incompatible with the new column type.

The point of the thread is about copying the aggregated numbers stored
in pgstats. These numbers have a fixed size, for contents in
PgStat_StatTabEntry. The point of the patch is about copying these
entries in the pgstats hash table across rewrites, so I am not sure to
follow your argument.

Same here.

My point was slightly different: I am questioning if a reset does not
make more sense in most cases as an attribute type change may cause
the planner to choose a different Path, making the new stats generated
leading to decisions that are inconsistent when aggregated with the
numbers copied across the rewrites.

See my reply in [1]/messages/by-id/aPCVvWZjvvC1ZO78@ip-10-97-1-34.eu-west-3.compute.internal.

[1]: /messages/by-id/aPCVvWZjvvC1ZO78@ip-10-97-1-34.eu-west-3.compute.internal

Regards,

--
Bertrand Drouvot
PostgreSQL Contributors Team
RDS Open Source Databases
Amazon Web Services: https://aws.amazon.com

#10Sami Imseih
samimseih@gmail.com
In reply to: Bertrand Drouvot (#8)
Re: Preserve index stats during ALTER TABLE ... TYPE ...

Hence, the planner may decide
to treat a given index differently (doesn't it? Tuple width or
whole-row references come into mind).

I do think so, yes.

The planner may also treat the index differently after
ALTER INDEX ... ALTER COLUMN ... SET STATISTICS ...; ANALYZE,
but in

I am not sure the planner aspect is a good reason to not preserve
cumulative stats for an index.

In the case where the table is not rewritten, Isn't that a clear case in
which stats should be preserved?

Keeping the past stats may
actually lead to confusing conclusions when overlapping them with some
of the new number generated under the new type? Could there be more
benefits in always resetting them as we do now?

The issue is that these stats are also exposed at the table level
(idx_scan, last_idx_scan, idx_tup_fetch in pg_stat_all_tables).
That's valuable information for understanding table access patterns
that is currently lost.

It would make more sense to reset the index stats if table level
stats were tracked independently from the underlying index stats.

This sounds like a good enhancement. This will also take care of the
index stats being preserved on a table in the case an index is dropped.

But that means we will need some new fields to aggregate index access
in PgStat_StatTabEntry, which may not be so good in
terms of memory and performance.

--
Sami

#11Bertrand Drouvot
bertranddrouvot.pg@gmail.com
In reply to: Sami Imseih (#10)
Re: Preserve index stats during ALTER TABLE ... TYPE ...

Hi,

On Thu, Oct 16, 2025 at 03:39:59PM -0500, Sami Imseih wrote:

The issue is that these stats are also exposed at the table level
(idx_scan, last_idx_scan, idx_tup_fetch in pg_stat_all_tables).
That's valuable information for understanding table access patterns
that is currently lost.

It would make more sense to reset the index stats if table level
stats were tracked independently from the underlying index stats.

This sounds like a good enhancement. This will also take care of the
index stats being preserved on a table in the case an index is dropped.

But that means we will need some new fields to aggregate index access
in PgStat_StatTabEntry,

Yeah, we'd need to add say:

total_idx_numscans
idx_lastscan
total_tuples_idx_fetched

to get rid of the pg_stat_get_*() calls on the indexes in pg_stat_all_tables().

That way we don't need to worry about copying the statistics during the alter
command.

which may not be so good in
terms of memory and performance.

Performance:

We could populate those fields at the "table" level when we flush the
index stats (similar to what we do currently for some tables stats that populate
some database stats at flush time). That would avoid double incrementing.

Memory:

Adding those 3 extra fields to PgStat_StatTabEntry does not worry me that
much given the number of fields already in PgStat_StatTabEntry.

The thing that is not ideal is that as PgStat_StatTabEntry is currently used
for both tables and indexes stats then we'll add fields that would be only used
for the table case. But that's already the case for some other fields and this
will be "solved" once we'll resume working on "Split index and table statistics
into different types of stats" ([1]/messages/by-id/f572abe7-a1bb-e13b-48c7-2ca150546822@gmail.com) means after relfilenode stats ([2]/messages/by-id/ZlGYokUIlERemvpB@ip-10-97-1-34.eu-west-3.compute.internal) are
implemented (I'm currently working on it).

I prefer this approach as compared to the current proposal (copying the stats
during the alter command). Thoughts?

[1]: /messages/by-id/f572abe7-a1bb-e13b-48c7-2ca150546822@gmail.com
[2]: /messages/by-id/ZlGYokUIlERemvpB@ip-10-97-1-34.eu-west-3.compute.internal

Regards,

--
Bertrand Drouvot
PostgreSQL Contributors Team
RDS Open Source Databases
Amazon Web Services: https://aws.amazon.com

#12Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#10)
Re: Preserve index stats during ALTER TABLE ... TYPE ...

On Thu, Oct 16, 2025 at 03:39:59PM -0500, Sami Imseih wrote:

This sounds like a good enhancement. This will also take care of the
index stats being preserved on a table in the case an index is dropped.

But that means we will need some new fields to aggregate index access
in PgStat_StatTabEntry, which may not be so good in
terms of memory and performance.

Putting aside the should-we-preserve-index-stats-on-relation-rewrite
problem for a minute.

FWIW, I think that aiming at less memory per entry is better in the
long term, because we are that it's going to be cheaper. One thing
that's been itching me quite a bit with pgstat_relation.c lately is
that PgStat_StatTabEntry is being used by both tables and indexes, but
we don't care about the most of its fields for indexes. The ones I
can see as used for indexes are:
- blocks_hit
- blocks_fetched
- reset_time
- tuples_returned
- tuples_fetched
- lastscan
- numscan

This means that we don't care about the business around HOT, vacuum
(we could care about the vacuum timings for individual index
cleanups), analyze, live/dead tuples.

It may be time to do a clean split, even if the current state of
business in pgstat.h is a kind of historical thing.
--
Michael

#13Bertrand Drouvot
bertranddrouvot.pg@gmail.com
In reply to: Michael Paquier (#12)
Re: Preserve index stats during ALTER TABLE ... TYPE ...

Hi,

On Mon, Oct 20, 2025 at 10:53:37AM +0900, Michael Paquier wrote:

On Thu, Oct 16, 2025 at 03:39:59PM -0500, Sami Imseih wrote:

This sounds like a good enhancement. This will also take care of the
index stats being preserved on a table in the case an index is dropped.

But that means we will need some new fields to aggregate index access
in PgStat_StatTabEntry, which may not be so good in
terms of memory and performance.

Putting aside the should-we-preserve-index-stats-on-relation-rewrite
problem for a minute.

Okay.

FWIW, I think that aiming at less memory per entry is better in the
long term, because we are that it's going to be cheaper. One thing
that's been itching me quite a bit with pgstat_relation.c lately is
that PgStat_StatTabEntry is being used by both tables and indexes, but
we don't care about the most of its fields for indexes. The ones I
can see as used for indexes are:
- blocks_hit
- blocks_fetched
- reset_time
- tuples_returned
- tuples_fetched
- lastscan
- numscan

This means that we don't care about the business around HOT, vacuum
(we could care about the vacuum timings for individual index
cleanups), analyze, live/dead tuples.

Exactly, and that's one of the reasons why the "Split index and table statistics
into different types of stats" work ([1]/messages/by-id/f572abe7-a1bb-e13b-48c7-2ca150546822@gmail.com) started.

It may be time to do a clean split, even if the current state of
business in pgstat.h is a kind of historical thing.

Yeah, but maybe it would make more sense to look at this once the relfilenode
stats one ([2]/messages/by-id/ZlGYokUIlERemvpB@ip-10-97-1-34.eu-west-3.compute.internal) is done? (see [3]/messages/by-id/20230105002733.ealhzubjaiqis6ua@awork3.anarazel.de).

[1]: /messages/by-id/f572abe7-a1bb-e13b-48c7-2ca150546822@gmail.com
[2]: /messages/by-id/ZlGYokUIlERemvpB@ip-10-97-1-34.eu-west-3.compute.internal
[3]: /messages/by-id/20230105002733.ealhzubjaiqis6ua@awork3.anarazel.de

Regards,

--
Bertrand Drouvot
PostgreSQL Contributors Team
RDS Open Source Databases
Amazon Web Services: https://aws.amazon.com

#14Michael Paquier
michael@paquier.xyz
In reply to: Bertrand Drouvot (#13)
Re: Preserve index stats during ALTER TABLE ... TYPE ...

On Mon, Oct 20, 2025 at 06:22:00AM +0000, Bertrand Drouvot wrote:

On Mon, Oct 20, 2025 at 10:53:37AM +0900, Michael Paquier wrote:

It may be time to do a clean split, even if the current state of
business in pgstat.h is a kind of historical thing.

Yeah, but maybe it would make more sense to look at this once the relfilenode
stats one ([2]) is done? (see [3]).

Ah, right, that rings a bell now. So as you mention the history of
events is that the refactoring related to relfilenodes should happen
first. Maybe we should just focus on that for now, then. TBH, I
cannot get excited for the moment in making tablecmds.c more complex
regarding its stats handling on rewrite without knowing if it could
become actually simpler. This is also assuming that we actually do
something about it, at the end, which is not something I am sure is
worth the extra complications in ALTER TABLE. And perhaps we could
get some nice side effects of the other discussion for what you are
proposing (first answer points to no, but it's hard to say as well if
that would be a definitive answer).
--
Michael