From 12ffeac87a299b22218b531eb511b1275c8b51b4 Mon Sep 17 00:00:00 2001
From: Dean Rasheed <dean.a.rasheed@gmail.com>
Date: Wed, 10 Jun 2026 18:23:24 +0100
Subject: [PATCH v10 05/11] Allow catalog tables to be global temporary and add
 pg_temp_class.

This commit allows system catalog tables to be global temporary
tables, and adds the first such example: pg_temp_class. The idea is
that pg_temp_class will contain one row for each global temporary
table accessed in the session, allowing a subset of the attributes
from pg_class to be overridden locally for that session.

To avoid bootstrapping difficulties when pg_temp_class itself is first
accessed, and needs to insert rows describing itself and its index,
all inserts to pg_temp_class are held in an in-memory cache, which is
not flushed to the database until after the relation has been opened.
The cache entries are then kept in memory for the duration of the
session, since they cannot be invalidated by another session, and
because on a hot standby, or when operating in parallel mode, they
cannot be flushed to the database at all. Thus, it's simpler to always
keep the cache entries in memory, and regard the in-memory copies as
the master copies. This is also required to support global temporary
sequences, for which the pg_temp_class entry needs to be inserted
non-transactionally (it shouldn't be deleted if the initialization of
the sequence is rolled back).

In this initial commit, pg_temp_class only has oid, relfilenode, and
reltablespace attributes, allowing CLUSTER, REINDEX, REPACK, TRUNCATE,
and VACUUM FULL to make changes locally to the current session,
without affecting other running sessions, by updating pg_temp_class
instead of pg_class.

ALTER TABLE SET TABLESPACE works similarly, except that it updates
both pg_class and pg_temp_class, so that the change applies to the
current session and any future sessions, but not any other currently
active sessions that have already accessed the table.
---
 doc/src/sgml/catalogs.sgml                  | 105 ++-
 doc/src/sgml/func/func-admin.sgml           |  10 +-
 doc/src/sgml/storage.sgml                   |  11 +-
 src/backend/access/common/relation.c        |  14 +-
 src/backend/access/transam/xact.c           |   6 +-
 src/backend/bootstrap/bootparse.y           |  23 +-
 src/backend/bootstrap/bootscanner.l         |   1 +
 src/backend/catalog/Catalog.pm              |   2 +
 src/backend/catalog/Makefile                |   1 +
 src/backend/catalog/genbki.pl               |  62 ++
 src/backend/catalog/global_temp.c           |  62 +-
 src/backend/catalog/index.c                 |  18 +
 src/backend/catalog/meson.build             |   1 +
 src/backend/catalog/pg_temp_class.c         | 234 ++++++
 src/backend/commands/repack.c               |  95 ++-
 src/backend/commands/tablecmds.c            |  40 +-
 src/backend/commands/vacuum.c               |  18 +
 src/backend/parser/parse_utilcmd.c          |   9 +-
 src/backend/utils/activity/pgstat_io.c      |  11 +-
 src/backend/utils/adt/dbsize.c              |  11 +-
 src/backend/utils/cache/Makefile            |   1 +
 src/backend/utils/cache/gtcatcache.c        | 873 ++++++++++++++++++++
 src/backend/utils/cache/inval.c             |  17 +-
 src/backend/utils/cache/lsyscache.c         |  14 +
 src/backend/utils/cache/meson.build         |   1 +
 src/backend/utils/cache/relcache.c          |  66 +-
 src/backend/utils/cache/syscache.c          |   7 +-
 src/include/access/htup_details.h           |  11 +
 src/include/catalog/Makefile                |   3 +-
 src/include/catalog/genbki.h                |   1 +
 src/include/catalog/meson.build             |   1 +
 src/include/catalog/pg_temp_class.h         | 134 +++
 src/include/utils/gtcatcache.h              |  41 +
 src/test/isolation/expected/global-temp.out | 197 +++++
 src/test/isolation/specs/global-temp.spec   |  47 ++
 src/test/recovery/t/001_stream_rep.pl       |  59 +-
 src/test/recovery/t/018_wal_optimize.pl     |   1 +
 src/test/regress/expected/global_temp.out   | 200 ++++-
 src/test/regress/expected/oidjoins.out      |   2 +
 src/test/regress/expected/stats.out         |   3 +-
 src/test/regress/sql/global_temp.sql        | 116 ++-
 src/tools/pgindent/typedefs.list            |   5 +
 42 files changed, 2445 insertions(+), 89 deletions(-)
 create mode 100644 src/backend/catalog/pg_temp_class.c
 create mode 100644 src/backend/utils/cache/gtcatcache.c
 create mode 100644 src/include/catalog/pg_temp_class.h
 create mode 100644 src/include/utils/gtcatcache.h

diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml
index 69a6f608303..910b98a2412 100644
--- a/doc/src/sgml/catalogs.sgml
+++ b/doc/src/sgml/catalogs.sgml
@@ -350,6 +350,11 @@
       <entry>tablespaces within this database cluster</entry>
      </row>
 
+     <row>
+      <entry><link linkend="catalog-pg-temp-class"><structname>pg_temp_class</structname></link></entry>
+      <entry>global temporary relations used in the current session</entry>
+     </row>
+
      <row>
       <entry><link linkend="catalog-pg-transform"><structname>pg_transform</structname></link></entry>
       <entry>transforms (data type to procedural language conversions)</entry>
@@ -2028,7 +2033,12 @@ SCRAM-SHA-256$<replaceable>&lt;iteration count&gt;</replaceable>:<replaceable>&l
       <para>
        Name of the on-disk file of this relation; zero means this
        is a <quote>mapped</quote> relation whose disk file name is determined
-       by low-level state
+       by low-level state.
+      </para>
+      <para>
+       For a global temporary relation, the value from
+       <link linkend="catalog-pg-temp-class"><structname>pg_temp_class</structname></link>.<structfield>relfilenode</structfield>,
+       if any, takes precedence over the value from this catalog.
       </para></entry>
      </row>
 
@@ -2044,6 +2054,11 @@ SCRAM-SHA-256$<replaceable>&lt;iteration count&gt;</replaceable>:<replaceable>&l
        except for partitioned tables, where this is the tablespace
        in which partitions will be created when one is not
        specified in the creation command.
+      </para>
+      <para>
+       For a global temporary relation, the value from
+       <link linkend="catalog-pg-temp-class"><structname>pg_temp_class</structname></link>.<structfield>reltablespace</structfield>,
+       if any, takes precedence over the value from this catalog.
       </para></entry>
      </row>
 
@@ -9021,6 +9036,94 @@ SCRAM-SHA-256$<replaceable>&lt;iteration count&gt;</replaceable>:<replaceable>&l
  </sect1>
 
 
+ <sect1 id="catalog-pg-temp-class">
+  <title><structname>pg_temp_class</structname></title>
+
+  <indexterm zone="catalog-pg-temp-class">
+   <primary>pg_temp_class</primary>
+  </indexterm>
+
+  <para>
+   The catalog <structname>pg_temp_class</structname> is a global temporary
+   table that stores information about all global temporary relations
+   (including <structname>pg_temp_class</structname> itself) in use in the
+   current session.
+  </para>
+
+  <para>
+   The attributes of <structname>pg_temp_class</structname> are a subset of
+   the attributes of
+   <link linkend="catalog-pg-class"><structname>pg_class</structname></link>,
+   used to store local overrides to the values from
+   <structname>pg_class</structname> for each session.
+  </para>
+
+  <table>
+   <title><structname>pg_temp_class</structname> Columns</title>
+   <tgroup cols="1">
+    <thead>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       Column Type
+      </para>
+      <para>
+       Description
+      </para></entry>
+     </row>
+    </thead>
+
+    <tbody>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>oid</structfield> <type>oid</type>
+      </para>
+      <para>
+       Row identifier
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relfilenode</structfield> <type>oid</type>
+      </para>
+      <para>
+       Name of the on-disk file of this relation in the current session,
+       overriding the value from
+       <link linkend="catalog-pg-class"><structname>pg_class</structname></link>.<structfield>relfilenode</structfield>.
+       This is always non-zero, because global temporary relations are never
+       <quote>mapped</quote>.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>reltablespace</structfield> <type>oid</type>
+       (references <link linkend="catalog-pg-tablespace"><structname>pg_tablespace</structname></link>.<structfield>oid</structfield>)
+      </para>
+      <para>
+       The tablespace in which this relation is stored in the current
+       session, overriding the value from
+       <link linkend="catalog-pg-class"><structname>pg_class</structname></link>.<structfield>reltablespace</structfield>.
+       If zero, the database's default tablespace is implied.
+       Not meaningful if the relation has no on-disk file,
+       except for partitioned tables, where this is the tablespace
+       in which partitions will be created when one is not
+       specified in the creation command.
+      </para></entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+
+  <note>
+   <para>
+    Tuples are only added to <structname>pg_temp_class</structname> for global
+    temporary relations that have been used in the current session.
+   </para>
+  </note>
+ </sect1>
+
+
  <sect1 id="catalog-pg-transform">
   <title><structname>pg_transform</structname></title>
 
diff --git a/doc/src/sgml/func/func-admin.sgml b/doc/src/sgml/func/func-admin.sgml
index 54eeb42e5bc..2745179ba86 100644
--- a/doc/src/sgml/func/func-admin.sgml
+++ b/doc/src/sgml/func/func-admin.sgml
@@ -1819,9 +1819,13 @@ postgres=# SELECT '0/0'::pg_lsn + pd.segment_number * ps.setting::int + :offset
         For most relations the result is the same as
         <structname>pg_class</structname>.<structfield>relfilenode</structfield>,
         but for certain system catalogs <structfield>relfilenode</structfield>
-        is zero and this function must be used to get the correct value.  The
-        function returns NULL if passed a relation that does not have storage,
-        such as a view.
+        is zero and this function must be used to get the correct value.  For
+        global temporary relations the result is
+        <structname>pg_temp_class</structname>.<structfield>relfilenode</structfield>,
+        if the relation has been used in the current session, and
+        <structname>pg_class</structname>.<structfield>relfilenode</structfield>
+        otherwise.  The function returns NULL if passed a relation that does
+        not have storage, such as a view.
        </para></entry>
       </row>
 
diff --git a/doc/src/sgml/storage.sgml b/doc/src/sgml/storage.sgml
index 19924b98d71..ce3bc3ef3b4 100644
--- a/doc/src/sgml/storage.sgml
+++ b/doc/src/sgml/storage.sgml
@@ -205,7 +205,13 @@ which can be found in <structname>pg_class</structname>.<structfield>relfilenode
 for temporary relations, the file name is of the form
 <literal>t<replaceable>BBB</replaceable>_<replaceable>FFF</replaceable></literal>, where <replaceable>BBB</replaceable>
 is the process number of the backend which created the file, and <replaceable>FFF</replaceable>
