Hi Zsolt

On 19/08/2026 14:17, Zsolt Parragi wrote:
> I attached a rebased version, otherwise there are no changes.
While doing some research for a new project (REFRESH ALL MATERIALIZED
VIEWS) I found this patch. 0002 and 0003 were not applying due to the
latest changes in meson.build and catversion.h. In order to test the
feature I had to rebase them -- PFA the rebased 0002 and 0003.

A few initial comments:

== The docs do not compile ==

Apparently the new ids in varlistentry, which are currently not used,
are breaking the file.

generating docs ...
ERROR: id attribute missing on <varlistentry> element under /book[@id =
'postgres']/part[@id = 'reference']/reference[@id =
'sql-commands']/refentry[@id =
'sql-altermaterializedview']/refsect1/variablelist
no result for postgres-full.xml
make[3]: *** [Makefile:130: html-stamp] Error 10
make[2]: *** [Makefile:8: html] Error 2
make[1]: *** [Makefile:16: html] Error 2
make: *** [GNUmakefile:27: html] Error 2

== tab completion ==

The tab completion does not suggest the new flag:

CREATE UNLOGGED <TAB>
SEQUENCE  TABLE

== \d output ==

\d shows an unlogged matview as a normal one

postgres=# CREATE UNLOGGED MATERIALIZED VIEW umv1 AS SELECT 1;
SELECT 1
postgres=# \d umv1
           Materialized view "public.umv1"
  Column  |  Type   | Collation | Nullable | Default
----------+---------+-----------+----------+---------
 ?column? | integer |           |          |

A unlogged table is shown as unlogged, so I believe this should also
apply to matviews:

postgres=# CREATE UNLOGGED TABLE ut1 AS SELECT 1;
SELECT 1
postgres=# \d ut1
             Unlogged table "public.ut1"
  Column  |  Type   | Collation | Nullable | Default
----------+---------+-----------+----------+---------
 ?column? | integer |           |          |


PFA 004 with a few suggestions for the above mentioned issues. I'll take
a closer look at the code in the coming week.

Best, Jim


From 7f4198ccdf7edbf751a968ef38e3b51ea3d449c8 Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <[email protected]>
Date: Thu, 2 Jul 2026 17:48:55 +0000
Subject: [PATCH v3 1/4] Add durable unlogged-reset generation counter

Bump a control-file counter whenever crash recovery or pg_resetwal
resets unlogged relations, and mirror it in shared memory. Add
GetUnloggedPopulatedEpoch() = (timeline << 32) | reset-gen to stamp
when an unlogged matview was populated.
---
 src/backend/access/transam/xlog.c       | 43 +++++++++++++++++++++++++
 src/bin/pg_controldata/pg_controldata.c |  2 ++
 src/bin/pg_resetwal/pg_resetwal.c       |  8 +++++
 src/include/access/xlog.h               |  2 ++
 src/include/catalog/pg_control.h        |  5 ++-
 5 files changed, 59 insertions(+), 1 deletion(-)

diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 2e3f177100b..5169939fd21 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -469,6 +469,9 @@ typedef struct XLogCtlData
 	/* Fake LSN counter, for unlogged relations. */
 	pg_atomic_uint64 unloggedLSN;
 
+	/* Shared-memory mirror of ControlFile->unloggedResetGen. */
+	pg_atomic_uint64 unloggedResetGen;
+
 	/* Time and LSN of last xlog segment switch. Protected by WALWriteLock. */
 	pg_time_t	lastSegSwitchTime;
 	XLogRecPtr	lastSegSwitchLSN;
@@ -4280,6 +4283,7 @@ InitControlFile(uint64 sysidentifier, uint32 data_checksum_version)
 	memcpy(ControlFile->mock_authentication_nonce, mock_auth_nonce, MOCK_AUTH_NONCE_LEN);
 	ControlFile->state = DB_SHUTDOWNED;
 	ControlFile->unloggedLSN = FirstNormalUnloggedLSN;
+	ControlFile->unloggedResetGen = 0;
 
 	/* Set important parameter values for use when replaying WAL */
 	ControlFile->MaxConnections = MaxConnections;
@@ -5036,6 +5040,29 @@ GetFakeLSNForUnloggedRel(void)
 	return pg_atomic_fetch_add_u64(&XLogCtl->unloggedLSN, 1);
 }
 
+/* Cluster-wide unlogged-reset generation, read from the shmem mirror. */
+uint64
+GetUnloggedResetGeneration(void)
+{
+	return pg_atomic_read_u64(&XLogCtl->unloggedResetGen);
+}
+
+/*
+ * Epoch stamped into pg_class.relpopulated for a populated unlogged matview:
+ * (timeline << 32) | reset-gen.  Crash recovery bumps the gen and
+ * promotion/PITR changes the timeline, so either invalidates old stamps.
+ * tli >= 1 keeps the epoch above the reserved values 0 and 1.
+ */
+uint64
+GetUnloggedPopulatedEpoch(void)
+{
+	uint64		gen = GetUnloggedResetGeneration();
+	TimeLineID	tli = GetWALInsertionTimeLine();
+
+	Assert(tli >= 1);
+	return ((uint64) tli << 32) | (uint32) gen;
+}
+
 /*
  * Auto-tune the number of XLOG buffers.
  *
@@ -5463,6 +5490,7 @@ XLOGShmemInit(void *arg)
 	pg_atomic_init_u64(&XLogCtl->logFlushResult, InvalidXLogRecPtr);
 	pg_atomic_init_u64(&XLogCtl->unloggedLSN, InvalidXLogRecPtr);
 	pg_atomic_init_u64(&XLogCtl->lastChecksumChangeRecPtr, InvalidXLogRecPtr);
+	pg_atomic_init_u64(&XLogCtl->unloggedResetGen, 0);
 }
 
 /*
@@ -6101,6 +6129,10 @@ StartupXLOG(void)
 		pg_atomic_write_membarrier_u64(&XLogCtl->unloggedLSN,
 									   FirstNormalUnloggedLSN);
 
+	/* Mirror the durable unlogged-reset counter into shared memory. */
+	pg_atomic_write_membarrier_u64(&XLogCtl->unloggedResetGen,
+								   ControlFile->unloggedResetGen);
+
 	/*
 	 * Copy any missing timeline history files between 'now' and the recovery
 	 * target timeline from archive to pg_wal. While we don't need those files
@@ -6376,6 +6408,17 @@ StartupXLOG(void)
 	if (InRecovery)
 		ResetUnloggedRelations(UNLOGGED_RELATION_INIT);
 
+	/*
+	 * Crash recovery reset all unlogged relations; bump the durable reset
+	 * generation and refresh the mirror.  ControlFile is persisted below.
+	 */
+	if (InRecovery)
+	{
+		ControlFile->unloggedResetGen++;
+		pg_atomic_write_membarrier_u64(&XLogCtl->unloggedResetGen,
+									   ControlFile->unloggedResetGen);
+	}
+
 	/*
 	 * Pre-scan prepared transactions to find out the range of XIDs present.
 	 * This information is not quite needed yet, but it is positioned here so
diff --git a/src/bin/pg_controldata/pg_controldata.c b/src/bin/pg_controldata/pg_controldata.c
index 6fc87ed114d..40d6d121f83 100644
--- a/src/bin/pg_controldata/pg_controldata.c
+++ b/src/bin/pg_controldata/pg_controldata.c
@@ -295,6 +295,8 @@ main(int argc, char *argv[])
 		   ckpttime_str);
 	printf(_("Fake LSN counter for unlogged rels:   %X/%08X\n"),
 		   LSN_FORMAT_ARGS(ControlFile->unloggedLSN));
+	printf(_("Unlogged reset generation:            " UINT64_FORMAT "\n"),
+		   ControlFile->unloggedResetGen);
 	printf(_("Minimum recovery ending location:     %X/%08X\n"),
 		   LSN_FORMAT_ARGS(ControlFile->minRecoveryPoint));
 	printf(_("Min recovery ending loc's timeline:   %u\n"),
diff --git a/src/bin/pg_resetwal/pg_resetwal.c b/src/bin/pg_resetwal/pg_resetwal.c
index 1542a56ca4b..84a79b4a32c 100644
--- a/src/bin/pg_resetwal/pg_resetwal.c
+++ b/src/bin/pg_resetwal/pg_resetwal.c
@@ -716,6 +716,7 @@ GuessControlValues(void)
 	ControlFile.time = (pg_time_t) time(NULL);
 	ControlFile.checkPoint = ControlFile.checkPointCopy.redo;
 	ControlFile.unloggedLSN = FirstNormalUnloggedLSN;
+	ControlFile.unloggedResetGen = 0;
 
 	/* minRecoveryPoint, backupStartPoint and backupEndPoint can be left zero */
 
@@ -917,6 +918,13 @@ RewriteControlFile(void)
 
 	ControlFile.state = DB_SHUTDOWNED;
 	ControlFile.checkPoint = ControlFile.checkPointCopy.redo;
+
+	/*
+	 * WAL reset skips crash recovery, so unlogged storage is suspect.  Bump
+	 * the reset generation so pre-reset epoch stamps read as "not populated".
+	 */
+	ControlFile.unloggedResetGen++;
+
 	ControlFile.minRecoveryPoint = InvalidXLogRecPtr;
 	ControlFile.minRecoveryPointTLI = 0;
 	ControlFile.backupStartPoint = InvalidXLogRecPtr;
diff --git a/src/include/access/xlog.h b/src/include/access/xlog.h
index 130ba929109..377c00aaa1f 100644
--- a/src/include/access/xlog.h
+++ b/src/include/access/xlog.h
@@ -275,6 +275,8 @@ extern void InitLocalDataChecksumState(void);
 extern void SetLocalDataChecksumState(uint32 data_checksum_version);
 extern bool GetDefaultCharSignedness(void);
 extern XLogRecPtr GetFakeLSNForUnloggedRel(void);
+extern uint64 GetUnloggedResetGeneration(void);
+extern uint64 GetUnloggedPopulatedEpoch(void);
 extern void BootStrapXLOG(uint32 data_checksum_version);
 extern void InitializeWalConsistencyChecking(void);
 extern void LocalProcessControlFile(bool reset);
diff --git a/src/include/catalog/pg_control.h b/src/include/catalog/pg_control.h
index 7b5404460ec..e97d50d948d 100644
--- a/src/include/catalog/pg_control.h
+++ b/src/include/catalog/pg_control.h
@@ -22,7 +22,7 @@
 
 
 /* Version identifier for this pg_control format */
-#define PG_CONTROL_VERSION	1903
+#define PG_CONTROL_VERSION	1904
 
 /* Nonce key length, see below */
 #define MOCK_AUTH_NONCE_LEN		32
@@ -143,6 +143,9 @@ typedef struct ControlFileData
 	CheckPoint	checkPointCopy; /* copy of last check point record */
 
 	XLogRecPtr	unloggedLSN;	/* current fake LSN value, for unlogged rels */
