On 2026-10-01 Th 10:57 AM, Álvaro Herrera wrote:
On 2026-Sep-30, Andrew Dunstan wrote:

diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index ea3f2a6be99..6dfc6e8026c 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -17,6 +17,7 @@
  #ifndef TABLEAM_H
  #define TABLEAM_H
+#include "access/amapi.h"
  #include "access/relscan.h"
  #include "access/sdir.h"
  #include "access/xact.h"
I don't love this.  I have a bunch of patches queued to remove includes
from other includes to reduce cross-header contamination.  This kind of
change makes it impossible to remove the cross inclusion here and is
more or less a step backwards.  (It's not _too_ bad because tableam.h is
not as widely used

Is there a better way to have a function definition that can be used in
both amapi.h and tableam.h without this cross-header inclusion?


Yes, there's no need for it. All the other TableAmRoutine members are
declared as bare function pointers, so amoptions now is too:

     bytea      *(*amoptions) (Datum reloptions, bool validate);

and the include is gone. The type is identical to amoptions_function,
so nothing else changes.



Other comments on verbiage, just passing by:

I'm not much in love with the LLM-written comments TBH -- I don't think
phrases like "owns the option set entirely" are valuable, for example.


You're absolutely right! :-)

I should have been much more careful. I hope the attached is more to your liking.


Also, the comment just above the ATValidateAccessMethodOptions() call in
ATController is redundant: it would be enough to say "validate options
as needed", and then have the comment atop ATValidateAccessMethodOptions()
itself carry the explanation of what we do and why.


Done



In tableam.sgml, I'm not sure it makes much sense to state "The callback
has the same signature as the corresponding index AM callback".  Why not
just say what the signature is without directing the user to read a
reference page that's not relevant to the topic of table AMs?  I think
the first mention of reloptions in that page should be <firstterm>.

It also talks about validating and throwing ereport(ERROR) but it
doesn't say in so many words what must happen or not happen on each
possible value of 'validate'.  It could be clearer.


I've rewritten that section. It's now called "Table Access Method
Options", since a lot of what goes under the user-facing name "storage
parameters" isn't about storage, and refers to CREATE TABLE for that.
It uses <firstterm> for reloptions, gives the signature, and says what
the callback must do when validate is true (error on unrecognized or
invalid values, and never substitute something else for them) and when
it's false (ignore invalid entries without error).


There's also "raises an error rather than silently dropping the value".
I mean, why not say "rather than launching an ICBM"?  Why not just
"raises an error, period"?


Done



If I were the user of such an AM, I would not be sure how to interpret
the phrase "validate that the option read with SELECT reloptions FROM
pg_class are the ones that will be used".  What does that mean exactly?
Should it say "examine" rather than "validate"?


That sentence is gone. What it was trying to say is now stated
directly: the callback must not accept an invalid value and use
something else in its place, since the stored parameters would then
not be the ones in effect.


I wrote:

On 2026-09-30 We 5:22 PM, Zsolt Parragi wrote:
pg_dump has the option "--no-table-access-method". Should that somehow
interact with this feature? As currently that can result in non
restorable dumps.


Good catch.

Worse, the CREATE TABLE fails, so the table and its data are lost,
with both pg_dump and pg_restore --no-table-access-method.

We can't just move the options to a later ALTER TABLE, because some
standard ones (toast_value_type) only matter at creation time. So my
plan, as a second patch:

- A function pg_reloption_is_standard(name), true if the option is
  registered for RELOPT_KIND_HEAP (including ones an AM inherits via
  add_reloption_to_kind()).

- For non-heap tables and matviews, pg_dump keeps standard options in
  the CREATE, and puts AM-specific ones in a separate ALTER TABLE ...
  SET (...) TOC entry, which --no-table-access-method omits or skips.

- TAP tests using dummy_table_am.

That requires AMs to inherit standard option names rather than
redefine them, and to have their own options work when set by ALTER
TABLE on an empty table, since every restore would apply them that
way. I'd document both in tableam.sgml. The second one isn't ideal,
but I don't see a simpler way.

patch 2 implements this.


cheers


andrew


--
Andrew Dunstan
EDB:https://www.enterprisedb.com
From b182b80607231a8c20d6e3091191344f76326d4e Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Sat, 29 Aug 2026 11:00:02 -0400
Subject: [PATCH v10 1/2] Add amoptions callback to table access methods

Table AMs have had no way to define their own storage parameters:
CREATE TABLE and ALTER TABLE always parsed them with the heap parser,
whatever the table's access method.  Index AMs have long been able to
do this with IndexAmRoutine.amoptions; give table AMs the same.

TableAmRoutine gets an optional amoptions callback, with the same
signature as the index AM one.  table_reloptions() calls it if it's
set, and heap_reloptions() otherwise.  The bytea it returns is stored
in rd_options, in whatever layout the AM chooses.

Core code that reads StdRdOptions fields from rd_options, such as
RelationGetFillFactor() and vacuum_rel(), now checks
RelationHasStdRdOptions() first.  That is true if the AM has no
amoptions callback, or if it sets the new has_std_options_prefix flag
to say that its options struct begins with a StdRdOptions.

add_reloption_to_kind() makes an existing option valid for another
relopt_kind, so that an AM can accept standard options without
defining them again.  This is also the only way for an AM to accept
autovacuum_* options that affect autovacuum, which always uses the
heap parser to read them.

ALTER TABLE ... SET ACCESS METHOD checks the table's options against
the new AM after applying any SET or RESET in the same command, and
raises an error if the new AM doesn't accept them.

src/test/modules/dummy_table_am provides two test AMs: dummy_table_am,
whose options struct begins with StdRdOptions, and
dummy_custom_table_am, whose options struct doesn't.

Discussion: https://www.postgresql.org/message-id/flat/[email protected]
---
 doc/src/sgml/ref/alter_table.sgml             |  15 +
 doc/src/sgml/tableam.sgml                     |  86 +++++
 src/backend/access/common/reloptions.c        |  66 +++-
 src/backend/catalog/index.c                   |   3 +-
 src/backend/commands/tablecmds.c              | 138 ++++++--
 src/backend/commands/vacuum.c                 |   5 +-
 src/backend/utils/cache/relcache.c            |   4 +-
 src/include/access/reloptions.h               |   3 +
 src/include/access/tableam.h                  |  26 ++
 src/include/utils/rel.h                       |  19 +-
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 ++
 src/test/modules/dummy_table_am/README        |  25 ++
 .../dummy_table_am/dummy_table_am--1.0.sql    |  21 ++
 .../modules/dummy_table_am/dummy_table_am.c   | 314 ++++++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/custom_layout.out |  64 ++++
 .../dummy_table_am/expected/reloptions.out    | 264 +++++++++++++++
 src/test/modules/dummy_table_am/meson.build   |  34 ++
 .../dummy_table_am/sql/custom_layout.sql      |  41 +++
 .../modules/dummy_table_am/sql/reloptions.sql | 141 ++++++++
 src/test/modules/meson.build                  |   1 +
 22 files changed, 1260 insertions(+), 36 deletions(-)
 create mode 100644 src/test/modules/dummy_table_am/Makefile
 create mode 100644 src/test/modules/dummy_table_am/README
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.c
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.control
 create mode 100644 src/test/modules/dummy_table_am/expected/custom_layout.out
 create mode 100644 src/test/modules/dummy_table_am/expected/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 create mode 100644 src/test/modules/dummy_table_am/sql/custom_layout.sql
 create mode 100644 src/test/modules/dummy_table_am/sql/reloptions.sql

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0f9d698d170..d73f5759852 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -807,6 +807,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       causing future partitions to default to
       <varname>default_table_access_method</varname>.
      </para>
+     <para>
+      The new access method must accept all of the table's storage
+      parameters, or an error is raised.  An access method can define its
+      own storage parameters, so parameters set under the old access method
+      might not be accepted by the new one.  Such parameters can be removed
+      in the same command, for example:
+<programlisting>
+ALTER TABLE measurement
+    SET ACCESS METHOD heap,
+    RESET (compression);
+</programlisting>
+      The parameters are checked after any <literal>SET</literal> or
+      <literal>RESET</literal> of storage parameters in the same command has
+      been applied, so the order of the subcommands does not matter.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/tableam.sgml b/doc/src/sgml/tableam.sgml
index 9ccf5b739ed..374cb1862b1 100644
--- a/doc/src/sgml/tableam.sgml
+++ b/doc/src/sgml/tableam.sgml
@@ -152,4 +152,90 @@ my_tableam_handler(PG_FUNCTION_ARGS)
   its implementation.
  </para>
 
+ <sect1 id="tableam-reloptions">
+  <title>Table Access Method Options</title>
+
+  <para>
+   A table access method can define its own options, also called
+   <firstterm>reloptions</firstterm>, which users set as storage parameters
+   (see <xref linkend="sql-createtable-storage-parameters"/>), with the
+   <literal>WITH</literal> clause of <command>CREATE TABLE</command> and with
+   <command>ALTER TABLE ... SET</command>.  To do so it supplies an
+   <structfield>amoptions</structfield> callback in its
+   <structname>TableAmRoutine</structname>:
+<programlisting>
+bytea *
+amoptions (Datum reloptions,
+           bool validate);
+</programlisting>
+   <parameter>reloptions</parameter> is a <type>text</type> array containing
+   entries of the form
+   <replaceable>name</replaceable><literal>=</literal><replaceable>value</replaceable>;
+   the callback is not called if the relation has no options.
+   The function should construct a <type>bytea</type> value, which will be
+   copied into the <structfield>rd_options</structfield> field of the
+   relation's relcache entry.  The contents of the <type>bytea</type> value
+   are up to the access method.  It is OK to return NULL if default behavior
+   is wanted.
+  </para>
+
+  <para>
+   <parameter>validate</parameter> is true when the options are being set by
+   <command>CREATE TABLE</command> or <command>ALTER TABLE</command>.  The
+   function must then raise an error if any option is unrecognized or has an
+   invalid value.  It must not accept such a value and use something else in
+   its place, for example by substituting a default for an out-of-range
+   value, since the stored options would then not be the ones in effect.
+   <parameter>validate</parameter> is false when building the relcache entry
+   from options already stored in <structname>pg_class</structname>; the
+   function must then ignore invalid entries without raising an error.
+  </para>
+
+  <para>
+   An access method that does not supply <structfield>amoptions</structfield>
+   accepts the same options as <literal>heap</literal>, and
+   <structfield>rd_options</structfield> holds a
+   <structname>StdRdOptions</structname> struct.  Some core code, such as the
+   code that determines a table's fill factor, reads
+   <structname>StdRdOptions</structname> fields directly.  For a relation
+   whose access method supplies <structfield>amoptions</structfield>, that
+   code assumes the options are not set, unless the access method also sets
+   <structfield>has_std_options_prefix</structfield> to declare that the
+   struct its callback returns begins with a
+   <structname>StdRdOptions</structname>.  In that case every field of the
+   embedded <structname>StdRdOptions</structname> must be filled in.
+  </para>
+
+  <para>
+   An access method that uses the reloption functions in
+   <filename>src/include/access/reloptions.h</filename> registers its own
+   options for a kind it obtains from
+   <function>add_reloption_kind()</function>.  To accept a standard option
+   such as <literal>fillfactor</literal> as well, it calls
+   <function>add_reloption_to_kind()</function> to make the existing
+   definition valid for its kind, rather than defining an option with the
+   same name.
+  </para>
+
+  <para>
+   Autovacuum always reads a table's <literal>autovacuum_*</literal> options
+   using the standard heap parser, regardless of its access method.  So an
+   access method that wants these options to affect autovacuum must accept
+   them under their standard names, using
+   <function>add_reloption_to_kind()</function>.
+  </para>
+
+  <para>
+   <command>ALTER TABLE ... SET ACCESS METHOD</command> checks the table's
+   options against the new access method, after applying any
+   <literal>SET</literal> or <literal>RESET</literal> of options in the same
+   command.
+  </para>
+
+  <para>
+   <filename>src/test/modules/dummy_table_am</filename> contains two example
+   access methods that define their own options.
+  </para>
+ </sect1>
+
 </chapter>
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index ea9a0417909..79a5b404ee1 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -24,6 +24,7 @@
 #include "access/nbtree.h"
 #include "access/reloptions.h"
 #include "access/spgist_private.h"
+#include "access/tableam.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "commands/tablespace.h"
@@ -621,6 +622,7 @@ static relopt_gen **custom_options = NULL;
 static bool need_initialization = true;
 
 static void initialize_reloptions(void);
+static relopt_gen *find_reloption(const char *name, relopt_kind kind);
 static void parse_one_reloption(relopt_value *option, char *text_str,
 								int text_len, bool validate);
 
@@ -843,6 +845,26 @@ add_reloption_kind(void)
 	return (relopt_kind) last_assigned_kind;
 }
 