-is the filenode number.  In either case, in addition to the main file (a/k/a
+is the filenode number.  Global temporary relations have no physical storage until
+they are used by a backend, at which point their filenode number can be found in
+<structname>pg_temp_class</structname>.<structfield>relfilenode</structfield>.
+The initial value of <structname>pg_temp_class</structname>.<structfield>relfilenode</structfield>
+comes from <structname>pg_class</structname>.<structfield>relfilenode</structfield>,
+but as noted below, certain operations may cause the filenode to change.
+In each case, in addition to the main file (a/k/a
 main fork), each table and index has a <firstterm>free space map</firstterm> (see <xref
 linkend="storage-fsm"/>), which stores information about free space available in
 the relation.  The free space map is stored in a file named with the filenode
@@ -221,7 +227,8 @@ with the suffix <literal>_init</literal> (see <xref linkend="storage-init"/>).
 <para>
 Note that while a table's filenode often matches its OID, this is
 <emphasis>not</emphasis> necessarily the case; some operations, like
-<command>TRUNCATE</command>, <command>REINDEX</command>, <command>CLUSTER</command> and some forms
+<command>TRUNCATE</command>, <command>REINDEX</command>, <command>CLUSTER</command>,
+<command>REPACK</command>, <command>VACUUM FULL</command>, and some forms
 of <command>ALTER TABLE</command>, can change the filenode while preserving the OID.
 Avoid assuming that filenode and table OID are the same.
 Also, for certain system catalogs including <structname>pg_class</structname> itself,
diff --git a/src/backend/access/common/relation.c b/src/backend/access/common/relation.c
index 38b356b8239..84a09217657 100644
--- a/src/backend/access/common/relation.c
+++ b/src/backend/access/common/relation.c
@@ -22,14 +22,15 @@
 
 #include "access/relation.h"
 #include "access/xact.h"
+#include "catalog/global_temp.h"
 #include "catalog/namespace.h"
 #include "pgstat.h"
 #include "storage/lmgr.h"
 #include "storage/lock.h"
+#include "utils/gtcatcache.h"
 #include "utils/inval.h"
 #include "utils/syscache.h"
 
-
 /* ----------------
  *		relation_open - open any relation by relation OID
  *
@@ -55,6 +56,17 @@ relation_open(Oid relationId, LOCKMODE lockmode)
 	if (lockmode != NoLock)
 		LockRelationOid(relationId, lockmode);
 
+	/*
+	 * Before opening a global temporary system catalog table, process any
+	 * invalidated global temporary relations and flush the global temporary
+	 * catalog caches, so that the contents of the catalogs are up to date.
+	 */
+	if (IsGlobalTempCatalogTable(relationId) && !IsBootstrapProcessingMode())
+	{
+		ProcessInvalidatedGlobalTempRelations();
+		GTCatCacheFlush();
+	}
+
 	/* The relcache does all the real work... */
 	r = RelationIdGetRelation(relationId);
 
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index aa520ff6fdc..a6497752c96 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -65,6 +65,7 @@
 #include "storage/smgr.h"
 #include "utils/builtins.h"
 #include "utils/combocid.h"
+#include "utils/gtcatcache.h"
 #include "utils/guc.h"
 #include "utils/inval.h"
 #include "utils/memutils.h"
@@ -2350,9 +2351,12 @@ CommitTransaction(void)
 	 * Process any invalidated global temporary relations, dealing with any
 	 * that were dropped by other backends.  This needs to be done before any
 	 * ON COMMIT handling, so that we don't try to perform ON COMMIT actions
-	 * on deleted global temporary tables.
+	 * on deleted global temporary tables.  While at it, flush the global
+	 * temporary catalog caches, so that any new entries are written out
+	 * before we commit.
 	 */
 	ProcessInvalidatedGlobalTempRelations();
+	GTCatCacheFlush();
 
 	/*
 	 * Let ON COMMIT management do its thing (must happen after closing
diff --git a/src/backend/bootstrap/bootparse.y b/src/backend/bootstrap/bootparse.y
index 305a5654ff3..7026a138375 100644
--- a/src/backend/bootstrap/bootparse.y
+++ b/src/backend/bootstrap/bootparse.y
@@ -96,7 +96,7 @@ static int num_columns_read = 0;
 %type <list>  boot_index_params
 %type <ielem> boot_index_param
 %type <str>   boot_ident
-%type <ival>  optbootstrap optsharedrelation boot_column_nullness
+%type <ival>  optbootstrap optsharedrelation opttemprelation boot_column_nullness
 %type <oidval> oidspec optrowtypeoid
 
 %token <str> ID
@@ -106,7 +106,7 @@ static int num_columns_read = 0;
 /* All the rest are unreserved, and should be handled in boot_ident! */
 %token <kw> OPEN XCLOSE XCREATE INSERT_TUPLE
 %token <kw> XDECLARE INDEX ON USING XBUILD INDICES UNIQUE XTOAST
-%token <kw> OBJ_ID XBOOTSTRAP XSHARED_RELATION XROWTYPE_OID
+%token <kw> OBJ_ID XBOOTSTRAP XSHARED_RELATION XTEMP_RELATION XROWTYPE_OID
 %token <kw> XFORCE XNOT XNULL
 
 %start TopLevel
@@ -155,13 +155,14 @@ Boot_CloseStmt:
 		;
 
 Boot_CreateStmt:
-		  XCREATE boot_ident oidspec optbootstrap optsharedrelation optrowtypeoid LPAREN
+		  XCREATE boot_ident oidspec optbootstrap optsharedrelation opttemprelation optrowtypeoid LPAREN
 				{
 					do_start();
 					numattr = 0;
-					elog(DEBUG4, "creating%s%s relation %s %u",
+					elog(DEBUG4, "creating%s%s%s relation %s %u",
 						 $4 ? " bootstrap" : "",
 						 $5 ? " shared" : "",
+						 $6 ? " global temp" : "",
 						 $2,
 						 $3);
 				}
@@ -173,6 +174,7 @@ Boot_CreateStmt:
 				{
 					TupleDesc	tupdesc;
 					bool		shared_relation;
+					bool		temp_relation;
 					bool		mapped_relation;
 
 					do_start();
@@ -180,6 +182,7 @@ Boot_CreateStmt:
 					tupdesc = CreateTupleDesc(numattr, attrtypes);
 
 					shared_relation = $5;
+					temp_relation = $6;
 
 					/*
 					 * The catalogs that use the relation mapper are the
@@ -211,6 +214,8 @@ Boot_CreateStmt:
 												   HEAP_TABLE_AM_OID,
 												   tupdesc,
 												   RELKIND_RELATION,
+												   temp_relation ?
+												   RELPERSISTENCE_GLOBAL_TEMP :
 												   RELPERSISTENCE_PERMANENT,
 												   shared_relation,
 												   mapped_relation,
@@ -229,13 +234,15 @@ Boot_CreateStmt:
 													  PG_CATALOG_NAMESPACE,
 													  shared_relation ? GLOBALTABLESPACE_OID : 0,
 													  $3,
-													  $6,
+													  $7,
 													  InvalidOid,
 													  BOOTSTRAP_SUPERUSERID,
 													  HEAP_TABLE_AM_OID,
 													  tupdesc,
 													  NIL,
 													  RELKIND_RELATION,
+													  temp_relation ?
+													  RELPERSISTENCE_GLOBAL_TEMP :
 													  RELPERSISTENCE_PERMANENT,
 													  shared_relation,
 													  mapped_relation,
@@ -433,6 +440,11 @@ optsharedrelation:
 		|						{ $$ = 0; }
 		;
 
+opttemprelation:
+			XTEMP_RELATION	{ $$ = 1; }
+		|					{ $$ = 0; }
+		;
+
 optrowtypeoid:
 			XROWTYPE_OID oidspec	{ $$ = $2; }
 		|							{ $$ = InvalidOid; }
@@ -492,6 +504,7 @@ boot_ident:
 		| OBJ_ID		{ $$ = pstrdup($1); }
 		| XBOOTSTRAP	{ $$ = pstrdup($1); }
 		| XSHARED_RELATION	{ $$ = pstrdup($1); }
+		| XTEMP_RELATION	{ $$ = pstrdup($1); }
 		| XROWTYPE_OID	{ $$ = pstrdup($1); }
 		| XFORCE		{ $$ = pstrdup($1); }
 		| XNOT			{ $$ = pstrdup($1); }
diff --git a/src/backend/bootstrap/bootscanner.l b/src/backend/bootstrap/bootscanner.l
index 9674f2795d1..f8c1a671712 100644
--- a/src/backend/bootstrap/bootscanner.l
+++ b/src/backend/bootstrap/bootscanner.l
@@ -82,6 +82,7 @@ create			{ yylval->kw = "create"; return XCREATE; }
 OID				{ yylval->kw = "OID"; return OBJ_ID; }
 bootstrap		{ yylval->kw = "bootstrap"; return XBOOTSTRAP; }
 shared_relation	{ yylval->kw = "shared_relation"; return XSHARED_RELATION; }
+temp_relation	{ yylval->kw = "temp_relation"; return XTEMP_RELATION; }
 rowtype_oid		{ yylval->kw = "rowtype_oid"; return XROWTYPE_OID; }
 
 insert			{ yylval->kw = "insert"; return INSERT_TUPLE; }
diff --git a/src/backend/catalog/Catalog.pm b/src/backend/catalog/Catalog.pm
index 219af5884d9..78e69b3f0d3 100644
--- a/src/backend/catalog/Catalog.pm
+++ b/src/backend/catalog/Catalog.pm
@@ -176,6 +176,8 @@ sub ParseHeader
 			$catalog{bootstrap} = /BKI_BOOTSTRAP/ ? ' bootstrap' : '';
 			$catalog{shared_relation} =
 			  /BKI_SHARED_RELATION/ ? ' shared_relation' : '';
+			$catalog{temp_relation} =
+			  /BKI_TEMP_RELATION/ ? ' temp_relation' : '';
 			if (/BKI_ROWTYPE_OID\(\s*
 				 (?<rowtype_oid>\d+),\s*
 				 (?<rowtype_oid_macro>\w+)\s*
diff --git a/src/backend/catalog/Makefile b/src/backend/catalog/Makefile
index 0fb085fd8ee..b13293a933e 100644
--- a/src/backend/catalog/Makefile
+++ b/src/backend/catalog/Makefile
@@ -46,6 +46,7 @@ OBJS = \
 	pg_shdepend.o \
 	pg_subscription.o \
 	pg_tablespace.o \
+	pg_temp_class.o \
 	pg_type.o \
 	storage.o \
 	toasting.o
diff --git a/src/backend/catalog/genbki.pl b/src/backend/catalog/genbki.pl
index 86f3135f9c7..7623760912e 100644
--- a/src/backend/catalog/genbki.pl
+++ b/src/backend/catalog/genbki.pl
@@ -174,6 +174,7 @@ foreach my $header (@ARGV)
 			index_oid_macro => $index->{index_oid_macro},
 			key => $key,
 			nbuckets => $syscache->{syscache_nbuckets},
+			table_is_temp => $catalogs{$tblname}->{temp_relation} eq "" ? 0 : 1,
 		};
 
 		$syscache_catalogs{$catname} = 1;
@@ -518,6 +519,7 @@ EOM
 	# .bki CREATE command for this catalog
 	print $bki "create $catname $catalog->{relation_oid}"
 	  . $catalog->{shared_relation}
+	  . $catalog->{temp_relation}
 	  . $catalog->{bootstrap}
 	  . $catalog->{rowtype_oid_clause};
 
@@ -798,6 +800,8 @@ print_boilerplate($syscache_ids_fh, "syscache_ids.h", "SysCache identifiers");
 print $syscache_ids_fh "#ifndef SYSCACHE_IDS_H
 #define SYSCACHE_IDS_H
 
+#include \"catalog/pg_temp_class_d.h\"
+
 typedef enum SysCacheIdentifier
 {
 \tSYSCACHEID_INVALID = -1,\n";
@@ -838,6 +842,64 @@ foreach my $syscache (sort keys %syscaches)
 print $syscache_ids_fh "} SysCacheIdentifier;\n";
 print $syscache_ids_fh "#define SysCacheSize ($last_syscache + 1)\n\n";
 
+# Macro to test if a catalog relation is a global temporary table
+print $syscache_ids_fh "/* Is the specified catalog relation a global temporary table? */\n";
+print $syscache_ids_fh "#define IsGlobalTempCatalogTable(relid) \\\n";
+
+my $num_clauses = 0;
+foreach my $catname (sort keys %catalogs)
+{
+	my $catalog = $catalogs{$catname};
+
+	if ($catalog->{temp_relation})
+	{
+		print $syscache_ids_fh $num_clauses == 0 ? "\t(" : " || \\\n\t ";
+		print $syscache_ids_fh "(relid) == $catalog->{relation_oid_macro}";
+		$num_clauses++;
+	}
+}
+print $syscache_ids_fh $num_clauses == 0 ? "false\n\n" : ")\n\n";
+
+# Macro to test if a catalog relation is a global temporary table or index
+print $syscache_ids_fh "/* Is the specified catalog relation a global temporary table or index? */\n";
+print $syscache_ids_fh "#define IsGlobalTempCatalogRelation(relid) \\\n";
+
+$num_clauses = 0;
+foreach my $catname (sort keys %catalogs)
+{
+	my $catalog = $catalogs{$catname};
+
+	if ($catalog->{temp_relation})
+	{
+		print $syscache_ids_fh $num_clauses == 0 ? "\t(" : " || \\\n\t ";
+		print $syscache_ids_fh "(relid) == $catalog->{relation_oid_macro}";
+		$num_clauses++;
+
+		foreach my $index (@{ $catalog->{indexing} })
+		{
+			print $syscache_ids_fh " || \\\n\t (relid) == $index->{index_oid_macro}";
+			$num_clauses++;
+		}
+	}
+}
+print $syscache_ids_fh $num_clauses == 0 ? "false\n\n" : ")\n\n";
+
+# Macro to test if a syscache's catalog table is global temporary
+print $syscache_ids_fh "/* Does the specified SysCache use a global temporary table? */\n";
+print $syscache_ids_fh "#define SysCacheTableIsGlobalTemp(cacheId) \\\n";
+
+$num_clauses = 0;
+foreach my $syscache (sort keys %syscaches)
+{
+	if ($syscaches{$syscache}{table_is_temp})
+	{
+		print $syscache_ids_fh $num_clauses == 0 ? "\t(" : " || \\\n\t ";
+		print $syscache_ids_fh "(cacheId) == $syscache";
+		$num_clauses++;
+	}
+}
+print $syscache_ids_fh $num_clauses == 0 ? "false\n\n" : ")\n\n";
+
 # Closing boilerplate for syscache_ids.h
 print $syscache_ids_fh "#endif\t\t\t\t\t\t\t/* SYSCACHE_IDS_H */\n";
 
diff --git a/src/backend/catalog/global_temp.c b/src/backend/catalog/global_temp.c
index 6ff85777f62..133c775a080 100644
--- a/src/backend/catalog/global_temp.c
+++ b/src/backend/catalog/global_temp.c
@@ -59,6 +59,7 @@
 #include "access/xact.h"
 #include "access/xlogutils.h"
 #include "catalog/global_temp.h"
+#include "catalog/pg_temp_class.h"
 #include "catalog/storage.h"
 #include "commands/sequence.h"
 #include "commands/tablecmds.h"
@@ -68,6 +69,7 @@
 #include "storage/lwlock.h"
 #include "storage/shmem.h"
 #include "storage/subsystems.h"
+#include "utils/gtcatcache.h"
 #include "utils/memutils.h"
 #include "utils/syscache.h"
 #include "utils/tuplestore.h"
@@ -153,12 +155,17 @@ static bool eoxact_usage_list_overflowed = false;
  *		OIDs of global temporary relations that we were using, which have been
  *		dropped by another backend (excludes locally dropped relations).
  *
+ *	processing_invalidated_gtrs
+ *		True while processing invalidated global temporary relations (used to
+ *		prevent infinite recursion).
+ *
  *	processed_dropped_subid
  *		Subtransaction ID in which we processed global temporary relations
  *		dropped by other backends.
  */
 static List *gtrs_invalidated = NIL;
 static List *gtrs_dropped = NIL;
+static bool processing_invalidated_gtrs = false;
 static SubTransactionId processed_dropped_subid = InvalidSubTransactionId;
 
 /*
@@ -936,8 +943,20 @@ InitGlobalTempRelation(Relation relation)
 void
 TrackGlobalTempRelation(Relation relation)
 {
-	/* Record our use of the relation */
-	gtr_record_usage(relation->rd_id, relation->rd_rel->relkind);
+	/*
+	 * Record our use of the relation and insert a pg_temp_class tuple for it.
+	 * We arrange things so that the presence of a usage record implies the
+	 * presence of a pg_temp_class tuple and vice versa, so it's sufficient to
+	 * do just one hash table lookup.
+	 */
+	if (gtr_local_usage == NULL ||
+		hash_search(gtr_local_usage,
+					&relation->rd_id, HASH_FIND, NULL) == NULL)
+	{
+		gtr_record_usage(relation->rd_id, relation->rd_rel->relkind);
+		InsertPgTempClassTuple(relation);
+	}
+	Assert(PgTempClassTupleExists(relation->rd_id));
 }
 
 /*
@@ -961,6 +980,9 @@ ForgetGlobalTempRelation(Oid relid)
 
 	entry->stopped_subid = GetCurrentSubTransactionId();
 	EOXactUsageListAdd(relid);
+
+	/* Delete its pg_temp_class tuple */
+	DeletePgTempClassTuple(relid);
 }
 
 /*
@@ -1031,6 +1053,12 @@ InvalidateGlobalTempRelation(Oid relid)
 void
 ProcessInvalidatedGlobalTempRelations(void)
 {
+	/* Prevent infinite recursion */
+	if (processing_invalidated_gtrs)
+		return;
+
+	processing_invalidated_gtrs = true;
+
 	/*
 	 * Scan the list of invalidated global temporary relations for any more
 	 * relations dropped by other backends (may already have found some in a
@@ -1090,6 +1118,8 @@ ProcessInvalidatedGlobalTempRelations(void)
 	 */
 	if (gtrs_dropped && processed_dropped_subid == InvalidSubTransactionId)
 	{
+		bool		tuples_deleted = false;
+
 		/*
 		 * Delete and forget locally-created storage for dropped relations.
 		 * This is done non-transactionally, since gtrs_dropped contains only
@@ -1121,19 +1151,34 @@ ProcessInvalidatedGlobalTempRelations(void)
 		}
 
 		/*
-		 * Remove all usage records and forget any ON COMMIT actions for the
-		 * dropped relations.  The former is non-transactional, but the latter
-		 * may be undone by a (sub)rollback.
+		 * Remove all usage records, forget any ON COMMIT actions, and delete
+		 * any temporary catalog entries for the dropped relations.  The usage
+		 * record removal is non-transactional, but the rest may be undone by
+		 * (sub)rollback.
 		 */
 		foreach_oid(relid, gtrs_dropped)
 		{
 			gtr_remove_usage(relid);
 			remove_on_commit_action(relid);
+
+			/* Delete the relation's pg_temp_class tuple, if it has one */
+			if (PgTempClassTupleExists(relid))
+			{
+				DeletePgTempClassTuple(relid);
+				tuples_deleted = true;
+			}
 		}
 
+		/* If we deleted anything, make the changes visible */
+		if (tuples_deleted)
+			CommandCounterIncrement();
+
 		/* All dropped relations have been processed, as of this subxact */
 		processed_dropped_subid = GetCurrentSubTransactionId();
 	}
+
+	/* Done processing */
+	processing_invalidated_gtrs = false;
 }
 
 /*
@@ -1208,7 +1253,11 @@ AtEOXact_GlobalTempRelation(bool isCommit)
 		list_free(gtrs_dropped);
 		gtrs_dropped = NIL;
 	}
+	processing_invalidated_gtrs = false;
 	processed_dropped_subid = InvalidSubTransactionId;
+
+	/* Clean up global temporary catalog caches */
+	AtEOXact_GTCatCache(isCommit);
 }
 
 /*
@@ -1282,6 +1331,9 @@ AtEOSubXact_GlobalTempRelation(bool isCommit, SubTransactionId mySubid,
 			processed_dropped_subid = InvalidSubTransactionId;
 	}
 
+	/* Clean up global temporary catalog caches */
+	AtEOSubXact_GTCatCache(isCommit, mySubid, parentSubid);
+
 	/* Don't reset the lists; we still need more cleanup later */
 }
 
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 199c8be332b..63abce22fe2 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -49,6 +49,7 @@
 #include "catalog/pg_opclass.h"
 #include "catalog/pg_operator.h"
 #include "catalog/pg_tablespace.h"
+#include "catalog/pg_temp_class.h"
 #include "catalog/pg_trigger.h"
 #include "catalog/pg_type.h"
 #include "catalog/storage.h"
