From a9540bbfb59f8d957b49ed6bdbdb6098f1fe4e45 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Sun, 15 Mar 2026 10:32:39 -0400
Subject: [PATCH 08/12] Global temporary tables: utility command restrictions

Restrict utility commands that are unsafe for global temporary tables.

CLUSTER and REINDEX are blocked because they assign a new relfilenode
in the shared catalog via RelationSetNewRelfilenumber, which would
desynchronize per-session storage mappings.

CREATE INDEX CONCURRENTLY and REINDEX CONCURRENTLY fall back to their
non-concurrent equivalents, since the multi-transaction protocol would
operate on shared catalog state without matching per-session data.
This mirrors the existing behavior for regular temporary tables.

VACUUM on a GTT skips the vacuum phase -- GTT data lives in
per-session local buffers with no shared freeze state, so the vacuum
machinery (which assumes valid relfrozenxid/relminmxid) cannot safely
process it.  The skip is reported at INFO for a standalone VACUUM and
at DEBUG1 when the command also requests ANALYZE (to avoid spamming
the user during VACUUM (ANALYZE), which is usually what they actually
wanted).  VACUUM (ANALYZE) still runs the analyze phase on the
relation.
---
 src/backend/catalog/index.c              | 12 ++++
 src/backend/commands/indexcmds.c         | 77 +++++++++++++++++++++---
 src/backend/commands/repack.c            | 45 ++++++++++++++
 src/backend/commands/statscmds.c         | 13 ++++
 src/backend/commands/tablecmds.c         | 19 +++++-
 src/backend/commands/vacuum.c            | 49 +++++++++++++++
 src/backend/parser/parse_utilcmd.c       | 55 +++++++++++------
 src/backend/statistics/attribute_stats.c | 13 ++++
 src/backend/statistics/relation_stats.c  | 10 +++
 src/backend/statistics/stat_utils.c      | 19 ++++++
 src/include/statistics/stat_utils.h      |  1 +
 11 files changed, 284 insertions(+), 29 deletions(-)

diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index cff8d39fb44..c234cfb8398 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -52,6 +52,7 @@
 #include "catalog/pg_trigger.h"
 #include "catalog/pg_type.h"
 #include "catalog/storage.h"
+#include "catalog/storage_gtt.h"
 #include "catalog/storage_xlog.h"
 #include "commands/event_trigger.h"
 #include "commands/progress.h"
@@ -3801,6 +3802,17 @@ reindex_index(const ReindexStmt *stmt, Oid indexId,
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("cannot reindex temporary tables of other sessions")));
 
+	/*
+	 * Don't allow reindex on global temporary tables.  REINDEX assigns a new
+	 * relfilenode in the shared catalog via RelationSetNewRelfilenumber,
+	 * which would desynchronize per-session storage mappings in other
+	 * sessions.  GTT indexes are rebuilt lazily per-session as needed.
+	 */
+	if (RelationIsGlobalTemp(iRel))
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("cannot reindex global temporary tables"));
+
 	/*
 	 * Don't allow reindex of an invalid index on TOAST table.  This is a
 	 * leftover from a failed REINDEX CONCURRENTLY, and if rebuilt it would
diff --git a/src/backend/commands/indexcmds.c b/src/backend/commands/indexcmds.c
index 9ab74c8df0a..0157a8deee4 100644
--- a/src/backend/commands/indexcmds.c
+++ b/src/backend/commands/indexcmds.c
@@ -38,6 +38,7 @@
 #include "catalog/pg_opclass.h"
 #include "catalog/pg_tablespace.h"
 #include "catalog/pg_type.h"
+#include "catalog/storage_gtt.h"
 #include "commands/comment.h"
 #include "commands/defrem.h"
 #include "commands/event_trigger.h"
@@ -556,6 +557,7 @@ DefineIndex(ParseState *pstate,
 			bool quiet)
 {
 	bool		concurrent;
+	char		persistence;
 	char	   *indexRelationName;
 	char	   *accessMethodName;
 	Oid		   *typeIds;
@@ -615,11 +617,41 @@ DefineIndex(ParseState *pstate,
 	 * there's no harm in grabbing a stronger lock, and a non-concurrent DROP
 	 * is more efficient.  Do this before any use of the concurrent option is
 	 * done.
+	 *
+	 * Similarly, global temporary tables have per-session local storage that
+	 * other backends cannot see, so concurrent builds are neither necessary
+	 * nor safe (the multi-transaction protocol would operate on the shared
+	 * catalog without matching per-session data).
 	 */
