From 4407cbc0166783362cfa132819af97d143fe12dc Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
Date: Sun, 6 Sep 2026 20:50:14 +0000
Subject: [PATCH v4 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, teach psql to report an unlogged materialized view
as such and to tab-complete the new syntax.  Add recovery tests for the
crash, standby and promotion cases, plus docs.
---
 doc/src/sgml/func/func-info.sgml              |   4 +-
 doc/src/sgml/ref/alter_materialized_view.sgml |  24 ++
 .../sgml/ref/create_materialized_view.sgml    |  44 +++-
 doc/src/sgml/storage.sgml                     |  10 +
 src/backend/access/heap/heapam_handler.c      |   3 +-
 src/backend/commands/matview.c                |  41 ++-
 src/backend/commands/repack.c                 |  23 ++
 src/backend/commands/tablecmds.c              |   3 +-
 src/backend/optimizer/util/plancat.c          |  19 ++
 src/backend/parser/analyze.c                  |  12 -
 src/bin/pg_dump/pg_dump.c                     |   9 +-
 src/bin/pg_dump/t/002_pg_dump.pl              |  36 +++
 src/bin/psql/describe.c                       |   8 +-
 src/bin/psql/tab-complete.in.c                |  23 +-
 src/test/recovery/meson.build                 |   2 +
 src/test/recovery/t/058_unlogged_matview.pl   | 150 +++++++++++
 .../t/059_unlogged_matview_standby.pl         | 205 +++++++++++++++
 src/test/regress/expected/matview.out         | 238 ++++++++++++++++++
 src/test/regress/sql/matview.sql              |  74 ++++++
 19 files changed, 886 insertions(+), 42 deletions(-)
 create mode 100644 src/test/recovery/t/058_unlogged_matview.pl
 create mode 100644 src/test/recovery/t/059_unlogged_matview_standby.pl

diff --git a/doc/src/sgml/func/func-info.sgml b/doc/src/sgml/func/func-info.sgml
index 7ac5ec409aa..1242d12ade0 100644
--- a/doc/src/sgml/func/func-info.sgml
+++ b/doc/src/sgml/func/func-info.sgml
@@ -1872,7 +1872,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..9fc6e4a4b34 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,29 @@ ALTER MATERIALIZED VIEW ALL IN TABLESPACE <replaceable class="parameter">name</r
      </para>
     </listitem>
    </varlistentry>
+
+   <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
+      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:
+      whatever the materialized view currently holds is preserved and remains
+      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.  Conversely, setting an unlogged
+      materialized view that has already reverted to the unpopulated state
+      back to <literal>LOGGED</literal> leaves it unpopulated; there is no
+      data left to make crash-safe.
+     </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..74f6485d4d9 100644
--- a/doc/src/sgml/ref/create_materialized_view.sgml
+++ b/doc/src/sgml/ref/create_materialized_view.sgml
@@ -21,7 +21,7 @@ PostgreSQL documentation
 
  <refsynopsisdiv>
 <synopsis>
-CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
+CREATE [ UNLOGGED ] MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
     [ (<replaceable>column_name</replaceable> [, ...] ) ]
     [ USING <replaceable class="parameter">method</replaceable> ]
     [ WITH ( <replaceable class="parameter">storage_parameter</replaceable> [= <replaceable class="parameter">value</replaceable>] [, ... ] ) ]
@@ -60,7 +60,33 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
   <title>Parameters</title>
 
   <variablelist>
-   <varlistentry>
+   <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 id="sql-creatematerializedview-parms-if-not-exists">
     <term><literal>IF NOT EXISTS</literal></term>
     <listitem>
      <para>
@@ -72,7 +98,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
     </listitem>
    </varlistentry>
 
-   <varlistentry>
+   <varlistentry id="sql-creatematerializedview-parms-table-name">
     <term><replaceable>table_name</replaceable></term>
     <listitem>
      <para>
@@ -84,7 +110,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
     </listitem>
    </varlistentry>
 
-   <varlistentry>
+   <varlistentry id="sql-creatematerializedview-parms-column-name">
     <term><replaceable>column_name</replaceable></term>
     <listitem>
      <para>
@@ -94,7 +120,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
     </listitem>
    </varlistentry>
 
-   <varlistentry>
+   <varlistentry id="sql-creatematerializedview-parms-using">
     <term><literal>USING <replaceable class="parameter">method</replaceable></literal></term>
     <listitem>
      <para>
@@ -109,7 +135,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
     </listitem>
    </varlistentry>
 
-   <varlistentry>
+   <varlistentry id="sql-creatematerializedview-parms-with">
     <term><literal>WITH ( <replaceable class="parameter">storage_parameter</replaceable> [= <replaceable class="parameter">value</replaceable>] [, ... ] )</literal></term>
     <listitem>
      <para>
@@ -125,7 +151,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
     </listitem>
    </varlistentry>
 
-   <varlistentry>
+   <varlistentry id="sql-creatematerializedview-parms-tablespace">
     <term><literal>TABLESPACE <replaceable class="parameter">tablespace_name</replaceable></literal></term>
     <listitem>
      <para>
@@ -136,7 +162,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
     </listitem>
    </varlistentry>
 
-   <varlistentry>
+   <varlistentry id="sql-creatematerializedview-parms-query">
     <term><replaceable>query</replaceable></term>
     <listitem>
      <para>
@@ -150,7 +176,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
     </listitem>
    </varlistentry>
 
-   <varlistentry>
+   <varlistentry id="sql-creatematerializedview-parms-with-data">
     <term><literal>WITH [ NO ] DATA</literal></term>
     <listitem>
      <para>
diff --git a/doc/src/sgml/storage.sgml b/doc/src/sgml/storage.sgml
index 83de016eaa5..c9735372881 100644
--- a/doc/src/sgml/storage.sgml
+++ b/doc/src/sgml/storage.sgml
@@ -702,6 +702,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 6adb760b54f..60c37731fd5 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..8573876e4be 100644
--- a/src/backend/commands/matview.c
+++ b/src/backend/commands/matview.c
@@ -120,14 +120,25 @@ 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 epoch is still the one its base backup came with, which
+	 * may well be the primary's current one.
+	 */
+	if (RecoveryInProgress())
+		return false;
+
+	return (uint64) value == GetUnloggedPopulatedEpoch();
 }
 
 /*
@@ -138,6 +149,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);
 }
 
@@ -356,10 +373,20 @@ RefreshMatViewByOid(Oid matviewOid, bool is_create, bool skipData,
 
 	/*
 	 * Tentatively mark the matview as populated or not, if its state is
-	 * changing (this will roll back if we fail later).
+	 * changing (this will roll back if we fail later).  WITH NO DATA must
+	 * also clear a stale epoch stamp, which reads as not populated but still
+	 * records that the matview is meant to hold data.
 	 */