+/*
+ * add_reloption_to_kind
+ *		Make a heap reloption valid for an additional kind.
+ *
+ * This lets a table access method with its own relopt_kind accept a
+ * standard option such as fillfactor without defining it again.
+ */
+void
+add_reloption_to_kind(const char *name, relopt_kind kind)
+{
+	relopt_gen *gen = find_reloption(name, RELOPT_KIND_HEAP);
+
+	if (gen == NULL)
+		ereport(ERROR,
+				(errcode(ERRCODE_UNDEFINED_OBJECT),
+				 errmsg("reloption \"%s\" does not exist", name)));
+
+	gen->kinds |= kind;
+}
+
 /*
  * add_reloption
  *		Add an already-created custom reloption to the list, and recompute the
@@ -1609,8 +1631,11 @@ extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
 	switch (classForm->relkind)
 	{
 		case RELKIND_RELATION:
-		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
+			options = table_reloptions(amoptions, classForm->relkind,
+									   datum, false);
+			break;
+		case RELKIND_TOASTVALUE:
 			options = heap_reloptions(classForm->relkind, datum, false);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
@@ -2390,6 +2415,45 @@ heap_reloptions(char relkind, Datum reloptions, bool validate)
 	}
 }
 
+/*
+ * Parse options for a table or materialized view.
+ *
+ *	amoptions	the table AM's amoptions callback, or NULL to use
+ *				heap_reloptions()
+ *	relkind		the relation's kind
+ *	reloptions	options as text[] datum
+ *	validate	error flag
+ */
+bytea *
+table_reloptions(amoptions_function amoptions, char relkind,
+				 Datum reloptions, bool validate)
+{
+	if (amoptions == NULL)
+		return heap_reloptions(relkind, reloptions, validate);
+
+	/* Assume the AM's defaults are wanted if no options were given */
+	if (DatumGetPointer(reloptions) == NULL)
+		return NULL;
+
+	return amoptions(reloptions, validate);
+}
+
+/*
+ * Returns true if the relation's rd_options is a StdRdOptions, or begins
+ * with one.  That is so if its table AM has no amoptions callback, or has
+ * one and sets has_std_options_prefix.
+ */
+bool
+RelationHasStdRdOptions(Relation relation)
+{
+	if (relation->rd_options == NULL)
+		return false;
+	if (relation->rd_tableam == NULL)
+		return false;
+	return relation->rd_tableam->amoptions == NULL ||
+		relation->rd_tableam->has_std_options_prefix;
+}
+
 
 /*
  * Parse options for indexes.
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 4d232b85ad5..c2e4b959509 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2972,7 +2972,8 @@ index_update_stats(Relation rel,
 	{
 		if (AutoVacuumingActive())
 		{
-			StdRdOptions *options = (StdRdOptions *) rel->rd_options;
+			StdRdOptions *options = RelationHasStdRdOptions(rel) ?
+				(StdRdOptions *) rel->rd_options : NULL;
 
 			if (options != NULL &&
 				options->autovacuum.enabled == PG_TERNARY_FALSE)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 0274d892f2e..f1616a7d6f4 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -696,9 +696,12 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
 								const char *tablespacename, LOCKMODE lockmode);
 static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
 static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
+static void ATValidateAccessMethodOptions(List **wqueue);
+static amoptions_function GetTableAmOptions(Oid amoid);
 static void ATExecSetRelOptions(Relation rel, List *defList,
 								AlterTableType operation,
-								LOCKMODE lockmode);
+								LOCKMODE lockmode,
+								bool deferValidation);
 static void ATExecEnableDisableTrigger(Relation rel, const char *trigname,
 									   char fires_when, bool skip_system, bool recurse,
 									   LOCKMODE lockmode);
@@ -945,6 +948,28 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 	if (!OidIsValid(ownerId))
 		ownerId = GetUserId();
 
+	/*
+	 * For relations with table AM and partitioned tables, select access
+	 * method to use: an explicitly indicated one, or (in the case of a
+	 * partitioned table) the parent's, if it has one.
+	 */
+	if (stmt->accessMethod != NULL)
+	{
+		Assert(RELKIND_HAS_TABLE_AM(relkind) || relkind == RELKIND_PARTITIONED_TABLE);
+		accessMethodId = get_table_am_oid(stmt->accessMethod, false);
+	}
+	else if (RELKIND_HAS_TABLE_AM(relkind) || relkind == RELKIND_PARTITIONED_TABLE)
+	{
+		if (stmt->partbound)
+		{
+			Assert(list_length(inheritOids) == 1);
+			accessMethodId = get_rel_relam(linitial_oid(inheritOids));
+		}
+
+		if (RELKIND_HAS_TABLE_AM(relkind) && !OidIsValid(accessMethodId))
+			accessMethodId = get_table_am_oid(default_table_access_method, false);
+	}
+
 	/*
 	 * Parse and validate reloptions, if any.
 	 */
@@ -959,6 +984,11 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(reloptions, true);
 			break;
+		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			(void) table_reloptions(GetTableAmOptions(accessMethodId),
+									relkind, reloptions, true);
+			break;
 		default:
 			(void) heap_reloptions(relkind, reloptions, true);
 	}
@@ -1053,28 +1083,6 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 
 	TupleDescFinalize(descriptor);
 
-	/*
-	 * For relations with table AM and partitioned tables, select access
-	 * method to use: an explicitly indicated one, or (in the case of a
-	 * partitioned table) the parent's, if it has one.
-	 */
-	if (stmt->accessMethod != NULL)
-	{
-		Assert(RELKIND_HAS_TABLE_AM(relkind) || relkind == RELKIND_PARTITIONED_TABLE);
-		accessMethodId = get_table_am_oid(stmt->accessMethod, false);
-	}
-	else if (RELKIND_HAS_TABLE_AM(relkind) || relkind == RELKIND_PARTITIONED_TABLE)
-	{
-		if (stmt->partbound)
-		{
-			Assert(list_length(inheritOids) == 1);
-			accessMethodId = get_rel_relam(linitial_oid(inheritOids));
-		}
-
-		if (RELKIND_HAS_TABLE_AM(relkind) && !OidIsValid(accessMethodId))
-			accessMethodId = get_table_am_oid(default_table_access_method, false);
-	}
-
 	/*
 	 * Create the relation.  Inherited defaults and CHECK constraints are
 	 * passed in for immediate handling --- since they don't need parsing,
@@ -4947,6 +4955,9 @@ ATController(AlterTableStmt *parsetree,
 	/* Phase 2: update system catalogs */
 	ATRewriteCatalogs(&wqueue, lockmode, context);
 
+	/* Validate options as needed */
+	ATValidateAccessMethodOptions(&wqueue);
+
 	/* Phase 3: scan/rewrite tables as needed, and run afterStmts */
 	ATRewriteTables(parsetree, &wqueue, lockmode, context);
 }
@@ -5612,7 +5623,13 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 		case AT_SetRelOptions:	/* SET (...) */
 		case AT_ResetRelOptions:	/* RESET (...) */
 		case AT_ReplaceRelOptions:	/* replace entire option list */
-			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode);
+
+			/*
+			 * With SET ACCESS METHOD, ATValidateAccessMethodOptions() checks
+			 * the final option list instead.
+			 */
+			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode,
+								tab->chgAccessMethod);
 			break;
 		case AT_EnableTrig:		/* ENABLE TRIGGER name */
 			ATExecEnableDisableTrigger(rel, cmd->name,
@@ -17304,12 +17321,77 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 	tab->newTableSpace = tablespaceId;
 }
 