@@ -3674,6 +3675,23 @@ reindex_index(const ReindexStmt *stmt, Oid indexId,
 
 	pg_rusage_init(&ru0);
 
+	/*
+	 * Special case: cannot recreate pg_temp_class_oid_index --- to do so
+	 * would require pg_temp_class to be a mapped relation (to avoid use of
+	 * the index while rebuilding it) and the relmapper does not support
+	 * temporary tables.  It might be possible to make this work, but it
+	 * doesn't seem worth the effort, so just punt.
+	 */
+	if (indexId == TempClassOidIndexId)
+	{
+		ereport(NOTICE,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("cannot reindex temporary system index \"%s\", skipping",
+					   get_rel_name(indexId)));
+		RemoveReindexPending(indexId);
+		return;
+	}
+
 	/*
 	 * Open and lock the parent heap relation.  ShareLock is sufficient since
 	 * we only need to be sure no schema or data changes are going on.
diff --git a/src/backend/catalog/meson.build b/src/backend/catalog/meson.build
index 7285ab2dfcf..5386d960b40 100644
--- a/src/backend/catalog/meson.build
+++ b/src/backend/catalog/meson.build
@@ -33,6 +33,7 @@ backend_sources += files(
   'pg_shdepend.c',
   'pg_subscription.c',
   'pg_tablespace.c',
+  'pg_temp_class.c',
   'pg_type.c',
   'storage.c',
   'toasting.c',
diff --git a/src/backend/catalog/pg_temp_class.c b/src/backend/catalog/pg_temp_class.c
new file mode 100644
index 00000000000..d01b5a02162
--- /dev/null
+++ b/src/backend/catalog/pg_temp_class.c
@@ -0,0 +1,234 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_temp_class.c
+ *	  routines to support manipulation of the pg_temp_class relation
+ *
+ * The pg_temp_class system catalog table is a global temporary table that
+ * stores local overrides to various fields from the pg_class table for the
+ * duration of the current session.  Currently, this is only used for
+ * global temporary relations, though in the future, it might also be used
+ * for local temporary relations.
+ *
+ * Tuples are first added to pg_temp_class when global temporary relations
+ * (including pg_temp_class itself) are created or opened for the first
+ * time in a session.  This "first time" might be repeated if the effects
+ * of a previous "first time" are rolled back.
+ *
+ * All pg_temp_class tuples are held in a cache, managed by gtcatcache.c,
+ * and all updates to pg_temp_class by backend code should go through the
+ * routines defined here.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/backend/catalog/pg_temp_class.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/htup_details.h"
+#include "catalog/pg_temp_class.h"
+#include "utils/gtcatcache.h"
+#include "utils/memutils.h"
+#include "utils/syscache.h"
+
+/* Cached copy of the pg_temp_class tuple descriptor */
+static TupleDesc pg_temp_class_tupdesc = NULL;
+
+/*
+ * get_pg_temp_class_tupdesc
+ *
+ *	Returns the tuple descriptor for pg_temp_class.
+ */
+static TupleDesc
+get_pg_temp_class_tupdesc(void)
+{
+	/* Build the tuple descriptor the first time through */
+	if (pg_temp_class_tupdesc == NULL)
+	{
+		MemoryContext oldcontext;
+		TupleDesc	tupdesc;
+
+		oldcontext = MemoryContextSwitchTo(TopMemoryContext);
+
+		tupdesc = CreateTemplateTupleDesc(Natts_pg_temp_class);
+		TupleDescInitEntry(tupdesc,
+						   (AttrNumber) Anum_pg_temp_class_oid,
+						   "oid", OIDOID, -1, 0);
+		TupleDescInitEntry(tupdesc,
+						   (AttrNumber) Anum_pg_temp_class_relfilenode,
+						   "relfilenode", OIDOID, -1, 0);
+		TupleDescInitEntry(tupdesc,
+						   (AttrNumber) Anum_pg_temp_class_reltablespace,
+						   "reltablespace", OIDOID, -1, 0);
+		TupleDescFinalize(tupdesc);
+
+		MemoryContextSwitchTo(oldcontext);
+
+		/* Cache it for all future use */
+		pg_temp_class_tupdesc = tupdesc;
+	}
+	return pg_temp_class_tupdesc;
+}
+
+/*
+ * PgTempClassTupleExists
+ *
+ *	Test if a pg_temp_class tuple for a global temporary relation exists.
+ */
+bool
+PgTempClassTupleExists(Oid relid)
+{
+	return GTCatCacheTupleExists(PG_TEMP_CLASS, relid);
+}
+
+/*
+ * GetPgTempClassTuple
+ *
+ *	Get the pg_temp_class tuple for a global temporary relation.
+ *
+ *	Returns NULL if the tuple could not be found.  Otherwise, the tuple
+ *	returned should be freed with heap_freetuple().
+ */
+HeapTuple
+GetPgTempClassTuple(Oid relid)
+{
+	return GTCatCacheSearch(PG_TEMP_CLASS, relid);
+}
+
+/*
+ * InsertPgTempClassTuple
+ *
+ *	Insert a new pg_temp_class tuple for a global temporary relation.
+ *
+ *	This is called when a global temporary relation is created or accessed for
+ *	the first time in a session.  All tuple data is taken from rel->rd_rel.
+ *
+ *	Note: The new tuple is not written to the database unless and until
+ *	CommandCounterIncrement() is called for a non-read-only command, or the
+ *	(sub)transaction is committed.  However, the new tuple *is* visible to all
+ *	the functions defined here.
+ */
+void
+InsertPgTempClassTuple(Relation rel)
+{
+	Form_pg_class form = rel->rd_rel;
+	Datum		values[Natts_pg_temp_class];
+	bool		nulls[Natts_pg_temp_class] = {0};
+
+	values[Anum_pg_temp_class_oid - 1] = ObjectIdGetDatum(RelationGetRelid(rel));
+	values[Anum_pg_temp_class_relfilenode - 1] = ObjectIdGetDatum(form->relfilenode);
+	values[Anum_pg_temp_class_reltablespace - 1] = ObjectIdGetDatum(form->reltablespace);
+
+	GTCatCacheTupleInsert(PG_TEMP_CLASS,
+						  RelationGetRelid(rel),
+						  rel->rd_rel->relkind,
+						  get_pg_temp_class_tupdesc(),
+						  values, nulls);
+}
+
+/*
+ * UpdatePgTempClassTuple
+ *
+ *	Update the pg_temp_class tuple for a global temporary relation.
+ */
+void
+UpdatePgTempClassTuple(Oid relid, HeapTuple newtuple)
+{
+	GTCatCacheTupleUpdate(PG_TEMP_CLASS, relid, newtuple);
+}
+
+/*
+ * DeletePgTempClassTuple
+ *
+ *	Delete the pg_temp_class tuple for a global temporary relation.
+ */
+void
+DeletePgTempClassTuple(Oid relid)
+{
+	GTCatCacheTupleDelete(PG_TEMP_CLASS, relid);
+}
+
+/*
+ * GetPgClassAndPgTempClassTuples
+ *
+ *	Get the pg_class tuple for a relation, and if it's a global temporary
+ *	relation, also get the corresponding pg_temp_class tuple.
+ *
+ *	If lock_tuple is true, the pg_class tuple will be locked, but not the
+ *	pg_temp_class tuple.
+ *
+ *	If check_temp is true, an error will be raised if a global temporary
+ *	relation's pg_temp_class tuple is not found.  After a global temporary
+ *	relation has been opened, its pg_temp_class tuple should always exist.
+ *
+ *	Returns NULL if the pg_class tuple could not be found.  Otherwise, the
+ *	tuple(s) returned should be freed with heap_freetuple().
+ */
+HeapTuple
+GetPgClassAndPgTempClassTuples(Oid relid, bool lock_tuple,
+							   HeapTuple *temp_tuple, bool check_temp)
+{
+	HeapTuple	tuple;
+
+	/* Get a copy of the pg_class tuple */
+	if (lock_tuple)
+		tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(relid));
+	else
+		tuple = SearchSysCacheCopy1(RELOID, ObjectIdGetDatum(relid));
+
+	if (HeapTupleIsValid(tuple) &&
+		((Form_pg_class) GETSTRUCT(tuple))->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+	{
+		/* Get the pg_temp_class tuple, and check it exists, if requested */
+		*temp_tuple = GetPgTempClassTuple(relid);
+		if (check_temp && !HeapTupleIsValid(*temp_tuple))
+			elog(ERROR, "cache lookup failed for global temp relation %u", relid);
+	}
+	else
+		*temp_tuple = NULL;
+
+	return tuple;
+}
+
+/*
+ * GetEffectivePgClassTuple
+ *
+ *	Get the effective pg_class tuple for a relation.
+ *
+ *	This will fetch the pg_class tuple for the relation and then, if it's a
+ *	global temporary relation, fetch the corresponding pg_temp_class tuple and
+ *	use the values in it to override the corresponding values in the pg_class
+ *	tuple.  Thus, the result represents the effective state of the relation in
+ *	this session.
+ *
+ *	For a global temporary relation that has not yet been opened in this
+ *	session, there will be no pg_temp_class tuple, and the pg_class tuple will
+ *	be returned unchanged.
+ *
+ *	Returns NULL if the pg_class tuple could not be found.  Otherwise, the
+ *	tuple returned should be freed with heap_freetuple().
+ */
+HeapTuple
+GetEffectivePgClassTuple(Oid relid)
+{
+	HeapTuple	tuple;
+	HeapTuple	temp_tuple;
+	Form_pg_class classform;
+	Form_pg_temp_class temp_classform;
+
+	/*
+	 * Get the pg_class and pg_temp_class tuples.  If we have the latter, use
+	 * it to update the former.
+	 */
+	tuple = GetPgClassAndPgTempClassTuples(relid, false, &temp_tuple, false);
+
+	if (HeapTupleIsValid(tuple) && HeapTupleIsValid(temp_tuple))
+	{
+		classform = (Form_pg_class) GETSTRUCT(tuple);
+		temp_classform = (Form_pg_temp_class) GETSTRUCT(temp_tuple);
+		COPY_PG_TEMP_CLASS_ATTRS(temp_classform, classform);
+	}
+	return tuple;
+}
diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 95a13aadb68..89ae4fc3b8a 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -52,6 +52,7 @@
 #include "catalog/pg_attrdef.h"
 #include "catalog/pg_constraint.h"
 #include "catalog/pg_inherits.h"
+#include "catalog/pg_temp_class.h"
 #include "catalog/toasting.h"
 #include "commands/defrem.h"
 #include "commands/progress.h"
@@ -1306,11 +1307,17 @@ copy_table_data(Relation NewHeap, Relation OldHeap, Relation OldIndex,
 	 * are only RECENTLY_DEAD.  Then we'd fail while trying to copy those
 	 * tuples.
 	 *
-	 * We don't need to open the toast relation here, just lock it.  The lock
-	 * will be held till end of transaction.
+	 * If it's a global temporary relation, we must open it to ensure that
+	 * it's properly initialized, so we might as well do that for all relation
+	 * types.  The lock will be held till end of transaction.
 	 */
 	if (OldHeap->rd_rel->reltoastrelid)
-		LockRelationOid(OldHeap->rd_rel->reltoastrelid, lmode);
+	{
+		Relation	toastRel;
+
+		toastRel = relation_open(OldHeap->rd_rel->reltoastrelid, lmode);
+		relation_close(toastRel, NoLock);
+	}
 
 	/*
 	 * If both tables have TOAST tables, perform toast swap by content.  It is
@@ -1514,9 +1521,13 @@ swap_relation_files(Oid r1, Oid r2, bool target_is_pg_class,
 {
 	Relation	relRelation;
 	HeapTuple	reltup1,
-				reltup2;
+				reltup2,
+				temp_reltup1,
+				temp_reltup2;
 	Form_pg_class relform1,
 				relform2;
+	Form_pg_temp_class temp_relform1,
+				temp_relform2;
 	RelFileNumber relfilenumber1,
 				relfilenumber2;
 	RelFileNumber swaptemp;
@@ -1524,21 +1535,29 @@ swap_relation_files(Oid r1, Oid r2, bool target_is_pg_class,
 	Oid			relam1,
 				relam2;
 
-	/* We need writable copies of both pg_class tuples. */
+	/*
+	 * We need writable copies of both pg_class tuples, and the corresponding
+	 * pg_temp_class tuples, if they're global temporary relations.
+	 */
 	relRelation = table_open(RelationRelationId, RowExclusiveLock);
 
-	reltup1 = SearchSysCacheCopy1(RELOID, ObjectIdGetDatum(r1));
+	reltup1 = GetPgClassAndPgTempClassTuples(r1, false, &temp_reltup1, true);
 	if (!HeapTupleIsValid(reltup1))
 		elog(ERROR, "cache lookup failed for relation %u", r1);
 	relform1 = (Form_pg_class) GETSTRUCT(reltup1);
+	temp_relform1 = (Form_pg_temp_class) GETSTRUCT_SAFE(temp_reltup1);
 
-	reltup2 = SearchSysCacheCopy1(RELOID, ObjectIdGetDatum(r2));
+	reltup2 = GetPgClassAndPgTempClassTuples(r2, false, &temp_reltup2, true);
 	if (!HeapTupleIsValid(reltup2))
 		elog(ERROR, "cache lookup failed for relation %u", r2);
 	relform2 = (Form_pg_class) GETSTRUCT(reltup2);
+	temp_relform2 = (Form_pg_temp_class) GETSTRUCT_SAFE(temp_reltup2);
+
+	if (HeapTupleIsValid(temp_reltup1) != HeapTupleIsValid(temp_reltup2))
+		elog(ERROR, "relkind mismatch: cannot swap global temporary relation with a relation that is not global temporary");
 
-	relfilenumber1 = relform1->relfilenode;
-	relfilenumber2 = relform2->relfilenode;
+	relfilenumber1 = GetEffective_relfilenode(relform1, temp_relform1);
+	relfilenumber2 = GetEffective_relfilenode(relform2, temp_relform2);
 	relam1 = relform1->relam;
 	relam2 = relform2->relam;
 
@@ -1551,13 +1570,14 @@ swap_relation_files(Oid r1, Oid r2, bool target_is_pg_class,
 		 */
 		Assert(!target_is_pg_class);
 
-		swaptemp = relform1->relfilenode;
-		relform1->relfilenode = relform2->relfilenode;
-		relform2->relfilenode = swaptemp;
+		SetEffective_relfilenode(relform1, temp_relform1, relfilenumber2);
+		SetEffective_relfilenode(relform2, temp_relform2, relfilenumber1);
 
-		swaptemp = relform1->reltablespace;
-		relform1->reltablespace = relform2->reltablespace;
-		relform2->reltablespace = swaptemp;
+		swaptemp = GetEffective_reltablespace(relform1, temp_relform1);
+		SetEffective_reltablespace(relform1, temp_relform1,
+								   GetEffective_reltablespace(relform2,
+															  temp_relform2));
+		SetEffective_reltablespace(relform2, temp_relform2, swaptemp);
 
 		swaptemp = relform1->relam;
 		relform1->relam = relform2->relam;
@@ -1726,6 +1746,17 @@ swap_relation_files(Oid r1, Oid r2, bool target_is_pg_class,
 		CacheInvalidateRelcacheByTuple(reltup2);
 	}
 
+	/*
+	 * For global temporary relations, update the tuples in pg_temp_class.
+	 */
+	if (HeapTupleIsValid(temp_reltup1) && HeapTupleIsValid(temp_reltup2))
+	{
+		UpdatePgTempClassTuple(r1, temp_reltup1);
+		UpdatePgTempClassTuple(r2, temp_reltup2);
+		heap_freetuple(temp_reltup1);
+		heap_freetuple(temp_reltup2);
+	}
+
 	/*
 	 * Now that pg_class has been updated with its relevant information for
 	 * the swap, update the dependency of the relations to point to their new
@@ -2152,6 +2183,16 @@ get_tables_to_repack(RepackCommand cmd, bool usingindex, MemoryContext permcxt)
 
 			index = (Form_pg_index) GETSTRUCT(tuple);
 
+			/*
+			 * Silently skip pg_temp_class --- it does not support relfilenode
+			 * changes, because that would require it to be a mapped relation,
+			 * and the relmapper does not support temporary tables.  It might
+			 * be possible to make this work, but it doesn't seem worth the
+			 * effort.
+			 */
+			if (index->indrelid == TempRelationRelationId)
+				continue;
+
 			classtup = SearchSysCache1(RELOID, ObjectIdGetDatum(index->indrelid));
 			if (!HeapTupleIsValid(classtup))
 				continue;
@@ -2196,6 +2237,16 @@ get_tables_to_repack(RepackCommand cmd, bool usingindex, MemoryContext permcxt)
 
 			class = (Form_pg_class) GETSTRUCT(tuple);
 
+			/*
+			 * Silently skip pg_temp_class --- it does not support relfilenode
+			 * changes, because that would require it to be a mapped relation,
+			 * and the relmapper does not support temporary tables.  It might
+			 * be possible to make this work, but it doesn't seem worth the
+			 * effort.
+			 */
+			if (class->oid == TempRelationRelationId)
+				continue;
+
 			/* Can only process plain tables and matviews */
 			if (class->relkind != RELKIND_RELATION &&
 				class->relkind != RELKIND_MATVIEW)
@@ -2464,6 +2515,20 @@ process_single_relation(RepackStmt *stmt, LOCKMODE lockmode, bool isTopLevel,
 				errmsg("cannot execute %s on temporary tables of other sessions",
 					   RepackCommandAsString(stmt->command)));
 
+	/*
+	 * Reject clustering pg_temp_class --- it does not support relfilenode
+	 * changes, because that would require it to be a mapped relation, and the
+	 * relmapper does not support temporary tables.  It might be possible to
+	 * make this work, but it doesn't seem worth the effort.
+	 */
+	if (tableOid == TempRelationRelationId)
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+		/*- translator: first %s is name of a SQL command, eg. REPACK */
+				errmsg("cannot execute %s on temporary system catalog \"%s\"",
+					   RepackCommandAsString(stmt->command),
+					   RelationGetRelationName(rel)));
+
 	/*
 	 * For partitioned tables, let caller handle this.  Otherwise, process it
 	 * here and we're done.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 8587b42d665..cb4132ee4ed 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -53,6 +53,7 @@
 #include "catalog/pg_rewrite.h"
 #include "catalog/pg_statistic_ext.h"
 #include "catalog/pg_tablespace.h"
+#include "catalog/pg_temp_class.h"
 #include "catalog/pg_trigger.h"
 #include "catalog/pg_type.h"
 #include "catalog/storage.h"
@@ -3906,7 +3907,8 @@ CheckRelationTableSpaceMove(Relation rel, Oid newTableSpaceId)
 
 /*
  * SetRelationTableSpace
- *		Set new reltablespace and relfilenumber in pg_class entry.
+ *		Set new reltablespace and relfilenumber in pg_class (and/or
+ *		pg_temp_class for a global temporary relation).
  *
  * newTableSpaceId is the new tablespace for the relation, and
  * newRelFilenumber its new filenumber.  If newRelFilenumber is
@@ -3926,33 +3928,55 @@ SetRelationTableSpace(Relation rel,
 {
 	Relation	pg_class;
 	HeapTuple	tuple;
+	HeapTuple	temp_tuple;
 	ItemPointerData otid;
 	Form_pg_class rd_rel;
+	Form_pg_temp_class temp_rd_rel;
 	Oid			reloid = RelationGetRelid(rel);
 
 	Assert(CheckRelationTableSpaceMove(rel, newTableSpaceId));
 
-	/* Get a modifiable copy of the relation's pg_class row. */
+	/*
+	 * Get a modifiable copy of the relation's pg_class row and, for a global
+	 * temporary relation, its pg_temp_class row.
+	 */
 	pg_class = table_open(RelationRelationId, RowExclusiveLock);
 
-	tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(reloid));
+	tuple = GetPgClassAndPgTempClassTuples(reloid, true, &temp_tuple, true);
 	if (!HeapTupleIsValid(tuple))
 		elog(ERROR, "cache lookup failed for relation %u", reloid);
 	otid = tuple->t_self;
 	rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+	temp_rd_rel = (Form_pg_temp_class) GETSTRUCT_SAFE(temp_tuple);
 
-	/* Update the pg_class row. */
-	rd_rel->reltablespace = (newTableSpaceId == MyDatabaseTableSpace) ?
-		InvalidOid : newTableSpaceId;
+	/*
+	 * Update the pg_class and/or pg_temp_class rows.  For global temporary
+	 * relations, the new tablespace is set in both pg_class and pg_temp_class
+	 * so that the change is made in the current session and for all future
+	 * sessions.  Other current sessions using the relation are not affected.
+	 */
+	SetEffective_reltablespace(rd_rel, temp_rd_rel,
+							   newTableSpaceId == MyDatabaseTableSpace ?
+							   InvalidOid : newTableSpaceId);
 	if (RelFileNumberIsValid(newRelFilenumber))
-		rd_rel->relfilenode = newRelFilenumber;
+		SetEffective_relfilenode(rd_rel, temp_rd_rel, newRelFilenumber);
+
 	CatalogTupleUpdate(pg_class, &otid, tuple);
 	UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+	if (HeapTupleIsValid(temp_tuple))
+	{
+		UpdatePgTempClassTuple(reloid, temp_tuple);
+		heap_freetuple(temp_tuple);
+	}
 
 	/*
 	 * Record dependency on tablespace.  This is required for relations that
 	 * have no physical storage, and for global temporary relations whose
-	 * physical storage is temporary.
+	 * physical storage is temporary.  Note that a global temporary relation
+	 * being used in another session will not see the change in tablespace,
+	 * and will continue to use the original tablespace until the session
+	 * exits, but its local temporary storage will prevent the old tablespace
+	 * from being dropped until then.
 	 */
 	if (!RELKIND_HAS_STORAGE(rel->rd_rel->relkind) ||
 		RELATION_IS_GLOBAL_TEMP(rel))
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index b6205a1dd40..03fe9dba839 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -39,6 +39,7 @@
 #include "catalog/namespace.h"
 #include "catalog/pg_database.h"
 #include "catalog/pg_inherits.h"
+#include "catalog/pg_temp_class.h"
 #include "commands/async.h"
 #include "commands/defrem.h"
 #include "commands/progress.h"
@@ -2184,6 +2185,23 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 		return false;
 	}
 
+	/*
+	 * VACUUM FULL on pg_temp_class is not supported --- it does not support
+	 * relfilenode changes, because that would require it to be a mapped
+	 * relation, and the relmapper does not support temporary tables. It might
+	 * be possible to make this work, but it doesn't seem worth the effort, so
+	 * do an "aggressive" VACUUM FREEZE instead.
+	 */
+	if (relid == TempRelationRelationId && (params.options & VACOPT_FULL))
+	{
+		params.options &= ~VACOPT_FULL;
+		params.options |= VACOPT_FREEZE;
+		params.freeze_min_age = 0;
+		params.freeze_table_age = 0;
+		params.multixact_freeze_min_age = 0;
+		params.multixact_freeze_table_age = 0;
+	}
+
 	/*
 	 * 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 69e80673427..684c2af9fff 100644
--- a/src/backend/parser/parse_utilcmd.c
+++ b/src/backend/parser/parse_utilcmd.c
@@ -39,6 +39,7 @@
 #include "catalog/pg_opclass.h"
 #include "catalog/pg_operator.h"
 #include "catalog/pg_statistic_ext.h"
+#include "catalog/pg_temp_class.h"
 #include "catalog/pg_type.h"
 #include "commands/comment.h"
 #include "commands/defrem.h"
@@ -1721,10 +1722,10 @@ generateClonedIndexStmt(RangeVar *heapRel, Relation source_idx,
 		*constraintOid = InvalidOid;
 
 	/*
-	 * Fetch pg_class tuple of source index.  We can't use the copy in the
-	 * relcache entry because it doesn't include optional fields.
+	 * Fetch effective pg_class tuple of source index.  We can't use the copy
+	 * in the relcache entry because it doesn't include optional fields.
 	 */
-	ht_idxrel = SearchSysCache1(RELOID, ObjectIdGetDatum(source_relid));
+	ht_idxrel = GetEffectivePgClassTuple(source_relid);
 	if (!HeapTupleIsValid(ht_idxrel))
 		elog(ERROR, "cache lookup failed for relation %u", source_relid);
 	idxrelrec = (Form_pg_class) GETSTRUCT(ht_idxrel);
@@ -2039,7 +2040,7 @@ generateClonedIndexStmt(RangeVar *heapRel, Relation source_idx,
 	}
 
 	/* Clean up */
-	ReleaseSysCache(ht_idxrel);
+	heap_freetuple(ht_idxrel);
 	ReleaseSysCache(ht_am);
 
 	return index;