+	uint64		unloggedResetGen;	/* bumped whenever unlogged relations are
+									 * reset (end of recovery) or become
+									 * unsafe to trust (pg_resetwal) */
 
 	/*
 	 * These two values determine the minimum point we must recover up to
-- 
2.55.0

From cdae305960ff955de65c6642cd41ebeaa3995c51 Mon Sep 17 00:00:00 2001
From: Jim Jones <[email protected]>
Date: Sun, 6 Sep 2026 20:27:36 +0200
Subject: [PATCH v3 2/4] Replace pg_class.relispopulated with epoch-capable
 relpopulated

Retype the bool relispopulated to an int64 relpopulated: 0 means no
data, 1 means crash-safe populated (every relation except unlogged
matviews), anything else is an epoch stamp recording when an unlogged
matview was populated.

The read side moves to RelationIsPopulated() in matview.c, replacing
the old rel.h macros. Rename rather than reuse the column so external
queries fail loudly. Add pg_matview_is_populated(regclass), used by
pg_matviews. pg_dump reads relpopulated <> 0 and, in binary upgrade,
calls binary_upgrade_set_matview_populated() instead of poking pg_class.
---
 doc/src/sgml/catalogs.sgml                 | 27 +++++----
 doc/src/sgml/func/func-info.sgml           | 16 ++++++
 src/backend/catalog/heap.c                 |  2 +-
 src/backend/catalog/system_views.sql       |  2 +-
 src/backend/commands/copyto.c              |  1 +
 src/backend/commands/matview.c             | 64 +++++++++++++++++++++-
 src/backend/commands/repack.c              |  1 +
 src/backend/executor/execUtils.c           |  3 +-
 src/backend/utils/adt/pg_upgrade_support.c | 20 +++++++
 src/backend/utils/cache/relcache.c         |  8 +--
 src/bin/pg_dump/pg_dump.c                  | 39 +++++++++----
 src/include/catalog/catversion.h           |  2 +-
 src/include/catalog/pg_class.h             | 18 ++++--
 src/include/catalog/pg_proc.dat            |  8 +++
 src/include/commands/matview.h             |  2 +
 src/include/utils/rel.h                    | 16 ------
 src/test/regress/expected/matview.out      | 31 ++++++++---
 src/test/regress/expected/rules.out        |  2 +-
 src/test/regress/sql/matview.sql           |  9 ++-
 19 files changed, 207 insertions(+), 64 deletions(-)

diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml
index ecac7332e8e..1eb996d93df 100644
--- a/doc/src/sgml/catalogs.sgml
+++ b/doc/src/sgml/catalogs.sgml
@@ -2254,16 +2254,6 @@ SCRAM-SHA-256$<replaceable>&lt;iteration count&gt;</replaceable>:<replaceable>&l
       </para></entry>
      </row>
 
-     <row>
-      <entry role="catalog_table_entry"><para role="column_definition">
-       <structfield>relispopulated</structfield> <type>bool</type>
-      </para>
-      <para>
-       True if relation is populated (this is true for all
-       relations other than some materialized views)
-      </para></entry>
-     </row>
-
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
        <structfield>relreplident</structfield> <type>char</type>
@@ -2327,6 +2317,23 @@ SCRAM-SHA-256$<replaceable>&lt;iteration count&gt;</replaceable>:<replaceable>&l
       </para></entry>
      </row>
 
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relpopulated</structfield> <type>int8</type>
+      </para>
+      <para>
+       Populated epoch.  Zero if the relation does not hold data (a
+       materialized view created or refreshed <literal>WITH NO DATA</literal>).
+       One if the relation is populated with crash-safe storage; this is the
+       value for every relation other than a materialized view, and for
+       populated permanent materialized views.  Any other value is an epoch
+       stamp recording when an unlogged materialized view was populated; its
+       data is valid only while the cluster remains in that epoch.  Use
+       <function>pg_matview_is_populated</function> instead of reading this
+       column directly.
+      </para></entry>
+     </row>
+
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
        <structfield>relacl</structfield> <type>aclitem[]</type>
diff --git a/doc/src/sgml/func/func-info.sgml b/doc/src/sgml/func/func-info.sgml
index 2f03766b67a..56dc58ba626 100644
--- a/doc/src/sgml/func/func-info.sgml
+++ b/doc/src/sgml/func/func-info.sgml
@@ -1875,6 +1875,22 @@ SELECT currval(pg_get_serial_sequence('sometable', 'id'));
        </para></entry>
       </row>
 
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>pg_matview_is_populated</primary>
+        </indexterm>
+        <function>pg_matview_is_populated</function> ( <type>regclass</type> )
+        <returnvalue>boolean</returnvalue>
+       </para>
+       <para>
+        Returns true if the materialized view currently holds valid data,
+        false if it must be refreshed before use.  Returns
+        <literal>NULL</literal> for arguments that are not materialized
+        views.
+       </para></entry>
+      </row>
+
       <row>
        <entry role="func_table_entry"><para role="func_signature">
         <indexterm>
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index b018f26545b..68f3acea8a3 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -965,12 +965,12 @@ InsertPgClassTuple(Relation pg_class_desc,
 	values[Anum_pg_class_relrowsecurity - 1] = BoolGetDatum(rd_rel->relrowsecurity);
 	values[Anum_pg_class_relforcerowsecurity - 1] = BoolGetDatum(rd_rel->relforcerowsecurity);
 	values[Anum_pg_class_relhassubclass - 1] = BoolGetDatum(rd_rel->relhassubclass);
-	values[Anum_pg_class_relispopulated - 1] = BoolGetDatum(rd_rel->relispopulated);
 	values[Anum_pg_class_relreplident - 1] = CharGetDatum(rd_rel->relreplident);
 	values[Anum_pg_class_relispartition - 1] = BoolGetDatum(rd_rel->relispartition);
 	values[Anum_pg_class_relrewrite - 1] = ObjectIdGetDatum(rd_rel->relrewrite);
 	values[Anum_pg_class_relfrozenxid - 1] = TransactionIdGetDatum(rd_rel->relfrozenxid);
 	values[Anum_pg_class_relminmxid - 1] = MultiXactIdGetDatum(rd_rel->relminmxid);
+	values[Anum_pg_class_relpopulated - 1] = Int64GetDatum(rd_rel->relpopulated);
 	if (relacl != (Datum) 0)
 		values[Anum_pg_class_relacl - 1] = relacl;
 	else
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 8612d99a890..be344ff327f 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -150,7 +150,7 @@ CREATE VIEW pg_matviews AS
         pg_get_userbyid(C.relowner) AS matviewowner,
         T.spcname AS tablespace,
         C.relhasindex AS hasindexes,
-        C.relispopulated AS ispopulated,
+        pg_matview_is_populated(C.oid) AS ispopulated,
         pg_get_viewdef(C.oid) AS definition
     FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace)
          LEFT JOIN pg_tablespace T ON (T.oid = C.reltablespace)
diff --git a/src/backend/commands/copyto.c b/src/backend/commands/copyto.c
index 5850608a3fb..e84c6719d18 100644
--- a/src/backend/commands/copyto.c
+++ b/src/backend/commands/copyto.c
@@ -23,6 +23,7 @@
 #include "access/tupconvert.h"
 #include "catalog/pg_inherits.h"
 #include "commands/copyapi.h"
+#include "commands/matview.h"
 #include "commands/progress.h"
 #include "executor/execdesc.h"
 #include "executor/executor.h"
diff --git a/src/backend/commands/matview.c b/src/backend/commands/matview.c
index 40748958eaf..f8c18720e99 100644
--- a/src/backend/commands/matview.c
+++ b/src/backend/commands/matview.c
@@ -20,6 +20,7 @@
 #include "access/multixact.h"
 #include "access/tableam.h"
 #include "access/xact.h"
+#include "access/xlog.h"
 #include "catalog/indexing.h"
 #include "catalog/namespace.h"
 #include "catalog/pg_am.h"
@@ -79,6 +80,7 @@ SetMatViewPopulatedState(Relation relation, bool newstate)
 {
 	Relation	pgrel;
 	HeapTuple	tuple;
+	Form_pg_class classform;
 
 	Assert(relation->rd_rel->relkind == RELKIND_MATVIEW);
 
@@ -94,7 +96,14 @@ SetMatViewPopulatedState(Relation relation, bool newstate)
 		elog(ERROR, "cache lookup failed for relation %u",
 			 RelationGetRelid(relation));
 
-	((Form_pg_class) GETSTRUCT(tuple))->relispopulated = newstate;
+	classform = (Form_pg_class) GETSTRUCT(tuple);
+
+	if (!newstate)
+		classform->relpopulated = RELPOPULATED_NONE;
+	else if (relation->rd_rel->relpersistence == RELPERSISTENCE_UNLOGGED)
+		classform->relpopulated = (int64) GetUnloggedPopulatedEpoch();
+	else
+		classform->relpopulated = RELPOPULATED_ETERNAL;
 
 	CatalogTupleUpdate(pgrel, &tuple->t_self, tuple);
 
@@ -108,6 +117,59 @@ SetMatViewPopulatedState(Relation relation, bool newstate)
 	CommandCounterIncrement();
 }
 
+/*
+ * MatViewPopulatedValueIsValid
+ *		Does this pg_class.relpopulated value denote currently valid data?
+ *
+ * This only distinguishes RELPOPULATED_NONE from everything else; any other
+ * value, whether RELPOPULATED_ETERNAL or an epoch stamp, counts as valid.
+ */
+bool
+MatViewPopulatedValueIsValid(int64 value)
+{
+	return value != RELPOPULATED_NONE;
+}
+
+/*
+ * RelationIsPopulated
+ *		Does this relation currently hold valid data?  Only a materialized
+ *		view can return false.
+ */
+bool
+RelationIsPopulated(Relation relation)
+{
+	return MatViewPopulatedValueIsValid(relation->rd_rel->relpopulated);
+}
+
+/*
+ * pg_matview_is_populated
+ *		Does the materialized view currently hold valid data?
+ *
+ * Returns NULL if the argument is not a materialized view, or if it does
+ * not exist.
+ */
+Datum
+pg_matview_is_populated(PG_FUNCTION_ARGS)
+{
+	Oid			relid = PG_GETARG_OID(0);
+	HeapTuple	tuple;
+	Form_pg_class classform;
+	bool		result;
+
+	tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+	if (!HeapTupleIsValid(tuple))
+		PG_RETURN_NULL();
+	classform = (Form_pg_class) GETSTRUCT(tuple);
+	if (classform->relkind != RELKIND_MATVIEW)
+	{
+		ReleaseSysCache(tuple);
+		PG_RETURN_NULL();
+	}
+	result = MatViewPopulatedValueIsValid(classform->relpopulated);
+	ReleaseSysCache(tuple);
+	PG_RETURN_BOOL(result);
+}
+
 /*
  * ExecRefreshMatView -- execute a REFRESH MATERIALIZED VIEW command
  *
diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 477c86b2ba6..926057426b1 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -53,6 +53,7 @@
 #include "catalog/pg_inherits.h"
 #include "catalog/toasting.h"
 #include "commands/defrem.h"
+#include "commands/matview.h"
 #include "commands/progress.h"
 #include "commands/repack.h"
 #include "commands/repack_internal.h"
diff --git a/src/backend/executor/execUtils.c b/src/backend/executor/execUtils.c
index c0c4276acbf..3433e29cad8 100644
--- a/src/backend/executor/execUtils.c
+++ b/src/backend/executor/execUtils.c
@@ -49,6 +49,7 @@
 #include "access/table.h"
 #include "access/tableam.h"
 #include "access/tupconvert.h"
+#include "commands/matview.h"
 #include "executor/executor.h"
 #include "executor/nodeModifyTable.h"
 #include "jit/jit.h"
@@ -778,7 +779,7 @@ ExecOpenScanRelation(EState *estate, Index scanrelid, int eflags)
 	 * to do this, perhaps, but there is no better place.
 	 */
 	if ((eflags & (EXEC_FLAG_EXPLAIN_ONLY | EXEC_FLAG_WITH_NO_DATA)) == 0 &&
-		!RelationIsScannable(rel))
+		!RelationIsPopulated(rel))
 		ereport(ERROR,
 				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
 				 errmsg("materialized view \"%s\" has not been populated",
diff --git a/src/backend/utils/adt/pg_upgrade_support.c b/src/backend/utils/adt/pg_upgrade_support.c
index b505a6b4fee..2f2a01e3a49 100644
--- a/src/backend/utils/adt/pg_upgrade_support.c
+++ b/src/backend/utils/adt/pg_upgrade_support.c
@@ -19,6 +19,7 @@
 #include "catalog/pg_subscription_rel.h"
 #include "catalog/pg_type.h"
 #include "commands/extension.h"
+#include "commands/matview.h"
 #include "miscadmin.h"
 #include "replication/logical.h"
 #include "replication/logicallauncher.h"
@@ -29,6 +30,7 @@
 #include "utils/builtins.h"
 #include "utils/lsyscache.h"
 #include "utils/pg_lsn.h"
+#include "utils/rel.h"
 
 
 #define CHECK_IS_BINARY_UPGRADE									\
@@ -181,6 +183,24 @@ binary_upgrade_set_next_pg_authid_oid(PG_FUNCTION_ARGS)
 	PG_RETURN_VOID();
 }
 
+Datum
+binary_upgrade_set_matview_populated(PG_FUNCTION_ARGS)
+{
+	Oid			relid = PG_GETARG_OID(0);
+	Relation	rel;
+
+	CHECK_IS_BINARY_UPGRADE;
+
+	rel = relation_open(relid, AccessExclusiveLock);
+	if (rel->rd_rel->relkind != RELKIND_MATVIEW)
+		elog(ERROR, "relation \"%s\" is not a materialized view",
+			 RelationGetRelationName(rel));
+	SetMatViewPopulatedState(rel, true);
+	relation_close(rel, NoLock);
+
+	PG_RETURN_VOID();
+}
+
 Datum
 binary_upgrade_create_empty_extension(PG_FUNCTION_ARGS)
 {
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index f475d703977..fa443dc6527 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -1944,7 +1944,7 @@ formrdesc(const char *relationName, Oid relationReltype,
 	relation->rd_rel->relpersistence = RELPERSISTENCE_PERMANENT;
 
 	/* ... and they're always populated, too */
-	relation->rd_rel->relispopulated = true;
+	relation->rd_rel->relpopulated = RELPOPULATED_ETERNAL;
 
 	relation->rd_rel->relreplident = REPLICA_IDENTITY_NOTHING;
 	relation->rd_rel->relpages = 0;
@@ -3668,10 +3668,8 @@ RelationBuildLocalRelation(const char *relname,
 	}
 
 	/* if it's a materialized view, it's not populated initially */
-	if (relkind == RELKIND_MATVIEW)
-		rel->rd_rel->relispopulated = false;
-	else
-		rel->rd_rel->relispopulated = true;
+	rel->rd_rel->relpopulated = (relkind == RELKIND_MATVIEW) ?
+		RELPOPULATED_NONE : RELPOPULATED_ETERNAL;
 
 	/* set replica identity -- system catalogs and non-tables don't have one */
 	if (!IsCatalogNamespace(relnamespace) &&
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index b5b257f9983..483ce625f06 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -7253,8 +7253,26 @@ getTables(Archive *fout, int *numTables)
 		appendPQExpBufferStr(query,
 							 "c.relhasoids, ");
 
-	appendPQExpBufferStr(query,
-						 "c.relispopulated, ");
+	/*
+	 * A normal dump repopulates matviews with REFRESH, so any nonzero
+	 * relpopulated value (the user's intent to have the matview populated)
+	 * should dump as populated.  Binary upgrade instead transfers the heap
+	 * storage as-is, so it must use the effective state: an unlogged matview
+	 * whose epoch stamp went stale (storage reset by a crash and never
+	 * refreshed) has to be restored as unpopulated, or the new cluster would
+	 * present the transferred empty heap as valid data.
+	 */
+	if (fout->remoteVersion >= 190000 && fout->dopt->binary_upgrade)
+		appendPQExpBufferStr(query,
+							 "(CASE WHEN c.relkind = " CppAsString2(RELKIND_MATVIEW)
+							 " THEN pg_catalog.pg_matview_is_populated(c.oid) "
+							 "ELSE true END) AS relispopulated, ");
+	else if (fout->remoteVersion >= 190000)
+		appendPQExpBufferStr(query,
+							 "c.relpopulated <> 0 AS relispopulated, ");
+	else
+		appendPQExpBufferStr(query,
+							 "c.relispopulated, ");
 
 	appendPQExpBufferStr(query,
 						 "c.relreplident, ");
@@ -17812,21 +17830,20 @@ dumpTableSchema(Archive *fout, const TableInfo *tbinfo)
 		}
 
 		/*
-		 * In binary_upgrade mode, restore matviews' populated status by
-		 * poking pg_class directly.  This is pretty ugly, but we can't use
-		 * REFRESH MATERIALIZED VIEW since it's possible that some underlying
-		 * matview is not populated even though this matview is; in any case,
-		 * we want to transfer the matview's heap storage, not run REFRESH.
+		 * In binary_upgrade mode, restore matviews' populated status using
+		 * the binary_upgrade_set_matview_populated() support function.  We
+		 * can't use REFRESH MATERIALIZED VIEW since it's possible that some
+		 * underlying matview is not populated even though this matview is;
+		 * in any case, we want to transfer the matview's heap storage, not
+		 * run REFRESH.
 		 */
 		if (dopt->binary_upgrade && tbinfo->relkind == RELKIND_MATVIEW &&
 			tbinfo->relispopulated)
 		{
 			appendPQExpBufferStr(q, "\n-- For binary upgrade, mark materialized view as populated\n");
-			appendPQExpBufferStr(q, "UPDATE pg_catalog.pg_class\n"
-								 "SET relispopulated = 't'\n"
-								 "WHERE oid = ");
+			appendPQExpBufferStr(q, "SELECT pg_catalog.binary_upgrade_set_matview_populated(");
 			appendStringLiteralAH(q, qualrelname, fout);
-			appendPQExpBufferStr(q, "::pg_catalog.regclass;\n");
+			appendPQExpBufferStr(q, "::pg_catalog.regclass);\n");
 		}
 
 		/*
diff --git a/src/include/catalog/catversion.h b/src/include/catalog/catversion.h
index f11e244899e..6e4b76e7533 100644
--- a/src/include/catalog/catversion.h
+++ b/src/include/catalog/catversion.h
@@ -57,6 +57,6 @@
  */
 
 /*							yyyymmddN */
-#define CATALOG_VERSION_NO	202608271
+#define CATALOG_VERSION_NO	202609061
 
 #endif
diff --git a/src/include/catalog/pg_class.h b/src/include/catalog/pg_class.h
index c4af599dc90..7038ce0d51e 100644
--- a/src/include/catalog/pg_class.h
+++ b/src/include/catalog/pg_class.h
@@ -115,9 +115,6 @@ CATALOG(pg_class,1259,RelationRelationId) BKI_BOOTSTRAP BKI_ROWTYPE_OID(83,Relat
 	/* row security forced for owners or not */
 	bool		relforcerowsecurity BKI_DEFAULT(f);
 
-	/* matview currently holds query results */
-	bool		relispopulated BKI_DEFAULT(t);
-
 	/* see REPLICA_IDENTITY_xxx constants */
 	char		relreplident BKI_DEFAULT(n);
 
@@ -133,6 +130,15 @@ CATALOG(pg_class,1259,RelationRelationId) BKI_BOOTSTRAP BKI_ROWTYPE_OID(83,Relat
 	/* all multixacts in this rel are >= this; it is really a MultiXactId */
 	TransactionId relminmxid BKI_DEFAULT(1);	/* FirstMultiXactId */
 
+	/*
+	 * Populated epoch.  0 (RELPOPULATED_NONE): no data.  1
+	 * (RELPOPULATED_ETERNAL): crash-safe populated, used for every relation
+	 * except unlogged matviews.  Any other value is an epoch stamp for a
+	 * populated unlogged matview, valid only while the cluster stays in that
+	 * epoch.  See MatViewPopulatedValueIsValid().
+	 */
+	int64		relpopulated BKI_DEFAULT(1);
+
 #ifdef CATALOG_VARLEN			/* variable-length fields start here */
 	/* NOTE: These fields are not present in a relcache entry's rd_rel field. */
 	/* access permissions */
@@ -150,7 +156,7 @@ END_CATALOG_STRUCT
 
 /* Size of fixed part of pg_class tuples, not counting var-length fields */
 #define CLASS_TUPLE_SIZE \
-	 (offsetof(FormData_pg_class,relminmxid) + sizeof(TransactionId))
+	 (offsetof(FormData_pg_class,relpopulated) + sizeof(int64))
 
 /* ----------------
  *		Form_pg_class corresponds to a pointer to a tuple with
@@ -180,6 +186,10 @@ MAKE_SYSCACHE(RELNAMENSP, pg_class_relname_nsp_index, 128);
 #define		  RELKIND_PARTITIONED_INDEX 'I' /* partitioned index */
 #define		  RELKIND_PROPGRAPH		  'g'	/* property graph */
 
+/* Reserved values of pg_class.relpopulated; any other value is an epoch. */
+#define		  RELPOPULATED_NONE		  0 /* not populated */
+#define		  RELPOPULATED_ETERNAL	  1 /* populated, storage is crash-safe */
+
 #define		  RELPERSISTENCE_PERMANENT	'p' /* regular table */
 #define		  RELPERSISTENCE_UNLOGGED	'u' /* unlogged permanent table */
 #define		  RELPERSISTENCE_TEMP		't' /* temporary table */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 6979c7d1161..a5a29434c42 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12032,6 +12032,10 @@
   proname => 'binary_upgrade_set_next_pg_tablespace_oid', provolatile => 'v',
   proparallel => 'u', prorettype => 'void', proargtypes => 'oid',
   prosrc => 'binary_upgrade_set_next_pg_tablespace_oid' },
+{ oid => '8668', descr => 'for use by pg_upgrade',
+  proname => 'binary_upgrade_set_matview_populated', provolatile => 'v',
+  proparallel => 'u', prorettype => 'void', proargtypes => 'oid',
+  prosrc => 'binary_upgrade_set_matview_populated' },
 { oid => '6312', descr => 'for use by pg_upgrade',
   proname => 'binary_upgrade_check_logical_slot_pending_wal',
   provolatile => 'v', proparallel => 'u', prorettype => 'pg_lsn',
@@ -12400,6 +12404,10 @@
   proname => 'pg_relation_is_publishable', provolatile => 's',
   prorettype => 'bool', proargtypes => 'regclass',
   prosrc => 'pg_relation_is_publishable' },
+{ oid => '8667', descr => 'is materialized view populated',
+  proname => 'pg_matview_is_populated', provolatile => 's',
+  prorettype => 'bool', proargtypes => 'regclass',
+  prosrc => 'pg_matview_is_populated' },
 
 # rls
 { oid => '3298',
diff --git a/src/include/commands/matview.h b/src/include/commands/matview.h
index 738c731c1a9..06096e43601 100644
--- a/src/include/commands/matview.h
+++ b/src/include/commands/matview.h
@@ -22,6 +22,8 @@
 
 
 extern void SetMatViewPopulatedState(Relation relation, bool newstate);
+extern bool MatViewPopulatedValueIsValid(int64 value);
+extern bool RelationIsPopulated(Relation relation);
 
 extern ObjectAddress ExecRefreshMatView(RefreshMatViewStmt *stmt, const char *queryString,
 										QueryCompletion *qc);
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index 41ab4586c6b..a3e2ec89778 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -681,22 +681,6 @@ RelationCloseSmgr(Relation relation)
 	 !(relation)->rd_islocaltemp)
 
 
-/*
- * RelationIsScannable
- *		Currently can only be false for a materialized view which has not been
- *		populated by its query.  This is likely to get more complicated later,
- *		so use a macro which looks like a function.
- */
-#define RelationIsScannable(relation) ((relation)->rd_rel->relispopulated)
-
-/*
- * RelationIsPopulated
- *		Currently, we don't physically distinguish the "populated" and
- *		"scannable" properties of matviews, but that may change later.
- *		Hence, use the appropriate one of these macros in code tests.
- */
-#define RelationIsPopulated(relation) ((relation)->rd_rel->relispopulated)
-
 /*
  * RelationIsAccessibleInLogicalDecoding
  *		True if we need to log enough information to have access via
diff --git a/src/test/regress/expected/matview.out b/src/test/regress/expected/matview.out
index 0355720dfc6..7500bf027da 100644
--- a/src/test/regress/expected/matview.out
+++ b/src/test/regress/expected/matview.out
@@ -27,9 +27,9 @@ EXPLAIN (costs off)
 (3 rows)
 
 CREATE MATERIALIZED VIEW mvtest_tm AS SELECT type, sum(amt) AS totamt FROM mvtest_t GROUP BY type WITH NO DATA;
-SELECT relispopulated FROM pg_class WHERE oid = 'mvtest_tm'::regclass;
- relispopulated 
-----------------
+SELECT pg_matview_is_populated('mvtest_tm'::regclass);
+ pg_matview_is_populated 
+-------------------------
  f
 (1 row)
 
@@ -37,12 +37,25 @@ SELECT * FROM mvtest_tm ORDER BY type;
 ERROR:  materialized view "mvtest_tm" has not been populated
 HINT:  Use the REFRESH MATERIALIZED VIEW command.
 REFRESH MATERIALIZED VIEW mvtest_tm;
-SELECT relispopulated FROM pg_class WHERE oid = 'mvtest_tm'::regclass;
- relispopulated 
-----------------
+SELECT pg_matview_is_populated('mvtest_tm'::regclass);
+ pg_matview_is_populated 
+-------------------------
  t
 (1 row)
 
+-- ... but NULL for a relation that is not a materialized view, or nonexistent
+SELECT pg_matview_is_populated('mvtest_t'::regclass);
+ pg_matview_is_populated 
+-------------------------
+ 
+(1 row)
+
+SELECT pg_matview_is_populated(0);
+ pg_matview_is_populated 
+-------------------------
+ 
+(1 row)
+
 CREATE UNIQUE INDEX mvtest_tm_type ON mvtest_tm (type);
 SELECT * FROM mvtest_tm ORDER BY type;
  type | totamt 
@@ -376,9 +389,9 @@ UNION ALL
    FROM mvtest_vt2;
 
 CREATE MATERIALIZED VIEW mv_test3 AS SELECT * FROM mv_test2 WHERE moo = 12345;
-SELECT relispopulated FROM pg_class WHERE oid = 'mv_test3'::regclass;
- relispopulated 
-----------------
+SELECT pg_matview_is_populated('mv_test3'::regclass);
+ pg_matview_is_populated 
+-------------------------
  t
 (1 row)
 
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 1a29d46213e..feca35216b4 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -1414,7 +1414,7 @@ pg_matviews| SELECT n.nspname AS schemaname,
     pg_get_userbyid(c.relowner) AS matviewowner,
     t.spcname AS tablespace,
     c.relhasindex AS hasindexes,
-    c.relispopulated AS ispopulated,
+    pg_matview_is_populated((c.oid)::regclass) AS ispopulated,
     pg_get_viewdef(c.oid) AS definition
    FROM ((pg_class c
      LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace)))
diff --git a/src/test/regress/sql/matview.sql b/src/test/regress/sql/matview.sql
index 934426b9ae8..8890a2f7932 100644
--- a/src/test/regress/sql/matview.sql
+++ b/src/test/regress/sql/matview.sql
@@ -15,10 +15,13 @@ SELECT * FROM mvtest_tv ORDER BY type;
 EXPLAIN (costs off)
   CREATE MATERIALIZED VIEW mvtest_tm AS SELECT type, sum(amt) AS totamt FROM mvtest_t GROUP BY type WITH NO DATA;
 CREATE MATERIALIZED VIEW mvtest_tm AS SELECT type, sum(amt) AS totamt FROM mvtest_t GROUP BY type WITH NO DATA;
-SELECT relispopulated FROM pg_class WHERE oid = 'mvtest_tm'::regclass;
+SELECT pg_matview_is_populated('mvtest_tm'::regclass);
 SELECT * FROM mvtest_tm ORDER BY type;
 REFRESH MATERIALIZED VIEW mvtest_tm;
-SELECT relispopulated FROM pg_class WHERE oid = 'mvtest_tm'::regclass;
+SELECT pg_matview_is_populated('mvtest_tm'::regclass);
+-- ... but NULL for a relation that is not a materialized view, or nonexistent
+SELECT pg_matview_is_populated('mvtest_t'::regclass);
+SELECT pg_matview_is_populated(0);
 CREATE UNIQUE INDEX mvtest_tm_type ON mvtest_tm (type);
 SELECT * FROM mvtest_tm ORDER BY type;
 
@@ -122,7 +125,7 @@ CREATE VIEW mvtest_vt2 AS SELECT moo, 2*moo FROM mvtest_vt1 UNION ALL SELECT moo
 CREATE MATERIALIZED VIEW mv_test2 AS SELECT moo, 2*moo FROM mvtest_vt2 UNION ALL SELECT moo, 3*moo FROM mvtest_vt2;
 \d+ mv_test2
 CREATE MATERIALIZED VIEW mv_test3 AS SELECT * FROM mv_test2 WHERE moo = 12345;
-SELECT relispopulated FROM pg_class WHERE oid = 'mv_test3'::regclass;
+SELECT pg_matview_is_populated('mv_test3'::regclass);
 
 DROP VIEW mvtest_vt1 CASCADE;
 
-- 
2.55.0

From 9f497abb5b92bcaf612ae96c778e78a0dc2717fd Mon Sep 17 00:00:00 2001
From: Jim Jones <[email protected]>
Date: Sun, 6 Sep 2026 20:44:06 +0200
Subject: [PATCH v3 3/4] Add unlogged materialized views

Allow CREATE and ALTER UNLOGGED MATERIALIZED VIEW. Storage is unlogged,
so REFRESH populates the data without WAL-logging it. Stamp the
populated epoch on refresh and treat stale epochs (after a crash,
promotion, or PITR) as unpopulated. Support ALTER MATERIALIZED VIEW
SET LOGGED/UNLOGGED. Add recovery tests for standby and promotion,
plus docs.
---
 doc/src/sgml/func/func-info.sgml              |   4 +-
 doc/src/sgml/ref/alter_materialized_view.sgml |  21 ++
 .../sgml/ref/create_materialized_view.sgml    |  26 ++
 doc/src/sgml/storage.sgml                     |  10 +
 src/backend/access/heap/heapam_handler.c      |   3 +-
 src/backend/commands/matview.c                |  24 +-
 src/backend/commands/repack.c                 |  23 ++
 src/backend/commands/tablecmds.c              |   3 +-
 src/backend/optimizer/util/plancat.c          |  18 ++
 src/backend/parser/analyze.c                  |  12 -
 src/test/recovery/meson.build                 |   3 +
 .../t/057_unlogged_matview_standby.pl         | 145 +++++++++++
 .../t/058_unlogged_matview_promotion.pl       | 231 ++++++++++++++++++
 src/test/recovery/t/059_unlogged_matview.pl   | 178 ++++++++++++++
 src/test/regress/expected/matview.out         | 206 ++++++++++++++++
 src/test/regress/sql/matview.sql              |  65 +++++
 16 files changed, 953 insertions(+), 19 deletions(-)
 create mode 100644 src/test/recovery/t/057_unlogged_matview_standby.pl
 create mode 100644 src/test/recovery/t/058_unlogged_matview_promotion.pl
 create mode 100644 src/test/recovery/t/059_unlogged_matview.pl

diff --git a/doc/src/sgml/func/func-info.sgml b/doc/src/sgml/func/func-info.sgml
index 56dc58ba626..8f133343c04 100644
--- a/doc/src/sgml/func/func-info.sgml
+++ b/doc/src/sgml/func/func-info.sgml
@@ -1887,7 +1887,9 @@ SELECT currval(pg_get_serial_sequence('sometable', 'id'));
         Returns true if the materialized view currently holds valid data,
         false if it must be refreshed before use.  Returns
         <literal>NULL</literal> for arguments that are not materialized
-        views.
+        views.  Unlike reading <structname>pg_class</structname> directly,
+        this accounts for unlogged materialized views whose contents were
+        removed by a crash or are unavailable during recovery.
        </para></entry>
       </row>
 
diff --git a/doc/src/sgml/ref/alter_materialized_view.sgml b/doc/src/sgml/ref/alter_materialized_view.sgml
index f81a7393f5d..1e2c8f0797d 100644
--- a/doc/src/sgml/ref/alter_materialized_view.sgml
+++ b/doc/src/sgml/ref/alter_materialized_view.sgml
@@ -45,6 +45,7 @@ ALTER MATERIALIZED VIEW ALL IN TABLESPACE <replaceable class="parameter">name</r
     SET WITHOUT CLUSTER
     SET ACCESS METHOD <replaceable class="parameter">new_access_method</replaceable>
     SET TABLESPACE <replaceable class="parameter">new_tablespace</replaceable>
+    SET { LOGGED | UNLOGGED }
     SET ( <replaceable class="parameter">storage_parameter</replaceable> [= <replaceable class="parameter">value</replaceable>] [, ... ] )
     RESET ( <replaceable class="parameter">storage_parameter</replaceable> [, ... ] )
     OWNER TO { <replaceable class="parameter">new_owner</replaceable> | CURRENT_ROLE | CURRENT_USER | SESSION_USER }
@@ -152,6 +153,26 @@ ALTER MATERIALIZED VIEW ALL IN TABLESPACE <replaceable class="parameter">name</r
      </para>
     </listitem>
    </varlistentry>
+
+   <varlistentry id="sql-altermaterializedview-set-logged-unlogged">
+    <term><literal>SET { LOGGED | UNLOGGED }</literal></term>
+    <listitem>
+     <para>
+      This form changes the materialized view from unlogged to logged or
+      vice-versa (see <literal>UNLOGGED</literal> in
+      <link linkend="sql-creatematerializedview-unlogged"><command>CREATE
+      MATERIALIZED VIEW</command></link>), the same as
+      <link linkend="sql-altertable-desc-set-logged-unlogged"><literal>SET
+      { LOGGED | UNLOGGED }</literal></link> does for
+      <command>ALTER TABLE</command>.  The change takes effect immediately:
+      the materialized view's current contents, if any, are preserved and
+      remain queryable, no crash or refresh is involved.  Once set to
+      <literal>UNLOGGED</literal>, however, the contents will be lost after
+      a future crash or unclean shutdown, at which point the materialized
+      view reverts to the unpopulated state.
+     </para>
+    </listitem>
+   </varlistentry>
   </variablelist>
  </refsect1>
 
diff --git a/doc/src/sgml/ref/create_materialized_view.sgml b/doc/src/sgml/ref/create_materialized_view.sgml
index 62d897931c3..cb76bcfd69c 100644
--- a/doc/src/sgml/ref/create_materialized_view.sgml
+++ b/doc/src/sgml/ref/create_materialized_view.sgml
@@ -60,6 +60,32 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
   <title>Parameters</title>
 
   <variablelist>
+   <varlistentry id="sql-creatematerializedview-unlogged">
+    <term><literal>UNLOGGED</literal></term>
+    <listitem>
+     <para>
+      If specified, the materialized view is created as an unlogged
+      materialized view.  Data written to an unlogged materialized view is
+      not written to the write-ahead log (see <xref linkend="wal"/>), which
+      makes populating it, whether by <command>CREATE MATERIALIZED
+      VIEW</command> or by a later <command>REFRESH MATERIALIZED
+      VIEW</command>, considerably faster.  However, an unlogged materialized
+      view is not crash-safe: its contents are discarded after a crash or
+      unclean shutdown, at which point the materialized view reverts to the
+      unpopulated state and must be rebuilt with <command>REFRESH
+      MATERIALIZED VIEW</command> before it can be queried again, the same as
+      a materialized view created with <literal>WITH NO DATA</literal> (see
+      below).  Unlike an unlogged table, which reads as empty after a crash,
+      an unlogged materialized view instead reports that it has not been
+      populated.  Contents are preserved across a normal shutdown and
+      restart.  The contents of an unlogged materialized view are also not
+      replicated to standby servers, so on a standby such a materialized view
+      always reports itself as unpopulated, even immediately after the
+      primary refreshes it.
+     </para>
+    </listitem>
+   </varlistentry>
+
    <varlistentry>
     <term><literal>IF NOT EXISTS</literal></term>
     <listitem>
diff --git a/doc/src/sgml/storage.sgml b/doc/src/sgml/storage.sgml
index 19924b98d71..789e24b89fe 100644
--- a/doc/src/sgml/storage.sgml
+++ b/doc/src/sgml/storage.sgml
@@ -697,6 +697,16 @@ initialization fork is copied over the main fork, and any other forks are
 erased (they will be recreated automatically as needed).
 </para>
 
+<para>
+An unlogged materialized view has an initialization fork too, and its
+storage is reset the same way after a crash.  Because a materialized view
+additionally tracks whether it is populated, resetting its storage also
+makes it read as unpopulated rather than as empty; a subsequent
+<command>REFRESH MATERIALIZED VIEW</command> repopulates it.  This check
+happens whenever the materialized view is accessed, so no explicit repair
+step or new session is required after the crash.
+</para>
+
 </sect1>
 
 <sect1 id="storage-page-layout">
diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
index 0f24a132564..6c47a99349b 100644
--- a/src/backend/access/heap/heapam_handler.c
+++ b/src/backend/access/heap/heapam_handler.c
@@ -517,7 +517,8 @@ heapam_relation_set_new_filelocator(Relation rel,
 	if (persistence == RELPERSISTENCE_UNLOGGED)
 	{
 		Assert(rel->rd_rel->relkind == RELKIND_RELATION ||
-			   rel->rd_rel->relkind == RELKIND_TOASTVALUE);
+			   rel->rd_rel->relkind == RELKIND_TOASTVALUE ||
+			   rel->rd_rel->relkind == RELKIND_MATVIEW);
 		smgrcreate(srel, INIT_FORKNUM, false);
 		log_smgrcreate(newrlocator, INIT_FORKNUM);
 	}
diff --git a/src/backend/commands/matview.c b/src/backend/commands/matview.c
index f8c18720e99..01164125f34 100644
--- a/src/backend/commands/matview.c
+++ b/src/backend/commands/matview.c
@@ -120,14 +120,24 @@ SetMatViewPopulatedState(Relation relation, bool newstate)
 /*
  * MatViewPopulatedValueIsValid
  *		Does this pg_class.relpopulated value denote currently valid data?
- *
- * This only distinguishes RELPOPULATED_NONE from everything else; any other
- * value, whether RELPOPULATED_ETERNAL or an epoch stamp, counts as valid.
  */
 bool
 MatViewPopulatedValueIsValid(int64 value)
 {
-	return value != RELPOPULATED_NONE;
+	if (value == RELPOPULATED_NONE)
+		return false;
+	if (value == RELPOPULATED_ETERNAL)
+		return true;
+
+	/*
+	 * Epoch stamp: valid only if it matches the current epoch.  During
+	 * recovery always treat it as invalid -- a standby never has the unlogged
+	 * data, and its node-local counters may collide with the primary's.
+	 */
+	if (RecoveryInProgress())
+		return false;
+
+	return (uint64) value == GetUnloggedPopulatedEpoch();
 }
 
 /*
@@ -138,6 +148,12 @@ MatViewPopulatedValueIsValid(int64 value)
 bool
 RelationIsPopulated(Relation relation)
 {
+	/* Only unlogged matviews may carry an epoch stamp. */
+	Assert(relation->rd_rel->relpopulated == RELPOPULATED_NONE ||
+		   relation->rd_rel->relpopulated == RELPOPULATED_ETERNAL ||
+		   (relation->rd_rel->relkind == RELKIND_MATVIEW &&
+			relation->rd_rel->relpersistence == RELPERSISTENCE_UNLOGGED));
+
 	return MatViewPopulatedValueIsValid(relation->rd_rel->relpopulated);
 }
 
diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 926057426b1..66259f41401 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -1567,6 +1567,29 @@ swap_relation_files(Oid r1, Oid r2, bool target_is_pg_class,
 		relform1->relpersistence = relform2->relpersistence;
 		relform2->relpersistence = swptmpchr;
 
+		/*
+		 * A matview's relpopulated value encodes its persistence class:
+		 * permanent matviews use RELPOPULATED_ETERNAL, unlogged matviews
+		 * carry an epoch stamp.  Convert the value alongside the persistence
+		 * change (relform1 is the surviving relation's own pg_class row; the
+		 * relkind check skips the transient heap and TOAST rows).  A stale
+		 * stamp (populated before the last crash or promotion) means the
+		 * storage is empty, so converting it to LOGGED must yield "not
+		 * populated" rather than eternally-populated garbage.
+		 */
+		if (relform1->relkind == RELKIND_MATVIEW &&
+			relform1->relpopulated != RELPOPULATED_NONE)
+		{
+			if (relform1->relpersistence == RELPERSISTENCE_UNLOGGED &&
+				relform1->relpopulated == RELPOPULATED_ETERNAL)
+				relform1->relpopulated = (int64) GetUnloggedPopulatedEpoch();
+			else if (relform1->relpersistence == RELPERSISTENCE_PERMANENT &&
+					 relform1->relpopulated != RELPOPULATED_ETERNAL)
+				relform1->relpopulated =
+					MatViewPopulatedValueIsValid(relform1->relpopulated)
+					? RELPOPULATED_ETERNAL : RELPOPULATED_NONE;
+		}
+
 		/* Also swap toast links, if we're swapping by links */
 		if (!swap_toast_by_content)
 		{
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..7b94e1d571e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -5215,7 +5215,8 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
 			break;
 		case AT_SetLogged:		/* SET LOGGED */
 		case AT_SetUnLogged:	/* SET UNLOGGED */
-			ATSimplePermissions(cmd->subtype, rel, ATT_TABLE | ATT_SEQUENCE);
+			ATSimplePermissions(cmd->subtype, rel,
+								ATT_TABLE | ATT_SEQUENCE | ATT_MATVIEW);
 			if (tab->chgPersistence)
 				ereport(ERROR,
 						(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c
index 4c9c5e9fc33..ab7067b2973 100644
--- a/src/backend/optimizer/util/plancat.c
+++ b/src/backend/optimizer/util/plancat.c
@@ -31,6 +31,7 @@
 #include "catalog/pg_proc.h"
 #include "catalog/pg_statistic_ext.h"
 #include "catalog/pg_statistic_ext_data.h"
+#include "commands/matview.h"
 #include "foreign/fdwapi.h"
 #include "miscadmin.h"
 #include "nodes/makefuncs.h"
@@ -151,6 +152,23 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent,
 					 errdetail_relkind_not_supported(relation->rd_rel->relkind)));
 	}
 
+	/*
+	 * An unlogged matview has no storage on a standby, though its
+	 * relpopulated may still carry an epoch stamp from the primary.  Report it
+	 * as unpopulated here -- before the generic recovery guard or
+	 * estimate_rel_size() touch the missing relfiles.  Permanent matviews are
+	 * excluded so plan-time behavior is unchanged.
+	 */
+	if (relation->rd_rel->relkind == RELKIND_MATVIEW &&
+		!RelationIsPermanent(relation) &&
+		RecoveryInProgress() &&
+		!RelationIsPopulated(relation))
+		ereport(ERROR,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("materialized view \"%s\" has not been populated",
+						RelationGetRelationName(relation)),
+				 errhint("Use the REFRESH MATERIALIZED VIEW command.")));
+
 	/* Temporary and unlogged relations are inaccessible during recovery. */
 	if (!RelationIsPermanent(relation) && RecoveryInProgress())
 		ereport(ERROR,
diff --git a/src/backend/parser/analyze.c b/src/backend/parser/analyze.c
index 263d1b6e1cc..733c26dc704 100644
--- a/src/backend/parser/analyze.c
+++ b/src/backend/parser/analyze.c
@@ -3552,18 +3552,6 @@ transformCreateTableAsStmt(ParseState *pstate, CreateTableAsStmt *stmt)
 					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 					 errmsg("materialized views may not be defined using bound parameters")));
 
-		/*
-		 * For now, we disallow unlogged materialized views, because it seems
-		 * like a bad idea for them to just go to empty after a crash. (If we
-		 * could mark them as unpopulated, that would be better, but that
-		 * requires catalog changes which crash recovery can't presently
-		 * handle.)
-		 */
-		if (stmt->into->rel->relpersistence == RELPERSISTENCE_UNLOGGED)
-			ereport(ERROR,
-					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-					 errmsg("materialized views cannot be unlogged")));
-
 		/*
 		 * At runtime, we'll need a copy of the parsed-but-not-rewritten Query
 		 * for purposes of creating the view's ON SELECT rule.  We stash that
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index 72113c5ac6e..fee88f5f53b 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -65,6 +65,9 @@ tests += {
       't/054_unlogged_sequence_promotion.pl',
       't/055_cascade_reconnect.pl',
       't/056_standby_snapshot_export.pl',
+      't/057_unlogged_matview.pl',
+      't/058_unlogged_matview_standby.pl',
+      't/059_unlogged_matview_promotion.pl',      
     ],
   },
 }
diff --git a/src/test/recovery/t/057_unlogged_matview_standby.pl b/src/test/recovery/t/057_unlogged_matview_standby.pl
new file mode 100644
index 00000000000..48ed68499c4
--- /dev/null
+++ b/src/test/recovery/t/057_unlogged_matview_standby.pl
@@ -0,0 +1,145 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Tests the standby scannability contract for UNLOGGED MATERIALIZED VIEWs.
+#
+# Unlogged relations are never streamed to a standby, so an unlogged
+# matview's storage does not exist there.  Its pg_class.relpopulated epoch
+# stamp is replicated verbatim from the primary, but
+# MatViewPopulatedValueIsValid() treats any epoch stamp as invalid whenever
+# RecoveryInProgress() is true, so on a standby every unlogged matview reads
+# as unpopulated regardless of what the primary thinks.  A SELECT, EXPLAIN,
+# or COPY TO must therefore raise the standard "has not been populated" /
+# "unpopulated materialized view" errors rather than silently returning zero
+# rows or failing with the generic "cannot access temporary or unlogged
+# relations during recovery" message.  A logged matview is fully replicated
+# and remains scannable on the standby.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize primary node with streaming replication enabled.
+my $node_primary = PostgreSQL::Test::Cluster->new('primary');
+$node_primary->init(allows_streaming => 1);
+$node_primary->start;
+
+$node_primary->safe_psql('postgres', <<'SQL');
+CREATE UNLOGGED MATERIALIZED VIEW mv_u AS SELECT 42 AS x;
+CREATE MATERIALIZED VIEW mv_p AS SELECT 42 AS x;
+SQL
+
+# Refresh the unlogged matview once more before backing up: REFRESH swaps in
+# a new relfilenode whose main fork is populated via a bulk-insert path that
+# is not WAL-logged for unlogged relations.  This exercises the plan-time
+# guard's handling of a "current" epoch stamp whose main fork the standby
+# never receives.
+$node_primary->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_u');
+
+# Both matviews are populated and scannable on the primary.
+is($node_primary->safe_psql('postgres', 'SELECT count(*) FROM mv_u'),
+	'1', 'unlogged matview is scannable on the primary');
+is($node_primary->safe_psql('postgres', 'SELECT count(*) FROM mv_p'),
+	'1', 'logged matview is scannable on the primary');
+
+# Take a base backup and create a streaming standby.
+$node_primary->backup('bkp');
+
+my $node_standby = PostgreSQL::Test::Cluster->new('standby');
+$node_standby->init_from_backup($node_primary, 'bkp', has_streaming => 1);
+$node_standby->start;
+
+$node_primary->wait_for_catchup($node_standby);
+
+# The logged matview is fully replicated and remains scannable on the
+# standby, returning the primary's data.
+is($node_standby->safe_psql('postgres', 'SELECT count(*) FROM mv_p'),
+	'1', 'logged matview is scannable on the standby (no regression)');
+
+# The unlogged matview must report as unpopulated on the standby: not zero
+# rows, and not the generic unlogged-relation-during-recovery error.
+my ($rc, $out, $err) =
+  $node_standby->psql('postgres', 'SELECT count(*) FROM mv_u');
+isnt($rc, 0, 'SELECT on unlogged matview fails on standby');
+like(
+	$err,
+	qr/has not been populated/,
+	'unlogged matview reports "has not been populated" on standby');
+unlike(
+	$err,
+	qr/cannot access temporary or unlogged relations during recovery/,
+	'unlogged matview does not report the generic unlogged-relation error');
+
+# The plan-time guard in plancat.c must reject it too, before execution.
+($rc, $out, $err) =
+  $node_standby->psql('postgres', 'EXPLAIN SELECT * FROM mv_u');
+isnt($rc, 0, 'EXPLAIN on unlogged matview fails on standby');
+like(
+	$err,
+	qr/has not been populated/,
+	'EXPLAIN on unlogged matview reports "has not been populated" on standby'
+);
+
+# COPY TO must honor the same scannability contract.
+($rc, $out, $err) = $node_standby->psql('postgres', 'COPY mv_u TO stdout');
+isnt($rc, 0, 'COPY from unlogged matview on standby fails');
+like(
+	$err,
+	qr/unpopulated materialized view/,
+	'COPY from unlogged matview reports unpopulated error on standby');
+
+# pg_matview_is_populated() must be accurate per-node: false on the standby
+# while the very same matview reports true on the primary.
+is( $node_standby->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}),
+	'f',
+	'pg_matview_is_populated is false for unlogged matview on standby');
+is( $node_primary->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}),
+	't',
+	'pg_matview_is_populated is still true for unlogged matview on primary');
+
+# Likewise for pg_matviews.ispopulated.
+is( $node_standby->safe_psql(
+		'postgres',
+		q{SELECT ispopulated FROM pg_matviews WHERE matviewname = 'mv_u'}),
+	'f',
+	'pg_matviews.ispopulated is false for unlogged matview on standby');
+is( $node_primary->safe_psql(
+		'postgres',
+		q{SELECT ispopulated FROM pg_matviews WHERE matviewname = 'mv_u'}),
+	't',
+	'pg_matviews.ispopulated is still true for unlogged matview on primary');
+
+# --- Primary-side REFRESH does not change the standby's verdict ------------
+#
+# A fresh REFRESH on the primary writes a new "current" epoch stamp and
+# replicates it to the standby, but the standby is still in recovery, so the
+# stamp is still treated as invalid there.  This also exercises the
+# missing-main-fork safety: the standby never received the new relfilenode's
+# main fork contents (unlogged relfilenodes are not streamed), yet no code
+# path attempts to actually read it.
+
+$node_primary->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_u');
+$node_primary->wait_for_catchup($node_standby);
+
+($rc, $out, $err) =
+  $node_standby->psql('postgres', 'SELECT count(*) FROM mv_u');
+isnt($rc, 0,
+	'SELECT on unlogged matview still fails on standby after primary REFRESH'
+);
+like(
+	$err,
+	qr/has not been populated/,
+	'unlogged matview still reports "has not been populated" on standby after primary REFRESH'
+);
+
+# The primary itself is unaffected and remains populated.
+is($node_primary->safe_psql('postgres', 'SELECT count(*) FROM mv_u'),
+	'1', 'unlogged matview remains scannable on the primary after REFRESH');
+
+$node_standby->stop;
+$node_primary->stop;
+
+done_testing();
diff --git a/src/test/recovery/t/058_unlogged_matview_promotion.pl b/src/test/recovery/t/058_unlogged_matview_promotion.pl
new file mode 100644
index 00000000000..637d231de8f
--- /dev/null
+++ b/src/test/recovery/t/058_unlogged_matview_promotion.pl
@@ -0,0 +1,231 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Tests unlogged matview validity across standby promotion.
+#
+# A populated unlogged matview's pg_class.relpopulated holds the epoch stamp
+# (TimeLineID << 32) | unloggedResetGen of the node that populated it, and
+# the stamp only reads as populated when it equals the reading node's current
+# epoch.  Promotion switches the timeline, so every stamp replicated from the
+# old primary instantly reads as unpopulated on the promoted node, with no
+# catalog write, no reconcile step, and no reconnect requirement.
+#
+# The choreography below deliberately constructs a generation-counter
+# collision to prove that the timeline half of the stamp is load-bearing: the
+# primary crashes once BEFORE the base backup (its generation becomes 1, and
+# the backup carries generation 1 into the standby's pg_control) and a second
+# time AFTER the backup (generation 2), then refreshes the matview so the
+# replicated stamp is (tli 1, gen 2).  When the standby promotes it bumps its
+# own generation 1->2 and moves to timeline 2, so the stamp's generation
+# numerically EQUALS the promoted node's current generation and only the
+# timeline distinguishes them.  A design comparing generations alone would
+# wrongly consider the matview populated over storage that was never
+# replicated.
+#
+# The test also keeps one psql session connected across the promotion:
+# because validity is recomputed from the current epoch at every scan, the
+# surviving session must start reporting "has not been populated" as soon as
+# recovery ends, rather than silently returning zero rows.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Extract "Unlogged reset generation" from a node's pg_controldata output.
+sub unlogged_reset_gen
+{
+	my ($node) = @_;
+	my ($stdout, $stderr) = run_command([ 'pg_controldata', $node->data_dir ]);
+	$stdout =~ /^Unlogged reset generation:\s+(\d+)\r?$/m
+	  or die "no unlogged reset generation in pg_controldata output";
+	return $1;
+}
+
+# --- Primary, crashed once so its generation counter is 1 ------------------
+
+my $node_primary = PostgreSQL::Test::Cluster->new('primary');
+$node_primary->init(allows_streaming => 1);
+$node_primary->start;
+
+$node_primary->safe_psql('postgres',
+	'CREATE UNLOGGED MATERIALIZED VIEW mv_u AS SELECT 42 AS x');
+
+$node_primary->stop('immediate');
+$node_primary->start;
+
+is(unlogged_reset_gen($node_primary),
+	'1', 'first crash bumped the primary unlogged reset generation to 1');
+
+my ($rc, $out, $err) =
+  $node_primary->psql('postgres', 'SELECT count(*) FROM mv_u');
+isnt($rc, 0, 'unlogged matview is unpopulated on the primary after crash');
+like(
+	$err,
+	qr/has not been populated/,
+	'crash-stale unlogged matview reports "has not been populated"');
+
+# Repopulate: the stamp is now (tli 1, gen 1).
+$node_primary->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_u');
+
+# --- Standby whose base backup carries local generation 1 ------------------
+
+$node_primary->backup('bkp');
+
+my $node_standby = PostgreSQL::Test::Cluster->new('standby');
+$node_standby->init_from_backup($node_primary, 'bkp', has_streaming => 1);
+$node_standby->start;
+
+$node_primary->wait_for_catchup($node_standby);
+
+# --- Second primary crash drifts the replicated stamp to generation 2 ------
+
+$node_primary->stop('immediate');
+$node_primary->start;
+
+is(unlogged_reset_gen($node_primary),
+	'2', 'second crash bumped the primary unlogged reset generation to 2');
+
+# Repopulate again: the stamp is now (tli 1, gen 2), and it replicates to the
+# standby, whose own pg_control still says generation 1.
+$node_primary->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_u');
+$node_primary->wait_for_catchup($node_standby);
+
+my $stamp_sql = q{SELECT relpopulated FROM pg_class WHERE relname = 'mv_u'};
+my $stamp_primary = $node_primary->safe_psql('postgres', $stamp_sql);
+my $stamp_standby = $node_standby->safe_psql('postgres', $stamp_sql);
+is($stamp_standby, $stamp_primary,
+	'standby replicated the primary\'s new epoch stamp');
+is( $node_standby->safe_psql(
+		'postgres',
+		q{SELECT relpopulated >> 32, relpopulated & 4294967295
+		  FROM pg_class WHERE relname = 'mv_u'}),
+	'1|2',
+	'replicated stamp carries timeline 1, generation 2');
+
+# --- A session that will survive the promotion -----------------------------
+
+my $bg = $node_standby->background_psql('postgres', on_error_stop => 0);
+
+is($bg->query_safe('SELECT pg_is_in_recovery()'),
+	't', 'surviving session is connected to the standby before promotion');
+
+# On the standby the unlogged matview is unpopulated (in-recovery rule).
+my ($bg_out, $bg_err) = $bg->query('SELECT count(*) FROM mv_u');
+is($bg_err, 1,
+	'surviving session: unlogged matview errors on the standby');
+like(
+	$bg->{stderr},
+	qr/has not been populated/,
+	'surviving session: standby reports "has not been populated"');
+$bg->{stderr} = '';
+
+# --- Promote ----------------------------------------------------------------
+
+$node_standby->promote;
+$node_standby->poll_query_until('postgres', 'SELECT NOT pg_is_in_recovery()')
+  or die "standby never left recovery after promotion";
+
+# --- The surviving session must see the matview as unpopulated -------------
+#
+# This is the heart of the test: the session predates the promotion, so no
+# connect-time repair could have run for it.  If the epoch check were not
+# recomputed at scan time, the SELECT would silently return a zero count over
+# the empty, never-replicated storage.
+
+is($bg->query_safe('SELECT pg_is_in_recovery()'),
+	'f', 'surviving session survived the promotion');
+
+($bg_out, $bg_err) = $bg->query('SELECT count(*) FROM mv_u');
+is($bg_err, 1,
+	'surviving session: SELECT on unlogged matview errors after promotion');
+isnt($bg_out, '0',
+	'surviving session: SELECT did not silently return a zero count');
+is($bg_out, '', 'surviving session: SELECT returned no rows at all');
+like(
+	$bg->{stderr},
+	qr/has not been populated/,
+	'surviving session: promoted node reports "has not been populated"');
+$bg->{stderr} = '';
+
+# --- A new connection agrees, and the collision arithmetic holds -----------
+
+($rc, $out, $err) =
+  $node_standby->psql('postgres', 'SELECT count(*) FROM mv_u');
+isnt($rc, 0, 'new connection: SELECT on unlogged matview fails after promotion');
+like(
+	$err,
+	qr/has not been populated/,
+	'new connection: promoted node reports "has not been populated"');
+is( $node_standby->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}),
+	'f',
+	'pg_matview_is_populated is false on the promoted node');
+
+# Verify the collision was constructed as intended: promotion bumped the
+# node's generation to 2, numerically equal to the stamp's generation, so
+# only the timeline (stamp 1 vs node 2) marks the stamp as stale.
+$node_standby->safe_psql('postgres', 'CHECKPOINT');
+is(unlogged_reset_gen($node_standby),
+	'2', 'promotion bumped the standby unlogged reset generation to 2');
+is( $node_standby->safe_psql(
+		'postgres',
+		q{SELECT relpopulated & 4294967295 FROM pg_class WHERE relname = 'mv_u'}
+	),
+	'2',
+	'stamp generation numerically equals the promoted node\'s generation');
+is( $node_standby->safe_psql(
+		'postgres',
+		q{SELECT relpopulated >> 32 FROM pg_class WHERE relname = 'mv_u'}),
+	'1',
+	'stamp timeline is still 1');
+is( $node_standby->safe_psql(
+		'postgres', 'SELECT timeline_id FROM pg_control_checkpoint()'),
+	'2', 'promoted node is on timeline 2');
+
+# --- REFRESH on the promoted node restores the matview ---------------------
+
+$bg->query_safe('REFRESH MATERIALIZED VIEW mv_u');
+
+is($bg->query_safe('SELECT count(*) FROM mv_u'),
+	'1', 'surviving session: REFRESH restored the matview');
+is($node_standby->safe_psql('postgres', 'SELECT count(*) FROM mv_u'),
+	'1', 'new connection sees the refreshed matview');
+is( $node_standby->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}),
+	't',
+	'pg_matview_is_populated is true again after REFRESH');
+is( $node_standby->safe_psql(
+		'postgres',
+		q{SELECT relpopulated >> 32, relpopulated & 4294967295
+		  FROM pg_class WHERE relname = 'mv_u'}),
+	'2|2',
+	'post-promotion REFRESH stamped the current epoch (tli 2, gen 2)');
+
+# One more, later, new connection: nothing (such as a v1-style late
+# reconciler) may clobber the post-promotion refresh.
+is($node_standby->safe_psql('postgres', 'SELECT count(*) FROM mv_u'),
+	'1', 'a later new connection still sees the refreshed matview');
+
+$bg->quit;
+
+# --- Crashing the promoted node makes it unpopulated again ------------------
+
+$node_standby->stop('immediate');
+$node_standby->start;
+
+is(unlogged_reset_gen($node_standby),
+	'3', 'crash bumped the promoted node\'s unlogged reset generation to 3');
+
+($rc, $out, $err) =
+  $node_standby->psql('postgres', 'SELECT count(*) FROM mv_u');
+isnt($rc, 0, 'unlogged matview is unpopulated after crashing the promoted node');
+like(
+	$err,
+	qr/has not been populated/,
+	'crash on the promoted node reports "has not been populated" again');
+
+$node_standby->stop;
+$node_primary->stop;
+
+done_testing();
diff --git a/src/test/recovery/t/059_unlogged_matview.pl b/src/test/recovery/t/059_unlogged_matview.pl
new file mode 100644
index 00000000000..f2ecce735e6
--- /dev/null
+++ b/src/test/recovery/t/059_unlogged_matview.pl
@@ -0,0 +1,178 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Tests the crash-recovery contract for UNLOGGED MATERIALIZED VIEWs.
+#
+# An unlogged matview's pg_class.relpopulated carries an epoch stamp of the
+# (timeline, unlogged-reset-generation) it was populated in.  Crash recovery
+# bumps the generation, so after a crash the stale stamp reads as unpopulated
+# at scan time, with no catalog repair needed.  A clean restart leaves the
+# epoch unchanged, so contents survive.  A logged matview is unaffected by a
+# crash.  REFRESH stamps the current epoch and restores the contents.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('umv');
+$node->init;
+$node->start;
+
+$node->safe_psql('postgres', <<'SQL');
+CREATE UNLOGGED MATERIALIZED VIEW mv_u AS SELECT 42 AS x;
+CREATE MATERIALIZED VIEW mv_p AS SELECT 43 AS x;
+SQL
+
+# Both matviews are populated and scannable right after creation.
+is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_u'),
+	'1', 'unlogged matview is scannable after creation');
+is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_p'),
+	'1', 'logged matview is scannable after creation');
+is( $node->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}),
+	't',
+	'unlogged matview reports populated after creation');
+is( $node->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_p'::regclass)}),
+	't',
+	'logged matview reports populated after creation');
+
+# --- Clean restart preserves contents (negative control) -------------------
+#
+# A clean shutdown does not change the timeline or the unlogged-reset
+# generation, so the epoch stamp is still current and the data survives.
+
+$node->restart;
+
+is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_u'),
+	'1', 'unlogged matview contents preserved across a clean restart');
+is( $node->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}),
+	't',
+	'unlogged matview still populated after a clean restart');
+
+# --- Crash makes the epoch stamp stale --------------------------------------
+#
+# Crash recovery bumps the unlogged-reset generation, so the stamp written at
+# population time no longer matches the current epoch.  Reads treat the
+# matview as unpopulated without any catalog write.
+
+$node->stop('immediate');
+$node->start;
+
+my ($rc, $out, $err) =
+  $node->psql('postgres', 'SELECT count(*) FROM mv_u');
+isnt($rc, 0, 'SELECT on crash-stale unlogged matview fails');
+like(
+	$err,
+	qr/has not been populated/,
+	'crash-stale unlogged matview reports "has not been populated"');
+
+is( $node->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}),
+	'f',
+	'pg_matview_is_populated is false for crash-stale unlogged matview');
+is( $node->safe_psql(
+		'postgres',
+		q{SELECT ispopulated FROM pg_matviews WHERE matviewname = 'mv_u'}),
+	'f',
+	'pg_matviews.ispopulated is false for crash-stale unlogged matview');
+
+# The logged matview is unaffected by the crash.
+is( $node->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_p'::regclass)}),
+	't',
+	'logged matview still populated after crash');
+is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_p'),
+	'1', 'logged matview still returns rows after crash');
+
+# REFRESH stamps the current epoch and restores the contents.
+$node->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_u');
+is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_u'),
+	'1', 'REFRESH restores the unlogged matview after crash');
+is( $node->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}),
+	't',
+	'unlogged matview reports populated again after REFRESH');
+
+# --- A second crash moves the epoch again -----------------------------------
+
+$node->stop('immediate');
+$node->start;
+
+($rc, $out, $err) = $node->psql('postgres', 'SELECT count(*) FROM mv_u');
+isnt($rc, 0, 'SELECT on unlogged matview fails after second crash');
+like(
+	$err,
+	qr/has not been populated/,
+	'unlogged matview unpopulated again after second crash');
+is( $node->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}),
+	'f',
+	'pg_matview_is_populated is false after second crash');
+
+# REFRESH again, then a clean restart: the new stamp stays current.
+$node->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_u');
+$node->restart;
+
+is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_u'),
+	'1', 'refreshed unlogged matview survives a clean restart');
+is( $node->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}),
+	't',
+	'refreshed unlogged matview still populated after clean restart');
+
+# --- SET LOGGED converts a stale stamp to "not populated" -------------------
+#
+# ALTER MATERIALIZED VIEW ... SET LOGGED must not launder a stale epoch stamp
+# into an eternally-populated state: the unlogged storage was reset by the
+# crash, so the converted matview must read as unpopulated until REFRESH.
+
+$node->safe_psql('postgres',
+	'CREATE UNLOGGED MATERIALIZED VIEW mv_conv AS SELECT 7 AS x');
+is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_conv'),
+	'1', 'second unlogged matview is scannable after creation');
+
+$node->stop('immediate');
+$node->start;
+
+$node->safe_psql('postgres', 'ALTER MATERIALIZED VIEW mv_conv SET LOGGED');
+is( $node->safe_psql(
+		'postgres',
+		q{SELECT relpersistence FROM pg_class WHERE oid = 'mv_conv'::regclass}
+	),
+	'p',
+	'crash-stale unlogged matview converted to LOGGED');
+
+($rc, $out, $err) = $node->psql('postgres', 'SELECT count(*) FROM mv_conv');
+isnt($rc, 0, 'SELECT on converted crash-stale matview fails');
+like(
+	$err,
+	qr/has not been populated/,
+	'stale stamp converted to "not populated", not to eternally-populated');
+is( $node->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_conv'::regclass)}),
+	'f',
+	'pg_matview_is_populated is false after stale-stamp conversion');
+
+# REFRESH repopulates it; being LOGGED now, it survives a crash.
+$node->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_conv');
+is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_conv'),
+	'1', 'REFRESH restores the converted matview');
+is( $node->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_conv'::regclass)}),
+	't',
+	'converted matview reports populated after REFRESH');
+
+$node->stop('immediate');
+$node->start;
+
+is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_conv'),
+	'1', 'converted LOGGED matview survives a crash');
+is( $node->safe_psql(
+		'postgres', q{SELECT pg_matview_is_populated('mv_conv'::regclass)}),
+	't',
+	'converted LOGGED matview still populated after a crash');
+
+done_testing();
diff --git a/src/test/regress/expected/matview.out b/src/test/regress/expected/matview.out
index 7500bf027da..31cf1d047c8 100644
--- a/src/test/regress/expected/matview.out
+++ b/src/test/regress/expected/matview.out
@@ -325,6 +325,212 @@ SELECT type, m.totamt AS mtot, v.totamt AS vtot FROM mvtest_tm m LEFT JOIN mvtes
  z    |   24 |   24
 (3 rows)
 
+-- unlogged materialized view: storage is unlogged, REFRESH/SELECT work.
+-- The data is not WAL-logged (relpersistence 'u'); crash semantics that mark
+-- the matview unpopulated after a crash are handled separately.
+CREATE UNLOGGED MATERIALIZED VIEW mvtest_unlogged AS
+  SELECT type, sum(amt) AS totamt FROM mvtest_t GROUP BY type;
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_unlogged'::regclass;
+ relpersistence 
+----------------
+ u
+(1 row)
+
+SELECT pg_matview_is_populated('mvtest_unlogged'::regclass);
+ pg_matview_is_populated 
+-------------------------
+ t
+(1 row)
+
+-- the matview's toast table is unlogged too
+SELECT t.relpersistence FROM pg_class c JOIN pg_class t ON c.reltoastrelid = t.oid
+  WHERE c.oid = 'mvtest_unlogged'::regclass;
+ relpersistence 
+----------------
+ u
+(1 row)
+
+SELECT * FROM mvtest_unlogged ORDER BY type;
+ type | totamt 
+------+--------
+ x    |      5
+ y    |     12
+ z    |     24
+(3 rows)
+
+-- an index on an unlogged matview is unlogged as well
+CREATE UNIQUE INDEX mvtest_unlogged_type ON mvtest_unlogged (type);
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_unlogged_type'::regclass;
+ relpersistence 
+----------------
+ u
+(1 row)
+
+-- REFRESH still works and leaves the data intact
+REFRESH MATERIALIZED VIEW mvtest_unlogged;
+SELECT * FROM mvtest_unlogged ORDER BY type;
+ type | totamt 
+------+--------
+ x    |      5
+ y    |     12
+ z    |     24
+(3 rows)
+
+DROP MATERIALIZED VIEW mvtest_unlogged;
+-- ALTER MATERIALIZED VIEW ... SET {LOGGED|UNLOGGED} rewrites persistence
+-- of the matview, its toast table and its indexes, preserving data.
+CREATE MATERIALIZED VIEW mvtest_setlog AS
+  SELECT type, sum(amt) AS totamt FROM mvtest_t GROUP BY type;
+CREATE UNIQUE INDEX mvtest_setlog_type ON mvtest_setlog (type);
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog'::regclass;  -- p
+ relpersistence 
+----------------
+ p
+(1 row)
+
+SELECT count(*) FROM mvtest_setlog;
+ count 
+-------
+     3
+(1 row)
+
+ALTER MATERIALIZED VIEW mvtest_setlog SET UNLOGGED;
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog'::regclass;  -- u
+ relpersistence 
+----------------
+ u
+(1 row)
+
+SELECT relpersistence FROM pg_class
+  WHERE oid = (SELECT reltoastrelid FROM pg_class WHERE oid = 'mvtest_setlog'::regclass);  -- u
+ relpersistence 
+----------------
+ u
+(1 row)
+
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog_type'::regclass;  -- u
+ relpersistence 
+----------------
+ u
+(1 row)
+
+SELECT pg_matview_is_populated('mvtest_setlog'::regclass);  -- t
+ pg_matview_is_populated 
+-------------------------
+ t
+(1 row)
+
+SELECT count(*) FROM mvtest_setlog;
+ count 
+-------
+     3
+(1 row)
+
+ALTER MATERIALIZED VIEW mvtest_setlog SET LOGGED;
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog'::regclass;  -- p
+ relpersistence 
+----------------
+ p
+(1 row)
+
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog_type'::regclass;  -- p
+ relpersistence 
+----------------
+ p
+(1 row)
+
+SELECT pg_matview_is_populated('mvtest_setlog'::regclass);  -- t
+ pg_matview_is_populated 
+-------------------------
+ t
+(1 row)
+
+SELECT count(*) FROM mvtest_setlog;
+ count 
+-------
+     3
+(1 row)
+
+-- cannot change persistence setting twice in one ALTER
+ALTER MATERIALIZED VIEW mvtest_setlog SET UNLOGGED, SET LOGGED;
+ERROR:  cannot change persistence setting twice
+DROP MATERIALIZED VIEW mvtest_setlog;
+-- ALTER MATERIALIZED VIEW SET LOGGED / SET UNLOGGED converts the populated state
+CREATE UNLOGGED MATERIALIZED VIEW mvtest_persist AS SELECT 1 AS a;
+SELECT pg_matview_is_populated('mvtest_persist'::regclass);
+ pg_matview_is_populated 
+-------------------------
+ t
+(1 row)
+
+ALTER MATERIALIZED VIEW mvtest_persist SET LOGGED;
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_persist'::regclass;
+ relpersistence 
+----------------
+ p
+(1 row)
+
+SELECT relpopulated FROM pg_class WHERE oid = 'mvtest_persist'::regclass;  -- 1: eternal
+ relpopulated 
+--------------
+            1
+(1 row)
+
+SELECT * FROM mvtest_persist;
+ a 
+---
+ 1
+(1 row)
+
+ALTER MATERIALIZED VIEW mvtest_persist SET UNLOGGED;
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_persist'::regclass;
+ relpersistence 
+----------------
+ u
+(1 row)
+
+SELECT relpopulated > 1 OR relpopulated < 0 AS is_epoch_stamp FROM pg_class WHERE oid = 'mvtest_persist'::regclass;
+ is_epoch_stamp 
+----------------
+ t
+(1 row)
+
+SELECT pg_matview_is_populated('mvtest_persist'::regclass);
+ pg_matview_is_populated 
+-------------------------
+ t
+(1 row)
+
+SELECT * FROM mvtest_persist;
+ a 
+---
+ 1
+(1 row)
+
+DROP MATERIALIZED VIEW mvtest_persist;
+-- SET UNLOGGED on an unpopulated matview keeps it unpopulated
+CREATE MATERIALIZED VIEW mvtest_nodata AS SELECT 1 AS a WITH NO DATA;
+ALTER MATERIALIZED VIEW mvtest_nodata SET UNLOGGED;
+SELECT relpopulated FROM pg_class WHERE oid = 'mvtest_nodata'::regclass;  -- 0: none
+ relpopulated 
+--------------
+            0
+(1 row)
+
+SELECT pg_matview_is_populated('mvtest_nodata'::regclass);
+ pg_matview_is_populated 
+-------------------------
+ f
+(1 row)
+
+REFRESH MATERIALIZED VIEW mvtest_nodata;
+SELECT pg_matview_is_populated('mvtest_nodata'::regclass);
+ pg_matview_is_populated 
+-------------------------
+ t
+(1 row)
+
+DROP MATERIALIZED VIEW mvtest_nodata;
 -- make sure that dependencies are reported properly when they block the drop
 DROP TABLE mvtest_t;
 ERROR:  cannot drop table mvtest_t because other objects depend on it
diff --git a/src/test/regress/sql/matview.sql b/src/test/regress/sql/matview.sql
index 8890a2f7932..53dac8ba9c0 100644
--- a/src/test/regress/sql/matview.sql
+++ b/src/test/regress/sql/matview.sql
@@ -108,6 +108,71 @@ CREATE MATERIALIZED VIEW mvtest_temp_tm AS SELECT * FROM mvtest_temp_t;
 -- test join of mv and view
 SELECT type, m.totamt AS mtot, v.totamt AS vtot FROM mvtest_tm m LEFT JOIN mvtest_tv v USING (type) ORDER BY type;
 
+-- unlogged materialized view: storage is unlogged, REFRESH/SELECT work.
+-- The data is not WAL-logged (relpersistence 'u'); crash semantics that mark
+-- the matview unpopulated after a crash are handled separately.
+CREATE UNLOGGED MATERIALIZED VIEW mvtest_unlogged AS
+  SELECT type, sum(amt) AS totamt FROM mvtest_t GROUP BY type;
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_unlogged'::regclass;
+SELECT pg_matview_is_populated('mvtest_unlogged'::regclass);
+-- the matview's toast table is unlogged too
+SELECT t.relpersistence FROM pg_class c JOIN pg_class t ON c.reltoastrelid = t.oid
+  WHERE c.oid = 'mvtest_unlogged'::regclass;
+SELECT * FROM mvtest_unlogged ORDER BY type;
+-- an index on an unlogged matview is unlogged as well
+CREATE UNIQUE INDEX mvtest_unlogged_type ON mvtest_unlogged (type);
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_unlogged_type'::regclass;
+-- REFRESH still works and leaves the data intact
+REFRESH MATERIALIZED VIEW mvtest_unlogged;
+SELECT * FROM mvtest_unlogged ORDER BY type;
+DROP MATERIALIZED VIEW mvtest_unlogged;
+
+-- ALTER MATERIALIZED VIEW ... SET {LOGGED|UNLOGGED} rewrites persistence
+-- of the matview, its toast table and its indexes, preserving data.
+CREATE MATERIALIZED VIEW mvtest_setlog AS
+  SELECT type, sum(amt) AS totamt FROM mvtest_t GROUP BY type;
+CREATE UNIQUE INDEX mvtest_setlog_type ON mvtest_setlog (type);
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog'::regclass;  -- p
+SELECT count(*) FROM mvtest_setlog;
+ALTER MATERIALIZED VIEW mvtest_setlog SET UNLOGGED;
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog'::regclass;  -- u
+SELECT relpersistence FROM pg_class
+  WHERE oid = (SELECT reltoastrelid FROM pg_class WHERE oid = 'mvtest_setlog'::regclass);  -- u
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog_type'::regclass;  -- u
+SELECT pg_matview_is_populated('mvtest_setlog'::regclass);  -- t
+SELECT count(*) FROM mvtest_setlog;
+ALTER MATERIALIZED VIEW mvtest_setlog SET LOGGED;
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog'::regclass;  -- p
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog_type'::regclass;  -- p
+SELECT pg_matview_is_populated('mvtest_setlog'::regclass);  -- t
+SELECT count(*) FROM mvtest_setlog;
+-- cannot change persistence setting twice in one ALTER
+ALTER MATERIALIZED VIEW mvtest_setlog SET UNLOGGED, SET LOGGED;
+DROP MATERIALIZED VIEW mvtest_setlog;
+
+-- ALTER MATERIALIZED VIEW SET LOGGED / SET UNLOGGED converts the populated state
+CREATE UNLOGGED MATERIALIZED VIEW mvtest_persist AS SELECT 1 AS a;
+SELECT pg_matview_is_populated('mvtest_persist'::regclass);
+ALTER MATERIALIZED VIEW mvtest_persist SET LOGGED;
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_persist'::regclass;
+SELECT relpopulated FROM pg_class WHERE oid = 'mvtest_persist'::regclass;  -- 1: eternal
+SELECT * FROM mvtest_persist;
+ALTER MATERIALIZED VIEW mvtest_persist SET UNLOGGED;
+SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_persist'::regclass;
+SELECT relpopulated > 1 OR relpopulated < 0 AS is_epoch_stamp FROM pg_class WHERE oid = 'mvtest_persist'::regclass;
+SELECT pg_matview_is_populated('mvtest_persist'::regclass);
+SELECT * FROM mvtest_persist;
+DROP MATERIALIZED VIEW mvtest_persist;
+
+-- SET UNLOGGED on an unpopulated matview keeps it unpopulated
+CREATE MATERIALIZED VIEW mvtest_nodata AS SELECT 1 AS a WITH NO DATA;
+ALTER MATERIALIZED VIEW mvtest_nodata SET UNLOGGED;
+SELECT relpopulated FROM pg_class WHERE oid = 'mvtest_nodata'::regclass;  -- 0: none
+SELECT pg_matview_is_populated('mvtest_nodata'::regclass);
+REFRESH MATERIALIZED VIEW mvtest_nodata;
+SELECT pg_matview_is_populated('mvtest_nodata'::regclass);
+DROP MATERIALIZED VIEW mvtest_nodata;
+
 -- make sure that dependencies are reported properly when they block the drop
 DROP TABLE mvtest_t;
 
-- 
2.55.0

From 045923b30f3680bab16afb6e5d4de5cf599922fc Mon Sep 17 00:00:00 2001
From: Jim Jones <[email protected]>
Date: Sun, 6 Sep 2026 21:14:58 +0200
Subject: [PATCH v3 4/4] Minor fixes to docs, tab completion and psql describe
 command

---
 doc/src/sgml/ref/alter_materialized_view.sgml |  4 ++--
 .../sgml/ref/create_materialized_view.sgml    |  2 +-
 src/bin/psql/describe.c                       |  8 +++++--
 src/bin/psql/tab-complete.in.c                | 23 ++++++++++++-------
 4 files changed, 24 insertions(+), 13 deletions(-)

diff --git a/doc/src/sgml/ref/alter_materialized_view.sgml b/doc/src/sgml/ref/alter_materialized_view.sgml
index 1e2c8f0797d..5819b849609 100644
--- a/doc/src/sgml/ref/alter_materialized_view.sgml
+++ b/doc/src/sgml/ref/alter_materialized_view.sgml
@@ -154,13 +154,13 @@ ALTER MATERIALIZED VIEW ALL IN TABLESPACE <replaceable class="parameter">name</r
     </listitem>
    </varlistentry>
 
-   <varlistentry id="sql-altermaterializedview-set-logged-unlogged">
+   <varlistentry>
     <term><literal>SET { LOGGED | UNLOGGED }</literal></term>
     <listitem>
      <para>
       This form changes the materialized view from unlogged to logged or
       vice-versa (see <literal>UNLOGGED</literal> in
-      <link linkend="sql-creatematerializedview-unlogged"><command>CREATE
+      <link linkend="sql-creatematerializedview"><command>CREATE
       MATERIALIZED VIEW</command></link>), the same as
       <link linkend="sql-altertable-desc-set-logged-unlogged"><literal>SET
       { LOGGED | UNLOGGED }</literal></link> does for
diff --git a/doc/src/sgml/ref/create_materialized_view.sgml b/doc/src/sgml/ref/create_materialized_view.sgml
index cb76bcfd69c..a05ee324232 100644
--- a/doc/src/sgml/ref/create_materialized_view.sgml
+++ b/doc/src/sgml/ref/create_materialized_view.sgml
@@ -60,7 +60,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
   <title>Parameters</title>
 
   <variablelist>
-   <varlistentry id="sql-creatematerializedview-unlogged">
+   <varlistentry>
     <term><literal>UNLOGGED</literal></term>
     <listitem>
      <para>
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 063e2555814..30f7dd9bab2 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -2013,8 +2013,12 @@ describeOneTableDetails(const char *schemaname,
 							  schemaname, relationname);
 			break;
 		case RELKIND_MATVIEW:
-			printfPQExpBuffer(&title, _("Materialized view \"%s.%s\""),
-							  schemaname, relationname);
+			if (tableinfo.relpersistence == RELPERSISTENCE_UNLOGGED)
+				printfPQExpBuffer(&title, _("Unlogged materialized view \"%s.%s\""),
+								  schemaname, relationname);
+			else
+				printfPQExpBuffer(&title, _("Materialized view \"%s.%s\""),
+								  schemaname, relationname);
 			break;
 		case RELKIND_INDEX:
 			if (tableinfo.relpersistence == RELPERSISTENCE_UNLOGGED)
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 1b74fa62c5c..66cf7f58364 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2730,7 +2730,8 @@ match_previous_words(int pattern_id,
 		COMPLETE_WITH("TO");
 	/* ALTER MATERIALIZED VIEW xxx SET */
 	else if (Matches("ALTER", "MATERIALIZED", "VIEW", MatchAny, "SET"))
-		COMPLETE_WITH("(", "ACCESS METHOD", "SCHEMA", "TABLESPACE", "WITHOUT CLUSTER");
+		COMPLETE_WITH("(", "ACCESS METHOD", "LOGGED", "SCHEMA", "TABLESPACE",
+					  "UNLOGGED", "WITHOUT CLUSTER");
 	/* ALTER MATERIALIZED VIEW xxx SET ACCESS METHOD */
 	else if (Matches("ALTER", "MATERIALIZED", "VIEW", MatchAny, "SET", "ACCESS", "METHOD"))
 		COMPLETE_WITH_QUERY(Query_for_list_of_table_access_methods);
@@ -3837,9 +3838,9 @@ match_previous_words(int pattern_id,
 	/* Complete "CREATE TEMP/TEMPORARY" with the possible temp objects */
 	else if (TailMatches("CREATE", "TEMP|TEMPORARY"))
 		COMPLETE_WITH("SEQUENCE", "TABLE", "VIEW");
-	/* Complete "CREATE UNLOGGED" with TABLE or SEQUENCE */
+	/* Complete "CREATE UNLOGGED" with the possible unlogged objects */
 	else if (TailMatches("CREATE", "UNLOGGED"))
-		COMPLETE_WITH("TABLE", "SEQUENCE");
+		COMPLETE_WITH("MATERIALIZED VIEW", "SEQUENCE", "TABLE");
 	/* Complete PARTITION BY with RANGE ( or LIST ( or ... */
 	else if (TailMatches("PARTITION", "BY"))
 		COMPLETE_WITH("RANGE (", "LIST (", "HASH (");
@@ -4231,20 +4232,24 @@ match_previous_words(int pattern_id,
 		COMPLETE_WITH("SELECT");
 
 /* CREATE MATERIALIZED VIEW */
-	else if (Matches("CREATE", "MATERIALIZED"))
+	else if (Matches("CREATE", "MATERIALIZED") ||
+			 Matches("CREATE", "UNLOGGED", "MATERIALIZED"))
 		COMPLETE_WITH("VIEW");
 	/* Complete CREATE MATERIALIZED VIEW <name> with AS or USING */
-	else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny))
+	else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny) ||
+			 Matches("CREATE", "UNLOGGED", "MATERIALIZED", "VIEW", MatchAny))
 		COMPLETE_WITH("AS", "USING");
 
 	/*
 	 * Complete CREATE MATERIALIZED VIEW <name> USING with list of access
 	 * methods
 	 */