+/*
+ * Check the reloptions of each relation whose access method is being changed
+ * against the new access method's parser.  This runs after phase 2, so the
+ * options checked are the final ones, after any SET, RESET or REPLACE
+ * subcommands; checking each of those against the new AM as it runs would
+ * make the outcome depend on their order.  The check is needed even when the
+ * new AM uses the standard heap parser, since the old AM may have accepted
+ * options that heap does not.  Without it, an option the new AM doesn't
+ * recognize would stay in pg_class.reloptions but be ignored when the
+ * relcache entry is built.
+ */
+static void
+ATValidateAccessMethodOptions(List **wqueue)
+{
+	ListCell   *ltab;
+
+	foreach(ltab, *wqueue)
+	{
+		AlteredTableInfo *tab = (AlteredTableInfo *) lfirst(ltab);
+		HeapTuple	reltup;
+		Datum		reloptions;
+		bool		isnull;
+
+		/*
+		 * A partitioned table may be set to have no access method, in which
+		 * case there's nothing to check.
+		 */
+		if (!tab->chgAccessMethod || !OidIsValid(tab->newAccessMethod))
+			continue;
+
+		reltup = SearchSysCache1(RELOID, ObjectIdGetDatum(tab->relid));
+		if (!HeapTupleIsValid(reltup))
+			elog(ERROR, "cache lookup failed for relation %u", tab->relid);
+		reloptions = SysCacheGetAttr(RELOID, reltup,
+									 Anum_pg_class_reloptions, &isnull);
+		if (!isnull)
+			(void) table_reloptions(GetTableAmOptions(tab->newAccessMethod),
+									((Form_pg_class) GETSTRUCT(reltup))->relkind,
+									reloptions, true);
+		ReleaseSysCache(reltup);
+	}
+}
+
+/*
+ * Return the amoptions callback of the table access method with the given
+ * OID, or NULL if it has none.
+ */
+static amoptions_function
+GetTableAmOptions(Oid amoid)
+{
+	HeapTuple	amtup;
+	Oid			amhandler;
+
+	amtup = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+	if (!HeapTupleIsValid(amtup))
+		elog(ERROR, "cache lookup failed for access method %u", amoid);
+	amhandler = ((Form_pg_am) GETSTRUCT(amtup))->amhandler;
+	ReleaseSysCache(amtup);
+
+	return GetTableAmRoutine(amhandler)->amoptions;
+}
+
 /*
  * Set, reset, or replace reloptions.
+ *
+ * If deferValidation is true, the new options of a table or materialized view
+ * are not validated here; the caller arranges for that to be done later.
  */
 static void
 ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