diff --git a/src/backend/utils/activity/pgstat_io.c b/src/backend/utils/activity/pgstat_io.c
index 8ec1aad5078..c9fb53c10de 100644
--- a/src/backend/utils/activity/pgstat_io.c
+++ b/src/backend/utils/activity/pgstat_io.c
@@ -413,18 +413,17 @@ pgstat_tracks_io_object(BackendType bktype, IOObject io_object,
 		return false;
 
 	/*
-	 * In core Postgres, only regular backends and WAL Sender processes
-	 * executing queries will use local buffers and operate on temporary
-	 * relations. Parallel workers will not use local buffers (see
+	 * In core Postgres, only initdb, regular backends, and WAL Sender
+	 * processes executing queries will use local buffers and operate on
+	 * temporary relations. Parallel workers will not use local buffers (see
 	 * InitLocalBuffers()); however, extensions leveraging background workers
 	 * have no such limitation, so track IO on IOOBJECT_TEMP_RELATION for
 	 * BackendType B_BG_WORKER.
 	 */
 	no_temp_rel = bktype == B_AUTOVAC_LAUNCHER || bktype == B_BG_WRITER ||
 		bktype == B_CHECKPOINTER || bktype == B_AUTOVAC_WORKER ||
-		bktype == B_STANDALONE_BACKEND || bktype == B_STARTUP ||
-		bktype == B_WAL_SUMMARIZER || bktype == B_WAL_WRITER ||
-		bktype == B_WAL_RECEIVER;
+		bktype == B_STARTUP || bktype == B_WAL_SUMMARIZER ||
+		bktype == B_WAL_WRITER || bktype == B_WAL_RECEIVER;
 
 	if (no_temp_rel && io_context == IOCONTEXT_NORMAL &&
 		io_object == IOOBJECT_TEMP_RELATION)
diff --git a/src/backend/utils/adt/dbsize.c b/src/backend/utils/adt/dbsize.c
index e09ea8fe220..df1961accb2 100644
--- a/src/backend/utils/adt/dbsize.c
+++ b/src/backend/utils/adt/dbsize.c
@@ -19,6 +19,7 @@
 #include "catalog/pg_authid.h"
 #include "catalog/pg_database.h"
 #include "catalog/pg_tablespace.h"
+#include "catalog/pg_temp_class.h"
 #include "commands/tablespace.h"
 #include "miscadmin.h"
 #include "storage/fd.h"
@@ -901,7 +902,7 @@ pg_relation_filenode(PG_FUNCTION_ARGS)
 	HeapTuple	tuple;
 	Form_pg_class relform;
 
-	tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+	tuple = GetEffectivePgClassTuple(relid);
 	if (!HeapTupleIsValid(tuple))
 		PG_RETURN_NULL();
 	relform = (Form_pg_class) GETSTRUCT(tuple);
@@ -920,7 +921,7 @@ pg_relation_filenode(PG_FUNCTION_ARGS)
 		result = InvalidRelFileNumber;
 	}
 
-	ReleaseSysCache(tuple);
+	heap_freetuple(tuple);
 
 	if (!RelFileNumberIsValid(result))
 		PG_RETURN_NULL();
@@ -978,7 +979,7 @@ pg_relation_filepath(PG_FUNCTION_ARGS)
 	ProcNumber	backend;
 	RelPathStr	path;
 
-	tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+	tuple = GetEffectivePgClassTuple(relid);
 	if (!HeapTupleIsValid(tuple))
 		PG_RETURN_NULL();
 	relform = (Form_pg_class) GETSTRUCT(tuple);
@@ -1011,7 +1012,7 @@ pg_relation_filepath(PG_FUNCTION_ARGS)
 
 	if (!RelFileNumberIsValid(rlocator.relNumber))
 	{
-		ReleaseSysCache(tuple);
+		heap_freetuple(tuple);
 		PG_RETURN_NULL();
 	}
 
@@ -1041,7 +1042,7 @@ pg_relation_filepath(PG_FUNCTION_ARGS)
 			break;
 	}
 
-	ReleaseSysCache(tuple);
+	heap_freetuple(tuple);
 
 	path = relpathbackend(rlocator, backend, MAIN_FORKNUM);
 
diff --git a/src/backend/utils/cache/Makefile b/src/backend/utils/cache/Makefile
index 77b3e1a037b..8cda8909e1d 100644
--- a/src/backend/utils/cache/Makefile
+++ b/src/backend/utils/cache/Makefile
@@ -17,6 +17,7 @@ OBJS = \
 	catcache.o \
 	evtcache.o \
 	funccache.o \
+	gtcatcache.o \
 	inval.o \
 	lsyscache.o \
 	partcache.o \