-	if (stmt->concurrent && get_rel_persistence(tableId) != RELPERSISTENCE_TEMP)
-		concurrent = true;
-	else
-		concurrent = false;
+	persistence = get_rel_persistence(tableId);
+
+	/*
+	 * Tell the user when we've silently downgraded a CONCURRENTLY request for
+	 * a global temporary table.  Scripts that rely on CONCURRENTLY for
+	 * minimum downtime would otherwise have no way to know the fallback
+	 * happened.
+	 */
+	if (stmt->concurrent && persistence == RELPERSISTENCE_GLOBAL_TEMP)
+		ereport(NOTICE,
+				errmsg("CREATE INDEX CONCURRENTLY is not supported for global temporary tables"),
+				errdetail("Falling back to a non-concurrent build."));
+
+	concurrent = stmt->concurrent &&
+		persistence != RELPERSISTENCE_TEMP &&
+		persistence != RELPERSISTENCE_GLOBAL_TEMP;
+
+	/*
+	 * For a direct CREATE INDEX on a global temporary table, refuse the
+	 * command if any peer session has live per-session data.  A new UNIQUE
+	 * index on a column with cross-session duplicates would later make the
+	 * peer's data inaccessible (its lazy build would fail with a
+	 * duplicate-key error); even a non-unique index changes planner choices
+	 * in surprising ways for the peer.  ALTER TABLE ADD CONSTRAINT reaches
+	 * DefineIndex with is_alter_table = true, and ATController has already
+	 * done the same check.
+	 */
+	if (!is_alter_table && persistence == RELPERSISTENCE_GLOBAL_TEMP)
+		GttCheckAlterable(tableId);
 
 	/*
 	 * Start progress report.  If we're building a partition, this was already
@@ -2981,12 +3013,19 @@ ReindexIndex(const ReindexStmt *stmt, const ReindexParams *params, bool isTopLev
 	if (relkind == RELKIND_PARTITIONED_INDEX)
 		ReindexPartitions(stmt, indOid, params, isTopLevel);
 	else if ((params->options & REINDEXOPT_CONCURRENTLY) != 0 &&
-			 persistence != RELPERSISTENCE_TEMP)
+			 persistence != RELPERSISTENCE_TEMP &&
+			 persistence != RELPERSISTENCE_GLOBAL_TEMP)
 		ReindexRelationConcurrently(stmt, indOid, params);
 	else
 	{
 		ReindexParams newparams = *params;
 
+		if ((params->options & REINDEXOPT_CONCURRENTLY) != 0 &&
+			persistence == RELPERSISTENCE_GLOBAL_TEMP)
+			ereport(NOTICE,
+					errmsg("REINDEX CONCURRENTLY is not supported for global temporary tables"),
+					errdetail("Falling back to a non-concurrent reindex."));
+
 		newparams.options |= REINDEXOPT_REPORT_PROGRESS;
 		reindex_index(stmt, indOid, false, persistence, &newparams);
 	}
@@ -3077,6 +3116,7 @@ static Oid
 ReindexTable(const ReindexStmt *stmt, const ReindexParams *params, bool isTopLevel)
 {
 	Oid			heapOid;
+	char		persistence;
 	bool		result;
 	const RangeVar *relation = stmt->relation;
 
@@ -3094,10 +3134,13 @@ ReindexTable(const ReindexStmt *stmt, const ReindexParams *params, bool isTopLev
 									   0,
 									   RangeVarCallbackMaintainsTable, NULL);
 
+	persistence = get_rel_persistence(heapOid);
+
 	if (get_rel_relkind(heapOid) == RELKIND_PARTITIONED_TABLE)
 		ReindexPartitions(stmt, heapOid, params, isTopLevel);
 	else if ((params->options & REINDEXOPT_CONCURRENTLY) != 0 &&
-			 get_rel_persistence(heapOid) != RELPERSISTENCE_TEMP)
+			 persistence != RELPERSISTENCE_TEMP &&
+			 persistence != RELPERSISTENCE_GLOBAL_TEMP)
 	{
 		result = ReindexRelationConcurrently(stmt, heapOid, params);
 
@@ -3110,6 +3153,12 @@ ReindexTable(const ReindexStmt *stmt, const ReindexParams *params, bool isTopLev
 	{
 		ReindexParams newparams = *params;
 
+		if ((params->options & REINDEXOPT_CONCURRENTLY) != 0 &&
+			persistence == RELPERSISTENCE_GLOBAL_TEMP)
+			ereport(NOTICE,
+					errmsg("REINDEX CONCURRENTLY is not supported for global temporary tables"),
+					errdetail("Falling back to a non-concurrent reindex."));
+
 		newparams.options |= REINDEXOPT_REPORT_PROGRESS;
 		result = reindex_relation(stmt, heapOid,
 								  REINDEX_REL_PROCESS_TOAST |
@@ -3250,6 +3299,14 @@ ReindexMultipleTables(const ReindexStmt *stmt, const ReindexParams *params)
 			!isTempNamespace(classtuple->relnamespace))
 			continue;
 
+		/*
+		 * Skip global temporary tables; they cannot be reindexed (see
+		 * reindex_index()), so we silently skip them here to avoid aborting a
+		 * database- or schema-wide REINDEX.
+		 */
+		if (classtuple->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+			continue;
+
 		/*
 		 * Check user/system classification.  SYSTEM processes all the
 		 * catalogs, and DATABASE processes everything that's not a catalog.
@@ -3521,7 +3578,8 @@ ReindexMultipleInternal(const ReindexStmt *stmt, const List *relids, const Reind
 		Assert(!RELKIND_HAS_PARTITIONS(relkind));
 
 		if ((params->options & REINDEXOPT_CONCURRENTLY) != 0 &&
-			relpersistence != RELPERSISTENCE_TEMP)
+			relpersistence != RELPERSISTENCE_TEMP &&
+			relpersistence != RELPERSISTENCE_GLOBAL_TEMP)
 		{
 			ReindexParams newparams = *params;
 
@@ -3962,8 +4020,9 @@ ReindexRelationConcurrently(const ReindexStmt *stmt, Oid relationOid, const Rein
 		idx->tableId = RelationGetRelid(heapRel);
 		idx->amId = indexRel->rd_rel->relam;
 
-		/* This function shouldn't be called for temporary relations. */
-		if (indexRel->rd_rel->relpersistence == RELPERSISTENCE_TEMP)
+		/* This function shouldn't be called for temporary or GTT relations. */
+		if (indexRel->rd_rel->relpersistence == RELPERSISTENCE_TEMP ||
+			RelationIsGlobalTemp(indexRel))
 			elog(ERROR, "cannot reindex a temporary table concurrently");
 
 		pgstat_progress_start_command(PROGRESS_COMMAND_CREATE_INDEX, idx->tableId);
diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 4d177c868bb..cf0808df2de 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -48,6 +48,7 @@
 #include "catalog/namespace.h"
 #include "catalog/objectaccess.h"
 #include "catalog/pg_am.h"
+#include "catalog/pg_class.h"
 #include "catalog/pg_constraint.h"
 #include "catalog/pg_inherits.h"
 #include "catalog/toasting.h"
@@ -601,6 +602,18 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 				errmsg("cannot execute %s on temporary tables of other sessions",
 					   RepackCommandAsString(cmd)));
 