-	if (RelationIsPopulated(matviewRel) != !skipData)
-		SetMatViewPopulatedState(matviewRel, !skipData);
+	if (skipData)
+	{
+		if (matviewRel->rd_rel->relpopulated != RELPOPULATED_NONE)
+			SetMatViewPopulatedState(matviewRel, false);
+	}
+	else
+	{
+		if (!RelationIsPopulated(matviewRel))
+			SetMatViewPopulatedState(matviewRel, true);
+	}
 
 	/* Concurrent refresh builds new data in temp tablespace, and does diff. */
 	if (concurrent)
diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index db7cf65d674..be833531032 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -1686,6 +1686,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 it in the recursive call for TOAST).  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 0274d892f2e..6631ca026d1 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -5205,7 +5205,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 d067368cfc1..eb5ea760cd7 100644
--- a/src/backend/optimizer/util/plancat.c
+++ b/src/backend/optimizer/util/plancat.c
@@ -32,6 +32,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"
@@ -152,6 +153,24 @@ 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.  The
+	 * generic recovery guard just below would reject it too, but reporting it
+	 * as unpopulated matches what the same matview does on the primary once a
+	 * crash has invalidated its stamp.  Permanent matviews are excluded so
+	 * plan-time behavior is unchanged.  No REFRESH hint, as that cannot run
+	 * during recovery.
+	 */
+	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))));
+
 	/* 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 08f99dff711..cde1ad7b7df 100644
--- a/src/backend/parser/analyze.c
+++ b/src/backend/parser/analyze.c
@@ -3209,18 +3209,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/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index d14e8f9a5c1..4a9d0f3c09f 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -3022,8 +3022,15 @@ makeTableDataInfo(DumpOptions *dopt, TableInfo *tbinfo)
 	if (tbinfo->relkind == RELKIND_PARTITIONED_TABLE)
 		return;
 
-	/* Don't dump data in unlogged tables, if so requested */
+	/*
+	 * Don't dump data in unlogged tables, if so requested.  This does not
+	 * apply to unlogged materialized views: their REFRESH carries no data,
+	 * and skipping it would break the REFRESH of any matview built on top.
+	 * XXX Whether the option should also cover them (and mark dependent
+	 * matviews unpopulated instead) is an open question.
+	 */
 	if (tbinfo->relpersistence == RELPERSISTENCE_UNLOGGED &&
+		tbinfo->relkind != RELKIND_MATVIEW &&
 		dopt->no_unlogged_table_data)
 		return;
 
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index 1299c837063..d91e6a92d25 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -2976,6 +2976,25 @@ my %tests = (
 		},
 	},
 