diff --git a/src/backend/utils/cache/gtcatcache.c b/src/backend/utils/cache/gtcatcache.c
new file mode 100644
index 00000000000..a300aef4df9
--- /dev/null
+++ b/src/backend/utils/cache/gtcatcache.c
@@ -0,0 +1,873 @@
+/*-------------------------------------------------------------------------
+ *
+ * gtcatcache.c
+ *	  Global temporary catalog cache.
+ *
+ * This caches of the contents of selected global temporary catalog tables,
+ * holding details about all global temporary relations in use.
+ *
+ * Since global temporary relations are reset on backend exit, the contents
+ * of these catalogs are themselves temporary.  Additionally, all data is
+ * local to this session, and is never invalidated by another session
+ * (except if another session drops a global temporary relation, which is
+ * handled by ProcessInvalidatedGlobalTempRelations() in global_temp.c).
+ * Therefore, tuples added to these caches are kept until the end of the
+ * session, unless explicitly deleted.
+ *
+ * In addition, the contents of these caches are regarded as the master
+ * copies of the data --- tuples added to the caches are not written to the
+ * database immediately, but instead, are only written out when necessary.
+ * Tuples in the database are updated from the contents of these caches,
+ * not the other way round.  This requires all reads and writes to these
+ * global temporary system catalogs by backend code to go through this API.
+ *
+ * One reason for this design is that on a hot standby, or when operating
+ * in parallel mode, we cannot write directly to the catalog tables, but we
+ * may still open global temporary relations, so we must rely solely on the
+ * in-cache tuples.
+ *
+ * Another reason is that tuples for global temporary sequences must be
+ * inserted non-transactionally, but any tuple written to the database
+ * might be removed by rollback, so we may need to re-insert a database
+ * tuple after rollback of initialization of a global temporary sequence.
+ *
+ * In addition, this design delays the point at which we have to actually
+ * open the underlying catalog tables, which solves the "chicken and egg"
+ * bootstrapping problem when opening pg_temp_class for the first time.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/backend/utils/cache/gtcatcache.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/genam.h"
+#include "access/htup_details.h"
+#include "access/multixact.h"
+#include "access/parallel.h"
+#include "access/table.h"
+#include "access/xact.h"
+#include "catalog/indexing.h"
+#include "catalog/pg_temp_class.h"
+#include "utils/fmgroids.h"
+#include "utils/gtcatcache.h"
+#include "utils/hsearch.h"
+#include "utils/memutils.h"
+#include "utils/syscache.h"
+
+/*
+ * GTCatCacheEntry
+ *
+ *	A cache entry holding a single global temporary catalog table tuple.  All
+ *	cache entries are keyed by relation OID (these caches are only used for
+ *	global temporary catalog tables whose primary key is the global temporary
+ *	relation's OID).
+ *
+ *	If a cache entry is edited in a transaction or subtransaction, a linked
+ *	list of previous versions of the entry is built, allowing it to be
+ *	restored on rollback or subrollback.
+ */
+typedef struct GTCatCacheEntry
+{
+	Oid			relid;			/* lookup key: OID the tuple is for */
+	HeapTuple	tuple;			/* cached copy of the tuple */
+	bool		written;		/* has tuple been written to the database? */
+	bool		deleted;		/* has tuple been deleted? */
+	SubTransactionId subid;		/* subxact ID of insert/update/delete/flush */
+	struct GTCatCacheEntry *prev;	/* previous version, for (sub)rollback */
+} GTCatCacheEntry;
+
+/*
+ * A cache entry needs to be flushed if it has been written to the database
+ * and subsequently deleted, or it it has not been written to the database and
+ * not deleted.  We don't need to worry about updates, because all updates are
+ * written to the database immediately.
+ */
+#define CACHE_ENTRY_NEEDS_FLUSH(entry) ((entry)->written == (entry)->deleted)
+
+/*
+ * GTCatCache
+ *
+ *	A single global temporary catalog cache.
+ */
+typedef struct GTCatCache
+{
+	char	   *name;			/* cache name, for debugging purposes */
+	Oid			catalog_relid;	/* OID of underlying catalog table */
+	Oid			index_relid;	/* OID of catalog table's OID index */
+	AttrNumber	key_attno;		/* attno of catalog's key (OID) column */
+	SysCacheIdentifier cacheid; /* catalog's syscache ID */
+	HTAB	   *hashtable;		/* hash table for catalog tuples */
+
+	/*
+	 * List of cache entries that (might) need AtEOXact cleanup work.  As with
+	 * the relcache's eoxact_list[], this list intentionally has limited size,
+	 * and we switch to a full hash table traversal if the list overflows.
+	 */
+#define MAX_EOXACT_LIST 32
+	Oid			eoxact_list[MAX_EOXACT_LIST];
+	int			eoxact_list_len;
+	bool		eoxact_list_overflowed;
+} GTCatCache;
+
+#define EOXactListAdd(cache, relid) \
+	do { \
+		if ((cache)->eoxact_list_len < MAX_EOXACT_LIST) \
+			(cache)->eoxact_list[(cache)->eoxact_list_len++] = (relid); \
+		else \
+			(cache)->eoxact_list_overflowed = true; \
+	} while (0)
+
+/* Do we have any entries that need to be flushed to the database? */
+static bool have_entries_to_flush;
+
+/* Are we currently flushing entries (used to prevent infinite recursion) */
+static bool flushing_entries;
+
+/* Memory context for all cached tuples */
+static MemoryContext gt_cat_cache_tupctx;
+
+/* The actual caches (the hash tables are lazily built) */
+static GTCatCache gt_cat_cache[NUM_GT_CAT_CACHES] = {
+	/* PG_TEMP_CLASS */
+	{
+		.name = "pg_temp_class cache",
+		.catalog_relid = TempRelationRelationId,
+		.index_relid = TempClassOidIndexId,
+		.key_attno = Anum_pg_temp_class_oid,
+		.cacheid = TEMPRELOID,
+		.hashtable = NULL,
+		.eoxact_list_len = 0,
+		.eoxact_list_overflowed = false,
+	},
+};
+
+/*
+ * can_flush_catalogs
+ *
+ *	Returns true if we can flush catalog entries to the database; false if the
+ *	database should be considered read-only.
+ */
+static inline bool
+can_flush_catalogs(void)
+{
+	/* Prevent infinite recursion */
+	if (flushing_entries)
+		return false;
+
+	/*
+	 * A hot standby may open global temporary relations, creating global
+	 * temporary catalog entries, but it can never write them out.
+	 */
+	if (RecoveryInProgress())
+		return false;
+
+	/* Similarly, while in parallel mode, the database is read-only */
+	if (IsInParallelMode() || IsParallelWorker())
+		return false;
+
+	return true;
+}
+
+/*
+ * initialize_cache
+ *
+ *	Lazily initialize the specified global temporary catalog cache.
+ */
+static void
+initialize_cache(GTCatCache *cache)
+{
+	/* Create the cache's hash table, if we haven't done so already */
+	if (cache->hashtable == NULL)
+	{
+		HASHCTL		ctl;
+
+		ctl.keysize = sizeof(Oid);
+		ctl.entrysize = sizeof(GTCatCacheEntry);
+
+		cache->hashtable = hash_create(cache->name, 128, &ctl,
+									   HASH_ELEM | HASH_BLOBS);
+	}
+
+	/* Create the tuple memory context, if we haven't done so already */
+	if (gt_cat_cache_tupctx == NULL)
+	{
+		gt_cat_cache_tupctx =
+			AllocSetContextCreate(TopMemoryContext,
+								  "Global temporary catalog cache tuples",
+								  ALLOCSET_DEFAULT_SIZES);
+	}
+}
+
+/*
+ * find_and_update_cache_entry
+ *
+ *	Find and update the cache entry tuple for the specified relation.
+ */
+static GTCatCacheEntry *
+find_and_update_cache_entry(GTCatCache *cache, Oid relid, HeapTuple newtuple)
+{
+	SubTransactionId mySubid = GetCurrentSubTransactionId();
+	GTCatCacheEntry *entry;
+	MemoryContext oldcontext;
+
+	/* Find the cache entry; must exist */
+	if (cache->hashtable == NULL ||
+		(entry = hash_search(cache->hashtable, &relid, HASH_FIND, NULL)) == NULL)
+		elog(ERROR, "cache lookup failed for global temp relation %u", relid);
+
+	/* Should not have been deleted */
+	if (entry->deleted)
+		elog(ERROR, "cache entry for global temp relation %u was deleted", relid);
+
+	Assert(HeapTupleIsValid(entry->tuple));
+
+	/* Update the cache entry, saving a copy for rollback, if necessary */
+	oldcontext = MemoryContextSwitchTo(gt_cat_cache_tupctx);
+
+	if (entry->subid != mySubid)
+	{
+		GTCatCacheEntry *save_entry;
+
+		save_entry = palloc_object(GTCatCacheEntry);
+		save_entry->relid = entry->relid;
+		save_entry->tuple = entry->tuple;
+		save_entry->written = entry->written;
+		save_entry->deleted = entry->deleted;
+		save_entry->subid = entry->subid;
+		save_entry->prev = entry->prev;
+
+		entry->subid = mySubid;
+		entry->prev = save_entry;
+
+		/* Flag the entry as needing eoxact cleanup */
+		EOXactListAdd(cache, relid);
+	}
+	else
+		heap_freetuple(entry->tuple);
+
+	entry->tuple = heap_copytuple(newtuple);
+
+	MemoryContextSwitchTo(oldcontext);
+
+	return entry;
+}
+
+/*
+ * flush_cache_entries
+ *
+ *	Flush all cache entries to their respective database catalogs, inserting
+ *	new entries, and removing deleted entries.  We needn't worry about updated
+ *	entries, because all updates are written to the database immediately.
+ */
+static void
+flush_cache_entries(void)
+{
+	SubTransactionId mySubid = GetCurrentSubTransactionId();
+	Relation	rel[NUM_GT_CAT_CACHES];
+	CatalogIndexState indstate[NUM_GT_CAT_CACHES];
+	bool		db_updated = false;
+
+	/* Prevent infinite recursion while flushing */
+	Assert(!flushing_entries);
+	flushing_entries = true;
+
+	/*
+	 * Check whether we actually have any cache entries to flush.  This is
+	 * worth doing, because have_entries_to_flush may be a false positive
+	 * after (sub)rollback, and we don't want to create global temporary
+	 * catalog entries unless we actually need to.
+	 */
+	have_entries_to_flush = false;
+
+	for (int cacheId = 0; cacheId < NUM_GT_CAT_CACHES; cacheId++)
+	{
+		GTCatCache *cache = &gt_cat_cache[cacheId];
+
+		if (cache->hashtable != NULL)
+		{
+			HASH_SEQ_STATUS status;
+			GTCatCacheEntry *entry;
+
+			hash_seq_init(&status, cache->hashtable);
+			while ((entry = hash_seq_search(&status)) != NULL)
+			{
+				if (CACHE_ENTRY_NEEDS_FLUSH(entry))
+				{
+					have_entries_to_flush = true;
+					hash_seq_term(&status);
+					break;
+				}
+			}
+			if (have_entries_to_flush)
+				break;
+		}
+	}
+
+	if (!have_entries_to_flush)
+	{
+		flushing_entries = false;
+		return;
+	}
+
+	/*
+	 * Open the catalog tables and their indexes for all the caches.  We do
+	 * this before anything else, because doing so might lead to additional
+	 * cache entries being inserted, which we would like to write out too.
+	 */
+	for (int cacheId = 0; cacheId < NUM_GT_CAT_CACHES; cacheId++)
+	{
+		GTCatCache *cache = &gt_cat_cache[cacheId];
+
+		rel[cacheId] = table_open(cache->catalog_relid, RowExclusiveLock);
+		indstate[cacheId] = CatalogOpenIndexes(rel[cacheId]);
+	}
+
+	/*
+	 * For each cache, write out all entries not already written, and delete
+	 * any database tuples for entries written and marked as deleted.
+	 */
+	for (int cacheId = 0; cacheId < NUM_GT_CAT_CACHES; cacheId++)
+	{
+		GTCatCache *cache = &gt_cat_cache[cacheId];
+
+		if (cache->hashtable != NULL)
+		{
+			HASH_SEQ_STATUS status;
+			GTCatCacheEntry *entry;
+
+			hash_seq_init(&status, cache->hashtable);
+			while ((entry = hash_seq_search(&status)) != NULL)
+			{
+				/* Ignore entries that don't need flushing */
+				if (!CACHE_ENTRY_NEEDS_FLUSH(entry))
+					continue;
+
+				/* Delete or insert the tuple, as necessary */
+				if (entry->deleted)
+				{
+					HeapTuple	tuple;
+
+					tuple = SearchSysCache1(cache->cacheid,
+											ObjectIdGetDatum(entry->relid));
+					if (HeapTupleIsValid(tuple))
+					{
+						CatalogTupleDelete(rel[cacheId], &tuple->t_self);
+						ReleaseSysCache(tuple);
+					}
+				}
+				else
+					CatalogTupleInsertWithInfo(rel[cacheId], entry->tuple,
+											   indstate[cacheId]);
+
+				/*
+				 * Update the entry's written status, saving a copy for
+				 * rollback, if necessary.
+				 */
+				if (entry->subid != mySubid)
+				{
+					MemoryContext oldcontext;
+					GTCatCacheEntry *save_entry;
+
+					oldcontext = MemoryContextSwitchTo(gt_cat_cache_tupctx);
+
+					save_entry = palloc_object(GTCatCacheEntry);
+					save_entry->relid = entry->relid;
+					save_entry->tuple = heap_copytuple(entry->tuple);
+					save_entry->written = entry->written;
+					save_entry->deleted = entry->deleted;
+					save_entry->subid = entry->subid;
+					save_entry->prev = entry->prev;
+
+					entry->subid = mySubid;
+					entry->prev = save_entry;
+
+					/* Flag the entry as needing eoxact cleanup */
+					EOXactListAdd(cache, entry->relid);
+
+					MemoryContextSwitchTo(oldcontext);
+				}
+
+				entry->written = !entry->deleted;
+				db_updated = true;
+			}
+		}
+	}
+
+	/* If we made any changes, make them visible */
+	if (db_updated)
+		CommandCounterIncrement();
+
+	/* Tidy up */
+	for (int cacheId = 0; cacheId < NUM_GT_CAT_CACHES; cacheId++)
+	{
+		CatalogCloseIndexes(indstate[cacheId]);
+		table_close(rel[cacheId], RowExclusiveLock);
+	}
+	have_entries_to_flush = false;
+	flushing_entries = false;
+}
+
+/*
+ * AtEOXact_GTCatCacheEntryCleanup
+ *
+ *	Clean up a single cache entry at main-transaction commit or abort.
+ *
+ *	NB: this processing must be idempotent, because EOXactListAdd() doesn't
+ *	bother to prevent duplicate entries in eoxact_list[].
+ */
+static void
+AtEOXact_GTCatCacheEntryCleanup(GTCatCache *cache, GTCatCacheEntry *entry,
+								bool isCommit)
+{
+	/*
+	 * Was the entry inserted, updated, deleted, or flushed in this
+	 * transaction?
+	 *
+	 * On commit, reset the subid, marking it as no longer belonging to a
+	 * transaction, and discard any previous copy of the entry that was saved
+	 * in case of rollback.  If the tuple has been deleted in both the cache
+	 * and the database, the cache entry is no longer needed, and is removed.
+	 *
+	 * On rollback of an update, delete, or flush, restore the saved copy
+	 * reflecting the state of the entry prior to the transaction.
+	 *
+	 * Otherwise (rollback of an insert), the tuple no longer exists, and has
+	 * been removed from the database, so remove the cache entry.
+	 */
+	if (entry->subid != InvalidSubTransactionId)
+	{
+		GTCatCacheEntry *prev = entry->prev;
+
+		/*
+		 * If there's a saved copy, it should be the version that existed
+		 * prior to this transaction.
+		 *
+		 * Note: the saved copy might be marked as deleted, and have no tuple
+		 * (the change made in this transaction might have been to flush that
+		 * delete to the database).
+		 */
+		Assert(prev == NULL ||
+			   (prev->relid == entry->relid &&
+				prev->subid == InvalidSubTransactionId &&
+				prev->prev == NULL));
+
+		if (isCommit)
+		{
+			/* Commit of an insert, update, delete, or flush */
+			entry->subid = InvalidSubTransactionId;
+			entry->prev = NULL;
+			if (prev != NULL)
+				heap_freetuple(prev->tuple);
+			if (entry->deleted && !entry->written)
+				hash_search(cache->hashtable, &entry->relid, HASH_REMOVE, NULL);
+		}
+		else if (prev != NULL)
+		{
+			/* Rollback of an update, delete, or flush */
+			if (HeapTupleIsValid(entry->tuple))
+				heap_freetuple(entry->tuple);
+			entry->tuple = prev->tuple;
+			entry->written = prev->written;
+			entry->deleted = prev->deleted;
+			entry->subid = prev->subid;
+			entry->prev = NULL;
+			if (CACHE_ENTRY_NEEDS_FLUSH(entry))
+				have_entries_to_flush = true;
+		}
+		else
+		{
+			/* Rollback of an insert */
+			if (HeapTupleIsValid(entry->tuple))
+				heap_freetuple(entry->tuple);
+			if (prev != NULL)
+				heap_freetuple(prev->tuple);
+			hash_search(cache->hashtable, &entry->relid, HASH_REMOVE, NULL);
+		}
+
+		/* Free previous saved copy */
+		if (prev)
+			pfree(prev);
+	}
+}
+
+/*
+ * AtEOSubXact_GTCatCacheEntryCleanup
+ *
+ *	Clean up a single cache entry at subtransaction commit or abort.
+ *
+ *	NB: this processing must be idempotent, because EOXactListAdd() doesn't
+ *	bother to prevent duplicate entries in eoxact_list[].
+ */
+static void
+AtEOSubXact_GTCatCacheEntryCleanup(GTCatCache *cache, GTCatCacheEntry *entry,
+								   bool isCommit, SubTransactionId mySubid,
+								   SubTransactionId parentSubid)
+{
+	/*
+	 * Was the entry inserted, updated, deleted, or flushed in the current
+	 * subtransaction?
+	 *
+	 * On subcommit, mark it as inserted, updated, deleted, or flushed in the
+	 * parent, instead, and discard any previous copy of the entry that was
+	 * saved in case of subrollback, if it was for the parent subtransaction.
+	 *
+	 * On subrollback of an update, delete, or flush, restore the saved copy
+	 * from the parent subtransaction (or possibly a lower level).
+	 *
+	 * Otherwise (subrollback of an insert), just remove the cache entry.
+	 */
+	if (entry->subid == mySubid)
+	{
+		GTCatCacheEntry *prev = entry->prev;
+
+		/*
+		 * If there's a saved copy, it should be a version from the parent
+		 * subtransaction, or a lower level.
+		 *
+		 * Note: the saved copy might be marked as deleted, and have no tuple
+		 * (the change made in this subtransaction might have been to flush
+		 * that delete to the database).
+		 */
+		Assert(prev == NULL ||
+			   (prev->relid == entry->relid && prev->subid <= parentSubid));
+
+		if (isCommit)
+		{
+			/* Subcommit of an insert, update, delete, or flush */
+			entry->subid = parentSubid;
+			if (prev != NULL && prev->subid == parentSubid)
+			{
+				if (HeapTupleIsValid(prev->tuple))
+					heap_freetuple(prev->tuple);
+				entry->prev = prev->prev;
+				pfree(prev);
+			}
+		}
+		else if (prev != NULL)
+		{
+			/* Subrollback of an update, delete, or flush */
+			if (HeapTupleIsValid(entry->tuple))
+				heap_freetuple(entry->tuple);
+			entry->tuple = prev->tuple;
+			entry->written = prev->written;
+			entry->deleted = prev->deleted;
+			entry->subid = prev->subid;
+			entry->prev = prev->prev;
+			pfree(prev);
+			if (CACHE_ENTRY_NEEDS_FLUSH(entry))
+				have_entries_to_flush = true;
+		}
+		else
+		{
+			/* Subrollback of an insert */
+			if (HeapTupleIsValid(entry->tuple))
+				heap_freetuple(entry->tuple);
+			hash_search(cache->hashtable, &entry->relid, HASH_REMOVE, NULL);
+		}
+	}
+}
+
+/*
+ * GTCatCacheTupleExists
+ *
+ *	Test if a catalog tuple for the specified relation exists.
+ */
+bool
+GTCatCacheTupleExists(GTCatCacheIdentifier cacheId, Oid relid)
+{
+	GTCatCache *cache = &gt_cat_cache[cacheId];
+	GTCatCacheEntry *entry;
+
+	if (cache->hashtable == NULL)
+		return false;
+
+	entry = hash_search(cache->hashtable, &relid, HASH_FIND, NULL);
+
+	return entry != NULL && !entry->deleted;
+}
+
+/*
+ * GTCatCacheSearch
+ *
+ *	Search for the catalog tuple for the specified relation.  Returns NULL if
+ *	not found.  Otherwise the tuple should be freed with heap_freetuple().
+ */
+HeapTuple
+GTCatCacheSearch(GTCatCacheIdentifier cacheId, Oid relid)
+{
+	GTCatCache *cache = &gt_cat_cache[cacheId];
+	GTCatCacheEntry *entry;
+
+	if (cache->hashtable == NULL)
+		return NULL;
+
+	entry = hash_search(cache->hashtable, &relid, HASH_FIND, NULL);
+	if (entry == NULL || entry->deleted)
+		return NULL;
+
+	return heap_copytuple(entry->tuple);
+}
+
+/*
+ * GTCatCacheTupleInsert
+ *
+ *	Insert a new catalog tuple, constructed from the specified values, for the
+ *	specified relation.
+ *
+ *	Note: The new tuple is not written to the database until GTCatCacheFlush()
+ *	is called.
+ */
+void
+GTCatCacheTupleInsert(GTCatCacheIdentifier cacheId,
+					  Oid relid, char relkind, TupleDesc tupdesc,
+					  const Datum *values, const bool *nulls)
+{
+	GTCatCache *cache = &gt_cat_cache[cacheId];
+	GTCatCacheEntry *entry;
+	bool		found;
+	MemoryContext oldcontext;
+
+	initialize_cache(cache);
+
+	/* Insert a new cache entry for the tuple */
+	entry = hash_search(cache->hashtable, &relid, HASH_ENTER, &found);
+	if (found && !entry->deleted)
+		/* Should never try to re-insert a tuple for the same relid */
+		elog(ERROR, "tuple for global temporary relation %u already exists", relid);
+
+	/* Fill in entry; copy tuple to long-term tuple memory context */
+	oldcontext = MemoryContextSwitchTo(gt_cat_cache_tupctx);
+
+	entry->tuple = heap_form_tuple(tupdesc, values, nulls);
+	entry->written = false;
+	entry->deleted = false;
+	entry->prev = NULL;
+
+	MemoryContextSwitchTo(oldcontext);
+
+	/*
+	 * For a sequence, the tuple is inserted non-transactionally, and isn't
+	 * deleted on (sub)rollback.  Otherwise, for any other relkind, mark the
+	 * entry as created in the current subtransaction, and flag it for eoxact
+	 * cleanup.
+	 */
+	if (relkind == RELKIND_SEQUENCE)
+		entry->subid = InvalidSubTransactionId;
+	else
+	{
+		entry->subid = GetCurrentSubTransactionId();
+		EOXactListAdd(cache, relid);
+	}
+
+	/* Ensure that it is written out when requested */
+	have_entries_to_flush = true;
+}
+
+/*
+ * GTCatCacheTupleUpdate
+ *
+ *	Update a catalog tuple for the specified relation.
+ *
+ *	Note: This updates both the cache entry, and the tuple in the database
+ *	(inserting it, if it hasn't already been written out).  This should not be
+ *	called while the database is read-only.
+ */
+void
+GTCatCacheTupleUpdate(GTCatCacheIdentifier cacheId, Oid relid,
+					  HeapTuple newtuple)
+{
+	GTCatCache *cache = &gt_cat_cache[cacheId];
+	GTCatCacheEntry *entry;
+	Relation	rel;
+
+	/* Find and update the cache entry for this relation */
+	entry = find_and_update_cache_entry(cache, relid, newtuple);
+
+	/* Update the tuple in the database to match */
+	rel = table_open(cache->catalog_relid, RowExclusiveLock);
+
+	if (entry->written)
+	{
+		HeapTuple	oldtuple;
+
+		oldtuple = SearchSysCache1(cache->cacheid, ObjectIdGetDatum(relid));
+		if (!HeapTupleIsValid(oldtuple))
+			elog(ERROR, "cache lookup failed for global temp relation %u", relid);
+
+		CatalogTupleUpdate(rel, &oldtuple->t_self, newtuple);
+
+		ReleaseSysCache(oldtuple);
+	}
+	else
+	{
+		CatalogTupleInsert(rel, newtuple);
+		entry->written = true;
+	}
+
+	table_close(rel, RowExclusiveLock);
+}
+
+/*
+ * GTCatCacheTupleDelete
+ *
+ *	Delete the catalog tuple for the specified relation.
+ *
+ *	Note: If the database is currently read-only, the tuple will only be
+ *	marked as deleted in the cache; it won't actually be deleted from the
+ *	database until GTCatCacheFlush() is called.
+ */
+void
+GTCatCacheTupleDelete(GTCatCacheIdentifier cacheId, Oid relid)
+{
+	GTCatCache *cache = &gt_cat_cache[cacheId];
+	GTCatCacheEntry *entry;
+
+	/*
+	 * Find and update the cache entry for this relation, setting its tuple to
+	 * NULL, and marking it as deleted.
+	 */
+	entry = find_and_update_cache_entry(cache, relid, NULL);
+	entry->deleted = true;
+
+	/*
+	 * If it was written to the database, delete the tuple there too, unless
+	 * the database is currently read-only.
+	 */
+	if (entry->written && can_flush_catalogs())
+	{
+		Relation	rel;
+
+		rel = table_open(cache->catalog_relid, RowExclusiveLock);
+
+		/* Re-check entry->written, in case an intervening flush deleted it */
+		if (entry->written)
+		{
+			HeapTuple	oldtuple;
+
+			oldtuple = SearchSysCache1(cache->cacheid, ObjectIdGetDatum(relid));
+			if (!HeapTupleIsValid(oldtuple))
+				elog(ERROR, "cache lookup failed for global temp relation %u", relid);
+
+			CatalogTupleDelete(rel, &oldtuple->t_self);
+			ReleaseSysCache(oldtuple);
+			entry->written = false;
+		}
+
+		table_close(rel, RowExclusiveLock);
+	}
+}
+
+/*
+ * GTCatCacheFlush
+ *
+ *	Write out any new cache entries to the database, so that the database is
+ *	in sync with the contents of the cache (unless the database is currently
+ *	read-only).
+ */
+void
+GTCatCacheFlush(void)
+{
+	if (have_entries_to_flush && can_flush_catalogs())
+		flush_cache_entries();
+}
+
+/*
+ * AtEOXact_GTCatCache
+ *
+ *	Clean up global temporary catalog caches at main-transaction commit or
+ *	abort.
+ */
+void
+AtEOXact_GTCatCache(bool isCommit)
+{
+	/* Clean up each cache */
+	for (int cacheId = 0; cacheId < NUM_GT_CAT_CACHES; cacheId++)
+	{
+		GTCatCache *cache = &gt_cat_cache[cacheId];
+		GTCatCacheEntry *entry;
+
+		/*
+		 * Unless the eoxact_list[] overflowed, we only need to examine the
+		 * entries listed in it.  Otherwise fall back on a hash_seq_search
+		 * scan --- see similar code in AtEOXact_RelationCache().
+		 */
+		if (cache->eoxact_list_overflowed)
+		{
+			HASH_SEQ_STATUS status;
+
+			hash_seq_init(&status, cache->hashtable);
+			while ((entry = hash_seq_search(&status)) != NULL)
+			{
+				AtEOXact_GTCatCacheEntryCleanup(cache, entry, isCommit);
+			}
+		}
+		else
+		{
+			for (int i = 0; i < cache->eoxact_list_len; i++)
+			{
+				entry = hash_search(cache->hashtable, &cache->eoxact_list[i],
+									HASH_FIND, NULL);
+				if (entry)
+					AtEOXact_GTCatCacheEntryCleanup(cache, entry, isCommit);
+			}
+		}
+
+		/* Now we're out of the transaction and can clear eoxact_list */
+		cache->eoxact_list_len = 0;
+		cache->eoxact_list_overflowed = false;
+	}
+	flushing_entries = false;
+}
+
+/*
+ * AtEOSubXact_GTCatCache
+ *
+ *	Clean up global temporary catalog caches at sub-transaction commit or
+ *	abort.
+ */
+void
+AtEOSubXact_GTCatCache(bool isCommit, SubTransactionId mySubid,
+					   SubTransactionId parentSubid)
+{
+	/* Clean up each cache */
+	for (int cacheId = 0; cacheId < NUM_GT_CAT_CACHES; cacheId++)
+	{
+		GTCatCache *cache = &gt_cat_cache[cacheId];
+		GTCatCacheEntry *entry;
+
+		/*
+		 * Unless the eoxact_list[] overflowed, we only need to examine the
+		 * entries listed in it.  Otherwise fall back on a hash_seq_search
+		 * scan.  Same logic as in AtEOXact_GTCatCache().
+		 */
+		if (cache->eoxact_list_overflowed)
+		{
+			HASH_SEQ_STATUS status;
+
+			hash_seq_init(&status, cache->hashtable);
+			while ((entry = hash_seq_search(&status)) != NULL)
+			{
+				AtEOSubXact_GTCatCacheEntryCleanup(cache, entry, isCommit,
+												   mySubid, parentSubid);
+			}
+		}
+		else
+		{
+			for (int i = 0; i < cache->eoxact_list_len; i++)
+			{
+				entry = hash_search(cache->hashtable, &cache->eoxact_list[i],
+									HASH_FIND, NULL);
+				if (entry)
+					AtEOSubXact_GTCatCacheEntryCleanup(cache, entry,
+													   isCommit, mySubid,
+													   parentSubid);
+			}
+		}
+
+		/* Don't reset eoxact_list; we still need more cleanup later */
+	}
+}
diff --git a/src/backend/utils/cache/inval.c b/src/backend/utils/cache/inval.c
index a46b0ae70e2..2adb9fc48b1 100644
--- a/src/backend/utils/cache/inval.c
+++ b/src/backend/utils/cache/inval.c
@@ -51,10 +51,11 @@
  *	PrepareToInvalidateCacheTuple() routine provides the knowledge of which
  *	catcaches may need invalidation for a given tuple.
  *
- *	Also, whenever we see an operation on a pg_class, pg_attribute, or
- *	pg_index tuple, we register a relcache flush operation for the relation
- *	described by that tuple (as specified in CacheInvalidateHeapTuple()).
- *	Likewise for pg_constraint tuples for foreign keys on relations.
+ *	Also, whenever we see an operation on a pg_class, pg_temp_class,
+ *	pg_attribute, or pg_index tuple, we register a relcache flush operation
+ *	for the relation described by that tuple (as specified in
+ *	CacheInvalidateHeapTuple()).  Likewise for pg_constraint tuples for
+ *	foreign keys on relations.
  *
  *	We keep the relcache flush requests in lists separate from the catcache
  *	tuple flush requests.  This allows us to issue all the pending catcache
@@ -119,6 +120,7 @@
 #include "access/xloginsert.h"
 #include "catalog/catalog.h"
 #include "catalog/pg_constraint.h"
+#include "catalog/pg_temp_class.h"
 #include "miscadmin.h"
 #include "storage/procnumber.h"
 #include "storage/sinval.h"
@@ -1493,6 +1495,13 @@ CacheInvalidateHeapTupleCommon(Relation relation,
 		else
 			databaseId = MyDatabaseId;
 	}