-					LOCKMODE lockmode)
+					LOCKMODE lockmode, bool deferValidation)
 {
 	Oid			relid;
 	Relation	pgclass;
@@ -17361,7 +17443,11 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 	{
 		case RELKIND_RELATION:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			if (!deferValidation)
+				(void) table_reloptions(rel->rd_tableam ?
+										rel->rd_tableam->amoptions : NULL,
+										rel->rd_rel->relkind,
+										newOptions, true);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(newOptions, true);
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index d8c2f33c615..0d7897b4e44 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -2211,7 +2211,8 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 * whose parameters the caller handed down for that purpose.  For anything
 	 * else, params.main_relopts is NULL, and this just copies our own.
 	 */
-	relopts = merge_toast_reloptions((StdRdOptions *) rel->rd_options,
+	relopts = merge_toast_reloptions(RelationHasStdRdOptions(rel) ?
+									 (StdRdOptions *) rel->rd_options : NULL,
 									 params.main_relopts);
 
 	/*
@@ -2306,7 +2307,7 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 * a copy while we still have the relation open; the relcache entry can go
 	 * away once we close it.
 	 */
-	if (OidIsValid(toast_relid) && rel->rd_options)
+	if (OidIsValid(toast_relid) && RelationHasStdRdOptions(rel))
 	{
 		memcpy(&relopts_copy, rel->rd_options, sizeof(StdRdOptions));
 		toast_vacuum_params.main_relopts = &relopts_copy;
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index d8f04a05309..dcb298f61db 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -483,9 +483,11 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	switch (relation->rd_rel->relkind)
 	{
 		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			amoptsfn = relation->rd_tableam ? relation->rd_tableam->amoptions : NULL;
+			break;
 		case RELKIND_TOASTVALUE:
 		case RELKIND_VIEW:
-		case RELKIND_MATVIEW:
 		case RELKIND_PARTITIONED_TABLE:
 			amoptsfn = NULL;
 			break;
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index ccff4717b62..dc07f78c470 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -187,6 +187,7 @@ typedef struct local_relopts
 	 (char *)(optstruct) + (optstruct)->member)
 
 extern relopt_kind add_reloption_kind(void);
+extern void add_reloption_to_kind(const char *name, relopt_kind kind);
 extern void add_bool_reloption(uint32 kinds, const char *name, const char *desc,
 							   bool default_val, LOCKMODE lockmode);
 extern void add_ternary_reloption(uint32 kinds, const char *name,
@@ -250,6 +251,8 @@ extern bytea *default_reloptions(Datum reloptions, bool validate,
 extern struct StdRdOptions *merge_toast_reloptions(const struct StdRdOptions *toast_opts,
 												   const struct StdRdOptions *main_opts);
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
+extern bytea *table_reloptions(amoptions_function amoptions, char relkind,
+							   Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
 extern bytea *index_reloptions(amoptions_function amoptions, Datum reloptions,
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index ea3f2a6be99..885f00ea535 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -325,6 +325,32 @@ typedef struct TableAmRoutine
 	NodeTag		type;
 
 
+	/* ------------------------------------------------------------------------
+	 * Reloption parsing.
+	 * ------------------------------------------------------------------------
+	 */
+
+	/*
+	 * Parse and validate the relation's reloptions, returning the bytea to be
+	 * stored in rd_options.  Optional; if NULL, the standard heap parser is
+	 * used and rd_options is a StdRdOptions.  See the "Table Access Method
+	 * Storage Parameters" section of the documentation.
+	 */
+	bytea	   *(*amoptions) (Datum reloptions, bool validate);
+
+	/*
+	 * Set this if amoptions is non-NULL and the struct it returns begins with
+	 * a StdRdOptions, in which case core code reads standard options such as
+	 * fillfactor straight out of rd_options, as it does for heap.  The AM
+	 * must then register every StdRdOptions option for its own kind with
+	 * add_reloption_to_kind() and list it in its parse table, so that each
+	 * field gets its default when the user doesn't set it; build_reloptions()
+	 * leaves unlisted fields zeroed, which is not a valid default for all of
+	 * them.  Ignored if amoptions is NULL.
+	 */
+	bool		has_std_options_prefix;
+
+
 	/* ------------------------------------------------------------------------
 	 * Slot related callbacks.
 	 * ------------------------------------------------------------------------
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index 7b8c2b1e362..8bed8705558 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -381,12 +381,21 @@ typedef struct StdRdOptions
 #define HEAP_MIN_FILLFACTOR			10
 #define HEAP_DEFAULT_FILLFACTOR		100
 
+/*
+ * RelationHasStdRdOptions
+ *		Returns true if rd_options can be read as StdRdOptions.  The macros
+ *		below must check this, since a table AM with its own amoptions
+ *		callback may use a different layout.  It's a function, in
+ *		reloptions.c, because it needs the TableAmRoutine definition.
+ */
+extern bool RelationHasStdRdOptions(Relation relation);
+
 /*
  * RelationGetToastTupleTarget
  *		Returns the relation's toast_tuple_target.  Note multiple eval of argument!
  */
 #define RelationGetToastTupleTarget(relation, defaulttarg) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->toast_tuple_target : (defaulttarg))
 
 /*
@@ -394,7 +403,7 @@ typedef struct StdRdOptions
  *		Returns the relation's toast_value_type.  Note multiple eval of argument!
  */
 #define RelationGetToastValueType(relation, defaulttarg) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->toast_value_type : (defaulttarg))
 
 /*
@@ -402,7 +411,7 @@ typedef struct StdRdOptions
  *		Returns the relation's fillfactor.  Note multiple eval of argument!
  */
 #define RelationGetFillFactor(relation, defaultff) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->fillfactor : (defaultff))
 
 /*
@@ -425,7 +434,7 @@ typedef struct StdRdOptions
  *		from the pov of logical decoding.  Note multiple eval of argument!
  */
 #define RelationIsUsedAsCatalogTable(relation)	\
-	((relation)->rd_options && \
+	(RelationHasStdRdOptions(relation) && \
 	 ((relation)->rd_rel->relkind == RELKIND_RELATION || \
 	  (relation)->rd_rel->relkind == RELKIND_MATVIEW) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->user_catalog_table : false)
@@ -436,7 +445,7 @@ typedef struct StdRdOptions
  *		Note multiple eval of argument!
  */
 #define RelationGetParallelWorkers(relation, defaultpw) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->parallel_workers : (defaultpw))
 
 /* ViewOptions->check_option values */
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 71a2e65ad70..e3c0ce7e051 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -10,6 +10,7 @@ SUBDIRS = \
 		  delay_execution \
 		  dummy_index_am \
 		  dummy_seclabel \
+		  dummy_table_am \
 		  index \
 		  libpq_pipeline \
 		  oauth_validator \
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
new file mode 100644
index 00000000000..cd9f63f2897
--- /dev/null
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/dummy_table_am/Makefile
+
+MODULES = dummy_table_am
+
+EXTENSION = dummy_table_am
+DATA = dummy_table_am--1.0.sql
+PGFILEDESC = "dummy_table_am - table access method template"
+
+REGRESS = reloptions custom_layout
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/dummy_table_am
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/dummy_table_am/README b/src/test/modules/dummy_table_am/README
new file mode 100644
index 00000000000..8d471c37d05
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,25 @@
+Dummy Table AM
+==============
+
+This module provides two table access methods for testing the amoptions
+callback and add_reloption_to_kind().  Both use heap's callbacks for
+everything except reloption parsing, so their tables behave like heap
+tables, except that indexes cannot be built on them: heap_getnext(),
+which index builds use, accepts only heap's own TableAmRoutine.
+
+dummy_table_am accepts all of heap's options, plus these:
+
+  - "option_int"     (integer, -10..100)
+  - "option_real"    (real, -10..100)
+  - "option_bool"    (boolean)
+  - "option_enum"    (enum, one|two)
+
+Its options struct begins with StdRdOptions, and it sets
+has_std_options_prefix.
+
+dummy_custom_table_am accepts only three integer options, "option_a",
+"option_b" and "option_c" (0..100, default 0).  Its options struct does
+not begin with StdRdOptions, and its fields are at the offsets of
+StdRdOptions' fillfactor, toast_tuple_target and toast_value_type, so
+that core code wrongly reading StdRdOptions fields from it gets values
+that show up in the tests.
diff --git a/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
new file mode 100644
index 00000000000..fb438a8c735
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,21 @@
+/* src/test/modules/dummy_table_am/dummy_table_am--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION dummy_table_am" to load this file. \quit
+
+CREATE FUNCTION dthandler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+-- Access method
+CREATE ACCESS METHOD dummy_table_am TYPE TABLE HANDLER dthandler;
+COMMENT ON ACCESS METHOD dummy_table_am IS 'dummy table access method';
+
+CREATE FUNCTION dcthandler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+CREATE ACCESS METHOD dummy_custom_table_am TYPE TABLE HANDLER dcthandler;
+COMMENT ON ACCESS METHOD dummy_custom_table_am IS 'dummy table access method with a custom options layout';
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.c b/src/test/modules/dummy_table_am/dummy_table_am.c
new file mode 100644
index 00000000000..6999a3be880
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,314 @@
+/*-------------------------------------------------------------------------
+ *
+ * dummy_table_am.c
+ *		Table AMs for testing the amoptions callback.
+ *
+ * This module provides two table access methods that use heap's callbacks
+ * for everything except reloption parsing.  dummy_table_am's options struct
+ * begins with a StdRdOptions; dummy_custom_table_am's does not.
+ *
+ * Indexes cannot be built on tables of either AM, since that uses
+ * heap_getnext(), which accepts only heap's own TableAmRoutine.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/dummy_table_am/dummy_table_am.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/reloptions.h"
+#include "access/tableam.h"
+#include "catalog/pg_am_d.h"
+#include "fmgr.h"
+#include "utils/rel.h"
+
+PG_MODULE_MAGIC;
+
+/* Kind of relation options for dummy_table_am */
+static relopt_kind dt_relopt_kind;
+
+/* Parse table and kind of relation options for dummy_custom_table_am */
+static relopt_parse_elt dct_relopt_tab[3];
+static relopt_kind dct_relopt_kind;
+
+typedef enum DummyTableEnum
+{
+	DUMMY_TABLE_ENUM_ONE,
+	DUMMY_TABLE_ENUM_TWO,
+}			DummyTableEnum;
+
+/*
+ * dummy_table_am options.  Since dummy_table_am sets has_std_options_prefix,
+ * "std" must be first, and each of its fields must be in the parse table so
+ * that it gets its default when not set.
+ */
+typedef struct DummyTableOptions
+{
+	StdRdOptions std;			/* must be first, see above */
+	int			option_int;
+	double		option_real;
+	bool		option_bool;
+	DummyTableEnum option_enum;
+}			DummyTableOptions;
+
+/* The standard options, all of which dummy_table_am accepts */
+static const relopt_parse_elt dt_std_options[] = {
+	{"fillfactor", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.fillfactor)},
+	{"toast_tuple_target", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.toast_tuple_target)},
+	{"toast_value_type", RELOPT_TYPE_ENUM,
+	offsetof(DummyTableOptions, std.toast_value_type)},
+	{"user_catalog_table", RELOPT_TYPE_BOOL,
+	offsetof(DummyTableOptions, std.user_catalog_table)},
+	{"parallel_workers", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.parallel_workers)},
+	{"vacuum_index_cleanup", RELOPT_TYPE_ENUM,
+	offsetof(DummyTableOptions, std.vacuum_index_cleanup)},
+	{"vacuum_truncate", RELOPT_TYPE_TERNARY,
+	offsetof(DummyTableOptions, std.vacuum_truncate)},
+	{"vacuum_max_eager_freeze_failure_rate", RELOPT_TYPE_REAL,
+	offsetof(DummyTableOptions, std.vacuum_max_eager_freeze_failure_rate)},
+	{"autovacuum_enabled", RELOPT_TYPE_TERNARY,
+	offsetof(DummyTableOptions, std.autovacuum.enabled)},
+	{"autovacuum_parallel_workers", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.autovacuum_parallel_workers)},
+	{"autovacuum_vacuum_threshold", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.vacuum_threshold)},
+	{"autovacuum_vacuum_max_threshold", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.vacuum_max_threshold)},
+	{"autovacuum_vacuum_insert_threshold", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.vacuum_ins_threshold)},
+	{"autovacuum_analyze_threshold", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.analyze_threshold)},
+	{"autovacuum_vacuum_cost_limit", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.vacuum_cost_limit)},
+	{"autovacuum_freeze_min_age", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.freeze_min_age)},
+	{"autovacuum_freeze_max_age", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.freeze_max_age)},
+	{"autovacuum_freeze_table_age", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.freeze_table_age)},
+	{"autovacuum_multixact_freeze_min_age", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.multixact_freeze_min_age)},
+	{"autovacuum_multixact_freeze_max_age", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.multixact_freeze_max_age)},
+	{"autovacuum_multixact_freeze_table_age", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.multixact_freeze_table_age)},
+	{"log_autovacuum_min_duration", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.log_vacuum_min_duration)},
+	{"log_autoanalyze_min_duration", RELOPT_TYPE_INT,
+	offsetof(DummyTableOptions, std.autovacuum.log_analyze_min_duration)},
+	{"autovacuum_vacuum_cost_delay", RELOPT_TYPE_REAL,
+	offsetof(DummyTableOptions, std.autovacuum.vacuum_cost_delay)},
+	{"autovacuum_vacuum_scale_factor", RELOPT_TYPE_REAL,
+	offsetof(DummyTableOptions, std.autovacuum.vacuum_scale_factor)},
+	{"autovacuum_vacuum_insert_scale_factor", RELOPT_TYPE_REAL,
+	offsetof(DummyTableOptions, std.autovacuum.vacuum_ins_scale_factor)},
+	{"autovacuum_analyze_scale_factor", RELOPT_TYPE_REAL,
+	offsetof(DummyTableOptions, std.autovacuum.analyze_scale_factor)},
+};
+
+/* Parse table for dummy_table_am: the standard options and 4 of its own */
+static relopt_parse_elt dt_relopt_tab[lengthof(dt_std_options) + 4];
+
+/*
+ * dummy_custom_table_am options.  This is smaller than StdRdOptions, and its
+ * fields are at the offsets of StdRdOptions' fillfactor, toast_tuple_target
+ * and toast_value_type, so that core code reading a StdRdOptions field
+ * without checking RelationHasStdRdOptions() gets a visibly wrong value.
+ */
+typedef struct DummyCustomTableOptions
+{
+	int32		vl_len_;		/* varlena header (do not touch directly!) */
+	int			option_a;
+	int			option_b;
+	int			option_c;
+}			DummyCustomTableOptions;
+
+static relopt_enum_elt_def dummyTableEnumValues[] =
+{
+	{"one", DUMMY_TABLE_ENUM_ONE},
+	{"two", DUMMY_TABLE_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+static void create_reloptions_table(void);
+static void create_custom_reloptions_table(void);
+static bytea *dtoptions(Datum reloptions, bool validate);
+static bytea *dctoptions(Datum reloptions, bool validate);
+static Oid	dummy_table_relation_toast_am(Relation rel);
+
+PG_FUNCTION_INFO_V1(dthandler);
+PG_FUNCTION_INFO_V1(dcthandler);
+
+/*
+ * Register dummy_table_am's options and populate its parse table.
+ */
+static void
+create_reloptions_table(void)
+{
+	int			i = 0;
+
+	dt_relopt_kind = add_reloption_kind();
+
+	for (int j = 0; j < lengthof(dt_std_options); j++)
+	{
+		add_reloption_to_kind(dt_std_options[j].optname, dt_relopt_kind);
+		dt_relopt_tab[i++] = dt_std_options[j];
+	}
+
+	add_int_reloption(dt_relopt_kind, "option_int",
+					  "Integer option for dummy_table_am",
+					  10, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_int";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_int);
+	i++;
+
+	add_real_reloption(dt_relopt_kind, "option_real",
+					   "Real option for dummy_table_am",
+					   3.1415, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_real";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_real);
+	i++;
+
+	add_bool_reloption(dt_relopt_kind, "option_bool",
+					   "Boolean option for dummy_table_am",
+					   true, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_bool";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_bool);
+	i++;
+
+	add_enum_reloption(dt_relopt_kind, "option_enum",
+					   "Enum option for dummy_table_am",
+					   dummyTableEnumValues,
+					   DUMMY_TABLE_ENUM_ONE,
+					   "Valid values are \"one\" and \"two\".",
+					   AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_enum";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_enum);
+	i++;
+}
+
+/*
+ * Register dummy_custom_table_am's options and populate its parse table.
+ */
+static void
+create_custom_reloptions_table(void)
+{
+	static const char *const names[] = {"option_a", "option_b", "option_c"};
+	static const int offsets[] = {
+		offsetof(DummyCustomTableOptions, option_a),
+		offsetof(DummyCustomTableOptions, option_b),
+		offsetof(DummyCustomTableOptions, option_c),
+	};
+
+	StaticAssertDecl(lengthof(names) == lengthof(dct_relopt_tab),
+					 "names and dct_relopt_tab must have the same length");
+	StaticAssertDecl(lengthof(offsets) == lengthof(dct_relopt_tab),
+					 "offsets and dct_relopt_tab must have the same length");
+
+	dct_relopt_kind = add_reloption_kind();
+
+	for (int i = 0; i < lengthof(dct_relopt_tab); i++)
+	{
+		add_int_reloption(dct_relopt_kind, names[i],
+						  "Integer option for dummy_custom_table_am",
+						  0, 0, 100, AccessExclusiveLock);
+		dct_relopt_tab[i].optname = names[i];
+		dct_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+		dct_relopt_tab[i].offset = offsets[i];
+	}
+}
+
+/*
+ * Parse reloptions for dummy_table_am.
+ */
+static bytea *
+dtoptions(Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dt_relopt_kind,
+									  sizeof(DummyTableOptions),
+									  dt_relopt_tab, lengthof(dt_relopt_tab));
+}
+
+/*
+ * Parse reloptions for dummy_custom_table_am.
+ */
+static bytea *
+dctoptions(Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dct_relopt_kind,
+									  sizeof(DummyCustomTableOptions),
+									  dct_relopt_tab, lengthof(dct_relopt_tab));
+}
+
+/*
+ * Make TOAST tables plain heap tables.  Heap's own callback returns the
+ * relation's AM, and building the TOAST table's index would then fail in
+ * heap_getnext().
+ */
+static Oid
+dummy_table_relation_toast_am(Relation rel)
+{
+	return HEAP_TABLE_AM_OID;
+}
+
+/*
+ * Handler for dummy_table_am.
+ */
+Datum
+dthandler(PG_FUNCTION_ARGS)
+{
+	static TableAmRoutine routine;
+	static bool initialized = false;
+
+	if (!initialized)
+	{
+		memcpy(&routine, GetHeapamTableAmRoutine(), sizeof(routine));
+		routine.amoptions = dtoptions;
+		routine.has_std_options_prefix = true;
+		routine.relation_toast_am = dummy_table_relation_toast_am;
+		initialized = true;
+	}
+
+	PG_RETURN_POINTER(&routine);
+}
+
+/*
+ * Handler for dummy_custom_table_am.
+ */
+Datum
+dcthandler(PG_FUNCTION_ARGS)
+{
+	static TableAmRoutine routine;
+	static bool initialized = false;
+
+	if (!initialized)
+	{
+		memcpy(&routine, GetHeapamTableAmRoutine(), sizeof(routine));
+		routine.amoptions = dctoptions;
+		routine.has_std_options_prefix = false;
+		routine.relation_toast_am = dummy_table_relation_toast_am;
+		initialized = true;
+	}
+
+	PG_RETURN_POINTER(&routine);
+}
+
+void
+_PG_init(void)
+{
+	create_reloptions_table();
+	create_custom_reloptions_table();
+}
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.control b/src/test/modules/dummy_table_am/dummy_table_am.control
new file mode 100644
index 00000000000..08f2f868d49
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.control
@@ -0,0 +1,5 @@
+# dummy_table_am extension
+comment = 'dummy_table_am - table access method template'
+default_version = '1.0'
+module_pathname = '$libdir/dummy_table_am'
+relocatable = true
diff --git a/src/test/modules/dummy_table_am/expected/custom_layout.out b/src/test/modules/dummy_table_am/expected/custom_layout.out
new file mode 100644
index 00000000000..abd1678994d
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/custom_layout.out
@@ -0,0 +1,64 @@
+-- Tests for a table AM whose options struct does not begin with StdRdOptions
+CREATE EXTENSION dummy_table_am;
+-- Standard options are not accepted
+CREATE TABLE dct_bad (a int) USING dummy_custom_table_am WITH (fillfactor = 50);
+ERROR:  unrecognized parameter "fillfactor"
+-- TOAST tables get the default toast_value_type.  option_c is at the offset
+-- of StdRdOptions.toast_value_type; 0 is not a valid value for that, and 2
+-- would select oid8.
+CREATE TABLE dct_default (a int, b text) USING dummy_custom_table_am;
+CREATE TABLE dct_c0 (a int, b text) USING dummy_custom_table_am
+    WITH (option_c = 0);
+CREATE TABLE dct_c2 (a int, b text) USING dummy_custom_table_am
+    WITH (option_a = 10, option_b = 20, option_c = 2);
+SELECT c.relname, c.reloptions, a.atttypid::regtype AS chunk_id_type
+    FROM pg_class c
+    JOIN pg_attribute a ON a.attrelid = c.reltoastrelid AND a.attname = 'chunk_id'
+    WHERE c.relname IN ('dct_default', 'dct_c0', 'dct_c2')
+    ORDER BY c.relname;
+   relname   |              reloptions              | chunk_id_type 
+-------------+--------------------------------------+---------------
+ dct_c0      | {option_c=0}                         | oid
+ dct_c2      | {option_a=10,option_b=20,option_c=2} | oid
+ dct_default |                                      | oid
+(3 rows)
+
+-- VACUUM of a table with TOAST data
+INSERT INTO dct_c2
+    SELECT 1, string_agg(md5(i::text), '') FROM generate_series(1, 500) i;
+SELECT pg_relation_size(reltoastrelid) > 0 AS has_toast_data
+    FROM pg_class WHERE oid = 'dct_c2'::regclass;
+ has_toast_data 
+----------------
+ t
+(1 row)
+
+VACUUM dct_c2;
+SELECT a, length(b) FROM dct_c2;
+ a | length 
+---+--------
+ 1 |  16000
+(1 row)
+
+-- Switching to heap requires resetting the AM's options
+ALTER TABLE dct_c2 SET ACCESS METHOD heap;
+ERROR:  unrecognized parameter "option_a"
+ALTER TABLE dct_c2 SET ACCESS METHOD heap, SET (fillfactor = 50),
+    RESET (option_a, option_b, option_c);
+SELECT (SELECT amname FROM pg_am WHERE oid = relam) AS amname, reloptions
+    FROM pg_class WHERE oid = 'dct_c2'::regclass;
+ amname |   reloptions    
+--------+-----------------
+ heap   | {fillfactor=50}
+(1 row)
+
+SELECT a, length(b) FROM dct_c2;
+ a | length 
+---+--------
+ 1 |  16000
+(1 row)
+
+DROP TABLE dct_default;
+DROP TABLE dct_c0;
+DROP TABLE dct_c2;
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..7b9853b995b
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -0,0 +1,264 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+-- AM-specific and standard options are accepted
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_int=17,option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- Out-of-range and unknown options are rejected
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+ERROR:  value 9999 out of bounds for option "option_int"
+DETAIL:  Valid values are between "-10" and "100".
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (no_such_option = 4);
+ERROR:  unrecognized parameter "no_such_option"
+-- Defaults are not stored in pg_class
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+DROP TABLE dummy_defaults;
+-- ALTER TABLE SET and RESET
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60,option_int=42}
+(1 row)
+
+ALTER TABLE dummy_t SET (no_such_option = 4);
+ERROR:  unrecognized parameter "no_such_option"
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                            reloptions                             
+-------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- SET ACCESS METHOD keeps standard options the new AM accepts
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+-- ... and fails if the new AM doesn't accept them, unless they're reset
+ALTER TABLE heap_t SET ACCESS METHOD dummy_custom_table_am;
+ERROR:  unrecognized parameter "fillfactor"
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+ALTER TABLE heap_t SET ACCESS METHOD dummy_custom_table_am,
+    RESET (fillfactor, autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+        amname         
+-----------------------
+ dummy_custom_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+-- SET ACCESS METHOD with SET of an option only the new AM accepts
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {option_int=25}
+(1 row)
+
+-- Switching to heap fails while an option heap doesn't accept is set
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap;
+ERROR:  unrecognized parameter "option_int"
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {option_int=25}
+(1 row)
+
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+-- Only the final options are checked, so a RESET may follow a SET
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, SET (option_real = 1),
+    RESET (option_int);
+ERROR:  unrecognized parameter "option_real"
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, SET (fillfactor = 50),
+    RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {fillfactor=50}
+(1 row)
+
+-- Option values are still checked
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 9999);
+ERROR:  value 9999 out of bounds for option "option_int"
+DETAIL:  Valid values are between "-10" and "100".
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {fillfactor=50}
+(1 row)
+
+-- fillfactor takes effect
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+ low_fillfactor_uses_more_pages 
+--------------------------------
+ t
+(1 row)
+
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+-- Tables with a TOAST table, with and without options
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+ a | length 
+---+--------
+ 1 |  10000
+(1 row)
+
+DROP TABLE dummy_txt;
+CREATE TABLE dummy_txt_opt (a int, b text)
+    USING dummy_table_am WITH (option_int = 7);
+INSERT INTO dummy_txt_opt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt_opt;
+ a | length 
+---+--------
+ 1 |  10000
+(1 row)
+
+DROP TABLE dummy_txt_opt;
+-- A partition's options are checked by the AM it inherits from its parent
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+  relname  |     amname     |   reloptions    
+-----------+----------------+-----------------
+ parted    | dummy_table_am | 
+ parted_p1 | dummy_table_am | {option_int=11}
+(2 rows)
+
+-- ... or the one given in USING
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+ERROR:  unrecognized parameter "option_int"
+-- ... or default_table_access_method, if the parent has no AM
+SET default_table_access_method = dummy_table_am;
+CREATE TABLE parted_noam (a int) PARTITION BY RANGE (a);
+CREATE TABLE parted_noam_p1 PARTITION OF parted_noam
+    FOR VALUES FROM (0) TO (100) WITH (option_int = 12);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted_noam'::regclass, 'parted_noam_p1'::regclass)
+    ORDER BY c.relname;
+    relname     |     amname     |   reloptions    
+----------------+----------------+-----------------
+ parted_noam    |                | 
+ parted_noam_p1 | dummy_table_am | {option_int=12}
+(2 rows)
+
+RESET default_table_access_method;
+DROP TABLE parted_noam;
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
new file mode 100644
index 00000000000..35ffde4d151
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,34 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+dummy_table_am_sources = files(
+  'dummy_table_am.c',
+)
+
+if host_system == 'windows'
+  dummy_table_am_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'dummy_table_am',
+    '--FILEDESC', 'dummy_table_am - table access method template',])
+endif
+
+dummy_table_am = shared_module('dummy_table_am',
+  dummy_table_am_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += dummy_table_am
+
+test_install_data += files(
+  'dummy_table_am.control',
+  'dummy_table_am--1.0.sql',
+)
+
+tests += {
+  'name': 'dummy_table_am',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'reloptions',
+      'custom_layout',
+    ],
+  },
+}
diff --git a/src/test/modules/dummy_table_am/sql/custom_layout.sql b/src/test/modules/dummy_table_am/sql/custom_layout.sql
new file mode 100644
index 00000000000..6eb3950a853
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/custom_layout.sql
@@ -0,0 +1,41 @@
+-- Tests for a table AM whose options struct does not begin with StdRdOptions
+CREATE EXTENSION dummy_table_am;
+
+-- Standard options are not accepted
+CREATE TABLE dct_bad (a int) USING dummy_custom_table_am WITH (fillfactor = 50);
+
+-- TOAST tables get the default toast_value_type.  option_c is at the offset
+-- of StdRdOptions.toast_value_type; 0 is not a valid value for that, and 2
+-- would select oid8.
+CREATE TABLE dct_default (a int, b text) USING dummy_custom_table_am;
+CREATE TABLE dct_c0 (a int, b text) USING dummy_custom_table_am
+    WITH (option_c = 0);
+CREATE TABLE dct_c2 (a int, b text) USING dummy_custom_table_am
+    WITH (option_a = 10, option_b = 20, option_c = 2);
+SELECT c.relname, c.reloptions, a.atttypid::regtype AS chunk_id_type
+    FROM pg_class c
+    JOIN pg_attribute a ON a.attrelid = c.reltoastrelid AND a.attname = 'chunk_id'
+    WHERE c.relname IN ('dct_default', 'dct_c0', 'dct_c2')
+    ORDER BY c.relname;
+
+-- VACUUM of a table with TOAST data
+INSERT INTO dct_c2
+    SELECT 1, string_agg(md5(i::text), '') FROM generate_series(1, 500) i;
+SELECT pg_relation_size(reltoastrelid) > 0 AS has_toast_data
+    FROM pg_class WHERE oid = 'dct_c2'::regclass;
+VACUUM dct_c2;
+SELECT a, length(b) FROM dct_c2;
+
+-- Switching to heap requires resetting the AM's options
+ALTER TABLE dct_c2 SET ACCESS METHOD heap;
+ALTER TABLE dct_c2 SET ACCESS METHOD heap, SET (fillfactor = 50),
+    RESET (option_a, option_b, option_c);
+SELECT (SELECT amname FROM pg_am WHERE oid = relam) AS amname, reloptions
+    FROM pg_class WHERE oid = 'dct_c2'::regclass;
+SELECT a, length(b) FROM dct_c2;
+
+DROP TABLE dct_default;
+DROP TABLE dct_c0;
+DROP TABLE dct_c2;
+
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
new file mode 100644
index 00000000000..3b714939d74
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,141 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+
+-- AM-specific and standard options are accepted
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+
+-- Out-of-range and unknown options are rejected
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (no_such_option = 4);
+
+-- Defaults are not stored in pg_class
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+DROP TABLE dummy_defaults;
+
+-- ALTER TABLE SET and RESET
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+ALTER TABLE dummy_t SET (no_such_option = 4);
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- SET ACCESS METHOD keeps standard options the new AM accepts
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- ... and fails if the new AM doesn't accept them, unless they're reset
+ALTER TABLE heap_t SET ACCESS METHOD dummy_custom_table_am;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+ALTER TABLE heap_t SET ACCESS METHOD dummy_custom_table_am,
+    RESET (fillfactor, autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+
+-- SET ACCESS METHOD with SET of an option only the new AM accepts
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- Switching to heap fails while an option heap doesn't accept is set
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- Only the final options are checked, so a RESET may follow a SET
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, SET (option_real = 1),
+    RESET (option_int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, SET (fillfactor = 50),
+    RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- Option values are still checked
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 9999);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- fillfactor takes effect
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+
+-- Tables with a TOAST table, with and without options
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+DROP TABLE dummy_txt;
+
+CREATE TABLE dummy_txt_opt (a int, b text)
+    USING dummy_table_am WITH (option_int = 7);
+INSERT INTO dummy_txt_opt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt_opt;
+DROP TABLE dummy_txt_opt;
+
+-- A partition's options are checked by the AM it inherits from its parent
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+
+-- ... or the one given in USING
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+
+-- ... or default_table_access_method, if the parent has no AM
+SET default_table_access_method = dummy_table_am;
+CREATE TABLE parted_noam (a int) PARTITION BY RANGE (a);
+CREATE TABLE parted_noam_p1 PARTITION OF parted_noam
+    FOR VALUES FROM (0) TO (100) WITH (option_int = 12);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted_noam'::regclass, 'parted_noam_p1'::regclass)
+    ORDER BY c.relname;
+RESET default_table_access_method;
+DROP TABLE parted_noam;
+
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 77e1a2810e5..d1fcbae0cdb 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -5,6 +5,7 @@ subdir('commit_ts')
 subdir('delay_execution')
 subdir('dummy_index_am')
 subdir('dummy_seclabel')
+subdir('dummy_table_am')
 subdir('gin')
 subdir('index')
 subdir('injection_points')
-- 
2.43.0

From 7a50b6d18d40b0052f1f2a88f01594814634c578 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Fri, 2 Oct 2026 09:21:27 -0400
Subject: [PATCH v10 2/2] pg_dump: Set table AM-specific storage parameters
 separately

pg_dump put all of a table's storage parameters in the WITH clause of
its CREATE TABLE.  With --no-table-access-method, the table is created
with the default access method, which may not accept parameters
specific to the table's own access method.  The CREATE TABLE then
failed, so the table and its data were not restored at all.  The same
happened with pg_restore --no-table-access-method.

Parameters can't all be moved to a later ALTER TABLE, because some
standard ones only have an effect when the table is created, such as
toast_value_type.  So for tables and materialized views whose access
method isn't heap, pg_dump now keeps the standard parameters in the
WITH clause, and sets the others with ALTER TABLE in a separate TOC
entry of type TABLE AM OPTIONS, which depends on the table.
--no-table-access-method leaves that entry out, and pg_restore --table
includes it with its table.

To tell the two kinds of parameter apart, add a function
pg_reloption_is_standard(), which returns true for parameters heap
accepts.  A table access method that accepts such a parameter is
expected to have inherited it with add_reloption_to_kind(), so it has
the same meaning as for heap.

This requires that an access method's own parameters have the same
effect when set by ALTER TABLE on an empty table as when given to
CREATE TABLE.  Document that in tableam.sgml.

Discussion: https://www.postgresql.org/message-id/flat/[email protected]
---
 doc/src/sgml/func/func-info.sgml              |  17 ++
 doc/src/sgml/ref/pg_dump.sgml                 |   4 +-
 doc/src/sgml/ref/pg_dumpall.sgml              |   4 +-
 doc/src/sgml/ref/pg_restore.sgml              |   4 +-
 doc/src/sgml/tableam.sgml                     |  11 ++
 src/backend/access/common/reloptions.c        |  17 ++
 src/bin/pg_dump/pg_backup_archiver.c          |   8 +
 src/bin/pg_dump/pg_dump.c                     |  64 ++++++-
 src/bin/pg_dump/pg_dump.h                     |   1 +
 src/include/catalog/pg_proc.dat               |   5 +
 src/test/modules/dummy_table_am/Makefile      |   1 +
 .../dummy_table_am/expected/reloptions.out    |   8 +
 src/test/modules/dummy_table_am/meson.build   |   5 +
 .../modules/dummy_table_am/sql/reloptions.sql |   4 +
 .../modules/dummy_table_am/t/001_pg_dump.pl   | 158 ++++++++++++++++++
 src/test/regress/expected/reloptions.out      |  13 ++
 src/test/regress/sql/reloptions.sql           |   5 +
 17 files changed, 325 insertions(+), 4 deletions(-)
 create mode 100644 src/test/modules/dummy_table_am/t/001_pg_dump.pl

diff --git a/doc/src/sgml/func/func-info.sgml b/doc/src/sgml/func/func-info.sgml
index e56c9a22c42..3100f9139f1 100644
--- a/doc/src/sgml/func/func-info.sgml
+++ b/doc/src/sgml/func/func-info.sgml
@@ -1877,6 +1877,23 @@ SELECT currval(pg_get_serial_sequence('sometable', 'id'));
        </para></entry>
       </row>
 
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>pg_reloption_is_standard</primary>
+        </indexterm>
+        <function>pg_reloption_is_standard</function> ( <parameter>name</parameter> <type>text</type> )
+        <returnvalue>boolean</returnvalue>
+       </para>
+       <para>
+        Returns true if the named storage parameter is one accepted by
+        tables using the <literal>heap</literal> access method.  A table
+        access method that defines its own storage parameters can also accept
+        these ones (see <xref linkend="tableam-reloptions"/>); any other
+        storage parameter of a table is specific to its access method.
+       </para></entry>
+      </row>
+
       <row>
        <entry role="func_table_entry"><para role="func_signature">
         <indexterm>
diff --git a/doc/src/sgml/ref/pg_dump.sgml b/doc/src/sgml/ref/pg_dump.sgml
index 774be23b4f9..0d5433d105b 100644
--- a/doc/src/sgml/ref/pg_dump.sgml
+++ b/doc/src/sgml/ref/pg_dump.sgml
@@ -1146,7 +1146,9 @@ PostgreSQL documentation
        <para>
         Do not output commands to select table access methods.
         With this option, all objects will be created with whichever
-        table access method is the default during restore.
+        table access method is the default during restore.  Storage
+        parameters specific to a table's access method are not output
+        either (see <xref linkend="tableam-reloptions"/>).
        </para>
 
        <para>
diff --git a/doc/src/sgml/ref/pg_dumpall.sgml b/doc/src/sgml/ref/pg_dumpall.sgml
index 238c87c13f5..9e71b35775e 100644
--- a/doc/src/sgml/ref/pg_dumpall.sgml
+++ b/doc/src/sgml/ref/pg_dumpall.sgml
@@ -531,7 +531,9 @@ exclude database <replaceable class="parameter">PATTERN</replaceable>
        <para>
         Do not output commands to select table access methods.
         With this option, all objects will be created with whichever
-        table access method is the default during restore.
+        table access method is the default during restore.  Storage
+        parameters specific to a table's access method are not output
+        either (see <xref linkend="tableam-reloptions"/>).
        </para>
       </listitem>
      </varlistentry>
diff --git a/doc/src/sgml/ref/pg_restore.sgml b/doc/src/sgml/ref/pg_restore.sgml
index b6c5299c36e..48ea1c74373 100644
--- a/doc/src/sgml/ref/pg_restore.sgml
+++ b/doc/src/sgml/ref/pg_restore.sgml
@@ -789,7 +789,9 @@ PostgreSQL documentation
        <para>
         Do not output commands to select table access methods.
         With this option, all objects will be created with whichever
-        table access method is the default during restore.
+        table access method is the default during restore.  Storage
+        parameters specific to a table's access method are not output
+        either (see <xref linkend="tableam-reloptions"/>).
        </para>
       </listitem>
      </varlistentry>
diff --git a/doc/src/sgml/tableam.sgml b/doc/src/sgml/tableam.sgml
index 374cb1862b1..b33a6c7c8be 100644
--- a/doc/src/sgml/tableam.sgml
+++ b/doc/src/sgml/tableam.sgml
@@ -217,6 +217,17 @@ amoptions (Datum reloptions,
    same name.
   </para>
 
+  <para>
+   <application>pg_dump</application> puts the standard options of a table
+   in its <command>CREATE TABLE</command> command, and sets the ones specific
+   to its access method with a separate <command>ALTER TABLE</command>
+   command, which it leaves out if <option>--no-table-access-method</option>
+   is used.  So an access method's own options must have the same effect
+   when set by <command>ALTER TABLE</command> on an empty table as when given
+   to <command>CREATE TABLE</command>.  An option is treated as a standard
+   one if <function>pg_reloption_is_standard</function> returns true for it.
+  </para>
+
   <para>
    Autovacuum always reads a table's <literal>autovacuum_*</literal> options
    using the standard heap parser, regardless of its access method.  So an
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index 79a5b404ee1..b71d3ac462e 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -2455,6 +2455,23 @@ RelationHasStdRdOptions(Relation relation)
 }
 
 
+/*
+ * pg_reloption_is_standard
+ *		SQL-callable: is the named option one that heap accepts?
+ *
+ * A table AM that accepts such an option is expected to have inherited it
+ * with add_reloption_to_kind(), so that it has the same meaning as for heap.
+ * Any other option of a table must be specific to its AM.  pg_dump uses this
+ * to tell the two apart.
+ */
+Datum
+pg_reloption_is_standard(PG_FUNCTION_ARGS)
+{
+	char	   *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	PG_RETURN_BOOL(find_reloption(name, RELOPT_KIND_HEAP) != NULL);
+}
+
 /*
  * Parse options for indexes.
  *
diff --git a/src/bin/pg_dump/pg_backup_archiver.c b/src/bin/pg_dump/pg_backup_archiver.c
index abedc89b085..dcb1365f43a 100644
--- a/src/bin/pg_dump/pg_backup_archiver.c
+++ b/src/bin/pg_dump/pg_backup_archiver.c
@@ -3040,6 +3040,13 @@ _tocEntryRequired(TocEntry *te, teSection curSection, ArchiveHandle *AH)
 	if (ropt->no_comments && strcmp(te->desc, "COMMENT") == 0)
 		return 0;
 
+	/*
+	 * If the table access method isn't to be restored, ignore the options
+	 * specific to it.
+	 */
+	if (ropt->noTableAm && strcmp(te->desc, "TABLE AM OPTIONS") == 0)
+		return 0;
+
 	/* If it's a policy, maybe ignore it */
 	if (ropt->no_policies &&
 		(strcmp(te->desc, "POLICY") == 0 ||
@@ -3239,6 +3246,7 @@ _tocEntryRequired(TocEntry *te, teSection curSection, ArchiveHandle *AH)
 			}
 			else if (strcmp(te->desc, "TABLE") == 0 ||
 					 strcmp(te->desc, "TABLE DATA") == 0 ||
+					 strcmp(te->desc, "TABLE AM OPTIONS") == 0 ||
 					 strcmp(te->desc, "VIEW") == 0 ||
 					 strcmp(te->desc, "FOREIGN TABLE") == 0 ||
 					 strcmp(te->desc, "MATERIALIZED VIEW") == 0 ||
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 388c3b9c346..86aead729ca 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -7187,6 +7187,7 @@ getTables(Archive *fout, int *numTables)
 	int			i_reloptions;
 	int			i_checkoption;
 	int			i_toastreloptions;
+	int			i_amreloptions;
 	int			i_reloftype;
 	int			i_foreignserver;
 	int			i_amname;
@@ -7261,8 +7262,37 @@ getTables(Archive *fout, int *numTables)
 	appendPQExpBufferStr(query,
 						 "c.relminmxid, tc.relminmxid AS tminmxid, ");
 
+	/*
+	 * The options of a table or matview whose access method isn't heap are
+	 * split into standard ones, which go in the WITH clause, and ones
+	 * specific to the access method, which are set separately; see
+	 * dumpTableSchema().
+	 */
+	if (fout->remoteVersion >= 200000)
+		appendPQExpBufferStr(query,
+							 "CASE WHEN c.relkind IN (" CppAsString2(RELKIND_RELATION) ", "
+							 CppAsString2(RELKIND_MATVIEW) ") AND "
+							 "c.relam <> " CppAsString2(HEAP_TABLE_AM_OID) " THEN "
+							 "ARRAY(SELECT o FROM pg_catalog.unnest(c.reloptions) "
+							 "WITH ORDINALITY AS u(o, n) "
+							 "WHERE pg_catalog.pg_reloption_is_standard(pg_catalog.split_part(o, '=', 1)) "
+							 "ORDER BY n) "
+							 "ELSE array_remove(array_remove(c.reloptions,'check_option=local'),'check_option=cascaded') "
+							 "END AS reloptions, "
+							 "CASE WHEN c.relkind IN (" CppAsString2(RELKIND_RELATION) ", "
+							 CppAsString2(RELKIND_MATVIEW) ") AND "
+							 "c.relam <> " CppAsString2(HEAP_TABLE_AM_OID) " THEN "
+							 "ARRAY(SELECT o FROM pg_catalog.unnest(c.reloptions) "
+							 "WITH ORDINALITY AS u(o, n) "
+							 "WHERE NOT pg_catalog.pg_reloption_is_standard(pg_catalog.split_part(o, '=', 1)) "
+							 "ORDER BY n) "
+							 "END AS amreloptions, ");
+	else
+		appendPQExpBufferStr(query,
+							 "array_remove(array_remove(c.reloptions,'check_option=local'),'check_option=cascaded') AS reloptions, "
+							 "NULL AS amreloptions, ");
+
 	appendPQExpBufferStr(query,
-						 "array_remove(array_remove(c.reloptions,'check_option=local'),'check_option=cascaded') AS reloptions, "
 						 "CASE WHEN 'check_option=local' = ANY (c.reloptions) THEN 'LOCAL'::text "
 						 "WHEN 'check_option=cascaded' = ANY (c.reloptions) THEN 'CASCADED'::text ELSE NULL END AS checkoption, ");
 
@@ -7377,6 +7407,7 @@ getTables(Archive *fout, int *numTables)
 	i_reloptions = PQfnumber(res, "reloptions");
 	i_checkoption = PQfnumber(res, "checkoption");
 	i_toastreloptions = PQfnumber(res, "toast_reloptions");
+	i_amreloptions = PQfnumber(res, "amreloptions");
 	i_reloftype = PQfnumber(res, "reloftype");
 	i_foreignserver = PQfnumber(res, "foreignserver");
 	i_amname = PQfnumber(res, "amname");
@@ -7458,6 +7489,7 @@ getTables(Archive *fout, int *numTables)
 		else
 			tblinfo[i].checkoption = pg_strdup(PQgetvalue(res, i, i_checkoption));
 		tblinfo[i].toast_reloptions = pg_strdup(PQgetvalue(res, i, i_toastreloptions));
+		tblinfo[i].amreloptions = pg_strdup(PQgetvalue(res, i, i_amreloptions));
 		tblinfo[i].reloftype = atooid(PQgetvalue(res, i, i_reloftype));
 		tblinfo[i].foreign_server = atooid(PQgetvalue(res, i, i_foreignserver));
 		if (PQgetisnull(res, i, i_amname))
@@ -17927,6 +17959,36 @@ dumpTableSchema(Archive *fout, const TableInfo *tbinfo)
 								  SECTION_POST_DATA : SECTION_PRE_DATA,
 								  .createStmt = q->data,
 								  .dropStmt = delq->data));
+
+		/*
+		 * Options specific to the access method are set in a separate entry,
+		 * so that if the table is restored with a different access method (as
+		 * with --no-table-access-method) they can be left out, and if they're
+		 * not, a failure to set them doesn't stop the table being created.
+		 * The entry has no drop command, since dropping the table covers it,
+		 * and its owner is set so that it is run by the correct role.
+		 */
+		if (nonemptyReloptions(tbinfo->amreloptions))
+		{
+			resetPQExpBuffer(extra);
+			appendPQExpBuffer(extra, "ALTER %s %s SET (",
+							  tbinfo->relkind == RELKIND_MATVIEW ?
+							  "MATERIALIZED VIEW" : "TABLE ONLY",
+							  qualrelname);
+			appendReloptionsArrayAH(extra, tbinfo->amreloptions, "", fout);
+			appendPQExpBufferStr(extra, ");\n");
+
+			ArchiveEntry(fout, nilCatalogId, createDumpId(),
+						 ARCHIVE_OPTS(.tag = tbinfo->dobj.name,
+									  .namespace = tbinfo->dobj.namespace->dobj.name,
+									  .owner = tbinfo->rolname,
+									  .description = "TABLE AM OPTIONS",
+									  .section = tbinfo->postponed_def ?
+									  SECTION_POST_DATA : SECTION_PRE_DATA,
+									  .createStmt = extra->data,
+									  .deps = &(tbinfo->dobj.dumpId),
+									  .nDeps = 1));
+		}
 	}
 
 	/* Dump Table Comments */
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 2bbb5d5773b..1154d2dab69 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -315,6 +315,7 @@ typedef struct _tableInfo
 	char	   *reloptions;		/* options specified by WITH (...) */
 	char	   *checkoption;	/* WITH CHECK OPTION, if any */
 	char	   *toast_reloptions;	/* WITH options for the TOAST table */