+	/*
+	 * Global temporary tables cannot be repacked or clustered because these
+	 * operations assign a new relfilenode in the shared catalog, which would
+	 * desynchronize per-session storage mappings.
+	 */
+	if (RelationIsGlobalTemp(OldHeap))
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+		/*- translator: first %s is name of a SQL command, eg. REPACK */
+				errmsg("cannot execute %s on global temporary tables",
+					   RepackCommandAsString(cmd)));
+
 	/*
 	 * Also check for active uses of the relation in the current transaction,
 	 * including open scans and pending AFTER trigger events.
@@ -2186,6 +2199,18 @@ get_tables_to_repack(RepackCommand cmd, bool usingindex, MemoryContext permcxt)
 				continue;
 			}
 
+			/*
+			 * Skip global temporary tables; they cannot be repacked or
+			 * clustered (see cluster_rel()).  Aborting a database-wide REPACK
+			 * on every GTT in the catalog would be unhelpful.
+			 */
+			if (classForm->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+			{
+				ReleaseSysCache(classtup);
+				UnlockRelationOid(index->indrelid, AccessShareLock);
+				continue;
+			}
+
 			ReleaseSysCache(classtup);
 
 			/* noisily skip rels which the user can't process */
@@ -2250,6 +2275,13 @@ get_tables_to_repack(RepackCommand cmd, bool usingindex, MemoryContext permcxt)
 				continue;
 			}
 