+	else if (tupleRelId == TempRelationRelationId)
+	{
+		Form_pg_temp_class temp_classtup = (Form_pg_temp_class) GETSTRUCT(tuple);
+
+		relationId = temp_classtup->oid;
+		databaseId = MyDatabaseId;
+	}
 	else if (tupleRelId == AttributeRelationId)
 	{
 		Form_pg_attribute atttup = (Form_pg_attribute) GETSTRUCT(tuple);
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index 9ef3922d17c..80675888b89 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -40,6 +40,7 @@
 #include "catalog/pg_range.h"
 #include "catalog/pg_statistic.h"
 #include "catalog/pg_subscription.h"
+#include "catalog/pg_temp_class.h"
 #include "catalog/pg_transform.h"
 #include "catalog/pg_type.h"
 #include "miscadmin.h"
@@ -2376,6 +2377,19 @@ get_rel_tablespace(Oid relid)
 		Oid			result;
 
 		result = reltup->reltablespace;
+
+		/* Global temporary relations may override reltablespace locally */
+		if (reltup->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+		{
+			HeapTuple	temp_tp;
+
+			temp_tp = GetPgTempClassTuple(relid);
+			if (HeapTupleIsValid(temp_tp))
+			{
+				result = ((Form_pg_temp_class) GETSTRUCT(temp_tp))->reltablespace;
+				heap_freetuple(temp_tp);
+			}
+		}
 		ReleaseSysCache(tp);
 		return result;
 	}
diff --git a/src/backend/utils/cache/meson.build b/src/backend/utils/cache/meson.build
index a4435e0c3c6..d5e1a361d7f 100644
--- a/src/backend/utils/cache/meson.build
+++ b/src/backend/utils/cache/meson.build
@@ -5,6 +5,7 @@ backend_sources += files(
   'catcache.c',
   'evtcache.c',
   'funccache.c',
+  'gtcatcache.c',
   'inval.c',
   'lsyscache.c',
   'partcache.c',
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 3aec71824ec..cef2435c195 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -62,6 +62,7 @@
 #include "catalog/pg_statistic_ext.h"
 #include "catalog/pg_subscription.h"
 #include "catalog/pg_tablespace.h"
+#include "catalog/pg_temp_class.h"
 #include "catalog/pg_trigger.h"
 #include "catalog/pg_type.h"
 #include "catalog/schemapg.h"
@@ -338,6 +339,10 @@ static void unlink_initfile(const char *initfilename, int elevel);
  *		an attribute were to be added after scanning pg_class and before
  *		scanning pg_attribute, relnatts wouldn't match.
  *
+ *		If targetRelId is a global temporary relation, pg_temp_class is
+ *		also scanned, and if a matching tuple is found, its attributes are
+ *		used to override the corresponding attributes from pg_class.
+ *
  *		NB: the returned tuple has been copied into palloc'd storage
  *		and must eventually be freed with heap_freetuple.
  */
@@ -405,6 +410,33 @@ ScanPgRelation(Oid targetRelId, bool indexOK, bool force_non_historic)
 
 	table_close(pg_class_desc, AccessShareLock);
 
+	/*
+	 * For global temporary relations, also scan pg_temp_class and apply any
+	 * session-specific overrides to the pg_class tuple.
+	 */
+	if (HeapTupleIsValid(pg_class_tuple))
+	{
+		Form_pg_class pg_class_form;
+
+		pg_class_form = (Form_pg_class) GETSTRUCT(pg_class_tuple);
+
+		if (pg_class_form->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+		{
+			HeapTuple	pg_temp_class_tuple;
+
+			pg_temp_class_tuple = GetPgTempClassTuple(targetRelId);
+
+			if (HeapTupleIsValid(pg_temp_class_tuple))
+			{
+				Form_pg_temp_class pg_temp_class_form;
+
+				pg_temp_class_form = (Form_pg_temp_class) GETSTRUCT(pg_temp_class_tuple);
+				COPY_PG_TEMP_CLASS_ATTRS(pg_temp_class_form, pg_class_form);
+				heap_freetuple(pg_temp_class_tuple);
+			}
+		}
+	}
+
 	return pg_class_tuple;
 }
 
@@ -3843,7 +3875,9 @@ RelationSetNewRelfilenumber(Relation relation, char persistence)
 	Relation	pg_class;
 	ItemPointerData otid;
 	HeapTuple	tuple;
+	HeapTuple	temp_tuple;
 	Form_pg_class classform;
+	Form_pg_temp_class temp_classform;
 	MultiXactId minmulti = InvalidMultiXactId;
 	TransactionId freezeXid = InvalidTransactionId;
 	RelFileLocator newrlocator;
@@ -3880,17 +3914,19 @@ RelationSetNewRelfilenumber(Relation relation, char persistence)
 				 errmsg("unexpected request for new relfilenumber in binary upgrade mode")));
 
 	/*
-	 * Get a writable copy of the pg_class tuple for the given relation.
+	 * Get a writable copy of the relation's pg_class tuple and, for a global
+	 * temporary relation, its pg_temp_class tuple.
 	 */
 	pg_class = table_open(RelationRelationId, RowExclusiveLock);
 
-	tuple = SearchSysCacheLockedCopy1(RELOID,
-									  ObjectIdGetDatum(RelationGetRelid(relation)));
+	tuple = GetPgClassAndPgTempClassTuples(RelationGetRelid(relation), true,
+										   &temp_tuple, true);
 	if (!HeapTupleIsValid(tuple))
 		elog(ERROR, "could not find tuple for relation %u",
 			 RelationGetRelid(relation));
 	otid = tuple->t_self;
 	classform = (Form_pg_class) GETSTRUCT(tuple);
+	temp_classform = (Form_pg_temp_class) GETSTRUCT_SAFE(temp_tuple);
 
 	/*
 	 * Schedule unlinking of the old storage at transaction commit, except
@@ -3996,8 +4032,8 @@ RelationSetNewRelfilenumber(Relation relation, char persistence)
 	}
 	else
 	{
-		/* Normal case, update the pg_class entry */
-		classform->relfilenode = newrelfilenumber;
+		/* Normal case, update the pg_class and pg_temp_class entries */
+		SetEffective_relfilenode(classform, temp_classform, newrelfilenumber);
 
 		/* relpages etc. never change for sequences */
 		if (relation->rd_rel->relkind != RELKIND_SEQUENCE)
@@ -4012,6 +4048,11 @@ RelationSetNewRelfilenumber(Relation relation, char persistence)
 		classform->relpersistence = persistence;
 
 		CatalogTupleUpdate(pg_class, &otid, tuple);
+		if (HeapTupleIsValid(temp_tuple))
+		{
+			UpdatePgTempClassTuple(RelationGetRelid(relation), temp_tuple);
+			heap_freetuple(temp_tuple);
+		}
 	}
 
 	UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
@@ -4020,8 +4061,8 @@ RelationSetNewRelfilenumber(Relation relation, char persistence)
 	table_close(pg_class, RowExclusiveLock);
 
 	/*
-	 * Make the pg_class row change or relation map change visible.  This will
-	 * cause the relcache entry to get updated, too.
+	 * Make the pg_class and pg_temp_class row changes or relation map change
+	 * visible.  This will cause the relcache entry to get updated, too.
 	 */
 	CommandCounterIncrement();
 
@@ -6914,6 +6955,15 @@ write_item(const void *data, Size len, FILE *fp)
  * of the latter. The special cases are relations where
  * RelationCacheInitializePhase2/3 chooses to nail for efficiency reasons, but
  * which do not support any syscache.
+ *
+ * Global temporary relations are never nailed (because that would required
+ * them to be mapped, and the relmapper does not support temporary relations),
+ * but they do all support syscaches.  Despite this, we intentionally do not
+ * cache global temporary relations, since we don't want to load them on
+ * startup, because doing so would result in temporary relation storage being
+ * created when it might not be needed.  Instead, all global temporary
+ * relations are lazily initialized, if and when they are needed.  See also
+ * InitCatalogCachePhase2().
  */
 bool
 RelationIdIsInInitFile(Oid relationId)
@@ -6930,6 +6980,8 @@ RelationIdIsInInitFile(Oid relationId)
 		Assert(!RelationSupportsSysCache(relationId));
 		return true;
 	}
+	if (IsGlobalTempCatalogRelation(relationId))
+		return false;
 	return RelationSupportsSysCache(relationId);
 }
 
diff --git a/src/backend/utils/cache/syscache.c b/src/backend/utils/cache/syscache.c
index f4233f9e31a..d08c9088b71 100644
--- a/src/backend/utils/cache/syscache.c
+++ b/src/backend/utils/cache/syscache.c
@@ -176,6 +176,10 @@ InitCatalogCache(void)
  * relcache with entries for the most-commonly-used system catalogs.
  * Therefore, we invoke this routine when we need to write a new relcache
  * init file.
+ *
+ * We skip caches based on global temporary relations because we don't want
+ * temporary relation storage to be needlessly created on startup.  Instead,
+ * always initialize these caches on first use.
  */
 void
 InitCatalogCachePhase2(void)
@@ -185,7 +189,8 @@ InitCatalogCachePhase2(void)
 	Assert(CacheInitialized);
 
 	for (cacheId = 0; cacheId < SysCacheSize; cacheId++)
-		InitCatCachePhase2(SysCache[cacheId], true);
+		if (!SysCacheTableIsGlobalTemp(cacheId))
+			InitCatCachePhase2(SysCache[cacheId], true);
 }
 
 
diff --git a/src/include/access/htup_details.h b/src/include/access/htup_details.h
index 77a6c48fd71..4084fad6c6b 100644
--- a/src/include/access/htup_details.h
+++ b/src/include/access/htup_details.h
@@ -721,6 +721,17 @@ GETSTRUCT(const HeapTupleData *tuple)
 	return ((char *) (tuple->t_data) + tuple->t_data->t_hoff);
 }
 
+/*
+ * GETSTRUCT_SAFE - given a possibly NULL HeapTuple pointer, return the
+ * address of the user data or NULL
+ */
+static inline void *
+GETSTRUCT_SAFE(const HeapTupleData *tuple)
+{
+	return HeapTupleIsValid(tuple) ?
+		((char *) (tuple->t_data) + tuple->t_data->t_hoff) : NULL;
+}
+
 /*
  * Accessor functions to be used with HeapTuple pointers.
  */
diff --git a/src/include/catalog/Makefile b/src/include/catalog/Makefile
index bab57372b88..629a13edc24 100644
--- a/src/include/catalog/Makefile
+++ b/src/include/catalog/Makefile
@@ -86,7 +86,8 @@ CATALOG_HEADERS := \
 	pg_propgraph_element_label.h \
 	pg_propgraph_label.h \
 	pg_propgraph_label_property.h \
-	pg_propgraph_property.h
+	pg_propgraph_property.h \
+	pg_temp_class.h
 
 GENERATED_HEADERS := $(CATALOG_HEADERS:%.h=%_d.h)
 
diff --git a/src/include/catalog/genbki.h b/src/include/catalog/genbki.h
index 12d2a3e295b..2f1253281c5 100644
--- a/src/include/catalog/genbki.h
+++ b/src/include/catalog/genbki.h
@@ -44,6 +44,7 @@
 /* Options that may appear after CATALOG (on the same line) */
 #define BKI_BOOTSTRAP
 #define BKI_SHARED_RELATION
+#define BKI_TEMP_RELATION
 #define BKI_ROWTYPE_OID(oid,oidmacro)
 #define BKI_SCHEMA_MACRO
 
diff --git a/src/include/catalog/meson.build b/src/include/catalog/meson.build
index fa836e4ee25..404f8503276 100644
--- a/src/include/catalog/meson.build
+++ b/src/include/catalog/meson.build
@@ -74,6 +74,7 @@ catalog_headers = [
   'pg_propgraph_label.h',
   'pg_propgraph_label_property.h',
   'pg_propgraph_property.h',
+  'pg_temp_class.h',
 ]
 
 # The .dat files we need can just be listed alphabetically.
diff --git a/src/include/catalog/pg_temp_class.h b/src/include/catalog/pg_temp_class.h
new file mode 100644
index 00000000000..7c1e711a5f6
--- /dev/null
+++ b/src/include/catalog/pg_temp_class.h
@@ -0,0 +1,134 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_temp_class.h
+ *	  definition of the "temporary relation" system catalog (pg_temp_class)
+ *
+ * This is a global temporary system catalog table storing session-specific
+ * information about temporary relations.  Currently, it is only used for
+ * global temporary relations.  The attributes are a subset of those from
+ * pg_class, and their values take precedence over the values from pg_class.
+ *
+ * Portions Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * src/include/catalog/pg_temp_class.h
+ *
+ * NOTES
+ *	  The Catalog.pm module reads this file and derives schema
+ *	  information.
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_TEMP_CLASS_H
+#define PG_TEMP_CLASS_H
+
+#include "access/htup.h"
+#include "catalog/genbki.h"
+#include "catalog/pg_class.h"
+#include "catalog/pg_temp_class_d.h"	/* IWYU pragma: export */
+#include "utils/rel.h"
+
+/* ----------------
+ *		pg_temp_class definition.  cpp turns this into
+ *		typedef struct FormData_pg_temp_class
+ * ----------------
+ */
+BEGIN_CATALOG_STRUCT
+
+CATALOG(pg_temp_class,8082,TempRelationRelationId) BKI_TEMP_RELATION
+{
+	/* oid */
+	Oid			oid BKI_LOOKUP(pg_class);
+
+	/* identifier of physical storage file */
+	/* relfilenode == 0 means it is a "mapped" relation, see relmapper.c */
+	Oid			relfilenode BKI_DEFAULT(0);
+
+	/* identifier of table space for relation (0 means default for database) */
+	Oid			reltablespace BKI_DEFAULT(0) BKI_LOOKUP_OPT(pg_tablespace);
+} FormData_pg_temp_class;
+
+END_CATALOG_STRUCT
+
+/* ----------------
+ *		Form_pg_temp_class corresponds to a pointer to a tuple with
+ *		the format of pg_temp_class relation.
+ * ----------------
+ */
+typedef FormData_pg_temp_class *Form_pg_temp_class;
+
+DECLARE_UNIQUE_INDEX_PKEY(pg_temp_class_oid_index, 8083, TempClassOidIndexId, pg_temp_class, btree(oid oid_ops));
+
+MAKE_SYSCACHE(TEMPRELOID, pg_temp_class_oid_index, 128);
+
+/*
+ * Copy all pg_temp_class attributes from "source" to "target", where the
+ * source and target may be of type Form_pg_class or Form_pg_temp_class.
+ *
+ * Beware of multiple evaluations of arguments!
+ */
+#define COPY_PG_TEMP_CLASS_ATTRS(source, target) \
+	do { \
+		(target)->oid = (source)->oid; \
+		(target)->relfilenode = (source)->relfilenode; \
+		(target)->reltablespace = (source)->reltablespace; \
+	} while (0)
+
+/*
+ * Get the effective value of relfilenode from pg_class and pg_temp_class
+ * tuple data.  The value from pg_temp_class (if present) takes precedence.
+ */
+static inline Oid
+GetEffective_relfilenode(Form_pg_class cf, Form_pg_temp_class tf)
+{
+	return tf != NULL ? tf->relfilenode : cf->relfilenode;
+}
+
+/*
+ * Get the effective value of reltablespace from pg_class and pg_temp_class
+ * tuple data.  The value from pg_temp_class (if present) takes precedence.
+ */
+static inline Oid
+GetEffective_reltablespace(Form_pg_class cf, Form_pg_temp_class tf)
+{
+	return tf != NULL ? tf->reltablespace : cf->reltablespace;
+}
+
+/*
+ * Set the effective value of relfilenode in tuple form data from pg_class or
+ * pg_temp_class.  The value is set in pg_temp_class instead of pg_class, if
+ * the pg_temp_class tuple form data is non-NULL.
+ */
+static inline void
+SetEffective_relfilenode(Form_pg_class cf, Form_pg_temp_class tf, Oid val)
+{
+	if (tf != NULL)
+		tf->relfilenode = val;
+	else
+		cf->relfilenode = val;
+}
+
+/*
+ * Set the effective value of reltablespace in tuple form data from pg_class
+ * and pg_temp_class.  The value is set in pg_temp_class as well as pg_class,
+ * if the pg_temp_class tuple form data is non-NULL.
+ */
+static inline void
+SetEffective_reltablespace(Form_pg_class cf, Form_pg_temp_class tf, Oid val)
+{
+	/* NB: Value is set *both* locally and globally */
+	cf->reltablespace = val;
+	if (tf != NULL)
+		tf->reltablespace = val;
+}
+
+extern bool PgTempClassTupleExists(Oid relid);
+extern HeapTuple GetPgTempClassTuple(Oid relid);
+extern void InsertPgTempClassTuple(Relation rel);
+extern void UpdatePgTempClassTuple(Oid relid, HeapTuple newtuple);
+extern void DeletePgTempClassTuple(Oid relid);
+extern HeapTuple GetPgClassAndPgTempClassTuples(Oid relid, bool lock_tuple,
+												HeapTuple *temp_tuple,
+												bool check_temp);
+extern HeapTuple GetEffectivePgClassTuple(Oid relid);
+
+#endif							/* PG_TEMP_CLASS_H */
diff --git a/src/include/utils/gtcatcache.h b/src/include/utils/gtcatcache.h
new file mode 100644
index 00000000000..99a115ab99b
--- /dev/null
+++ b/src/include/utils/gtcatcache.h
@@ -0,0 +1,41 @@
+/*-------------------------------------------------------------------------
+ *
+ * gtcatcache.h
+ *	  Global temporary catalog cache.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * src/include/utils/gtcatcache.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef GTCATCACHE_H
+#define GTCATCACHE_H
+
+#include "access/htup.h"
+#include "access/tupdesc.h"
+
+/*
+ * Identifier of global temporary catalog cache.
+ */
+typedef enum GTCatCacheIdentifier
+{
+	PG_TEMP_CLASS,
+} GTCatCacheIdentifier;
+
+#define NUM_GT_CAT_CACHES	((int) PG_TEMP_CLASS + 1)
+
+extern bool GTCatCacheTupleExists(GTCatCacheIdentifier cacheId, Oid relid);
+extern HeapTuple GTCatCacheSearch(GTCatCacheIdentifier cacheId, Oid relid);
+extern void GTCatCacheTupleInsert(GTCatCacheIdentifier cacheId, Oid relid,
+								  char relkind, TupleDesc tupdesc,
+								  const Datum *values, const bool *nulls);
+extern void GTCatCacheTupleUpdate(GTCatCacheIdentifier cacheId, Oid relid,
+								  HeapTuple newtuple);
+extern void GTCatCacheTupleDelete(GTCatCacheIdentifier cacheId, Oid relid);
+extern void GTCatCacheFlush(void);
+extern void AtEOXact_GTCatCache(bool isCommit);
+extern void AtEOSubXact_GTCatCache(bool isCommit, SubTransactionId mySubid,
+								   SubTransactionId parentSubid);
+
+#endif							/* GTCATCACHE_H */
diff --git a/src/test/isolation/expected/global-temp.out b/src/test/isolation/expected/global-temp.out
index 9383e599dfa..0c5bc5887e5 100644
--- a/src/test/isolation/expected/global-temp.out
+++ b/src/test/isolation/expected/global-temp.out
@@ -1,5 +1,16 @@
 Parsed test spec with 2 sessions
 
+starting permutation: create_tblspace list_tblspaces
+step create_tblspace: CREATE TABLESPACE regress_isolation_tablespace LOCATION '';
+step list_tblspaces: SELECT spcname FROM pg_tablespace ORDER BY 1;
+spcname                     
+----------------------------
+pg_default                  
+pg_global                   
+regress_isolation_tablespace
+(3 rows)
+
+
 starting permutation: ins1 ins2 sel1 sel2
 step ins1: INSERT INTO tmp VALUES (1, 's1');
 step ins2: INSERT INTO tmp VALUES (1, 's2');