+	'CREATE UNLOGGED MATERIALIZED VIEW matview_unlogged' => {
+		create_order => 21,
+		create_sql => 'CREATE UNLOGGED MATERIALIZED VIEW
+						   dump_test.matview_unlogged (col1) AS
+						   SELECT * FROM dump_test.matview;',
+		regexp => qr/^
+			\QCREATE UNLOGGED MATERIALIZED VIEW dump_test.matview_unlogged AS\E
+			\n\s+\QSELECT col1\E
+			\n\s+\QFROM dump_test.matview\E
+			\n\s+\QWITH NO DATA;\E
+			/xm,
+		like =>
+		  { %full_runs, %dump_test_schema_runs, section_pre_data => 1, },
+		unlike => {
+			exclude_dump_test_schema => 1,
+			only_dump_measurement => 1,
+		},
+	},
+
 	'CREATE MATERIALIZED VIEW matview_third' => {
 		create_order => 58,
 		create_sql => 'CREATE MATERIALIZED VIEW
@@ -4649,6 +4668,23 @@ my %tests = (
 		},
 	},
 
+	# not affected by --no-unlogged-table-data
+	'REFRESH MATERIALIZED VIEW matview_unlogged' => {
+		regexp => qr/^
+			\QREFRESH MATERIALIZED VIEW dump_test.matview;\E
+			\n.*
+			\QREFRESH MATERIALIZED VIEW dump_test.matview_unlogged;\E
+			/xms,
+		like =>
+		  { %full_runs, %dump_test_schema_runs, section_post_data => 1, },
+		unlike => {
+			binary_upgrade => 1,
+			exclude_dump_test_schema => 1,
+			schema_only => 1,
+			only_dump_measurement => 1,
+		},
+	},
+
 	'REFRESH MATERIALIZED VIEW matview_second' => {
 		regexp => qr/^
 			\QREFRESH MATERIALIZED VIEW dump_test.matview;\E
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index dc9b3841592..25cda865b96 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -1935,8 +1935,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 b3bfe050b18..446fbdd5e89 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2718,7 +2718,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);
@@ -3790,9 +3791,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 (");
@@ -4184,20 +4185,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");
 
 	/*
@@ -4205,7 +4210,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 */
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index ebb12dd8766..5420ef5659b 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -66,6 +66,8 @@ tests += {
       't/055_cascade_reconnect.pl',
       't/056_standby_snapshot_export.pl',
       't/057_snapshot_commit_race.pl',
+      't/058_unlogged_matview.pl',
+      't/059_unlogged_matview_standby.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/058_unlogged_matview.pl b/src/test/recovery/t/058_unlogged_matview.pl
new file mode 100644
index 00000000000..7714e709aeb
--- /dev/null
+++ b/src/test/recovery/t/058_unlogged_matview.pl
@@ -0,0 +1,150 @@
+# 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: the end
+# of WAL at the last reset of unlogged relations before it was populated.
+# Crash recovery starts a new epoch at its own end of WAL, 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;
+
+# mv_conv is converted to LOGGED after the crash below.
+$node->safe_psql('postgres', <<'SQL');
+CREATE UNLOGGED MATERIALIZED VIEW mv_u AS SELECT 42 AS x;
+CREATE UNLOGGED MATERIALIZED VIEW mv_conv AS SELECT 7 AS x;
+CREATE MATERIALIZED VIEW mv_p AS SELECT 43 AS x;
+SQL
+
+is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_u'),
+	'1', 'unlogged 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');
+
+# --- Clean restart preserves contents -----------------------------------------
+#
+# A clean shutdown does not reset unlogged relations or start a new epoch, 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');
+
+# --- Crash makes the epoch stamp stale ----------------------------------------
+#
+# Crash recovery starts a new epoch, 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', 'SELECT count(*) FROM mv_p'),
+	'1', 'logged matview still returns rows after crash');
+
+# 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', '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');
+like(
+	$err,
+	qr/has not been populated/,
+	'stale stamp converted to "not populated", not to eternally-populated');
+
+# WITH NO DATA clears the stale stamp, so pg_dump no longer sees it as
+# meant to be populated.
+$node->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_u WITH NO DATA');
+is( $node->safe_psql(
+		'postgres',
+		q{SELECT relpopulated FROM pg_class WHERE oid = 'mv_u'::regclass}),
+	'0',
+	'REFRESH WITH NO DATA clears a stale stamp');
+
+# REFRESH stamps the current epoch and restores the contents.
+$node->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_u');
+$node->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_conv');
+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');
+is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_conv'),
+	'1', 'REFRESH restores the converted matview');
+
+# --- pg_resetwal after a clean shutdown keeps the contents --------------------
+#
+# Resetting the WAL of a cleanly shut down cluster does not touch unlogged
+# storage, and the next startup does not run crash recovery, so the epoch
+# stamp must stay current.  pg_upgrade relies on this: it resets the WAL of
+# the new cluster after restoring the schema, and the transferred unlogged
+# matview heaps have to remain usable.
+
+$node->stop;
+
+$node->command_ok([ 'pg_resetwal', '-D', $node->data_dir ],
+	'pg_resetwal on a cleanly shut down cluster');
+
+$node->start;
+
+is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_u'),
+	'1', 'unlogged matview survives pg_resetwal after a clean shutdown');
+
+# --- pg_resetwal after an unclean shutdown discards the contents --------------
+#
+# Here the unlogged storage is torn and nothing will reset it later, so the
+# epoch has to move and the matview must read as unpopulated.
+
+$node->stop('immediate');
+
+$node->command_ok([ 'pg_resetwal', '-f', '-D', $node->data_dir ],
+	'pg_resetwal -f on an uncleanly shut down cluster');
+
+$node->start;
+
+($rc, $out, $err) = $node->psql('postgres', 'SELECT count(*) FROM mv_u');
+like(
+	$err,
+	qr/has not been populated/,
+	'unlogged matview unpopulated after pg_resetwal -f on a dirty cluster');
+
+done_testing();
diff --git a/src/test/recovery/t/059_unlogged_matview_standby.pl b/src/test/recovery/t/059_unlogged_matview_standby.pl
new file mode 100644
index 00000000000..9746ba9a3ea
--- /dev/null
+++ b/src/test/recovery/t/059_unlogged_matview_standby.pl
@@ -0,0 +1,205 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Tests UNLOGGED MATERIALIZED VIEWs on a standby, and when it leaves
+# recovery.
+#
+# A standby never has unlogged storage, so an unlogged matview must read as
+# unpopulated there, whatever epoch stamp the primary replicated.  Once the
+# standby leaves recovery, its own epoch is past every replicated stamp, so
+# the matview stays unpopulated without any catalog write.  Two standbys
+# from one backup leave recovery differently: promotion (new timeline) and
+# restart without standby.signal (same timeline).  A session kept open
+# across the promotion must see the change at its next scan.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Extract "Unlogged relations reset at" from pg_controldata output.
+sub unlogged_reset_lsn
+{
+	my ($node) = @_;
+	my ($stdout, $stderr) = run_command([ 'pg_controldata', $node->data_dir ]);
+	$stdout =~ /^Unlogged relations reset at:\s+(\S+)\r?$/m
+	  or die "no unlogged reset LSN in pg_controldata output";
+	return $1;
+}
+
+# relpopulated of mv_u compared with an LSN: returns "<", "=" or ">".
+sub stamp_vs_lsn
+{
+	my ($node, $lsn) = @_;
+	return $node->safe_psql(
+		'postgres', qq{
+		SELECT CASE sign(relpopulated - ('$lsn'::pg_lsn - '0/0'))
+			   WHEN -1 THEN '<' WHEN 0 THEN '=' ELSE '>' END
+		FROM pg_class WHERE relname = 'mv_u'});
+}
+
+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
+
+# --- Two standbys from the same backup --------------------------------------
+
+$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;
+
+my $node_standby2 = PostgreSQL::Test::Cluster->new('standby2');
+$node_standby2->init_from_backup($node_primary, 'bkp', has_streaming => 1);
+$node_standby2->start;
+
+# --- Primary crash and a fresh stamp ------------------------------------------
+
+$node_primary->stop('immediate');
+$node_primary->start;
+
+my $reset_primary = unlogged_reset_lsn($node_primary);
+
+# Repopulate, and let the new stamp replicate to both standbys, whose own
+# pg_control still carries the epoch from the backup.
+$node_primary->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_u');
+$node_primary->wait_for_catchup($node_standby);
+$node_primary->wait_for_catchup($node_standby2);
+
+is(stamp_vs_lsn($node_standby, $reset_primary),
+	'=', 'standby replicated the primary\'s post-crash epoch stamp');
+
+# --- In recovery, the unlogged matview is unpopulated -------------------------
+
+is($node_standby->safe_psql('postgres', 'SELECT count(*) FROM mv_p'),
+	'1', 'logged matview is scannable on the standby');
+
+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');
+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');
+like(
+	$err,
+	qr/unpopulated materialized view/,
+	'COPY from unlogged matview reports unpopulated error on standby');
+
+# pg_matview_is_populated() is per node: false on the standby while the very
+# same stamp reads 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 true for unlogged matview on primary');
+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');
+
+# --- A session that will survive the promotion --------------------------------
+
+my $bg = $node_standby->background_psql('postgres', on_error_stop => 0);
+
+my ($bg_out, $bg_err) = $bg->query('SELECT count(*) FROM mv_u');
+is($bg_err, 1, 'surviving session: unlogged matview errors on the standby');
+$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 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');
+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 promoted node reset unlogged relations
+# past the replicated stamp.
+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');
+my $reset_promoted = unlogged_reset_lsn($node_standby);
+is(stamp_vs_lsn($node_standby, $reset_promoted),
+	'<', 'replicated stamp is below the promoted node\'s epoch');
+
+# 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(stamp_vs_lsn($node_standby, $reset_promoted),
+	'=', 'post-promotion REFRESH stamped the promoted node\'s epoch');
+
+$bg->quit;
+
+# --- Leaving recovery without promotion ---------------------------------------
+#
+# Restart the second standby without standby.signal.  That runs plain crash
+# recovery, which keeps timeline 1, yet the stamp it replicated from the
+# primary must still read as unpopulated: the standby has no data for it.
+
+$node_standby2->stop;
+unlink($node_standby2->data_dir . '/standby.signal')
+  or die "could not remove standby.signal: $!";
+$node_standby2->start;
+
+is( $node_standby2->safe_psql(
+		'postgres', 'SELECT timeline_id FROM pg_control_checkpoint()'),
+	'1', 'second standby left recovery on timeline 1');
+is(stamp_vs_lsn($node_standby2, unlogged_reset_lsn($node_standby2)),
+	'<', 'replicated stamp is below the second standby\'s epoch');
+
+($rc, $out, $err) =
+  $node_standby2->psql('postgres', 'SELECT count(*) FROM mv_u');
+like(
+	$err,
+	qr/has not been populated/,
+	'second standby reports "has not been populated"');
+
+$node_standby2->stop;
+$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..f9c6c8ea56d 100644
--- a/src/test/regress/expected/matview.out
+++ b/src/test/regress/expected/matview.out
@@ -325,6 +325,244 @@ 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)
+
+\d mvtest_unlogged
+Unlogged materialized view "public.mvtest_unlogged"
+ Column |  Type   | Collation | Nullable | Default 
+--------+---------+-----------+----------+---------
+ type   | text    |           |          | 
+ totamt | numeric |           |          | 
+
+-- 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)
+
+-- REFRESH CONCURRENTLY works too, and keeps the existing epoch stamp
+SELECT relpopulated AS mvtest_unlogged_stamp FROM pg_class
+  WHERE oid = 'mvtest_unlogged'::regclass \gset
+REFRESH MATERIALIZED VIEW CONCURRENTLY mvtest_unlogged;
+SELECT relpopulated = :mvtest_unlogged_stamp AS stamp_kept FROM pg_class
+  WHERE oid = 'mvtest_unlogged'::regclass;
+ stamp_kept 
+------------
+ t
+(1 row)
+
+SELECT pg_matview_is_populated('mvtest_unlogged'::regclass);
+ pg_matview_is_populated 
+-------------------------
+ t
+(1 row)
+
+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..11b1cd52a1b 100644
--- a/src/test/regress/sql/matview.sql
+++ b/src/test/regress/sql/matview.sql
@@ -108,6 +108,80 @@ 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);
+\d mvtest_unlogged
+-- 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;
+-- REFRESH CONCURRENTLY works too, and keeps the existing epoch stamp
+SELECT relpopulated AS mvtest_unlogged_stamp FROM pg_class
+  WHERE oid = 'mvtest_unlogged'::regclass \gset
+REFRESH MATERIALIZED VIEW CONCURRENTLY mvtest_unlogged;
+SELECT relpopulated = :mvtest_unlogged_stamp AS stamp_kept FROM pg_class
+  WHERE oid = 'mvtest_unlogged'::regclass;
+SELECT pg_matview_is_populated('mvtest_unlogged'::regclass);
+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