+			/* See the matching skip in the USING INDEX branch above. */
+			if (class->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+			{
+				UnlockRelationOid(class->oid, AccessShareLock);
+				continue;
+			}
+
 			/* noisily skip rels which the user can't process */
 			if (!repack_is_permitted_for_relation(cmd, class->oid,
 												  GetUserId()))
@@ -2410,6 +2442,19 @@ process_single_relation(RepackStmt *stmt, LOCKMODE lockmode, bool isTopLevel,
 				errmsg("cannot execute %s on temporary tables of other sessions",
 					   RepackCommandAsString(stmt->command)));
 
+	/*
+	 * Reject GTTs here as well as in cluster_rel(): for a partitioned GTT we
+	 * return below without calling cluster_rel(), so the caller would
+	 * otherwise iterate the partitions and produce a less helpful error
+	 * naming one of them.
+	 */
+	if (RelationIsGlobalTemp(rel))
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+		/*- translator: first %s is name of a SQL command, eg. REPACK */
+				errmsg("cannot execute %s on global temporary tables",
+					   RepackCommandAsString(stmt->command)));
+
 	/*
 	 * For partitioned tables, let caller handle this.  Otherwise, process it
 	 * here and we're done.
diff --git a/src/backend/commands/statscmds.c b/src/backend/commands/statscmds.c
index b354723be44..8b32d0f4110 100644
--- a/src/backend/commands/statscmds.c
+++ b/src/backend/commands/statscmds.c
@@ -136,6 +136,19 @@ CreateStatistics(CreateStatsStmt *stmt, bool check_rights)
 							RelationGetRelationName(rel)),
 					 errdetail_relkind_not_supported(rel->rd_rel->relkind)));
 
+		/*
+		 * Extended statistics are stored in the shared pg_statistic_ext_data
+		 * catalog, but global temporary tables have per-session data, so any
+		 * stats gathered by ANALYZE would reflect a single session's sample
+		 * and be misleading for others.  Reject the command outright rather
+		 * than silently accepting a definition that will never be populated.
+		 */
+		if (RelationIsGlobalTemp(rel))
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("cannot define statistics for global temporary table \"%s\"",
+						   RelationGetRelationName(rel)));
+
 		/*
 		 * You must own the relation to create stats on it.
 		 *
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 71dae783f76..3f6995b0f75 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -1765,9 +1765,26 @@ RemoveRelations(DropStmt *drop)
 		/*
 		 * Decide if concurrent mode needs to be used here or not.  The
 		 * callback retrieved the rel's persistence for us.
+		 *
+		 * Global temporary tables have per-session local storage that other
+		 * backends cannot see, so the multi-transaction concurrent protocol
+		 * is neither necessary nor safe.  Mirror the CREATE INDEX
+		 * CONCURRENTLY fallback in indexcmds.c: emit a NOTICE and proceed
+		 * with a non-concurrent drop, upgrading our lock from
+		 * ShareUpdateExclusiveLock to AccessExclusiveLock.  The final
+		 * heap_drop_with_catalog still passes through GttCheckDroppable, so
+		 * peers with live per-session storage block the drop either way.
 		 */
 		if (drop->concurrent &&
-			state.actual_relpersistence != RELPERSISTENCE_TEMP)
+			state.actual_relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+		{
+			ereport(NOTICE,
+					errmsg("DROP INDEX CONCURRENTLY is not supported for global temporary tables"),
+					errdetail("Falling back to a non-concurrent drop."));
+			LockRelationOid(relOid, AccessExclusiveLock);
+		}
+		else if (drop->concurrent &&
+				 state.actual_relpersistence != RELPERSISTENCE_TEMP)
 		{
 			Assert(list_length(drop->objects) == 1 &&
 				   drop->removeType == OBJECT_INDEX);
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index a4abb29cf64..ab0b1c493f7 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -35,6 +35,7 @@
 #include "access/transam.h"
 #include "access/xact.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_class.h"
 #include "catalog/pg_database.h"
 #include "catalog/pg_inherits.h"
 #include "commands/async.h"
@@ -1068,6 +1069,16 @@ get_all_vacuum_rels(MemoryContext vac_context, int options)
 			!isTempOrTempToastNamespace(classForm->relnamespace))
 			continue;
 