@@ -437,3 +448,189 @@ key|val|seq
   1|s2 |  1
 (1 row)
 
+
+starting permutation: ins1 ins2 t2 sel1 sel2 ins2 t1 sel1 sel2 ins1 t2 sel1 sel2
+step ins1: INSERT INTO tmp VALUES (1, 's1');
+step ins2: INSERT INTO tmp VALUES (1, 's2');
+step t2: TRUNCATE tmp;
+step sel1: SELECT * FROM tmp;
+key|val|seq
+---+---+---
+  1|s1 |  1
+(1 row)
+
+step sel2: SELECT * FROM tmp;
+key|val|seq
+---+---+---
+(0 rows)
+
+step ins2: INSERT INTO tmp VALUES (1, 's2');
+step t1: TRUNCATE tmp;
+step sel1: SELECT * FROM tmp;
+key|val|seq
+---+---+---
+(0 rows)
+
+step sel2: SELECT * FROM tmp;
+key|val|seq
+---+---+---
+  1|s2 |  2
+(1 row)
+
+step ins1: INSERT INTO tmp VALUES (1, 's1');
+step t2: TRUNCATE tmp;
+step sel1: SELECT * FROM tmp;
+key|val|seq
+---+---+---
+  1|s1 |  2
+(1 row)
+
+step sel2: SELECT * FROM tmp;
+key|val|seq
+---+---+---
+(0 rows)
+
+
+starting permutation: ins1 ins2 alt_tblspace get_tblspace1 get_tblspace2 sel1 sel2 reset_tblspace
+step ins1: INSERT INTO tmp VALUES (1, 's1');
+step ins2: INSERT INTO tmp VALUES (1, 's2');
+step alt_tblspace: ALTER TABLE tmp SET TABLESPACE regress_isolation_tablespace;
+step get_tblspace1: 
+  SELECT s1.spcname, s2.spcname,
+         regexp_replace(pg_relation_filepath('tmp'), '(\d+)', 'NNN', 'g')
+    FROM pg_class c
+    JOIN pg_tablespace s1 ON s1.oid = c.reltablespace
+    LEFT JOIN pg_temp_class t ON t.oid = c.oid
+    JOIN pg_tablespace s2 ON s2.oid = t.reltablespace
+   WHERE c.relname = 'tmp';
+
+spcname                     |spcname                     |regexp_replace                       
+----------------------------+----------------------------+-------------------------------------
+regress_isolation_tablespace|regress_isolation_tablespace|pg_tblspc/NNN/PG_NNN_NNN/NNN/tNNN_NNN
+(1 row)
+
+step get_tblspace2: 
+  SELECT s1.spcname, s2.spcname,
+         regexp_replace(pg_relation_filepath('tmp'), '(\d+)', 'NNN', 'g')
+    FROM pg_class c
+    JOIN pg_tablespace s1 ON s1.oid = c.reltablespace
+    LEFT JOIN pg_temp_class t ON t.oid = c.oid
+    LEFT JOIN pg_tablespace s2 ON s2.oid = t.reltablespace
+   WHERE c.relname = 'tmp';
+
+spcname                     |spcname|regexp_replace   
+----------------------------+-------+-----------------
+regress_isolation_tablespace|       |base/NNN/tNNN_NNN
+(1 row)
+
+step sel1: SELECT * FROM tmp;
+key|val|seq
+---+---+---
+  1|s1 |  1
+(1 row)
+
+step sel2: SELECT * FROM tmp;
+key|val|seq
+---+---+---
+  1|s2 |  1
+(1 row)
+
+step reset_tblspace: ALTER TABLE tmp SET TABLESPACE pg_default;
+
+starting permutation: create1 cat1 drop1 cat1
+step create1: CREATE GLOBAL TEMP TABLE tmp2 (key int, val text, icol int, bcol box);
+step cat1: SELECT count(*) FROM pg_temp_class WHERE oid >= 12000;
+count
+-----
+    3
+(1 row)
+
+step drop1: DROP TABLE tmp2;
+step cat1: SELECT count(*) FROM pg_temp_class WHERE oid >= 12000;
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: create1 cat1 drop2 cat1
+step create1: CREATE GLOBAL TEMP TABLE tmp2 (key int, val text, icol int, bcol box);
+step cat1: SELECT count(*) FROM pg_temp_class WHERE oid >= 12000;
+count
+-----
+    3
+(1 row)
+
+step drop2: DROP TABLE tmp2;
+step cat1: SELECT count(*) FROM pg_temp_class WHERE oid >= 12000;
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: create1 cat1 b1 drop2 cat1 r1 cat1
+step create1: CREATE GLOBAL TEMP TABLE tmp2 (key int, val text, icol int, bcol box);
+step cat1: SELECT count(*) FROM pg_temp_class WHERE oid >= 12000;
+count
+-----
+    3
+(1 row)
+
+step b1: BEGIN;
+step drop2: DROP TABLE tmp2;
+step cat1: SELECT count(*) FROM pg_temp_class WHERE oid >= 12000;
+count
+-----
+    0
+(1 row)
+
+step r1: ROLLBACK;
+step cat1: SELECT count(*) FROM pg_temp_class WHERE oid >= 12000;
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: create1 b1 cat1 sp1 drop2 cat1 rsp1 cat1 r1 cat1
+step create1: CREATE GLOBAL TEMP TABLE tmp2 (key int, val text, icol int, bcol box);
+step b1: BEGIN;
+step cat1: SELECT count(*) FROM pg_temp_class WHERE oid >= 12000;
+count
+-----
+    3
+(1 row)
+
+step sp1: SAVEPOINT sp;
+step drop2: DROP TABLE tmp2;
+step cat1: SELECT count(*) FROM pg_temp_class WHERE oid >= 12000;
+count
+-----
+    0
+(1 row)
+
+step rsp1: ROLLBACK TO SAVEPOINT sp;
+step cat1: SELECT count(*) FROM pg_temp_class WHERE oid >= 12000;
+count
+-----
+    0
+(1 row)
+
+step r1: ROLLBACK;
+step cat1: SELECT count(*) FROM pg_temp_class WHERE oid >= 12000;
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: drop_tblspace list_tblspaces
+step drop_tblspace: DROP TABLESPACE regress_isolation_tablespace;
+step list_tblspaces: SELECT spcname FROM pg_tablespace ORDER BY 1;
+spcname   
+----------
+pg_default
+pg_global 
+(2 rows)
+
diff --git a/src/test/isolation/specs/global-temp.spec b/src/test/isolation/specs/global-temp.spec
index 5d8a559eff4..d4457f6c787 100644
--- a/src/test/isolation/specs/global-temp.spec
+++ b/src/test/isolation/specs/global-temp.spec
@@ -13,6 +13,10 @@ teardown {
 }
 
 session s1
+setup { SET allow_in_place_tablespaces = true; }
+step create_tblspace { CREATE TABLESPACE regress_isolation_tablespace LOCATION ''; }
+step list_tblspaces { SELECT spcname FROM pg_tablespace ORDER BY 1; }
+step drop_tblspace { DROP TABLESPACE regress_isolation_tablespace; }
 step b1 { BEGIN; }
 step ins1 { INSERT INTO tmp VALUES (1, 's1'); }
 step ins1p1 { INSERT INTO tmp_parted VALUES (1, 's1 p1'); }
