From 631205b6e80b9bb946afe77a9d5ebf55fb2dfb45 Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
Date: Thu, 2 Jul 2026 17:49:24 +0000
Subject: [PATCH 3/3] 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 +
 src/test/recovery/t/055_unlogged_matview.pl   | 178 ++++++++++++++
 .../t/056_unlogged_matview_standby.pl         | 145 +++++++++++
 .../t/057_unlogged_matview_promotion.pl       | 231 ++++++++++++++++++
 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/055_unlogged_matview.pl
 create mode 100644 src/test/recovery/t/056_unlogged_matview_standby.pl
 create mode 100644 src/test/recovery/t/057_unlogged_matview_promotion.pl

diff --git a/doc/src/sgml/func/func-info.sgml b/doc/src/sgml/func/func-info.sgml
index df8a34c59ed..93fd77f2f6a 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 6b6377503bf..3419879811e 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 2268cc277bc..b3bd8e61ac3 100644
--- a/src/backend/access/heap/heapam_handler.c
+++ b/src/backend/access/heap/heapam_handler.c
@@ -523,7 +523,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 e6219ff7d0d..764a09d562f 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 de65741e033..a66fecbe926 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -1589,6 +1589,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 472db112fa7..2d4a247481e 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 7c4be174869..f1e80e510dc 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 2932d17a107..5c53bd2f94e 100644
--- a/src/backend/parser/analyze.c
+++ b/src/backend/parser/analyze.c
@@ -3556,18 +3556,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 ad0d85f4189..14c2d63cf2c 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -63,6 +63,9 @@ tests += {
       't/052_checkpoint_segment_missing.pl',
       't/053_standby_login_event_trigger.pl',
       't/054_unlogged_sequence_promotion.pl',
+      't/055_unlogged_matview.pl',
+      't/056_unlogged_matview_standby.pl',
+      't/057_unlogged_matview_promotion.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/055_unlogged_matview.pl b/src/test/recovery/t/055_unlogged_matview.pl
new file mode 100644
index 00000000000..f2ecce735e6
--- /dev/null
+++ b/src/test/recovery/t/055_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/recovery/t/056_unlogged_matview_standby.pl b/src/test/recovery/t/056_unlogged_matview_standby.pl
new file mode 100644
index 00000000000..48ed68499c4
--- /dev/null
+++ b/src/test/recovery/t/056_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/057_unlogged_matview_promotion.pl b/src/test/recovery/t/057_unlogged_matview_promotion.pl
new file mode 100644
index 00000000000..637d231de8f
--- /dev/null
+++ b/src/test/recovery/t/057_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/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.43.0