+		/*
+		 * Skip global temporary tables.  vacuum_rel() would skip them anyway,
+		 * but doing so silently here avoids emitting a stream of INFO
+		 * "skipping vacuum" messages on a database-wide VACUUM. A VACUUM that
+		 * names a GTT explicitly still reaches vacuum_rel() and is reported
+		 * there.
+		 */
+		if (classForm->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+			continue;
+
 		/* check permissions of relation */
 		if (!vacuum_is_permitted_for_relation(relid, classForm, options))
 			continue;
@@ -1118,6 +1129,21 @@ vacuum_get_cutoffs(Relation rel, const VacuumParams *params,
 				safeOldestMxact,
 				aggressiveMXIDCutoff;
 
+	/*
+	 * Global temporary tables have session-local storage and carry invalid
+	 * relfrozenxid/relminmxid in their shared pg_class row, so they must
+	 * never reach the freeze machinery.  Every caller is expected to skip
+	 * them well before this point (see vacuum_rel() and cluster_rel());
+	 * arriving here with one means a skip was missed.  Computing cutoffs
+	 * would either trip the downstream
+	 * TransactionIdIsNormal()/MultiXactIdIsValid() assertions or silently
+	 * freeze session-local data against bogus limits, so error out loudly
+	 * instead.
+	 */
+	if (RelationIsGlobalTemp(rel))
+		elog(ERROR, "cannot compute freeze cutoffs for global temporary table \"%s\"",
+			 RelationGetRelationName(rel));
+
 	/* Use mutable copies of freeze age parameters */
 	freeze_min_age = params->freeze_min_age;
 	multixact_freeze_min_age = params->multixact_freeze_min_age;
@@ -2155,6 +2181,29 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 		return false;
 	}
 
+	/*
+	 * Skip the vacuum portion for global temporary tables.  GTT data lives in
+	 * per-session local buffers with no shared freeze state, so the vacuum
+	 * machinery (which assumes valid relfrozenxid/relminmxid) cannot safely
+	 * process them.
+	 *
+	 * If ANALYZE was requested in the same command (VACUUM (ANALYZE)), we
+	 * return true so the caller still runs analyze_rel on this relation.
+	 * Otherwise we return false to short-circuit completely.
+	 */
+	if (RelationIsGlobalTemp(rel))
+	{
+		bool		can_analyze = (params.options & VACOPT_ANALYZE) != 0;
+
+		ereport(can_analyze ? DEBUG1 : INFO,
+				errmsg("skipping vacuum of \"%s\" --- data is session-local for a global temporary table",
+					   RelationGetRelationName(rel)));
+		relation_close(rel, lmode);
+		PopActiveSnapshot();
+		CommitTransactionCommand();
+		return can_analyze;
+	}
+
 	/*
 	 * Silently ignore partitioned tables as there is no work to be done.  The
 	 * useful work is on their child partitions, which have been queued up for
diff --git a/src/backend/parser/parse_utilcmd.c b/src/backend/parser/parse_utilcmd.c
index a049cc67ed6..592633a2eaa 100644
--- a/src/backend/parser/parse_utilcmd.c
+++ b/src/backend/parser/parse_utilcmd.c
@@ -1592,29 +1592,46 @@ expandTableLikeClause(RangeVar *heapRel, TableLikeClause *table_like_clause)
 
 		parent_extstats = RelationGetStatExtList(relation);
 
-		foreach(l, parent_extstats)
+		/*
+		 * Global temporary tables cannot have extended statistics: their data
+		 * is per-session, but pg_statistic_ext_data is shared, so any stats
+		 * would reflect a single session's sample and mislead others (see
+		 * CreateStatistics).  Rather than fail outright, skip cloning the
+		 * parent's statistics objects, but warn so the omission is not
+		 * silent.
+		 */
+		if (parent_extstats != NIL && RelationIsGlobalTemp(childrel))
+			ereport(WARNING,
+					(errmsg("statistics objects not copied to global temporary table \"%s\"",
+							RelationGetRelationName(childrel)),
+					 errdetail("Global temporary tables cannot have extended statistics.")));
+		else
 		{
-			Oid			parent_stat_oid = lfirst_oid(l);
-			CreateStatsStmt *stats_stmt;
-
-			stats_stmt = generateClonedExtStatsStmt(heapRel,
-													RelationGetRelid(childrel),
-													parent_stat_oid,
-													attmap);
-
-			/* Copy comment on statistics object, if requested */
-			if (table_like_clause->options & CREATE_TABLE_LIKE_COMMENTS)
+			foreach(l, parent_extstats)
 			{
-				comment = GetComment(parent_stat_oid, StatisticExtRelationId, 0);
+				Oid			parent_stat_oid = lfirst_oid(l);
+				CreateStatsStmt *stats_stmt;
 
-				/*
-				 * We make use of CreateStatsStmt's stxcomment option, so as
-				 * not to need to know now what name the statistics will have.
-				 */
-				stats_stmt->stxcomment = comment;
+				stats_stmt = generateClonedExtStatsStmt(heapRel,
+														RelationGetRelid(childrel),
+														parent_stat_oid,
+														attmap);
+
+				/* Copy comment on statistics object, if requested */
+				if (table_like_clause->options & CREATE_TABLE_LIKE_COMMENTS)
+				{
+					comment = GetComment(parent_stat_oid, StatisticExtRelationId, 0);
+
+					/*
+					 * We make use of CreateStatsStmt's stxcomment option, so
+					 * as not to need to know now what name the statistics
+					 * will have.
+					 */
+					stats_stmt->stxcomment = comment;
+				}
+
+				result = lappend(result, stats_stmt);
 			}
-
-			result = lappend(result, stats_stmt);
 		}
 
 		list_free(parent_extstats);
diff --git a/src/backend/statistics/attribute_stats.c b/src/backend/statistics/attribute_stats.c
index 1cc4d657231..48c0bdebd86 100644
--- a/src/backend/statistics/attribute_stats.c
+++ b/src/backend/statistics/attribute_stats.c
@@ -20,6 +20,7 @@
 #include "access/heapam.h"
 #include "catalog/indexing.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_class.h"
 #include "catalog/pg_operator.h"
 #include "nodes/makefuncs.h"
 #include "statistics/statistics.h"
@@ -185,6 +186,15 @@ attribute_statistics_update(FunctionCallInfo fcinfo)
 									  ShareUpdateExclusiveLock, 0,
 									  RangeVarCallbackForStats, &locked_table);
 