@@ -32,6 +36,10 @@ step alter1g { ALTER TABLE tmp2 ADD CONSTRAINT tmp2_fk FOREIGN KEY (icol) REFERE
 step alter1h { ALTER TABLE tmp2 ADD CONSTRAINT tmp2_ex EXCLUDE USING gist (bcol WITH &&); }
 step uniq_idx1 { CREATE UNIQUE INDEX tmp2_un ON tmp2(val); }
 step seltype1 { SELECT key, pg_typeof(key), val FROM tmp2; }
+step cat1 { SELECT count(*) FROM pg_temp_class WHERE oid >= 12000; }
+step r1 { ROLLBACK; }
+step sp1 { SAVEPOINT sp; }
+step rsp1 { ROLLBACK TO SAVEPOINT sp; }
 step drop1 { DROP TABLE tmp2; }
 step prep1 { PREPARE TRANSACTION 'tx'; }
 step cprep1 { COMMIT PREPARED 'tx'; }
@@ -43,6 +51,18 @@ step sel1_idx {
   SELECT * FROM tmp WHERE val = 's1';
   SELECT * FROM tmp WHERE val = 's1';
 }
+step t1 { TRUNCATE tmp; }
+step alt_tblspace { ALTER TABLE tmp SET TABLESPACE regress_isolation_tablespace; }
+step get_tblspace1 {
+  SELECT s1.spcname, s2.spcname,
+         regexp_replace(pg_relation_filepath('tmp'), '(\d+)', 'NNN', 'g')
+    FROM pg_class c
+    JOIN pg_tablespace s1 ON s1.oid = c.reltablespace
+    LEFT JOIN pg_temp_class t ON t.oid = c.oid
+    JOIN pg_tablespace s2 ON s2.oid = t.reltablespace
+   WHERE c.relname = 'tmp';
+}
+step reset_tblspace { ALTER TABLE tmp SET TABLESPACE pg_default; }
 
 session s2
 step b2 { BEGIN; }
@@ -67,6 +87,18 @@ step sel2_idx {
   SELECT * FROM tmp WHERE val = 's2';
 }
 step reidx2 { REINDEX INDEX tmp_val_idx; }
+step get_tblspace2 {
+  SELECT s1.spcname, s2.spcname,
+         regexp_replace(pg_relation_filepath('tmp'), '(\d+)', 'NNN', 'g')
+    FROM pg_class c
+    JOIN pg_tablespace s1 ON s1.oid = c.reltablespace
+    LEFT JOIN pg_temp_class t ON t.oid = c.oid
+    LEFT JOIN pg_tablespace s2 ON s2.oid = t.reltablespace
+   WHERE c.relname = 'tmp';
+}
+
+# Create test tablespace for remaining tests
+permutation create_tblspace list_tblspaces
 
 # Basic effects
 permutation ins1 ins2 sel1 sel2
@@ -99,3 +131,18 @@ permutation create1 b1 ins1_2 drop2 prep1
 permutation ins1 idx1 sel1_idx ins2 sel2_idx
 permutation ins1 ins2 idx1 sel1_idx sel2_idx
 permutation ins1 ins2 idx1 sel1_idx sel2_idx reidx2 sel2_idx
+
+# Test local TRUNCATE
+permutation ins1 ins2 t2 sel1 sel2 ins2 t1 sel1 sel2 ins1 t2 sel1 sel2
+
+# Test ALTER TABLE ... SET TABLESPACE
+permutation ins1 ins2 alt_tblspace get_tblspace1 get_tblspace2 sel1 sel2 reset_tblspace
+
+# Test global temp catalog tidy-up after DROP
+permutation create1 cat1 drop1 cat1
+permutation create1 cat1 drop2 cat1
+permutation create1 cat1 b1 drop2 cat1 r1 cat1
+permutation create1 b1 cat1 sp1 drop2 cat1 rsp1 cat1 r1 cat1
+
+# Tidy up
+permutation drop_tblspace list_tblspaces
diff --git a/src/test/recovery/t/001_stream_rep.pl b/src/test/recovery/t/001_stream_rep.pl
index a4fa4b96c61..a840aafb588 100644
--- a/src/test/recovery/t/001_stream_rep.pl
+++ b/src/test/recovery/t/001_stream_rep.pl
@@ -51,6 +51,9 @@ $node_primary->safe_psql('postgres',
 
 $node_primary->safe_psql(
 	'postgres', q{
+CREATE GLOBAL TEMP TABLE gtt (a int);
+INSERT INTO gtt VALUES (1);
+
 CREATE TABLE user_logins(id serial, who text);
 
 CREATE FUNCTION on_login_proc() RETURNS EVENT_TRIGGER AS $$
@@ -87,9 +90,27 @@ $result = $node_standby_1->safe_psql('postgres',
 );
 is($result, qq(1), 'check recovery state on standby 1');
 
+# Global temporary table should be inaccessible on standbys
+my ($ret, $stdout, $stderr) = $node_standby_1->psql(
+	'postgres', 'SELECT count(*) FROM gtt');
+like(
+	$stderr,
+	qr/ERROR:  cannot access temporary or unlogged relations during recovery/,
+	"Accessing GTT fails on standby 1");
+
+($ret, $stdout, $stderr) = $node_standby_2->psql(
+	'postgres', 'SELECT count(*) FROM gtt');
+like(
+	$stderr,
+	qr/ERROR:  cannot access temporary or unlogged relations during recovery/,
+	"Accessing GTT fails on standby 2");
+
 # Likewise, but for a sequence
-$node_primary->safe_psql('postgres',
-	"CREATE SEQUENCE seq1; SELECT nextval('seq1')");
+$node_primary->safe_psql('postgres', q{
+CREATE SEQUENCE seq1;
+SELECT nextval('seq1');
+CREATE GLOBAL TEMP SEQUENCE gtseq;
+});
 
 # Wait for standbys to catch up
 $node_primary->wait_for_replay_catchup($node_standby_1);
@@ -103,6 +124,20 @@ $result = $node_standby_2->safe_psql('postgres', "SELECT * FROM seq1");
 print "standby 2: $result\n";
 is($result, qq(33|0|t), 'check streamed sequence content on standby 2');
 
+($ret, $stdout, $stderr) = $node_standby_1->psql(
+	'postgres', 'SELECT * FROM gtseq');
+like(
+	$stderr,
+	qr/ERROR:  cannot access temporary or unlogged relations during recovery/,
+	"Accessing global temporary sequence fails on standby 1");
+
+($ret, $stdout, $stderr) = $node_standby_2->psql(
+	'postgres', 'SELECT * FROM gtseq');
+like(
+	$stderr,
+	qr/ERROR:  cannot access temporary or unlogged relations during recovery/,
+	"Accessing global temporary sequence fails on standby 2");
+
 # Check pg_sequence_last_value() returns NULL for unlogged sequence on standby
 $node_primary->safe_psql('postgres',
 	"CREATE UNLOGGED SEQUENCE ulseq; SELECT nextval('ulseq')");
@@ -113,12 +148,30 @@ is( $node_standby_1->safe_psql(
 	't',
 	'pg_sequence_last_value() on unlogged sequence on standby 1');
 
+# Likewise for global temporary sequence
+is( $node_standby_1->safe_psql(
+		'postgres',
+		"SELECT pg_sequence_last_value('gtseq'::regclass) IS NULL"),
+	't',
+	'pg_sequence_last_value() on global temporary sequence on standby 1');
+
 # Check that only READ-only queries can run on standbys
 is($node_standby_1->psql('postgres', 'INSERT INTO tab_int VALUES (1)'),
 	3, 'read-only queries on standby 1');
 is($node_standby_2->psql('postgres', 'INSERT INTO tab_int VALUES (1)'),
 	3, 'read-only queries on standby 2');
 
+# Test pg_relation_size() and pg_total_relation_size() on GTT on standby
+$result = $node_standby_1->safe_psql('postgres',
+	"SELECT pg_relation_size('gtt')");
+is( $result, qq(0), 'check pg_relation_size(GTT) on standby 1');
+
+$result = $node_standby_1->safe_psql('postgres', q{
+SELECT SUM(pg_total_relation_size(oid)) > 0
+FROM pg_class WHERE relkind='r'
+});
+is ( $result, qq(t), 'check pg_total_relation_size(*) on standby 1');
+
 # Tests for connection parameter target_session_attrs
 note "testing connection parameter \"target_session_attrs\"";
 
@@ -266,7 +319,7 @@ my $connstr_rep = "$connstr_common replication=1";
 my $connstr_db = "$connstr_common replication=database dbname=postgres";
 
 # Test SHOW ALL
-my ($ret, $stdout, $stderr) = $node_primary->psql(
+($ret, $stdout, $stderr) = $node_primary->psql(
 	'postgres', 'SHOW ALL;',
 	on_error_die => 1,
 	extra_params => [ '--dbname' => $connstr_rep ]);
diff --git a/src/test/recovery/t/018_wal_optimize.pl b/src/test/recovery/t/018_wal_optimize.pl
index 8f25b5dd165..8fd95980f36 100644
--- a/src/test/recovery/t/018_wal_optimize.pl
+++ b/src/test/recovery/t/018_wal_optimize.pl
@@ -29,6 +29,7 @@ sub check_orphan_relfilenodes
 		'postgres', "
 	   SELECT pg_relation_filepath(oid) FROM pg_class
 	   WHERE reltablespace = 0 AND relpersistence <> 't' AND
+       relpersistence <> 'g' AND
 	   pg_relation_filepath(oid) IS NOT NULL;");
 	is_deeply(
 		[
diff --git a/src/test/regress/expected/global_temp.out b/src/test/regress/expected/global_temp.out
index 3e2a9b219e7..a2858479fc7 100644
--- a/src/test/regress/expected/global_temp.out
+++ b/src/test/regress/expected/global_temp.out
@@ -62,11 +62,35 @@ SELECT * FROM tmp1;
 
 \c
 SET search_path = global_temp_tests;
+SELECT oid::regclass FROM pg_temp_class ORDER BY 1;
+           oid           
+-------------------------
+ pg_temp_class
+ pg_temp_class_oid_index
+(2 rows)
+
 SELECT * FROM tmp1;
  a | b | c 
 ---+---+---
 (0 rows)
 
+SELECT oid::regclass FROM pg_temp_class ORDER BY 1;
+           oid           
+-------------------------
+ pg_temp_class
+ pg_temp_class_oid_index
+ tmp1
+ tmp1_pkey
+(4 rows)
+
+-- Test pg_relation_filenode() matches global relfilenode
+SELECT relfilenode = pg_relation_filenode('tmp1'::regclass) AS ok
+  FROM pg_class WHERE oid = 'tmp1'::regclass;
+ ok 
+----
+ t
+(1 row)
+
 -- Test index
 INSERT INTO tmp1 VALUES (1, 'xxx');
 SET enable_seqscan = off;
@@ -105,7 +129,26 @@ SELECT * FROM tmp1 WHERE b = 'xxx';
 RESET enable_seqscan;
 REINDEX INDEX CONCURRENTLY tmp1_b_idx;
 REINDEX TABLE CONCURRENTLY tmp1;
+-- Test REINDEX -- relfilenode only changes locally
+SELECT c.relfilenode AS global_relfilenode, t.relfilenode AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1_b_idx' \gset
+REINDEX INDEX tmp1_b_idx;
+SELECT CASE WHEN c.relfilenode = :global_relfilenode THEN 'unchanged' ELSE 'changed' END AS global_relfilenode,
+       CASE WHEN t.relfilenode = :local_relfilenode THEN 'unchange' ELSE 'changed' END AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1_b_idx';
+ global_relfilenode | local_relfilenode 
+--------------------+-------------------
+ unchanged          | changed
+(1 row)
+
 DROP INDEX CONCURRENTLY tmp1_b_idx;
+-- REINDEX not allowed on pg_temp_class
+REINDEX INDEX pg_temp_class_oid_index;
+NOTICE:  cannot reindex temporary system index "pg_temp_class_oid_index", skipping
+REINDEX TABLE pg_temp_class;
+NOTICE:  cannot reindex temporary system index "pg_temp_class_oid_index", skipping
 -- Test ON COMMIT DELETE ROWS
 CREATE GLOBAL TEMP TABLE tmp2 (a int) ON COMMIT DELETE ROWS;
 BEGIN;
@@ -224,7 +267,7 @@ SELECT * FROM tmp2;
 (0 rows)
 
 DROP TABLE perm_pk_rel, temp_pk_rel, tmp2;
--- Test ALTER TABLE ... SET TABLESPACE
+-- Test ALTER TABLE ... SET TABLESPACE -- reltablespace changes locally and globally
 CREATE GLOBAL TEMP TABLE tmp2 (a int);
 INSERT INTO tmp2 VALUES (1);
 SELECT * FROM tmp2;
@@ -233,10 +276,15 @@ SELECT * FROM tmp2;
  1
 (1 row)
 
-SELECT regexp_replace(pg_relation_filepath('tmp2'), '(\d+)', 'NNN', 'g');
-  regexp_replace   
--------------------
- base/NNN/tNNN_NNN
+SELECT c.reltablespace AS global_tablespace,
+       t.reltablespace AS local_tablespace,
+       regexp_replace(pg_relation_filepath('tmp2'), '(\d+)', 'NNN', 'g')
+  FROM pg_class c
+  LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp2';
+ global_tablespace | local_tablespace |  regexp_replace   
+-------------------+------------------+-------------------
+                 0 |                0 | base/NNN/tNNN_NNN
 (1 row)
 
 ALTER TABLE tmp2 SET TABLESPACE regress_tblspace;
@@ -246,10 +294,16 @@ SELECT * FROM tmp2;
  1
 (1 row)
 
-SELECT regexp_replace(pg_relation_filepath('tmp2'), '(\d+)', 'NNN', 'g');
-            regexp_replace             
----------------------------------------
- pg_tblspc/NNN/PG_NNN_NNN/NNN/tNNN_NNN
+SELECT s1.spcname AS global_tablespace, s2.spcname AS local_tablespace,
+       regexp_replace(pg_relation_filepath('tmp2'), '(\d+)', 'NNN', 'g')
+  FROM pg_class c
+  JOIN pg_tablespace s1 ON s1.oid = c.reltablespace
+  LEFT JOIN pg_temp_class t ON t.oid = c.oid
+  JOIN pg_tablespace s2 ON s2.oid = t.reltablespace
+ WHERE c.relname = 'tmp2';
+ global_tablespace | local_tablespace |            regexp_replace             
+-------------------+------------------+---------------------------------------
+ regress_tblspace  | regress_tblspace | pg_tblspc/NNN/PG_NNN_NNN/NNN/tNNN_NNN
 (1 row)
 
 DROP TABLE tmp2;
@@ -340,13 +394,135 @@ SELECT * FROM tmp1;
 ---+---+---
 (0 rows)
 
--- Test view creation
+-- Test CLUSTER -- relfilenode only changes locally
+SELECT c.relfilenode AS global_relfilenode, t.relfilenode AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1' \gset
+CLUSTER tmp1 USING tmp1_pkey;
+SELECT CASE WHEN c.relfilenode = :global_relfilenode THEN 'unchanged' ELSE 'changed' END AS global_relfilenode,
+       CASE WHEN t.relfilenode = :local_relfilenode THEN 'unchange' ELSE 'changed' END AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1';
+ global_relfilenode | local_relfilenode 
+--------------------+-------------------
+ unchanged          | changed
+(1 row)
+
+-- CLUSTER not allowed on pg_temp_class
+CLUSTER pg_temp_class; -- fail
+ERROR:  cannot execute CLUSTER on temporary system catalog "pg_temp_class"
+-- Test REPACK -- relfilenode only changes locally
+SELECT c.relfilenode AS global_relfilenode, t.relfilenode AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1' \gset
+REPACK tmp1;
+SELECT CASE WHEN c.relfilenode = :global_relfilenode THEN 'unchanged' ELSE 'changed' END AS global_relfilenode,
+       CASE WHEN t.relfilenode = :local_relfilenode THEN 'unchange' ELSE 'changed' END AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1';
+ global_relfilenode | local_relfilenode 
+--------------------+-------------------
+ unchanged          | changed
+(1 row)
+
+-- REPACK not allowed on pg_temp_class
+REPACK pg_temp_class; -- fail
+ERROR:  cannot execute REPACK on temporary system catalog "pg_temp_class"
+-- Test VACUUM FULL -- relfilenode only changes locally
+SELECT c.relfilenode AS global_relfilenode, t.relfilenode AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1' \gset
+VACUUM FULL tmp1;
+SELECT CASE WHEN c.relfilenode = :global_relfilenode THEN 'unchanged' ELSE 'changed' END AS global_relfilenode,
+       CASE WHEN t.relfilenode = :local_relfilenode THEN 'unchange' ELSE 'changed' END AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1';
+ global_relfilenode | local_relfilenode 
+--------------------+-------------------
+ unchanged          | changed
+(1 row)
+
+-- VACUUM FULL not allowed on pg_temp_class
+VACUUM FULL pg_temp_class; -- silently ignored
+-- Test pg_relation_filenode() now matches local relfilenode
+SELECT relfilenode = pg_relation_filenode('tmp1'::regclass) AS ok
+  FROM pg_temp_class WHERE oid = 'tmp1'::regclass;
+ ok 
+----
+ t
+(1 row)
+
+-- VACUUM initializes toast tables
+\c
+SET search_path = global_temp_tests;
+SELECT EXISTS (SELECT 1 FROM pg_class t WHERE t.oid = c.reltoastrelid),
+       EXISTS (SELECT 1 FROM pg_temp_class t WHERE t.oid = c.reltoastrelid)
+FROM pg_class c
+WHERE oid = 'tmp1'::regclass;
+ exists | exists 
+--------+--------
+ t      | f
+(1 row)
+
+VACUUM tmp1;
+SELECT EXISTS (SELECT 1 FROM pg_class t WHERE t.oid = c.reltoastrelid),
+       EXISTS (SELECT 1 FROM pg_temp_class t WHERE t.oid = c.reltoastrelid)
+FROM pg_class c
+WHERE oid = 'tmp1'::regclass;
+ exists | exists 
+--------+--------
+ t      | t
+(1 row)
+
+\c
+SET search_path = global_temp_tests;
+VACUUM FULL tmp1;
+SELECT EXISTS (SELECT 1 FROM pg_class t WHERE t.oid = c.reltoastrelid),
+       EXISTS (SELECT 1 FROM pg_temp_class t WHERE t.oid = c.reltoastrelid)
+FROM pg_class c
+WHERE oid = 'tmp1'::regclass;
+ exists | exists 
+--------+--------
+ t      | t
+(1 row)
+
+-- Test subtransaction rollback of pending pg_temp_class inserts
+\c
+SET search_path = global_temp_tests;
+BEGIN;
+SELECT count(*) FROM tmp1;
+ count 
+-------
+     0
+(1 row)
+
+SAVEPOINT sp;
+DROP TABLE tmp1;
+ROLLBACK TO sp;
 INSERT INTO tmp1 VALUES (1, 'xxx');
+COMMIT;
+SELECT oid::regclass FROM pg_temp_class ORDER BY 1;
+           oid           
+-------------------------
+ pg_temp_class
+ pg_temp_class_oid_index
+ tmp1_c_seq
+ tmp1
+ tmp1_pkey
+(5 rows)
+
+SELECT * FROM tmp1;
+ a |  b  | c 
+---+-----+---
+ 1 | xxx | 1
+(1 row)
+
+-- Test view creation
 CREATE VIEW v AS SELECT * FROM tmp1;
 SELECT * FROM v;
  a |  b  | c 
 ---+-----+---
- 1 | xxx | 2
+ 1 | xxx | 1
 (1 row)
 
 DROP VIEW v;
@@ -354,7 +530,7 @@ CREATE TEMP VIEW v AS SELECT * FROM tmp1;
 SELECT * FROM v;
  a |  b  | c 
 ---+-----+---
- 1 | xxx | 2
+ 1 | xxx | 1
 (1 row)
 
 DROP VIEW v;
diff --git a/src/test/regress/expected/oidjoins.out b/src/test/regress/expected/oidjoins.out
index d64169b7bf0..3c3404e51b8 100644
--- a/src/test/regress/expected/oidjoins.out
+++ b/src/test/regress/expected/oidjoins.out
@@ -285,3 +285,5 @@ NOTICE:  checking pg_propgraph_label_property {plpellabelid} => pg_propgraph_ele
 NOTICE:  checking pg_propgraph_property {pgppgid} => pg_class {oid}
 NOTICE:  checking pg_propgraph_property {pgptypid} => pg_type {oid}
 NOTICE:  checking pg_propgraph_property {pgpcollation} => pg_collation {oid}
+NOTICE:  checking pg_temp_class {oid} => pg_class {oid}
+NOTICE:  checking pg_temp_class {reltablespace} => pg_tablespace {oid}
diff --git a/src/test/regress/expected/stats.out b/src/test/regress/expected/stats.out
index c682a9ed60a..dec9ea89a40 100644
--- a/src/test/regress/expected/stats.out
+++ b/src/test/regress/expected/stats.out
@@ -81,6 +81,7 @@ standalone backend|relation|bulkwrite
 standalone backend|relation|init
 standalone backend|relation|normal
 standalone backend|relation|vacuum
+standalone backend|temp relation|normal
 standalone backend|wal|init
 standalone backend|wal|normal
 startup|relation|bulkread
@@ -104,7 +105,7 @@ walsummarizer|wal|init
 walsummarizer|wal|normal
 walwriter|wal|init
 walwriter|wal|normal
-(88 rows)
+(89 rows)
 \a
 -- List of registered statistics kinds.
 SELECT id, name, fixed_amount,
diff --git a/src/test/regress/sql/global_temp.sql b/src/test/regress/sql/global_temp.sql
index 799e474a99a..9b24eccf028 100644
--- a/src/test/regress/sql/global_temp.sql
+++ b/src/test/regress/sql/global_temp.sql
@@ -33,7 +33,13 @@ INSERT INTO tmp1 VALUES (1, 'xxx');
 SELECT * FROM tmp1;
 \c
 SET search_path = global_temp_tests;
+SELECT oid::regclass FROM pg_temp_class ORDER BY 1;
 SELECT * FROM tmp1;
+SELECT oid::regclass FROM pg_temp_class ORDER BY 1;
+
+-- Test pg_relation_filenode() matches global relfilenode
+SELECT relfilenode = pg_relation_filenode('tmp1'::regclass) AS ok
+  FROM pg_class WHERE oid = 'tmp1'::regclass;
 
 -- Test index
 INSERT INTO tmp1 VALUES (1, 'xxx');
@@ -52,8 +58,22 @@ SELECT * FROM tmp1 WHERE b = 'xxx';
 RESET enable_seqscan;
 REINDEX INDEX CONCURRENTLY tmp1_b_idx;
 REINDEX TABLE CONCURRENTLY tmp1;
+
+-- Test REINDEX -- relfilenode only changes locally
+SELECT c.relfilenode AS global_relfilenode, t.relfilenode AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1_b_idx' \gset
+REINDEX INDEX tmp1_b_idx;
+SELECT CASE WHEN c.relfilenode = :global_relfilenode THEN 'unchanged' ELSE 'changed' END AS global_relfilenode,
+       CASE WHEN t.relfilenode = :local_relfilenode THEN 'unchange' ELSE 'changed' END AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1_b_idx';
 DROP INDEX CONCURRENTLY tmp1_b_idx;
 
+-- REINDEX not allowed on pg_temp_class
+REINDEX INDEX pg_temp_class_oid_index;
+REINDEX TABLE pg_temp_class;
+
 -- Test ON COMMIT DELETE ROWS
 CREATE GLOBAL TEMP TABLE tmp2 (a int) ON COMMIT DELETE ROWS;
 BEGIN;
@@ -127,14 +147,25 @@ DELETE FROM gtemp_pk_rel WHERE a = 1;
 SELECT * FROM tmp2;
 DROP TABLE perm_pk_rel, temp_pk_rel, tmp2;
 
--- Test ALTER TABLE ... SET TABLESPACE
+-- Test ALTER TABLE ... SET TABLESPACE -- reltablespace changes locally and globally
 CREATE GLOBAL TEMP TABLE tmp2 (a int);
 INSERT INTO tmp2 VALUES (1);
 SELECT * FROM tmp2;
-SELECT regexp_replace(pg_relation_filepath('tmp2'), '(\d+)', 'NNN', 'g');
+SELECT c.reltablespace AS global_tablespace,
+       t.reltablespace AS local_tablespace,
+       regexp_replace(pg_relation_filepath('tmp2'), '(\d+)', 'NNN', 'g')
+  FROM pg_class c
+  LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp2';
 ALTER TABLE tmp2 SET TABLESPACE regress_tblspace;
 SELECT * FROM tmp2;
-SELECT regexp_replace(pg_relation_filepath('tmp2'), '(\d+)', 'NNN', 'g');
+SELECT s1.spcname AS global_tablespace, s2.spcname AS local_tablespace,
+       regexp_replace(pg_relation_filepath('tmp2'), '(\d+)', 'NNN', 'g')
+  FROM pg_class c
+  JOIN pg_tablespace s1 ON s1.oid = c.reltablespace
+  LEFT JOIN pg_temp_class t ON t.oid = c.oid
+  JOIN pg_tablespace s2 ON s2.oid = t.reltablespace
+ WHERE c.relname = 'tmp2';
 DROP TABLE tmp2;
 
 -- Test dependency on tablespace
@@ -186,8 +217,85 @@ SELECT * FROM tmp1;
 TRUNCATE tmp1;
 SELECT * FROM tmp1;
 
--- Test view creation
+-- Test CLUSTER -- relfilenode only changes locally
+SELECT c.relfilenode AS global_relfilenode, t.relfilenode AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1' \gset
+CLUSTER tmp1 USING tmp1_pkey;
+SELECT CASE WHEN c.relfilenode = :global_relfilenode THEN 'unchanged' ELSE 'changed' END AS global_relfilenode,
+       CASE WHEN t.relfilenode = :local_relfilenode THEN 'unchange' ELSE 'changed' END AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1';
+
+-- CLUSTER not allowed on pg_temp_class
+CLUSTER pg_temp_class; -- fail
+
+-- Test REPACK -- relfilenode only changes locally
+SELECT c.relfilenode AS global_relfilenode, t.relfilenode AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1' \gset
+REPACK tmp1;
+SELECT CASE WHEN c.relfilenode = :global_relfilenode THEN 'unchanged' ELSE 'changed' END AS global_relfilenode,
+       CASE WHEN t.relfilenode = :local_relfilenode THEN 'unchange' ELSE 'changed' END AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1';
+
+-- REPACK not allowed on pg_temp_class
+REPACK pg_temp_class; -- fail
+
+-- Test VACUUM FULL -- relfilenode only changes locally
+SELECT c.relfilenode AS global_relfilenode, t.relfilenode AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1' \gset
+VACUUM FULL tmp1;
+SELECT CASE WHEN c.relfilenode = :global_relfilenode THEN 'unchanged' ELSE 'changed' END AS global_relfilenode,
+       CASE WHEN t.relfilenode = :local_relfilenode THEN 'unchange' ELSE 'changed' END AS local_relfilenode
+  FROM pg_class c LEFT JOIN pg_temp_class t ON t.oid = c.oid
+ WHERE c.relname = 'tmp1';
+
+-- VACUUM FULL not allowed on pg_temp_class
+VACUUM FULL pg_temp_class; -- silently ignored
+
+-- Test pg_relation_filenode() now matches local relfilenode
+SELECT relfilenode = pg_relation_filenode('tmp1'::regclass) AS ok
+  FROM pg_temp_class WHERE oid = 'tmp1'::regclass;
+
+-- VACUUM initializes toast tables
+\c
+SET search_path = global_temp_tests;
+SELECT EXISTS (SELECT 1 FROM pg_class t WHERE t.oid = c.reltoastrelid),
+       EXISTS (SELECT 1 FROM pg_temp_class t WHERE t.oid = c.reltoastrelid)
+FROM pg_class c
+WHERE oid = 'tmp1'::regclass;
+
+VACUUM tmp1;
+SELECT EXISTS (SELECT 1 FROM pg_class t WHERE t.oid = c.reltoastrelid),
+       EXISTS (SELECT 1 FROM pg_temp_class t WHERE t.oid = c.reltoastrelid)
+FROM pg_class c
+WHERE oid = 'tmp1'::regclass;
+
+\c
+SET search_path = global_temp_tests;
+VACUUM FULL tmp1;
+SELECT EXISTS (SELECT 1 FROM pg_class t WHERE t.oid = c.reltoastrelid),
+       EXISTS (SELECT 1 FROM pg_temp_class t WHERE t.oid = c.reltoastrelid)
+FROM pg_class c
+WHERE oid = 'tmp1'::regclass;
+
+-- Test subtransaction rollback of pending pg_temp_class inserts
+\c
+SET search_path = global_temp_tests;
+BEGIN;
+SELECT count(*) FROM tmp1;
+SAVEPOINT sp;
+DROP TABLE tmp1;
+ROLLBACK TO sp;
 INSERT INTO tmp1 VALUES (1, 'xxx');
+COMMIT;
+SELECT oid::regclass FROM pg_temp_class ORDER BY 1;
+SELECT * FROM tmp1;
+
+-- Test view creation
 CREATE VIEW v AS SELECT * FROM tmp1;
 SELECT * FROM v;
 DROP VIEW v;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index cd8d61c6cc3..225a1132bbd 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -956,6 +956,7 @@ FormData_pg_statistic_ext_data
 FormData_pg_subscription
 FormData_pg_subscription_rel
 FormData_pg_tablespace
+FormData_pg_temp_class
 FormData_pg_transform
 FormData_pg_trigger
 FormData_pg_ts_config
@@ -1021,6 +1022,7 @@ Form_pg_statistic_ext_data
 Form_pg_subscription
 Form_pg_subscription_rel
 Form_pg_tablespace
+Form_pg_temp_class
 Form_pg_transform
 Form_pg_trigger
 Form_pg_ts_config
@@ -1086,6 +1088,9 @@ GISTTYPE
 GIST_SPLITVEC
 GMReaderTupleBuffer
 GROUP
+GTCatCache
+GTCatCacheEntry
+GTCatCacheIdentifier
 GUCHashEntry
 GV
 Gather
-- 
2.43.0