+	char	   *amreloptions;	/* options specific to the access method */
 	bool		hasindex;		/* does it have any indexes? */
 	bool		hasrules;		/* does it have any rules? */
 	bool		hastriggers;	/* does it have any triggers? */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index f46427258e3..d9dde8e9ed7 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -4069,6 +4069,11 @@
   proargnames => '{options_array,option_name,option_value}',
   prosrc => 'pg_options_to_table' },
 
+{ oid => '8904', descr => 'whether a storage parameter is a standard one',
+  proname => 'pg_reloption_is_standard', provolatile => 's',
+  prorettype => 'bool', proargtypes => 'text',
+  prosrc => 'pg_reloption_is_standard' },
+
 { oid => '1619', descr => 'type of the argument',
   proname => 'pg_typeof', proisstrict => 'f', provolatile => 's',
   prorettype => 'regtype', proargtypes => 'any', prosrc => 'pg_typeof' },
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
index cd9f63f2897..45af951486c 100644
--- a/src/test/modules/dummy_table_am/Makefile
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -7,6 +7,7 @@ DATA = dummy_table_am--1.0.sql
 PGFILEDESC = "dummy_table_am - table access method template"
 
 REGRESS = reloptions custom_layout
+TAP_TESTS = 1
 
 ifdef USE_PGXS
 PG_CONFIG = pg_config
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
index 7b9853b995b..46e8e340eab 100644
--- a/src/test/modules/dummy_table_am/expected/reloptions.out
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -17,6 +17,14 @@ ERROR:  value 9999 out of bounds for option "option_int"
 DETAIL:  Valid values are between "-10" and "100".
 CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (no_such_option = 4);
 ERROR:  unrecognized parameter "no_such_option"