+	/*
+	 * Reject global temporary tables: ANALYZE on a GTT writes session-private
+	 * column statistics rather than pg_statistic rows, and the planner reads
+	 * them back via SearchStats().  Writing pg_statistic for a GTT
+	 * here would surface in any session that has not yet run ANALYZE (its
+	 * per-session miss would fall back on the syscache).
+	 */
+	stats_check_not_global_temp(reloid, relname);
+
 	/* user can specify either attname or attnum, but not both */
 	if (!PG_ARGISNULL(ATTNAME_ARG))
 	{
@@ -623,6 +633,9 @@ pg_clear_attribute_stats(PG_FUNCTION_ARGS)
 									  ShareUpdateExclusiveLock, 0,
 									  RangeVarCallbackForStats, &locked_table);
 
+	/* See attribute_statistics_update() for the rationale. */
+	stats_check_not_global_temp(reloid, relname);
+
 	attname = TextDatumGetCString(PG_GETARG_DATUM(C_ATTNAME_ARG));
 	attnum = get_attnum(reloid, attname);
 
diff --git a/src/backend/statistics/relation_stats.c b/src/backend/statistics/relation_stats.c
index d6631e9a9a4..3e70bfa989f 100644
--- a/src/backend/statistics/relation_stats.c
+++ b/src/backend/statistics/relation_stats.c
@@ -20,6 +20,7 @@
 #include "access/heapam.h"
 #include "catalog/indexing.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_class.h"
 #include "nodes/makefuncs.h"
 #include "statistics/stat_utils.h"
 #include "utils/builtins.h"