-	else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "USING"))
+	else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "USING") ||
+			 Matches("CREATE", "UNLOGGED", "MATERIALIZED", "VIEW", MatchAny, "USING"))
 		COMPLETE_WITH_QUERY(Query_for_list_of_table_access_methods);
 	/* Complete CREATE MATERIALIZED VIEW <name> USING <access method> with AS */
-	else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "USING", MatchAny))
+	else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "USING", MatchAny) ||
+			 Matches("CREATE", "UNLOGGED", "MATERIALIZED", "VIEW", MatchAny, "USING", MatchAny))
 		COMPLETE_WITH("AS");
 
 	/*
@@ -4252,7 +4257,9 @@ match_previous_words(int pattern_id,
 	 * with "SELECT"
 	 */
 	else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "AS") ||
-			 Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "USING", MatchAny, "AS"))
+			 Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "USING", MatchAny, "AS") ||
+			 Matches("CREATE", "UNLOGGED", "MATERIALIZED", "VIEW", MatchAny, "AS") ||
+			 Matches("CREATE", "UNLOGGED", "MATERIALIZED", "VIEW", MatchAny, "USING", MatchAny, "AS"))
 		COMPLETE_WITH("SELECT");
 
 /* CREATE EVENT TRIGGER */
-- 
2.55.0

Reply via email to