+-- Inherited options are standard ones, the AM's own are not
+SELECT pg_reloption_is_standard('fillfactor'),
+       pg_reloption_is_standard('option_int');
+ pg_reloption_is_standard | pg_reloption_is_standard 
+--------------------------+--------------------------
+ t                        | f
+(1 row)
+
 -- Defaults are not stored in pg_class
 CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
 SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
index 35ffde4d151..c15e54a6c62 100644
--- a/src/test/modules/dummy_table_am/meson.build
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -31,4 +31,9 @@ tests += {
       'custom_layout',
     ],
   },
+  'tap': {
+    'tests': [
+      't/001_pg_dump.pl',
+    ],
+  },
 }
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
index 3b714939d74..3a823622c06 100644
--- a/src/test/modules/dummy_table_am/sql/reloptions.sql
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -12,6 +12,10 @@ SELECT reloptions FROM pg_class
 CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
 CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (no_such_option = 4);
 
+-- Inherited options are standard ones, the AM's own are not
+SELECT pg_reloption_is_standard('fillfactor'),
+       pg_reloption_is_standard('option_int');
+
 -- Defaults are not stored in pg_class
 CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
 SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
diff --git a/src/test/modules/dummy_table_am/t/001_pg_dump.pl b/src/test/modules/dummy_table_am/t/001_pg_dump.pl
new file mode 100644
index 00000000000..a263cfcfc09
--- /dev/null
+++ b/src/test/modules/dummy_table_am/t/001_pg_dump.pl
@@ -0,0 +1,158 @@
+
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test dumping and restoring tables whose access method has options of its
+# own, with and without --no-table-access-method.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $tempdir = PostgreSQL::Test::Utils::tempdir;
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE DATABASE src');
+$node->safe_psql(
+	'src', q{
+	CREATE EXTENSION dummy_table_am;
+	CREATE TABLE t_mixed (a int, b text) USING dummy_table_am
+		WITH (fillfactor = 70, option_int = 7, option_bool = false);
+	INSERT INTO t_mixed SELECT i, 'row ' || i FROM generate_series(1, 10) i;
+	CREATE TABLE t_custom (a int) USING dummy_custom_table_am
+		WITH (option_a = 5);
+	INSERT INTO t_custom VALUES (1), (2);
+	CREATE TABLE t_heap (a int) WITH (fillfactor = 60);
+	CREATE MATERIALIZED VIEW mv USING dummy_table_am
+		WITH (fillfactor = 80, option_int = 3)
+		AS SELECT a FROM t_mixed;
+});
+
+# Show the access method and options of each relation, and the row counts
+my $describe = q{
+	SELECT c.relname, a.amname, c.reloptions
+		FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+		WHERE c.relname IN ('t_mixed', 't_custom', 't_heap', 'mv')
+		ORDER BY c.relname;
+	SELECT count(*) FROM t_mixed;
+	SELECT count(*) FROM t_custom;
+	SELECT count(*) FROM mv;
+};
+my $src_state = $node->safe_psql('src', $describe);
+
+# The default AM is heap, so --no-table-access-method recreates every
+# relation as heap, with only the standard options.
+my $noam_state = q{mv|heap|{fillfactor=80}
+t_custom|heap|
+t_heap|heap|{fillfactor=60}
+t_mixed|heap|{fillfactor=70}
+10
+2
+10};
+
+my $plain = "$tempdir/plain.sql";
+my $plain_noam = "$tempdir/plain_noam.sql";
+my $archive = "$tempdir/archive.dump";
+
+$node->command_ok([ 'pg_dump', '--file' => $plain, 'src' ],
+	'plain dump');
+$node->command_ok(
+	[
+		'pg_dump', '--no-table-access-method',
+		'--file' => $plain_noam, 'src'
+	],
+	'plain dump with --no-table-access-method');
+$node->command_ok(
+	[ 'pg_dump', '--format' => 'custom', '--file' => $archive, 'src' ],
+	'custom-format dump');
+
+# Standard options stay in the WITH clause, the others are set separately
+my $dump = slurp_file($plain);
+like(
+	$dump,
+	qr/CREATE TABLE public\.t_mixed \(.*?\)\nWITH \(fillfactor='70'\);/s,
+	'standard options of a non-heap table are in CREATE TABLE');
+like(
+	$dump,
+	qr/^ALTER TABLE ONLY public\.t_mixed SET \(option_int='7', option_bool='false'\);$/m,
+	'AM-specific options of a table are set by ALTER TABLE');
+like(
+	$dump,
+	qr/^ALTER MATERIALIZED VIEW public\.mv SET \(option_int='3'\);$/m,
+	'AM-specific options of a matview are set by ALTER MATERIALIZED VIEW');
+like(
+	$dump,
+	qr/^ALTER TABLE ONLY public\.t_custom SET \(option_a='5'\);$/m,
+	'table with only AM-specific options');
+like(
+	$dump,
+	qr/CREATE TABLE public\.t_heap \(.*?\)\nWITH \(fillfactor='60'\);/s,
+	'heap table options are in CREATE TABLE');
+unlike($dump, qr/ALTER TABLE ONLY public\.t_heap SET/,
+	'heap table options are not set separately');
+
+unlike(slurp_file($plain_noam), qr/TABLE AM OPTIONS|option_int|option_a/,
+	'--no-table-access-method leaves out AM-specific options');
+
+my @psql = ('psql', '--no-psqlrc', '--set' => 'ON_ERROR_STOP=1');
+
+$node->safe_psql('postgres', 'CREATE DATABASE dst_plain');
+$node->command_ok([ @psql, '--file' => $plain, '--dbname' => 'dst_plain' ],
+	'restore plain dump');
+is($node->safe_psql('dst_plain', $describe),
+	$src_state, 'plain dump restores the same options');
+
+$node->safe_psql('postgres', 'CREATE DATABASE dst_plain_noam');
+$node->command_ok(
+	[ @psql, '--file' => $plain_noam, '--dbname' => 'dst_plain_noam' ],
+	'restore plain dump made with --no-table-access-method');
+is($node->safe_psql('dst_plain_noam', $describe),
+	$noam_state,
+	'plain dump made with --no-table-access-method restores tables and data'
+);
+
+$node->safe_psql('postgres', 'CREATE DATABASE dst_archive');
+$node->command_ok(
+	[
+		'pg_restore', '--exit-on-error',
+		'--dbname' => 'dst_archive', $archive
+	],
+	'restore custom-format dump');
+is($node->safe_psql('dst_archive', $describe),
+	$src_state, 'custom-format dump restores the same options');
+
+$node->safe_psql('postgres', 'CREATE DATABASE dst_archive_noam');
+$node->command_ok(
+	[
+		'pg_restore', '--exit-on-error', '--no-table-access-method',
+		'--dbname' => 'dst_archive_noam', $archive
+	],
+	'pg_restore --no-table-access-method');
+is($node->safe_psql('dst_archive_noam', $describe),
+	$noam_state,
+	'pg_restore --no-table-access-method restores tables and data');
+
+# Selecting a table also selects its AM-specific options
+$node->safe_psql('postgres', 'CREATE DATABASE dst_table');
+$node->safe_psql('dst_table', 'CREATE EXTENSION dummy_table_am');
+$node->command_ok(
+	[
+		'pg_restore', '--exit-on-error',
+		'--table' => 't_mixed',
+		'--dbname' => 'dst_table', $archive
+	],
+	'pg_restore --table');
+is( $node->safe_psql(
+		'dst_table', q{SELECT reloptions FROM pg_class WHERE relname = 't_mixed'}
+	),
+	'{fillfactor=70,option_int=7,option_bool=false}',
+	'pg_restore --table restores AM-specific options');
+
+$node->stop;
+
+done_testing();
diff --git a/src/test/regress/expected/reloptions.out b/src/test/regress/expected/reloptions.out
index 95e4528f779..f66f974d336 100644
--- a/src/test/regress/expected/reloptions.out
+++ b/src/test/regress/expected/reloptions.out
@@ -288,3 +288,16 @@ SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test_idx3'::regclass;
  {fillfactor=40}
 (1 row)
 