@@ -101,6 +102,15 @@ relation_statistics_update(FunctionCallInfo fcinfo)
 									  ShareUpdateExclusiveLock, 0,
 									  RangeVarCallbackForStats, &locked_table);
 
+	/*
+	 * Reject global temporary tables.  Their data is per-session, so the
+	 * shared pg_class row is never read by the planner for query estimation
+	 * (GttGetSessionStats() supplies session-private values), and writing
+	 * shared values would mislead any session that has not yet run ANALYZE
+	 * because the per-session miss would fall back on these values.
+	 */
+	stats_check_not_global_temp(reloid, relname);
+
 	if (!PG_ARGISNULL(RELPAGES_ARG))
 	{
 		relpages = PG_GETARG_UINT32(RELPAGES_ARG);
diff --git a/src/backend/statistics/stat_utils.c b/src/backend/statistics/stat_utils.c
index a673e3c704b..790c0a5c959 100644
--- a/src/backend/statistics/stat_utils.c
+++ b/src/backend/statistics/stat_utils.c
@@ -46,6 +46,25 @@
 
 static Node *statatt_get_index_expr(Relation rel, int attnum);
 
+/*
+ * stats_check_not_global_temp
+ *		Reject statistics import for a global temporary table.
+ *
+ * A GTT's statistics are per-session (established by running ANALYZE in
+ * each session); its shared pg_class/pg_statistic rows must stay
+ * unpopulated, so the stats import functions cannot apply to it.
+ */
+void
+stats_check_not_global_temp(Oid reloid, const char *relname)
+{
+	if (get_rel_persistence(reloid) == RELPERSISTENCE_GLOBAL_TEMP)
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("cannot modify shared statistics for global temporary table \"%s\"",
+					   relname),
+				errhint("Run ANALYZE in each session that uses the table."));
+}
+
 /*
  * Ensure that a given argument is not null.
  */
diff --git a/src/include/statistics/stat_utils.h b/src/include/statistics/stat_utils.h
index 74da7790579..14baae4ee30 100644
--- a/src/include/statistics/stat_utils.h
+++ b/src/include/statistics/stat_utils.h
@@ -25,6 +25,7 @@ struct StatsArgInfo
 	Oid			argtype;
 };
 
+extern void stats_check_not_global_temp(Oid reloid, const char *relname);
 extern void stats_check_required_arg(FunctionCallInfo fcinfo,
 									 struct StatsArgInfo *arginfo,
 									 int argnum);
-- 
2.43.0