+-- Which options are standard ones
+SELECT name, pg_reloption_is_standard(name)
+  FROM unnest(ARRAY['fillfactor', 'autovacuum_enabled', 'toast_tuple_target',
+                    'fastupdate', 'no_such_option']) AS name;
+        name        | pg_reloption_is_standard 
+--------------------+--------------------------
+ fillfactor         | t
+ autovacuum_enabled | t
+ toast_tuple_target | t
+ fastupdate         | f
+ no_such_option     | f
+(5 rows)
+
diff --git a/src/test/regress/sql/reloptions.sql b/src/test/regress/sql/reloptions.sql
index 80939ee8b5b..0d1f7d88fde 100644
--- a/src/test/regress/sql/reloptions.sql
+++ b/src/test/regress/sql/reloptions.sql
@@ -168,3 +168,8 @@ SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test_idx'::regclass;
 CREATE INDEX reloptions_test_idx3 ON reloptions_test (s);
 ALTER INDEX reloptions_test_idx3 SET (fillfactor=40);
 SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test_idx3'::regclass;
+
+-- Which options are standard ones
+SELECT name, pg_reloption_is_standard(name)
+  FROM unnest(ARRAY['fillfactor', 'autovacuum_enabled', 'toast_tuple_target',
+                    'fastupdate', 'no_such_option']) AS name;
-- 
2.43.0

Reply via email to