diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml
index 4b474c13917..cc43ac8599e 100644
--- a/doc/src/sgml/catalogs.sgml
+++ b/doc/src/sgml/catalogs.sgml
@@ -4654,6 +4654,22 @@ SCRAM-SHA-256$<replaceable>&lt;iteration count&gt;</replaceable>:<replaceable>&l
        partial index.
       </para></entry>
      </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>indisvisible</structfield> <type>bool</type>
+      </para>
+      <para>
+       If true, the index is visible to the planner and may be used to
+       answer queries.  If false, the index is invisible to the planner
+       but is still maintained when the table is modified.  Unique
+       indexes continue to enforce uniqueness, and any uniqueness or
+       exclusion constraint backed by the index continues to be
+       enforced.  The default is true.  Use
+       <command>ALTER INDEX ... INVISIBLE</command> /
+       <command>ALTER INDEX ... VISIBLE</command> to change this state.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/doc/src/sgml/indices.sgml b/doc/src/sgml/indices.sgml
index 55f39b0df2f..1e6f4cbbcf6 100644
--- a/doc/src/sgml/indices.sgml
+++ b/doc/src/sgml/indices.sgml
@@ -1673,6 +1673,37 @@ CREATE INDEX test1c_content_y_index ON test1c (content COLLATE "y");
      <productname>PostgreSQL</productname> developers to examine the issue.
     </para>
    </listitem>
+
+   <listitem>
+    <para>
+     An index can be hidden from the query planner without dropping it.
+     Mark an existing index invisible with
+     <command>ALTER INDEX ... INVISIBLE</command>, or create a new index
+     in the invisible state with
+     <command>CREATE INDEX ... INVISIBLE</command>.  An invisible index is
+     ignored by the planner but is still maintained by DML.  Unique
+     indexes continue to enforce uniqueness, and any uniqueness or
+     exclusion constraint backed by the index continues to be enforced.
+     Use <command>ALTER INDEX ... VISIBLE</command> to make the index
+     available to the planner again.  No rebuild is needed.
+    </para>
+    <para>
+     On partitioned tables, altering the visibility of a partitioned
+     parent index applies to the whole attached index hierarchy: the
+     parent partitioned index and every attached descendant child index
+     — at every partition level — are flipped together.  Altering the
+     visibility of an index that lives on a single partition affects
+     only that index.  Visibility is a global catalog property; there
+     is no GUC or session-level override.
+    </para>
+    <para>
+     This is useful for evaluating whether an index is needed before
+     dropping it: if performance regresses while the index is invisible,
+     it can be reinstated immediately.  Consult
+     <structname>pg_stat_user_indexes</structname>.<structfield>idx_scan</structfield>
+     for additional clues about index usage.
+    </para>
+   </listitem>
   </itemizedlist>
  </sect1>
 </chapter>
diff --git a/doc/src/sgml/ref/alter_index.sgml b/doc/src/sgml/ref/alter_index.sgml
index fb7096c16ea..39f60fe7714 100644
--- a/doc/src/sgml/ref/alter_index.sgml
+++ b/doc/src/sgml/ref/alter_index.sgml
@@ -31,6 +31,7 @@ ALTER INDEX [ IF EXISTS ] <replaceable class="parameter">name</replaceable> ALTE
     SET STATISTICS <replaceable class="parameter">integer</replaceable>
 ALTER INDEX ALL IN TABLESPACE <replaceable class="parameter">name</replaceable> [ OWNED BY <replaceable class="parameter">role_name</replaceable> [, ... ] ]
     SET TABLESPACE <replaceable class="parameter">new_tablespace</replaceable> [ NOWAIT ]
+ALTER INDEX [ IF EXISTS ] <replaceable class="parameter">name</replaceable> { VISIBLE | INVISIBLE }
 </synopsis>
  </refsynopsisdiv>
 
@@ -164,6 +165,64 @@ ALTER INDEX ALL IN TABLESPACE <replaceable class="parameter">name</replaceable>
     </listitem>
    </varlistentry>
 
+   <varlistentry>
+    <term><literal>VISIBLE</literal></term>
+    <listitem>
+     <para>
+      Mark the index as visible to the query planner.  This is the default
+      state for newly created indexes.  Existing cached plans depending on
+      the underlying table are invalidated so they can be replanned and
+      consider this index again.
+     </para>
+     <para>
+      When applied to a partitioned index, the change recurses through
+      the partitioned-index hierarchy: the partitioned parent and every
+      attached descendant child index — at every partition level — are
+      made visible together.  Applying the form to a leaf partition
+      index affects only that one index and leaves the rest of the
+      hierarchy unchanged.
+     </para>
+     <para>
+      This form acquires a <literal>SHARE UPDATE EXCLUSIVE</literal> lock.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><literal>INVISIBLE</literal></term>
+    <listitem>
+     <para>
+      Mark the index as invisible to the query planner.  The index will not
+      be considered for query planning, but it is still maintained when the
+      table is modified.  Unique indexes continue to enforce uniqueness,
+      and any uniqueness or exclusion constraint backed by the index
+      continues to be enforced.  Existing cached plans depending on the
+      underlying table are invalidated so future executions stop using
+      this index.  An invisible index can be made visible again with
+      <literal>VISIBLE</literal>; no rebuild is needed.
+     </para>
+     <para>
+      When applied to a partitioned index, the change recurses through
+      the partitioned-index hierarchy: the partitioned parent and every
+      attached descendant child index — at every partition level — are
+      hidden from the planner together.  Applying the form to a leaf
+      partition index affects only that one index, so a single
+      partition's index can be hidden without disturbing other
+      partitions.  There is no GUC or session-level escape hatch:
+      visibility is a global catalog property.
+     </para>
+     <para>
+      This is useful for testing whether an index is needed before
+      dropping it: an invisible index that proves to be required can be
+      restored to service immediately, without paying the cost of
+      rebuilding it.
+     </para>
+     <para>
+      This form acquires a <literal>SHARE UPDATE EXCLUSIVE</literal> lock.
+     </para>
+    </listitem>
+   </varlistentry>
+
   </variablelist>
   </para>
 
@@ -304,6 +363,13 @@ REINDEX INDEX distributors;
 <programlisting>
 CREATE INDEX coord_idx ON measured (x, y, (z + t));
 ALTER INDEX coord_idx ALTER COLUMN 3 SET STATISTICS 1000;
+</programlisting></para>
+
+  <para>
+   To make an index invisible to the planner, then reinstate it:
+<programlisting>
+ALTER INDEX coord_idx INVISIBLE;
+ALTER INDEX coord_idx VISIBLE;
 </programlisting></para>
 
  </refsect1>
diff --git a/doc/src/sgml/ref/create_index.sgml b/doc/src/sgml/ref/create_index.sgml
index bb7505d171b..de8c3029918 100644
--- a/doc/src/sgml/ref/create_index.sgml
+++ b/doc/src/sgml/ref/create_index.sgml
@@ -28,6 +28,7 @@ CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] <replaceable class=
     [ WITH ( <replaceable class="parameter">storage_parameter</replaceable> [= <replaceable class="parameter">value</replaceable>] [, ... ] ) ]
     [ TABLESPACE <replaceable class="parameter">tablespace_name</replaceable> ]
     [ WHERE <replaceable class="parameter">predicate</replaceable> ]
+    [ VISIBLE | INVISIBLE ]
 </synopsis>
  </refsynopsisdiv>
 
@@ -380,6 +381,29 @@ CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] <replaceable class=
       </listitem>
      </varlistentry>
 
+     <varlistentry>
+      <term><literal>INVISIBLE</literal></term>
+      <listitem>
+       <para>
+        Create the index in the invisible state.  An invisible index is
+        not used by the query planner, but it is still maintained when
+        the underlying table is modified.  Unique indexes continue to
+        enforce uniqueness, and any uniqueness or exclusion constraint
+        backed by the index is enforced.  This is useful for preparing
+        an index without immediately disturbing query plans, or for
+        evaluating whether dropping an existing index is safe.  The
+        index can be made visible later with
+        <command>ALTER INDEX ... VISIBLE</command> without rebuilding it.
+       </para>
+       <para>
+        On a partitioned table, the visibility specified here is applied
+        to the partitioned parent index and propagated to every child
+        index that <command>CREATE INDEX</command> creates on the
+        partitions, so the entire hierarchy starts out in the same state.
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
 
   <refsect2 id="sql-createindex-storage-parameters" xreflabel="Index Storage Parameters">
diff --git a/src/backend/bootstrap/bootparse.y b/src/backend/bootstrap/bootparse.y
index 943ff4733d3..56af421306c 100644
--- a/src/backend/bootstrap/bootparse.y
+++ b/src/backend/bootstrap/bootparse.y
@@ -303,6 +303,7 @@ Boot_DeclareIndexStmt:
 					stmt->concurrent = false;
 					stmt->if_not_exists = false;
 					stmt->reset_default_tblspc = false;
+					stmt->isvisible = true;
 
 					/* locks and races need not concern us in bootstrap mode */
 					relationId = RangeVarGetRelid(stmt->relation, NoLock,
@@ -357,6 +358,7 @@ Boot_DeclareUniqueIndexStmt:
 					stmt->concurrent = false;
 					stmt->if_not_exists = false;
 					stmt->reset_default_tblspc = false;
+					stmt->isvisible = true;
 
 					/* locks and races need not concern us in bootstrap mode */
 					relationId = RangeVarGetRelid(stmt->relation, NoLock,
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 9407c357f27..083ee093430 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -120,7 +120,8 @@ static void UpdateIndexRelation(Oid indexoid, Oid heapoid,
 								bool isexclusion,
 								bool immediate,
 								bool isvalid,
-								bool isready);
+								bool isready,
+								bool isvisible);
 static void index_update_stats(Relation rel,
 							   bool hasindex,
 							   double reltuples);
@@ -572,7 +573,8 @@ UpdateIndexRelation(Oid indexoid,
 					bool isexclusion,
 					bool immediate,
 					bool isvalid,
-					bool isready)
+					bool isready,
+					bool isvisible)
 {
 	int2vector *indkey;
 	oidvector  *indcollation;
@@ -650,6 +652,7 @@ UpdateIndexRelation(Oid indexoid,
 	values[Anum_pg_index_indisready - 1] = BoolGetDatum(isready);
 	values[Anum_pg_index_indislive - 1] = BoolGetDatum(true);
 	values[Anum_pg_index_indisreplident - 1] = BoolGetDatum(false);
+	values[Anum_pg_index_indisvisible - 1] = BoolGetDatum(isvisible);
 	values[Anum_pg_index_indkey - 1] = PointerGetDatum(indkey);
 	values[Anum_pg_index_indcollation - 1] = PointerGetDatum(indcollation);
 	values[Anum_pg_index_indclass - 1] = PointerGetDatum(indclass);
@@ -764,6 +767,7 @@ index_create(Relation heapRelation,
 	bool		concurrent = (flags & INDEX_CREATE_CONCURRENT) != 0;
 	bool		partitioned = (flags & INDEX_CREATE_PARTITIONED) != 0;
 	bool		progress = (flags & INDEX_CREATE_SUPPRESS_PROGRESS) == 0;
+	bool		isvisible = (flags & INDEX_CREATE_INVISIBLE) == 0;
 	char		relkind;
 	TransactionId relfrozenxid;
 	MultiXactId relminmxid;
@@ -1053,7 +1057,8 @@ index_create(Relation heapRelation,
 						isprimary, is_exclusion,
 						(constr_flags & INDEX_CONSTR_CREATE_DEFERRABLE) == 0,
 						!concurrent && !invalid,
-						!concurrent);
+						!concurrent,
+						isvisible);
 
 	/*
 	 * Register relcache invalidation on the indexes' heap relation, to
@@ -1459,7 +1464,14 @@ index_create_copy(Relation heapRelation, uint16 flags,
 	 * For a partition index, we adjust the partition dependency later, to
 	 * ensure a consistent state at all times.  That is why parentIndexRelid
 	 * is not set here.
+	 *
+	 * Preserve invisibility from the source index: if the source index is
+	 * invisible to the planner, the rebuilt copy must be too.  Visible is
+	 * the default and needs no flag.
 	 */
+	if (!indexRelation->rd_index->indisvisible)
+		flags |= INDEX_CREATE_INVISIBLE;
+
 	newIndexId = index_create(heapRelation,
 							  newName,
 							  InvalidOid,	/* indexRelationId */
diff --git a/src/backend/commands/indexcmds.c b/src/backend/commands/indexcmds.c
index 9ab74c8df0a..a2aaabea637 100644
--- a/src/backend/commands/indexcmds.c
+++ b/src/backend/commands/indexcmds.c
@@ -1231,6 +1231,8 @@ DefineIndex(ParseState *pstate,
 		flags |= INDEX_CREATE_PARTITIONED;
 	if (stmt->primary)
 		flags |= INDEX_CREATE_IS_PRIMARY;
+	if (!stmt->isvisible)
+		flags |= INDEX_CREATE_INVISIBLE;
 
 	/*
 	 * If the table is partitioned, and recursion was declined but partitions
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 38f9ffcd04f..8553e94cf5d 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -777,6 +777,7 @@ static List *GetParentedForeignKeyRefs(Relation partition);
 static void ATDetachCheckNoForeignKeyRefs(Relation partition);
 static char GetAttributeCompression(Oid atttypid, const char *compression);
 static char GetAttributeStorage(Oid atttypid, const char *storagemode);
+static void ATExecSetIndexVisibility(Relation rel, bool visible);
 
 static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
 								  PartitionCmd *cmd, AlterTableUtilityContext *context);
@@ -4906,6 +4907,11 @@ AlterTableGetLockLevel(List *cmds)
 				cmd_lockmode = AccessExclusiveLock;
 				break;
 
+			case AT_SetIndexVisible:
+			case AT_SetIndexInvisible:
+				cmd_lockmode = ShareUpdateExclusiveLock;
+				break;
+
 			default:			/* oops */
 				elog(ERROR, "unrecognized alter table type: %d",
 					 (int) cmd->subtype);
@@ -5347,6 +5353,21 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
 			/* No command-specific prep needed */
 			pass = AT_PASS_MISC;
 			break;
+		case AT_SetIndexVisible:
+		case AT_SetIndexInvisible:
+			ATSimplePermissions(cmd->subtype, rel,
+								ATT_INDEX | ATT_PARTITIONED_INDEX);
+
+			/*
+			 * A partitioned index represents an index hierarchy.  Apply
+			 * visibility changes recursively so ALTER INDEX on the parent
+			 * affects all attached descendant indexes.  For a leaf index,
+			 * this is a no-op.
+			 */
+			ATSimpleRecursion(wqueue, rel, cmd, true, lockmode, context);
+			/* No command-specific prep needed */
+			pass = AT_PASS_MISC;
+			break;
 		default:				/* oops */
 			elog(ERROR, "unrecognized alter table type: %d",
 				 (int) cmd->subtype);
@@ -5759,6 +5780,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 			ATExecSplitPartition(wqueue, tab, rel, (PartitionCmd *) cmd->def,
 								 context);
 			break;
+		case AT_SetIndexVisible:
+			ATExecSetIndexVisibility(rel, true);
+			break;
+		case AT_SetIndexInvisible:
+			ATExecSetIndexVisibility(rel, false);
+			break;
 		default:				/* oops */
 			elog(ERROR, "unrecognized alter table type: %d",
 				 (int) cmd->subtype);
@@ -6711,6 +6738,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
 			return "DROP COLUMN";
 		case AT_AddIndex:
 		case AT_ReAddIndex:
+		case AT_SetIndexVisible:
+		case AT_SetIndexInvisible:
 			return NULL;		/* not real grammar */
 		case AT_AddConstraint:
 		case AT_ReAddConstraint:
@@ -15958,6 +15987,8 @@ ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId, char *cmd,
 			stmt->reset_default_tblspc = true;
 			/* keep the index's comment */
 			stmt->idxcomment = GetComment(oldId, RelationRelationId, 0);
+			/* preserve the index's visibility */
+			stmt->isvisible = get_index_visibility(oldId);
 
 			newcmd = makeNode(AlterTableCmd);
 			newcmd->subtype = AT_ReAddIndex;
@@ -15988,6 +16019,8 @@ ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId, char *cmd,
 					indstmt->idxcomment = GetComment(indoid,
 													 RelationRelationId, 0);
 					indstmt->reset_default_tblspc = true;
+					/* preserve the index's visibility */
+					indstmt->isvisible = get_index_visibility(indoid);
 
 					cmd->subtype = AT_ReAddIndex;
 					tab->subcmds[AT_PASS_OLD_INDEX] =
@@ -23873,3 +23906,65 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
 	/* Restore the userid and security context. */
 	SetUserIdAndSecContext(save_userid, save_sec_context);
 }
+
+/*
+ * ATExecSetIndexVisibility
+ *
+ * Toggle pg_index.indisvisible for one index.  When the value actually
+ * changes, invalidate the heap relation's relcache so that cached plans
+ * depending on it are replanned.  We don't open the heap; an OID-based
+ * relcache invalidation is sufficient and avoids taking a heap lock just
+ * to publish the invalidation message.
+ *
+ * Re-issuing the same value (e.g. ALTER INDEX idx INVISIBLE on an already
+ * invisible index) is a no-op: no catalog write and no invalidation.
+ *
+ * If pg_index.indcheckxmin is set, this command does not clear it and does
+ * not refuse the change.  The indcheckxmin safety check performed at
+ * planning time compares the pg_index tuple's xmin against TransactionXmin
+ * and continues to skip the index for transactions that might see broken
+ * HOT chains, regardless of indisvisible.  Restoring catalog visibility
+ * therefore cannot allow such a transaction to start trusting the index;
+ * the indcheckxmin check still applies after VISIBLE is set.
+ */
+static void
+ATExecSetIndexVisibility(Relation rel, bool visible)
+{
+	Oid			indexOid = RelationGetRelid(rel);
+	Oid			heapOid;
+	Relation	pg_index;
+	HeapTuple	indexTuple;
+	Form_pg_index indexForm;
+
+	heapOid = IndexGetRelation(indexOid, false);
+
+	pg_index = table_open(IndexRelationId, RowExclusiveLock);
+
+	indexTuple = SearchSysCacheCopy1(INDEXRELID, ObjectIdGetDatum(indexOid));
+	if (!HeapTupleIsValid(indexTuple))
+		elog(ERROR, "cache lookup failed for index %u", indexOid);
+	indexForm = (Form_pg_index) GETSTRUCT(indexTuple);
+
+	if (indexForm->indisvisible != visible)
+	{
+		indexForm->indisvisible = visible;
+		CatalogTupleUpdate(pg_index, &indexTuple->t_self, indexTuple);
+
+		/*
+		 * Changing index visibility changes the set of indexes the planner
+		 * may consider for the heap relation.  Invalidate the heap relation
+		 * relcache so cached plans depending on it are replanned.
+		 *
+		 * If indcheckxmin is set on this index, the indcheckxmin safety
+		 * check performed at planning time still applies after the index
+		 * becomes visible.
+		 */
+		CacheInvalidateRelcacheByRelid(heapOid);
+
+		InvokeObjectPostAlterHook(IndexRelationId, indexOid, 0);
+		CommandCounterIncrement();
+	}
+
+	heap_freetuple(indexTuple);
+	table_close(pg_index, RowExclusiveLock);
+}
diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c
index 7c4be174869..bee23a3e9b4 100644
--- a/src/backend/optimizer/util/plancat.c
+++ b/src/backend/optimizer/util/plancat.c
@@ -242,12 +242,14 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent,
 
 			/*
 			 * Ignore invalid indexes, since they can't safely be used for
-			 * queries.  Note that this is OK because the data structure we
+			 * queries.  Also ignore invisible indexes, which the DBA has
+			 * marked unavailable to the planner with ALTER INDEX ...
+			 * INVISIBLE.  Note that this is OK because the data structure we
 			 * are constructing is only used by the planner --- the executor
-			 * still needs to insert into "invalid" indexes, if they're marked
-			 * indisready.
+			 * still needs to insert into "invalid" or invisible indexes, if
+			 * they're marked indisready.
 			 */
-			if (!index->indisvalid)
+			if (!index->indisvalid || !index->indisvisible)
 			{
 				index_close(indexRelation, NoLock);
 				continue;
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..ae9c1e94289 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -338,7 +338,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 %type <ival>	add_drop opt_asc_desc opt_nulls_order
 
 %type <node>	alter_table_cmd alter_type_cmd opt_collate_clause
-	   replica_identity partition_cmd index_partition_cmd
+	   replica_identity partition_cmd index_partition_cmd index_alter_cmd
 %type <list>	alter_table_cmds alter_type_cmds
 %type <list>    alter_identity_column_option_list
 %type <defelt>  alter_identity_column_option
@@ -501,6 +501,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 %type <boolean> opt_instead
 %type <boolean> opt_unique opt_verbose opt_full
 %type <boolean> opt_freeze opt_analyze opt_default
+%type <boolean> opt_index_visibility
 %type <defelt>	opt_binary copy_delimiter
 
 %type <boolean> copy_from opt_program
@@ -778,7 +779,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	IDENTITY_P IF_P IGNORE_P ILIKE IMMEDIATE IMMUTABLE IMPLICIT_P IMPORT_P IN_P INCLUDE
 	INCLUDING INCREMENT INDENT INDEX INDEXES INHERIT INHERITS INITIALLY INLINE_P
 	INNER_P INOUT INPUT_P INSENSITIVE INSERT INSTEAD INT_P INTEGER
-	INTERSECT INTERVAL INTO INVOKER IS ISNULL ISOLATION
+	INTERSECT INTERVAL INTO INVISIBLE INVOKER IS ISNULL ISOLATION
 
 	JOIN JSON JSON_ARRAY JSON_ARRAYAGG JSON_EXISTS JSON_OBJECT JSON_OBJECTAGG
 	JSON_QUERY JSON_SCALAR JSON_SERIALIZE JSON_TABLE JSON_VALUE
@@ -829,7 +830,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	UNLISTEN UNLOGGED UNTIL UPDATE USER USING
 
 	VACUUM VALID VALIDATE VALIDATOR VALUE_P VALUES VARCHAR VARIADIC VARYING
-	VERBOSE VERSION_P VERTEX VIEW VIEWS VIRTUAL VOLATILE
+	VERBOSE VERSION_P VERTEX VIEW VIEWS VIRTUAL VISIBLE VOLATILE
 
 	WAIT WHEN WHERE WHITESPACE_P WINDOW WITH WITHIN WITHOUT WORK WRAPPER WRITE
 
@@ -2272,6 +2273,26 @@ AlterTableStmt:
 					n->nowait = $13;
 					$$ = (Node *) n;
 				}
+		|	ALTER INDEX qualified_name index_alter_cmd
+				{
+					AlterTableStmt *n = makeNode(AlterTableStmt);
+
+					n->relation = $3;
+					n->cmds = list_make1($4);
+					n->objtype = OBJECT_INDEX;
+					n->missing_ok = false;
+					$$ = (Node *) n;
+				}
+		|	ALTER INDEX IF_P EXISTS qualified_name index_alter_cmd
+				{
+					AlterTableStmt *n = makeNode(AlterTableStmt);
+
+					n->relation = $5;
+					n->cmds = list_make1($6);
+					n->objtype = OBJECT_INDEX;
+					n->missing_ok = true;
+					$$ = (Node *) n;
+				}
 		|	ALTER INDEX qualified_name alter_table_cmds
 				{
 					AlterTableStmt *n = makeNode(AlterTableStmt);
@@ -2546,6 +2567,24 @@ index_partition_cmd:
 				}
 		;
 
+index_alter_cmd:
+			/* ALTER INDEX <name> VISIBLE | INVISIBLE */
+			VISIBLE
+				{
+					AlterTableCmd *n = makeNode(AlterTableCmd);
+
+					n->subtype = AT_SetIndexVisible;
+					$$ = (Node *) n;
+				}
+			| INVISIBLE
+				{
+					AlterTableCmd *n = makeNode(AlterTableCmd);
+
+					n->subtype = AT_SetIndexInvisible;
+					$$ = (Node *) n;
+				}
+		;
+
 alter_table_cmd:
 			/* ALTER TABLE <name> ADD <coldef> */
 			ADD_P columnDef
@@ -8432,7 +8471,7 @@ defacl_privilege_target:
 
 IndexStmt:	CREATE opt_unique INDEX opt_concurrently opt_single_name
 			ON relation_expr access_method_clause '(' index_params ')'
-			opt_include opt_unique_null_treatment opt_reloptions OptTableSpace where_clause
+			opt_include opt_unique_null_treatment opt_reloptions OptTableSpace where_clause opt_index_visibility
 				{
 					IndexStmt *n = makeNode(IndexStmt);
 
@@ -8447,6 +8486,7 @@ IndexStmt:	CREATE opt_unique INDEX opt_concurrently opt_single_name
 					n->options = $14;
 					n->tableSpace = $15;
 					n->whereClause = $16;
+					n->isvisible = $17;
 					n->excludeOpNames = NIL;
 					n->idxcomment = NULL;
 					n->indexOid = InvalidOid;
@@ -8464,7 +8504,7 @@ IndexStmt:	CREATE opt_unique INDEX opt_concurrently opt_single_name
 				}
 			| CREATE opt_unique INDEX opt_concurrently IF_P NOT EXISTS name
 			ON relation_expr access_method_clause '(' index_params ')'
-			opt_include opt_unique_null_treatment opt_reloptions OptTableSpace where_clause
+			opt_include opt_unique_null_treatment opt_reloptions OptTableSpace where_clause opt_index_visibility
 				{
 					IndexStmt *n = makeNode(IndexStmt);
 
@@ -8479,6 +8519,7 @@ IndexStmt:	CREATE opt_unique INDEX opt_concurrently opt_single_name
 					n->options = $17;
 					n->tableSpace = $18;
 					n->whereClause = $19;
+					n->isvisible = $20;
 					n->excludeOpNames = NIL;
 					n->idxcomment = NULL;
 					n->indexOid = InvalidOid;
@@ -8501,6 +8542,12 @@ opt_unique:
 			| /*EMPTY*/								{ $$ = false; }
 		;
 
+opt_index_visibility:
+			VISIBLE									{ $$ = true; }
+			| INVISIBLE								{ $$ = false; }
+			| /*EMPTY*/								{ $$ = true; }
+		;
+
 access_method_clause:
 			USING name								{ $$ = $2; }
 			| /*EMPTY*/								{ $$ = DEFAULT_INDEX_TYPE; }
@@ -18958,6 +19005,7 @@ unreserved_keyword:
 			| INSENSITIVE
 			| INSERT
 			| INSTEAD
+			| INVISIBLE
 			| INVOKER
 			| ISOLATION
 			| KEEP
@@ -19155,6 +19203,7 @@ unreserved_keyword:
 			| VIEW
 			| VIEWS
 			| VIRTUAL
+			| VISIBLE
 			| VOLATILE
 			| WAIT
 			| WHITESPACE_P
@@ -19570,6 +19619,7 @@ bare_label_keyword:
 			| INT_P
 			| INTEGER
 			| INTERVAL
+			| INVISIBLE
 			| INVOKER
 			| IS
 			| ISOLATION
@@ -19828,6 +19878,7 @@ bare_label_keyword:
 			| VIEW
 			| VIEWS
 			| VIRTUAL
+			| VISIBLE
 			| VOLATILE
 			| WAIT
 			| WHEN
diff --git a/src/backend/parser/parse_utilcmd.c b/src/backend/parser/parse_utilcmd.c
index a049cc67ed6..0454ca710c2 100644
--- a/src/backend/parser/parse_utilcmd.c
+++ b/src/backend/parser/parse_utilcmd.c
@@ -1764,6 +1764,7 @@ generateClonedIndexStmt(RangeVar *heapRel, Relation source_idx,
 	index->concurrent = false;
 	index->if_not_exists = false;
 	index->reset_default_tblspc = false;
+	index->isvisible = idxrec->indisvisible;
 
 	/*
 	 * We don't try to preserve the name of the source index; instead, just
@@ -2393,6 +2394,8 @@ transformIndexConstraint(Constraint *constraint, CreateStmtContext *cxt)
 	index->concurrent = false;
 	index->if_not_exists = false;
 	index->reset_default_tblspc = constraint->reset_default_tblspc;
+	/* constraint-backed indexes are always created as visible */
+	index->isvisible = true;
 
 	/*
 	 * If it's ALTER TABLE ADD CONSTRAINT USING INDEX, look up the index and
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 88de5c0481c..6beea1ec1b6 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -1576,6 +1576,14 @@ pg_get_indexdef_worker(Oid indexrelid, int colno,
 			else
 				appendStringInfo(&buf, " WHERE %s", str);
 		}
+
+		/*
+		 * Append INVISIBLE for indexes hidden from the planner.  We only
+		 * print the non-default state; visible indexes are deparsed without
+		 * any visibility clause to keep output stable for the common case.
+		 */
+		if (!idxrec->indisvisible)
+			appendStringInfoString(&buf, " INVISIBLE");
 	}
 
 	/* Clean up */
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index 036de5f79ef..dd538336341 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3945,6 +3945,29 @@ get_index_isvalid(Oid index_oid)
 	return isvalid;
 }
 
+/*
+ * get_index_visibility
+ *
+ *		Given the index OID, return pg_index.indisvisible.
+ */
+bool
+get_index_visibility(Oid index_oid)
+{
+	bool		isvisible;
+	HeapTuple	tuple;
+	Form_pg_index rd_index;
+
+	tuple = SearchSysCache1(INDEXRELID, ObjectIdGetDatum(index_oid));
+	if (!HeapTupleIsValid(tuple))
+		elog(ERROR, "cache lookup failed for index %u", index_oid);
+
+	rd_index = (Form_pg_index) GETSTRUCT(tuple);
+	isvisible = rd_index->indisvisible;
+	ReleaseSysCache(tuple);
+
+	return isvisible;
+}
+
 /*
  * get_index_isclustered
  *
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 0572ab424e7..e85cf8a8292 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -2360,6 +2360,7 @@ RelationReloadIndexInfo(Relation relation)
 		relation->rd_index->indisready = index->indisready;
 		relation->rd_index->indislive = index->indislive;
 		relation->rd_index->indisreplident = index->indisreplident;
+		relation->rd_index->indisvisible = index->indisvisible;
 
 		/* Copy xmin too, as that is needed to make sense of indcheckxmin */
 		HeapTupleHeaderSetXmin(relation->rd_indextuple->t_data,
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index a0f7f8e2168..dfcdbf47e07 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -7970,7 +7970,8 @@ getIndexes(Archive *fout, TableInfo tblinfo[], int numTables)
 				i_tablespace,
 				i_indreloptions,
 				i_indstatcols,
-				i_indstatvals;
+				i_indstatvals,
+				i_indisvisible;
 
 	/*
 	 * We want to perform just one query against pg_index.  However, we
@@ -8035,6 +8036,13 @@ getIndexes(Archive *fout, TableInfo tblinfo[], int numTables)
 		appendPQExpBufferStr(query,
 							 "false AS indisreplident, ");
 
+	if (fout->remoteVersion >= 190000)
+		appendPQExpBufferStr(query,
+							 "i.indisvisible, ");
+	else
+		appendPQExpBufferStr(query,
+							 "true AS indisvisible, ");
+
 	if (fout->remoteVersion >= 110000)
 		appendPQExpBufferStr(query,
 							 "inh.inhparent AS parentidx, "
@@ -8149,6 +8157,7 @@ getIndexes(Archive *fout, TableInfo tblinfo[], int numTables)
 	i_indreloptions = PQfnumber(res, "indreloptions");
 	i_indstatcols = PQfnumber(res, "indstatcols");
 	i_indstatvals = PQfnumber(res, "indstatvals");
+	i_indisvisible = PQfnumber(res, "indisvisible");
 
 	indxinfo = pg_malloc_array(IndxInfo, ntups);
 
@@ -8221,6 +8230,7 @@ getIndexes(Archive *fout, TableInfo tblinfo[], int numTables)
 						  indxinfo[j].indkeys, indxinfo[j].indnattrs);
 			indxinfo[j].indisclustered = (PQgetvalue(res, j, i_indisclustered)[0] == 't');
 			indxinfo[j].indisreplident = (PQgetvalue(res, j, i_indisreplident)[0] == 't');
+			indxinfo[j].indisvisible = (PQgetvalue(res, j, i_indisvisible)[0] == 't');
 			indxinfo[j].indnullsnotdistinct = (PQgetvalue(res, j, i_indnullsnotdistinct)[0] == 't');
 			indxinfo[j].parentidx = atooid(PQgetvalue(res, j, i_parentidx));
 			indxinfo[j].partattaches = (SimplePtrList)
@@ -19168,6 +19178,15 @@ dumpConstraint(Archive *fout, const ConstraintInfo *coninfo)
 							  fmtId(indxinfo->dobj.name));
 		}
 
+		/*
+		 * pg_dump emits constraint-backed indexes through ALTER TABLE ADD
+		 * CONSTRAINT, which has no inline syntax for INVISIBLE.  Restore
+		 * the catalog state with an explicit ALTER INDEX after the fact.
+		 */
+		if (!indxinfo->indisvisible)
+			appendPQExpBuffer(q, "ALTER INDEX %s INVISIBLE;\n",
+							  fmtQualifiedDumpable(indxinfo));
+
 		/* Indexes can depend on extensions */
 		append_depends_on_extension(fout, q, &indxinfo->dobj,
 									"pg_catalog.pg_class", "INDEX",
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 5a6726d8b12..f893a7f2e9e 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -431,6 +431,7 @@ typedef struct _indxInfo
 	bool		indisclustered;
 	bool		indisreplident;
 	bool		indnullsnotdistinct;
+	bool		indisvisible;
 	Oid			parentidx;		/* if a partition, parent index OID */
 	SimplePtrList partattaches; /* if partitioned, partition attach objects */
 
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index 9258948b583..642d16cac2f 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -3976,6 +3976,89 @@ my %tests = (
 		},
 	},
 
+	'CREATE INDEX ... INVISIBLE' => {
+		create_order => 98,
+		create_sql => 'CREATE TABLE dump_test.test_invisible_idx_table (
+							 id int,
+							 v int
+						   );
+						   CREATE INDEX test_invisible_idx
+							 ON dump_test.test_invisible_idx_table(v) INVISIBLE;',
+		regexp => qr/^
+			\QCREATE INDEX test_invisible_idx ON dump_test.test_invisible_idx_table USING btree (v) INVISIBLE;\E
+			/xm,
+		like => { %full_runs, %dump_test_schema_runs, section_post_data => 1 },
+		unlike => { exclude_dump_test_schema => 1, only_dump_measurement => 1 },
+	},
+
+	'ALTER INDEX ... INVISIBLE for primary key' => {
+		create_order => 98,
+		create_sql => 'CREATE TABLE dump_test.test_invisible_pk_table (
+							 id int PRIMARY KEY
+						   );
+						   ALTER INDEX dump_test.test_invisible_pk_table_pkey INVISIBLE;',
+		regexp => qr/^
+			\QALTER TABLE ONLY dump_test.test_invisible_pk_table\E\n\s+
+			\QADD CONSTRAINT test_invisible_pk_table_pkey PRIMARY KEY (id);\E\n
+			\QALTER INDEX dump_test.test_invisible_pk_table_pkey INVISIBLE;\E
+			/xm,
+		like => { %full_runs, %dump_test_schema_runs, section_post_data => 1 },
+		unlike => { exclude_dump_test_schema => 1, only_dump_measurement => 1 },
+	},
+
+	'CREATE INDEX ... INVISIBLE on partitioned table (parent)' => {
+		create_order => 98,
+		create_sql => 'CREATE TABLE dump_test.test_invisible_partidx (
+							 a int,
+							 b int
+						   ) PARTITION BY RANGE (a);
+						   CREATE TABLE dump_test.test_invisible_partidx_p1
+							 PARTITION OF dump_test.test_invisible_partidx
+							 FOR VALUES FROM (0) TO (100);
+						   CREATE TABLE dump_test.test_invisible_partidx_p2
+							 PARTITION OF dump_test.test_invisible_partidx
+							 FOR VALUES FROM (100) TO (200);
+						   CREATE INDEX test_invisible_partidx_a_idx
+							 ON dump_test.test_invisible_partidx(a) INVISIBLE;',
+		regexp => qr/^
+			\QCREATE INDEX test_invisible_partidx_a_idx ON ONLY dump_test.test_invisible_partidx USING btree (a) INVISIBLE;\E
+			/xm,
+		like => { %full_runs, %dump_test_schema_runs, section_post_data => 1 },
+		unlike => { exclude_dump_test_schema => 1, only_dump_measurement => 1 },
+	},
+
+	'CREATE INDEX ... INVISIBLE on partitioned table (child)' => {
+		create_order => 99,
+		create_sql => '',
+		regexp => qr/^
+			\QCREATE INDEX test_invisible_partidx_p1_a_idx ON dump_test.test_invisible_partidx_p1 USING btree (a) INVISIBLE;\E
+			/xm,
+		like => { %full_runs, %dump_test_schema_runs, section_post_data => 1 },
+		unlike => { exclude_dump_test_schema => 1, only_dump_measurement => 1 },
+	},
+
+	'CREATE INDEX leaf-only INVISIBLE on partition' => {
+		create_order => 98,
+		create_sql => 'CREATE TABLE dump_test.test_invisible_leaf (
+							 a int
+						   ) PARTITION BY RANGE (a);
+						   CREATE TABLE dump_test.test_invisible_leaf_p1
+							 PARTITION OF dump_test.test_invisible_leaf
+							 FOR VALUES FROM (0) TO (100);
+						   CREATE TABLE dump_test.test_invisible_leaf_p2
+							 PARTITION OF dump_test.test_invisible_leaf
+							 FOR VALUES FROM (100) TO (200);
+						   CREATE INDEX test_invisible_leaf_a_idx
+							 ON dump_test.test_invisible_leaf(a);
+						   ALTER INDEX dump_test.test_invisible_leaf_p1_a_idx
+							 INVISIBLE;',
+		regexp => qr/^
+			\QCREATE INDEX test_invisible_leaf_p1_a_idx ON dump_test.test_invisible_leaf_p1 USING btree (a) INVISIBLE;\E
+			/xm,
+		like => { %full_runs, %dump_test_schema_runs, section_post_data => 1 },
+		unlike => { exclude_dump_test_schema => 1, only_dump_measurement => 1 },
+	},
+
 	'CREATE TABLE test_inheritance_parent' => {
 		create_order => 90,
 		create_sql => 'CREATE TABLE dump_test.test_inheritance_parent (
@@ -5374,6 +5457,177 @@ foreach my $run (sort keys %pgdump_runs)
 	}
 }
 
+#########################################
+# Round-trip test: dump and restore a database that mixes invisible-index
+# states across plain, constraint-backed, partitioned-parent, attached
+# child, and leaf-only-overridden indexes, then verify pg_index.indisvisible
+# is preserved exactly on the restored side.  This is the strongest
+# guarantee pg_dump can give for this feature; the regex-only tests above
+# only check that the expected DDL fragments appear in the dump output.
+
+$node->safe_psql('postgres', q{
+    CREATE DATABASE invvis_src;
+    CREATE DATABASE invvis_dst;
+});
+
+$node->safe_psql('invvis_src', q{
+    -- 1. Plain invisible index
+    CREATE TABLE plain_t(a int);
+    CREATE INDEX plain_t_idx ON plain_t(a) INVISIBLE;
+
+    -- 2. Constraint-backed (PRIMARY KEY) invisible index, also used as
+    -- replica identity.  REPLICA IDENTITY USING INDEX and INVISIBLE are
+    -- independent catalog properties: indisvisible only affects planner
+    -- visibility, not replica-identity selection.  Both must survive
+    -- dump/restore.
+    CREATE TABLE pk_t(a int PRIMARY KEY);
+    ALTER TABLE pk_t REPLICA IDENTITY USING INDEX pk_t_pkey;
+    ALTER INDEX pk_t_pkey INVISIBLE;
+
+    -- 3. & 4. Partitioned parent + attached children invisible
+    CREATE TABLE part_t(a int, b int, c int) PARTITION BY RANGE (a);
+    CREATE TABLE part_t_p1 PARTITION OF part_t FOR VALUES FROM (0) TO (100);
+    CREATE TABLE part_t_p2 PARTITION OF part_t FOR VALUES FROM (100) TO (200);
+    CREATE INDEX part_t_a_idx ON part_t(a) INVISIBLE;
+
+    -- 5. Leaf-only override on an otherwise-visible partitioned hierarchy
+    CREATE INDEX part_t_b_idx ON part_t(b);
+    ALTER INDEX part_t_p1_b_idx INVISIBLE;
+
+    -- 6. Unrelated visible index hierarchy on the same partitioned table.
+    -- Use a plain column so child names are deterministic
+    -- (part_t_p1_c_idx, part_t_p2_c_idx).
+    CREATE INDEX part_t_c_idx ON part_t(c);
+
+    -- 7. Partitioned constraint-backed (PRIMARY KEY) invisible index.
+    -- pg_dump restores this through ALTER TABLE ... ADD CONSTRAINT, so
+    -- the invisible state must be preserved by emitting an explicit
+    -- ALTER INDEX ... INVISIBLE on the parent constraint-backed index.
+    -- The recursion in ATExecSetIndexVisibility then propagates that to
+    -- every child *_pkey index in the hierarchy.
+    CREATE TABLE part_pk_invis_t (
+        a int NOT NULL,
+        b text,
+        PRIMARY KEY (a)
+    ) PARTITION BY RANGE (a);
+    CREATE TABLE part_pk_invis_t_p1
+        PARTITION OF part_pk_invis_t FOR VALUES FROM (0) TO (100);
+    CREATE TABLE part_pk_invis_t_p2
+        PARTITION OF part_pk_invis_t FOR VALUES FROM (100) TO (200);
+    ALTER INDEX part_pk_invis_t_pkey INVISIBLE;
+
+    -- Sanity check: PK enforcement still works while the backing index
+    -- hierarchy is invisible.  We just exercise it; the round-trip
+    -- assertion below covers catalog state after restore.
+    INSERT INTO part_pk_invis_t VALUES (1, 'one'), (101, 'a hundred one');
+});
+
+$node->command_ok(
+	[
+		'pg_dump',
+		'--port' => $node->port,
+		'--dbname' => 'invvis_src',
+		'--file' => "$tempdir/invvis.sql",
+	],
+	'invisible-index round-trip: dump');
+
+$node->command_ok(
+	[
+		'psql',
+		'--port' => $node->port,
+		'--dbname' => 'invvis_dst',
+		'--file' => "$tempdir/invvis.sql",
+	],
+	'invisible-index round-trip: restore');
+
+my $invvis_actual = $node->safe_psql(
+	'invvis_dst', q{
+    WITH parents(oid, label) AS (
+        SELECT c.oid, c.relname FROM pg_class c
+         WHERE c.relname IN ('plain_t_idx',
+                             'pk_t_pkey',
+                             'part_t_a_idx',
+                             'part_t_b_idx',
+                             'part_t_c_idx',
+                             'part_pk_invis_t_pkey')
+    )
+    SELECT
+        CASE
+          WHEN c.relname = 'plain_t_idx'    THEN 'plain'
+          WHEN c.relname = 'pk_t_pkey'      THEN 'pkey'
+          ELSE coalesce(p.label, p2.label) || ':' ||
+               CASE WHEN p.label IS NOT NULL THEN 'parent' ELSE 'child' END
+        END AS slot,
+        c.relname,
+        i.indisvisible::text
+    FROM pg_index i
+    JOIN pg_class c ON c.oid = i.indexrelid
+    LEFT JOIN parents p  ON p.oid = c.oid
+    LEFT JOIN pg_inherits inh ON inh.inhrelid = c.oid
+    LEFT JOIN parents p2 ON p2.oid = inh.inhparent
+    WHERE p.oid IS NOT NULL OR p2.oid IS NOT NULL
+    ORDER BY slot, c.relname;
+});
+
+my $invvis_expected = join("\n", (
+	'part_pk_invis_t_pkey:child|part_pk_invis_t_p1_pkey|false',
+	'part_pk_invis_t_pkey:child|part_pk_invis_t_p2_pkey|false',
+	'part_pk_invis_t_pkey:parent|part_pk_invis_t_pkey|false',
+	'part_t_a_idx:child|part_t_p1_a_idx|false',
+	'part_t_a_idx:child|part_t_p2_a_idx|false',
+	'part_t_a_idx:parent|part_t_a_idx|false',
+	'part_t_b_idx:child|part_t_p1_b_idx|false',
+	'part_t_b_idx:child|part_t_p2_b_idx|true',
+	'part_t_b_idx:parent|part_t_b_idx|true',
+	'part_t_c_idx:child|part_t_p1_c_idx|true',
+	'part_t_c_idx:child|part_t_p2_c_idx|true',
+	'part_t_c_idx:parent|part_t_c_idx|true',
+	'pkey|pk_t_pkey|false',
+	'plain|plain_t_idx|false',
+));
+
+is($invvis_actual, $invvis_expected,
+	'invisible-index round-trip: pg_index.indisvisible matches across dump/restore for plain, constraint-backed, partitioned parent, attached children, leaf-only override, partitioned constraint-backed, and unrelated indexes'
+);
+
+# REPLICA IDENTITY + invisible coexistence after restore: pk_t_pkey was
+# created as PRIMARY KEY, marked REPLICA IDENTITY USING INDEX, then made
+# INVISIBLE in the source database.  After dump/restore, both
+# indisreplident = true and indisvisible = false must be preserved
+# independently: indisvisible is a planner-only flag and does not affect
+# replica-identity selection.
+my $invvis_replident_actual = $node->safe_psql(
+	'invvis_dst', q{
+    SELECT c.relname, i.indisvisible, i.indisreplident
+    FROM pg_index i
+    JOIN pg_class c ON c.oid = i.indexrelid
+    WHERE c.relname = 'pk_t_pkey'
+    ORDER BY c.relname;
+});
+
+is($invvis_replident_actual,
+	'pk_t_pkey|f|t',
+	'invisible-index round-trip: REPLICA IDENTITY USING INDEX and indisvisible=false coexist and survive dump/restore on pk_t_pkey'
+);
+
+# Constraint enforcement after restore on the partitioned PK hierarchy
+# whose backing indexes are invisible: the duplicate must still be
+# rejected even though every index in the hierarchy is hidden from the
+# planner.
+my ($pk_dup_ret, $pk_dup_stdout, $pk_dup_stderr) = $node->psql(
+	'invvis_dst',
+	"INSERT INTO part_pk_invis_t VALUES (1, 'duplicate');");
+isnt($pk_dup_ret, 0,
+	'invisible-index round-trip: PK still rejects duplicates even when the constraint-backed index hierarchy is invisible'
+);
+like($pk_dup_stderr, qr/duplicate key value violates unique constraint/,
+	'invisible-index round-trip: duplicate insert fails with unique violation');
+
+$node->safe_psql('postgres', q{
+    DROP DATABASE invvis_src;
+    DROP DATABASE invvis_dst;
+});
+
 #########################################
 # Stop the database instance, which will be removed at the end of the tests.
 
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index af3935b0078..3fc3703b20a 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -2471,6 +2471,11 @@ describeOneTableDetails(const char *schemaname,
 		else
 			appendPQExpBufferStr(&buf, "false AS indnullsnotdistinct,\n");
 
+		if (pset.sversion >= 190000)
+			appendPQExpBufferStr(&buf, "i.indisvisible,\n");
+		else
+			appendPQExpBufferStr(&buf, "true AS indisvisible,\n");
+
 		appendPQExpBuffer(&buf, "  a.amname, c2.relname, "
 						  "pg_catalog.pg_get_expr(i.indpred, i.indrelid, true)\n"
 						  "FROM pg_catalog.pg_index i, pg_catalog.pg_class c, pg_catalog.pg_class c2, pg_catalog.pg_am a\n"
@@ -2496,9 +2501,10 @@ describeOneTableDetails(const char *schemaname,
 			char	   *deferred = PQgetvalue(result, 0, 5);
 			char	   *indisreplident = PQgetvalue(result, 0, 6);
 			char	   *indnullsnotdistinct = PQgetvalue(result, 0, 7);
-			char	   *indamname = PQgetvalue(result, 0, 8);
-			char	   *indtable = PQgetvalue(result, 0, 9);
-			char	   *indpred = PQgetvalue(result, 0, 10);
+			char	   *indisvisible = PQgetvalue(result, 0, 8);
+			char	   *indamname = PQgetvalue(result, 0, 9);
+			char	   *indtable = PQgetvalue(result, 0, 10);
+			char	   *indpred = PQgetvalue(result, 0, 11);
 
 			if (strcmp(indisprimary, "t") == 0)
 				printfPQExpBuffer(&tmpbuf, _("primary key, "));
@@ -2535,6 +2541,9 @@ describeOneTableDetails(const char *schemaname,
 			if (strcmp(indisreplident, "t") == 0)
 				appendPQExpBufferStr(&tmpbuf, _(", replica identity"));
 
+			if (strcmp(indisvisible, "t") != 0)
+				appendPQExpBufferStr(&tmpbuf, _(", invisible"));
+
 			printTableAddFooter(&cont, tmpbuf.data);
 
 			/*
@@ -2578,6 +2587,10 @@ describeOneTableDetails(const char *schemaname,
 				appendPQExpBufferStr(&buf, ", con.conperiod");
 			else
 				appendPQExpBufferStr(&buf, ", false AS conperiod");
+			if (pset.sversion >= 190000)
+				appendPQExpBufferStr(&buf, ", i.indisvisible");
+			else
+				appendPQExpBufferStr(&buf, ", true AS indisvisible");
 			appendPQExpBuffer(&buf,
 							  "\nFROM pg_catalog.pg_class c, pg_catalog.pg_class c2, pg_catalog.pg_index i\n"
 							  "  LEFT JOIN pg_catalog.pg_constraint con ON (conrelid = i.indrelid AND conindid = i.indexrelid AND contype IN ("
@@ -2616,6 +2629,10 @@ describeOneTableDetails(const char *schemaname,
 					{
 						const char *indexdef;
 						const char *usingpos;
+						bool		indisvisible;
+						const char *invisible_suffix = " INVISIBLE";
+						const size_t invisible_suffix_len = strlen(invisible_suffix);
+						size_t		defLen;
 
 						/* Label as primary key or unique (but not both) */
 						if (strcmp(PQgetvalue(result, i, 1), "t") == 0)
@@ -2633,7 +2650,27 @@ describeOneTableDetails(const char *schemaname,
 						usingpos = strstr(indexdef, " USING ");
 						if (usingpos)
 							indexdef = usingpos + 7;
-						appendPQExpBuffer(&buf, " %s", indexdef);
+
+						/*
+						 * pg_get_indexdef() appends " INVISIBLE" for invisible
+						 * indexes (SQL deparse style).  Strip it here so we
+						 * can render the same information with the descriptive
+						 * "invisible" annotation below, matching the style
+						 * \d uses for an index relation directly.
+						 */
+						indisvisible = (strcmp(PQgetvalue(result, i, 13), "t") == 0);
+						defLen = strlen(indexdef);
+						if (!indisvisible &&
+							defLen >= invisible_suffix_len &&
+							strcmp(indexdef + defLen - invisible_suffix_len,
+								   invisible_suffix) == 0)
+						{
+							appendPQExpBufferChar(&buf, ' ');
+							appendBinaryPQExpBuffer(&buf, indexdef,
+													defLen - invisible_suffix_len);
+						}
+						else
+							appendPQExpBuffer(&buf, " %s", indexdef);
 
 						/* Need these for deferrable PK/UNIQUE indexes */
 						if (strcmp(PQgetvalue(result, i, 8), "t") == 0)
@@ -2641,6 +2678,9 @@ describeOneTableDetails(const char *schemaname,
 
 						if (strcmp(PQgetvalue(result, i, 9), "t") == 0)
 							appendPQExpBufferStr(&buf, " INITIALLY DEFERRED");
+
+						if (!indisvisible)
+							appendPQExpBufferStr(&buf, ", invisible");
 					}
 
 					/* Add these for all cases */
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index de547a8cb37..bd78ee29d11 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2500,7 +2500,8 @@ match_previous_words(int pattern_id,
 	else if (Matches("ALTER", "INDEX", MatchAny))
 		COMPLETE_WITH("ALTER COLUMN", "OWNER TO", "RENAME TO", "SET",
 					  "RESET", "ATTACH PARTITION",
-					  "DEPENDS ON EXTENSION", "NO DEPENDS ON EXTENSION");
+					  "DEPENDS ON EXTENSION", "NO DEPENDS ON EXTENSION",
+					  "VISIBLE", "INVISIBLE");
 	else if (Matches("ALTER", "INDEX", MatchAny, "ATTACH"))
 		COMPLETE_WITH("PARTITION");
 	else if (Matches("ALTER", "INDEX", MatchAny, "ATTACH", "PARTITION"))
diff --git a/src/include/catalog/catversion.h b/src/include/catalog/catversion.h
index c4e94a3a09e..7f31a8a1548 100644
--- a/src/include/catalog/catversion.h
+++ b/src/include/catalog/catversion.h
@@ -57,6 +57,6 @@
  */
 
 /*							yyyymmddN */
-#define CATALOG_VERSION_NO	202606091
+#define CATALOG_VERSION_NO	202606151
 
 #endif
diff --git a/src/include/catalog/index.h b/src/include/catalog/index.h
index 9aee8226347..6b2153a388e 100644
--- a/src/include/catalog/index.h
+++ b/src/include/catalog/index.h
@@ -72,6 +72,7 @@ extern void index_check_primary_key(Relation heapRel,
 #define	INDEX_CREATE_PARTITIONED			(1 << 5)
 #define INDEX_CREATE_INVALID				(1 << 6)
 #define INDEX_CREATE_SUPPRESS_PROGRESS		(1 << 7)
+#define INDEX_CREATE_INVISIBLE				(1 << 8)
 
 extern Oid	index_create(Relation heapRelation,
 						 const char *indexRelationName,
diff --git a/src/include/catalog/pg_index.h b/src/include/catalog/pg_index.h
index d722efe49b4..b9d2cae4da3 100644
--- a/src/include/catalog/pg_index.h
+++ b/src/include/catalog/pg_index.h
@@ -46,6 +46,8 @@ CATALOG(pg_index,2610,IndexRelationId) BKI_SCHEMA_MACRO
 	bool		indisready;		/* is this index ready for inserts? */
 	bool		indislive;		/* is this index alive at all? */
 	bool		indisreplident; /* is this index the identity for replication? */
+	bool		indisvisible BKI_DEFAULT(t);	/* is this index visible to the
+												 * planner? */
 
 	/* variable-length fields start here, but we allow direct access to indkey */
 	int2vector	indkey BKI_FORCE_NOT_NULL;	/* column numbers of indexed cols,
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 4133c404a6b..3b5783cae6c 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2586,6 +2586,8 @@ typedef enum AlterTableType
 	AT_SetIdentity,				/* SET identity column options */
 	AT_DropIdentity,			/* DROP IDENTITY */
 	AT_ReAddStatistics,			/* internal to commands/tablecmds.c */
+	AT_SetIndexVisible,			/* ALTER INDEX VISIBLE */
+	AT_SetIndexInvisible,		/* ALTER INDEX INVISIBLE */
 } AlterTableType;
 
 typedef struct AlterTableCmd	/* one subcommand of an ALTER TABLE */
@@ -3616,6 +3618,8 @@ typedef struct IndexStmt
 	bool		if_not_exists;	/* just do nothing if index already exists? */
 	bool		reset_default_tblspc;	/* reset default_tablespace prior to
 										 * executing */
+	bool		isvisible;		/* true if VISIBLE (default), false if
+								 * INVISIBLE */
 } IndexStmt;
 
 /* ----------------------
diff --git a/src/include/parser/kwlist.h b/src/include/parser/kwlist.h
index 51ead54f015..455c6b2d649 100644
--- a/src/include/parser/kwlist.h
+++ b/src/include/parser/kwlist.h
@@ -234,6 +234,7 @@ PG_KEYWORD("integer", INTEGER, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("intersect", INTERSECT, RESERVED_KEYWORD, AS_LABEL)
 PG_KEYWORD("interval", INTERVAL, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("into", INTO, RESERVED_KEYWORD, AS_LABEL)
+PG_KEYWORD("invisible", INVISIBLE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("invoker", INVOKER, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("is", IS, TYPE_FUNC_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("isnull", ISNULL, TYPE_FUNC_NAME_KEYWORD, AS_LABEL)
@@ -509,6 +510,7 @@ PG_KEYWORD("vertex", VERTEX, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("view", VIEW, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("views", VIEWS, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("virtual", VIRTUAL, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("visible", VISIBLE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("volatile", VOLATILE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("wait", WAIT, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("when", WHEN, RESERVED_KEYWORD, BARE_LABEL)
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 865980cb0f1..08600df4269 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -212,6 +212,7 @@ extern Oid	get_index_column_opclass(Oid index_oid, int attno);
 extern bool get_index_isreplident(Oid index_oid);
 extern bool get_index_isvalid(Oid index_oid);
 extern bool get_index_isclustered(Oid index_oid);
+extern bool get_index_visibility(Oid index_oid);
 extern Oid	get_publication_oid(const char *pubname, bool missing_ok);
 extern char *get_publication_name(Oid pubid, bool missing_ok);
 extern Oid	get_subscription_oid(const char *subname, bool missing_ok);
diff --git a/src/test/modules/test_ddl_deparse/Makefile b/src/test/modules/test_ddl_deparse/Makefile
index f91a78d8d92..17ad1c0a33b 100644
--- a/src/test/modules/test_ddl_deparse/Makefile
+++ b/src/test/modules/test_ddl_deparse/Makefile
@@ -28,7 +28,8 @@ REGRESS = test_ddl_deparse \
 	opfamily \
 	defprivs \
 	textsearch \
-	matviews
+	matviews \
+	alter_index
 
 EXTRA_INSTALL = contrib/pg_stat_statements
 
diff --git a/src/test/modules/test_ddl_deparse/expected/alter_index.out b/src/test/modules/test_ddl_deparse/expected/alter_index.out
new file mode 100644
index 00000000000..e5428e29025
--- /dev/null
+++ b/src/test/modules/test_ddl_deparse/expected/alter_index.out
@@ -0,0 +1,34 @@
+--
+-- ALTER_INDEX
+--
+CREATE TABLE alter_idx_t (a int);
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+CREATE INDEX alter_idx_t_a_idx ON alter_idx_t (a);
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+ALTER INDEX alter_idx_t_a_idx INVISIBLE;
+NOTICE:  DDL test: type alter table, tag ALTER INDEX
+NOTICE:    subcommand: type SET INVISIBLE desc <NULL>
+ALTER INDEX alter_idx_t_a_idx VISIBLE;
+NOTICE:  DDL test: type alter table, tag ALTER INDEX
+NOTICE:    subcommand: type SET VISIBLE desc <NULL>
+-- IF EXISTS form, on a missing index: no DDL command fires
+ALTER INDEX IF EXISTS alter_idx_t_does_not_exist INVISIBLE;
+NOTICE:  relation "alter_idx_t_does_not_exist" does not exist, skipping
+-- Recursive form on a partitioned index
+CREATE TABLE alter_idx_part (a int) PARTITION BY RANGE (a);
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+CREATE TABLE alter_idx_part_p1
+    PARTITION OF alter_idx_part FOR VALUES FROM (0) TO (100);
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+CREATE INDEX alter_idx_part_a_idx ON alter_idx_part (a);
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+ALTER INDEX alter_idx_part_a_idx INVISIBLE;
+NOTICE:  DDL test: type alter table, tag ALTER INDEX
+NOTICE:    subcommand: type SET INVISIBLE desc <NULL>
+NOTICE:    subcommand: type SET INVISIBLE desc <NULL>
+ALTER INDEX alter_idx_part_a_idx VISIBLE;
+NOTICE:  DDL test: type alter table, tag ALTER INDEX
+NOTICE:    subcommand: type SET VISIBLE desc <NULL>
+NOTICE:    subcommand: type SET VISIBLE desc <NULL>
+DROP TABLE alter_idx_part;
+DROP TABLE alter_idx_t;
diff --git a/src/test/modules/test_ddl_deparse/meson.build b/src/test/modules/test_ddl_deparse/meson.build
index 85decdf1f2b..95d2db3606a 100644
--- a/src/test/modules/test_ddl_deparse/meson.build
+++ b/src/test/modules/test_ddl_deparse/meson.build
@@ -49,6 +49,7 @@ tests += {
       'defprivs',
       'textsearch',
       'matviews',
+      'alter_index',
     ],
   },
 }
diff --git a/src/test/modules/test_ddl_deparse/sql/alter_index.sql b/src/test/modules/test_ddl_deparse/sql/alter_index.sql
new file mode 100644
index 00000000000..67e3243b762
--- /dev/null
+++ b/src/test/modules/test_ddl_deparse/sql/alter_index.sql
@@ -0,0 +1,24 @@
+--
+-- ALTER_INDEX
+--
+
+CREATE TABLE alter_idx_t (a int);
+CREATE INDEX alter_idx_t_a_idx ON alter_idx_t (a);
+
+ALTER INDEX alter_idx_t_a_idx INVISIBLE;
+ALTER INDEX alter_idx_t_a_idx VISIBLE;
+
+-- IF EXISTS form, on a missing index: no DDL command fires
+ALTER INDEX IF EXISTS alter_idx_t_does_not_exist INVISIBLE;
+
+-- Recursive form on a partitioned index
+CREATE TABLE alter_idx_part (a int) PARTITION BY RANGE (a);
+CREATE TABLE alter_idx_part_p1
+    PARTITION OF alter_idx_part FOR VALUES FROM (0) TO (100);
+CREATE INDEX alter_idx_part_a_idx ON alter_idx_part (a);
+
+ALTER INDEX alter_idx_part_a_idx INVISIBLE;
+ALTER INDEX alter_idx_part_a_idx VISIBLE;
+
+DROP TABLE alter_idx_part;
+DROP TABLE alter_idx_t;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..2159f78dde6 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -315,6 +315,12 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
 			case AT_ReAddStatistics:
 				strtype = "(re) ADD STATS";
 				break;
+			case AT_SetIndexVisible:
+				strtype = "SET VISIBLE";
+				break;
+			case AT_SetIndexInvisible:
+				strtype = "SET INVISIBLE";
+				break;
 		}
 
 		if (subcmd->recurse)
diff --git a/src/test/regress/expected/create_index.out b/src/test/regress/expected/create_index.out
index 55538c4c41e..76edeac1c3c 100644
--- a/src/test/regress/expected/create_index.out
+++ b/src/test/regress/expected/create_index.out
@@ -3556,6 +3556,1133 @@ ERROR:  REINDEX SCHEMA cannot run inside a transaction block
 END;
 -- concurrently
 REINDEX SCHEMA CONCURRENTLY schema_to_reindex;
+--
+-- Index visibility (ALTER INDEX ... VISIBLE / INVISIBLE)
+--
+-- Group 1: basic planner visibility
+CREATE TABLE test_invisible_index(a int, b text);
+INSERT INTO test_invisible_index
+    SELECT i, md5(i::text)
+    FROM generate_series(1, 10000) i;
+CREATE INDEX test_invisible_index_a_idx ON test_invisible_index(a);
+ANALYZE test_invisible_index;
+SELECT indisvalid, indisready, indislive, indisvisible
+FROM pg_index WHERE indexrelid = 'test_invisible_index_a_idx'::regclass;
+ indisvalid | indisready | indislive | indisvisible 
+------------+------------+-----------+--------------
+ t          | t          | t         | t
+(1 row)
+
+-- Force a plan that uses the index when it is visible.  Invisible-index
+-- expectations below do not need to be stabilized: with no usable index
+-- the planner falls back to a sequential scan even with enable_seqscan
+-- and enable_bitmapscan turned off.
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invisible_index WHERE a = 42;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Index Scan using test_invisible_index_a_idx on test_invisible_index
+   Index Cond: (a = 42)
+(2 rows)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+ALTER INDEX test_invisible_index_a_idx INVISIBLE;
+SELECT indisvalid, indisready, indislive, indisvisible
+FROM pg_index WHERE indexrelid = 'test_invisible_index_a_idx'::regclass;
+ indisvalid | indisready | indislive | indisvisible 
+------------+------------+-----------+--------------
+ t          | t          | t         | f
+(1 row)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invisible_index WHERE a = 42;
+            QUERY PLAN            
+----------------------------------
+ Seq Scan on test_invisible_index
+   Filter: (a = 42)
+(2 rows)
+
+ALTER INDEX test_invisible_index_a_idx VISIBLE;
+SELECT indisvalid, indisready, indislive, indisvisible
+FROM pg_index WHERE indexrelid = 'test_invisible_index_a_idx'::regclass;
+ indisvalid | indisready | indislive | indisvisible 
+------------+------------+-----------+--------------
+ t          | t          | t         | t
+(1 row)
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invisible_index WHERE a = 42;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Index Scan using test_invisible_index_a_idx on test_invisible_index
+   Index Cond: (a = 42)
+(2 rows)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+-- Group 2: CREATE INDEX ... INVISIBLE
+CREATE INDEX test_invisible_index_b_idx
+    ON test_invisible_index(b) INVISIBLE;
+ANALYZE test_invisible_index;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_index_b_idx'::regclass;
+ indisvisible 
+--------------
+ f
+(1 row)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invisible_index WHERE b = md5('42');
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Seq Scan on test_invisible_index
+   Filter: (b = 'a1d0c6e83f027327d8461063f4ac58a6'::text)
+(2 rows)
+
+ALTER INDEX test_invisible_index_b_idx VISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_index_b_idx'::regclass;
+ indisvisible 
+--------------
+ t
+(1 row)
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invisible_index WHERE b = md5('42');
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Index Scan using test_invisible_index_b_idx on test_invisible_index
+   Index Cond: (b = 'a1d0c6e83f027327d8461063f4ac58a6'::text)
+(2 rows)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+-- Default visibility for plain CREATE INDEX
+CREATE INDEX test_invisible_index_default_idx ON test_invisible_index(a);
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_index_default_idx'::regclass;
+ indisvisible 
+--------------
+ t
+(1 row)
+
+-- Explicit VISIBLE keyword
+CREATE INDEX test_invisible_index_visible_idx
+    ON test_invisible_index(a) VISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_index_visible_idx'::regclass;
+ indisvisible 
+--------------
+ t
+(1 row)
+
+DROP INDEX test_invisible_index_default_idx;
+DROP INDEX test_invisible_index_visible_idx;
+-- Group 3: prepared statement / plan cache invalidation
+CREATE TABLE test_invisible_plan_cache(a int, b text);
+INSERT INTO test_invisible_plan_cache
+    SELECT i, md5(i::text)
+    FROM generate_series(1, 10000) i;
+CREATE INDEX test_invisible_plan_cache_a_idx
+    ON test_invisible_plan_cache(a);
+ANALYZE test_invisible_plan_cache;
+SET plan_cache_mode = force_generic_plan;
+-- Force the visible-side plans to use the index regardless of cost.  The
+-- invisible-side plan still drops to a sequential scan, since no usable
+-- index exists.
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+PREPARE q_invisible_cache(int) AS
+    SELECT * FROM test_invisible_plan_cache WHERE a = $1;
+EXPLAIN (COSTS OFF) EXECUTE q_invisible_cache(42);
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ Index Scan using test_invisible_plan_cache_a_idx on test_invisible_plan_cache
+   Index Cond: (a = $1)
+(2 rows)
+
+-- Visible -> invisible: cached plan must be replanned, no longer use the index
+ALTER INDEX test_invisible_plan_cache_a_idx INVISIBLE;
+EXPLAIN (COSTS OFF) EXECUTE q_invisible_cache(42);
+              QUERY PLAN               
+---------------------------------------
+ Seq Scan on test_invisible_plan_cache
+   Disabled: true
+   Filter: (a = $1)
+(3 rows)
+
+-- Invisible -> visible: cached plan must be replanned, can use the index again
+ALTER INDEX test_invisible_plan_cache_a_idx VISIBLE;
+EXPLAIN (COSTS OFF) EXECUTE q_invisible_cache(42);
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ Index Scan using test_invisible_plan_cache_a_idx on test_invisible_plan_cache
+   Index Cond: (a = $1)
+(2 rows)
+
+DEALLOCATE q_invisible_cache;
+RESET plan_cache_mode;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+-- Group 4: DML still maintains invisible indexes
+CREATE TABLE test_invisible_dml(a int, b int);
+CREATE UNIQUE INDEX test_invisible_dml_a_idx
+    ON test_invisible_dml(a) INVISIBLE;
+INSERT INTO test_invisible_dml VALUES (1, 10);
+INSERT INTO test_invisible_dml VALUES (2, 20);
+-- uniqueness must still be enforced while invisible
+DO $$
+BEGIN
+    BEGIN
+        INSERT INTO test_invisible_dml VALUES (1, 30);
+        RAISE EXCEPTION 'expected unique violation was not raised';
+    EXCEPTION WHEN unique_violation THEN
+        RAISE NOTICE 'unique violation correctly raised';
+    END;
+END $$;
+NOTICE:  unique violation correctly raised
+ALTER INDEX test_invisible_dml_a_idx VISIBLE;
+ANALYZE test_invisible_dml;
+-- After visibility flips back, the index is usable, proving it was
+-- maintained while invisible.
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invisible_dml WHERE a = 1;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Index Scan using test_invisible_dml_a_idx on test_invisible_dml
+   Index Cond: (a = 1)
+(2 rows)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+-- Group 5: primary key / constraint-backed indexes
+CREATE TABLE test_invisible_pk(a int PRIMARY KEY, b int);
+INSERT INTO test_invisible_pk VALUES (1, 10), (2, 20);
+ALTER INDEX test_invisible_pk_pkey INVISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_pk_pkey'::regclass;
+ indisvisible 
+--------------
+ f
+(1 row)
+
+DO $$
+BEGIN
+    BEGIN
+        INSERT INTO test_invisible_pk VALUES (1, 30);
+        RAISE EXCEPTION 'expected unique violation was not raised';
+    EXCEPTION WHEN unique_violation THEN
+        RAISE NOTICE 'unique violation correctly raised';
+    END;
+END $$;
+NOTICE:  unique violation correctly raised
+\d test_invisible_pk
+    Table "schema_to_reindex.test_invisible_pk"
+ Column |  Type   | Collation | Nullable | Default 
+--------+---------+-----------+----------+---------
+ a      | integer |           | not null | 
+ b      | integer |           |          | 
+Indexes:
+    "test_invisible_pk_pkey" PRIMARY KEY, btree (a), invisible
+
+ALTER INDEX test_invisible_pk_pkey VISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_pk_pkey'::regclass;
+ indisvisible 
+--------------
+ t
+(1 row)
+
+-- Group 6: pg_get_indexdef deparse
+ALTER INDEX test_invisible_index_b_idx INVISIBLE;
+SELECT pg_get_indexdef('test_invisible_index_b_idx'::regclass);
+                                               pg_get_indexdef                                               
+-------------------------------------------------------------------------------------------------------------
+ CREATE INDEX test_invisible_index_b_idx ON schema_to_reindex.test_invisible_index USING btree (b) INVISIBLE
+(1 row)
+
+ALTER INDEX test_invisible_index_b_idx VISIBLE;
+SELECT pg_get_indexdef('test_invisible_index_b_idx'::regclass);
+                                          pg_get_indexdef                                          
+---------------------------------------------------------------------------------------------------
+ CREATE INDEX test_invisible_index_b_idx ON schema_to_reindex.test_invisible_index USING btree (b)
+(1 row)
+
+-- Group 7: psql \d annotations.  Both \d <table> and \d <indexname>
+-- render invisible indexes using the descriptive ", invisible" style.
+ALTER INDEX test_invisible_index_b_idx INVISIBLE;
+\d test_invisible_index
+  Table "schema_to_reindex.test_invisible_index"
+ Column |  Type   | Collation | Nullable | Default 
+--------+---------+-----------+----------+---------
+ a      | integer |           |          | 
+ b      | text    |           |          | 
+Indexes:
+    "test_invisible_index_a_idx" btree (a)
+    "test_invisible_index_b_idx" btree (b), invisible
+
+\d test_invisible_index_b_idx
+Index "schema_to_reindex.test_invisible_index_b_idx"
+ Column | Type | Key? | Definition 
+--------+------+------+------------
+ b      | text | yes  | b
+btree, for table "schema_to_reindex.test_invisible_index", invisible
+
+ALTER INDEX test_invisible_index_b_idx VISIBLE;
+\d test_invisible_index_b_idx
+Index "schema_to_reindex.test_invisible_index_b_idx"
+ Column | Type | Key? | Definition 
+--------+------+------+------------
+ b      | text | yes  | b
+btree, for table "schema_to_reindex.test_invisible_index"
+
+-- Group 8: REINDEX preserves visibility
+CREATE TABLE test_invisible_reindex(a int);
+CREATE INDEX test_invisible_reindex_idx
+    ON test_invisible_reindex(a) INVISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_reindex_idx'::regclass;
+ indisvisible 
+--------------
+ f
+(1 row)
+
+REINDEX INDEX test_invisible_reindex_idx;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_reindex_idx'::regclass;
+ indisvisible 
+--------------
+ f
+(1 row)
+
+REINDEX INDEX CONCURRENTLY test_invisible_reindex_idx;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_reindex_idx'::regclass;
+ indisvisible 
+--------------
+ f
+(1 row)
+
+ALTER INDEX test_invisible_reindex_idx VISIBLE;
+REINDEX INDEX CONCURRENTLY test_invisible_reindex_idx;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_reindex_idx'::regclass;
+ indisvisible 
+--------------
+ t
+(1 row)
+
+-- Group 9: ALTER TABLE ALTER COLUMN TYPE rebuild path
+CREATE TABLE test_invisible_alter_type(a int);
+CREATE INDEX test_invisible_alter_type_idx
+    ON test_invisible_alter_type(a) INVISIBLE;
+ALTER TABLE test_invisible_alter_type
+    ALTER COLUMN a TYPE bigint;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_alter_type_idx'::regclass;
+ indisvisible 
+--------------
+ f
+(1 row)
+
+-- Group 10: expression index visibility and ANALYZE behavior.
+-- Confirm that (a) the planner ignores an invisible expression index
+-- and uses it once visible, and (b) ANALYZE still collects statistics
+-- for an invisible expression index, since invisibility is a planner
+-- hint only and does not stop other catalog-level work.
+CREATE TABLE test_invis_expr(a int, b int);
+INSERT INTO test_invis_expr
+    SELECT i, i * 2
+    FROM generate_series(1, 1000) AS i;
+CREATE INDEX test_invis_expr_idx
+    ON test_invis_expr ((a + b)) INVISIBLE;
+ANALYZE test_invis_expr;
+-- ANALYZE collected statistics on the expression-index attribute even
+-- though the index is invisible.
+SELECT COUNT(*) > 0 AS has_expr_stats
+FROM pg_statistic
+WHERE starelid = 'test_invis_expr_idx'::regclass;
+ has_expr_stats 
+----------------
+ t
+(1 row)
+
+-- Planner ignores the invisible expression index.
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invis_expr WHERE (a + b) = 30;
+         QUERY PLAN          
+-----------------------------
+ Seq Scan on test_invis_expr
+   Disabled: true
+   Filter: ((a + b) = 30)
+(3 rows)
+
+-- Made visible: planner picks the expression index back up.
+ALTER INDEX test_invis_expr_idx VISIBLE;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invis_expr WHERE (a + b) = 30;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using test_invis_expr_idx on test_invis_expr
+   Index Cond: ((a + b) = 30)
+(2 rows)
+
+-- Made invisible again: planner falls back to a sequential scan.
+ALTER INDEX test_invis_expr_idx INVISIBLE;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invis_expr WHERE (a + b) = 30;
+         QUERY PLAN          
+-----------------------------
+ Seq Scan on test_invis_expr
+   Disabled: true
+   Filter: ((a + b) = 30)
+(3 rows)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+-- Group 11: UNIQUE constraint-backed invisible index.
+-- A UNIQUE constraint declared inline gets a backing index (named
+-- <table>_<column>_key by default).  Marking that index invisible must
+-- hide it from the planner but must NOT disable the UNIQUE constraint:
+-- duplicate inserts must still be rejected.
+CREATE TABLE test_invis_unique_constraint
+(
+    a int,
+    b text,
+    UNIQUE (a)
+);
+INSERT INTO test_invis_unique_constraint
+    SELECT i, md5(i::text)
+    FROM generate_series(1, 1000) AS i;
+ANALYZE test_invis_unique_constraint;
+ALTER INDEX test_invis_unique_constraint_a_key INVISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invis_unique_constraint_a_key'::regclass;
+ indisvisible 
+--------------
+ f
+(1 row)
+
+-- Planner should not use the invisible unique backing index.
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invis_unique_constraint WHERE a = 1;
+                QUERY PLAN                
+------------------------------------------
+ Seq Scan on test_invis_unique_constraint
+   Disabled: true
+   Filter: (a = 1)
+(3 rows)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+-- Constraint enforcement still works.  Wrap the duplicate insert in a
+-- DO block so the regression output stays stable, mirroring Group 5.
+DO $$
+BEGIN
+    BEGIN
+        INSERT INTO test_invis_unique_constraint VALUES (1, 'duplicate');
+        RAISE EXCEPTION 'expected unique violation was not raised';
+    EXCEPTION WHEN unique_violation THEN
+        RAISE NOTICE 'unique violation correctly raised';
+    END;
+END $$;
+NOTICE:  unique violation correctly raised
+ALTER INDEX test_invis_unique_constraint_a_key VISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invis_unique_constraint_a_key'::regclass;
+ indisvisible 
+--------------
+ t
+(1 row)
+
+DROP TABLE test_invis_unique_constraint;
+-- Group 12: ALTER TABLE ... ADD CONSTRAINT ... USING INDEX with an
+-- already-invisible unique index.  Promoting an invisible index into a
+-- constraint must preserve indisvisible = false; indisvalid /
+-- indisready / indislive remain true; uniqueness must be enforced.
+CREATE TABLE test_invis_using_idx
+(
+    a int NOT NULL,
+    b text
+);
+CREATE UNIQUE INDEX test_invis_using_idx_a_idx
+    ON test_invis_using_idx(a) INVISIBLE;
+SELECT indisvisible, indisvalid, indisready, indislive FROM pg_index
+WHERE indexrelid = 'test_invis_using_idx_a_idx'::regclass;
+ indisvisible | indisvalid | indisready | indislive 
+--------------+------------+------------+-----------
+ f            | t          | t          | t
+(1 row)
+
+-- Promote: ADD CONSTRAINT USING INDEX renames the index to the
+-- constraint name.  The renamed index must still be invisible.
+ALTER TABLE test_invis_using_idx
+    ADD CONSTRAINT test_invis_using_idx_a_key
+    UNIQUE USING INDEX test_invis_using_idx_a_idx;
+NOTICE:  ALTER TABLE / ADD CONSTRAINT USING INDEX will rename index "test_invis_using_idx_a_idx" to "test_invis_using_idx_a_key"
+SELECT c.relname, i.indisvisible, i.indisvalid, i.indisready, i.indislive
+FROM pg_index i
+JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('test_invis_using_idx_a_idx',
+                    'test_invis_using_idx_a_key')
+ORDER BY c.relname;
+          relname           | indisvisible | indisvalid | indisready | indislive 
+----------------------------+--------------+------------+------------+-----------
+ test_invis_using_idx_a_key | f            | t          | t          | t
+(1 row)
+
+-- Constraint enforcement still works on the renamed, still-invisible
+-- backing index.
+INSERT INTO test_invis_using_idx VALUES (1, 'one');
+DO $$
+BEGIN
+    BEGIN
+        INSERT INTO test_invis_using_idx VALUES (1, 'duplicate');
+        RAISE EXCEPTION 'expected unique violation was not raised';
+    EXCEPTION WHEN unique_violation THEN
+        RAISE NOTICE 'unique violation correctly raised';
+    END;
+END $$;
+NOTICE:  unique violation correctly raised
+-- Flip the renamed constraint-backed index visible.
+ALTER INDEX test_invis_using_idx_a_key VISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invis_using_idx_a_key'::regclass;
+ indisvisible 
+--------------
+ t
+(1 row)
+
+DROP TABLE test_invis_using_idx;
+-- Group 13: psql \d INVISIBLE-suffix stripping on non-trivial index shapes.
+-- pg_get_indexdef() unconditionally appends " INVISIBLE" after any
+-- WITH/TABLESPACE/WHERE clause for invisible indexes; describe.c strips
+-- that suffix and renders the descriptive ", invisible" annotation
+-- instead.  Exercise that path against an expression index, a partial
+-- index, and a partitioned (ON ONLY) index so the strip-and-relabel
+-- logic is verified for shapes whose deparse output is non-trivial.
+CREATE TABLE test_invis_psql(a int, b int);
+CREATE INDEX test_invis_psql_expr_idx
+    ON test_invis_psql ((a + b)) INVISIBLE;
+CREATE INDEX test_invis_psql_partial_idx
+    ON test_invis_psql (a) WHERE a > 0;
+ALTER INDEX test_invis_psql_partial_idx INVISIBLE;
+-- pg_get_indexdef must show " INVISIBLE" at the very end.
+SELECT pg_get_indexdef('test_invis_psql_expr_idx'::regclass);
+                                               pg_get_indexdef                                                
+--------------------------------------------------------------------------------------------------------------
+ CREATE INDEX test_invis_psql_expr_idx ON schema_to_reindex.test_invis_psql USING btree (((a + b))) INVISIBLE
+(1 row)
+
+SELECT pg_get_indexdef('test_invis_psql_partial_idx'::regclass);
+                                                    pg_get_indexdef                                                    
+-----------------------------------------------------------------------------------------------------------------------
+ CREATE INDEX test_invis_psql_partial_idx ON schema_to_reindex.test_invis_psql USING btree (a) WHERE (a > 0) INVISIBLE
+(1 row)
+
+-- \d <table> shows the descriptive ", invisible" annotation, not the
+-- raw " INVISIBLE" SQL keyword.
+\d test_invis_psql
+     Table "schema_to_reindex.test_invis_psql"
+ Column |  Type   | Collation | Nullable | Default 
+--------+---------+-----------+----------+---------
+ a      | integer |           |          | 
+ b      | integer |           |          | 
+Indexes:
+    "test_invis_psql_expr_idx" btree ((a + b)), invisible
+    "test_invis_psql_partial_idx" btree (a) WHERE a > 0, invisible
+
+DROP TABLE test_invis_psql;
+-- Partitioned (ON ONLY) parent index whose deparse includes INVISIBLE.
+CREATE TABLE test_invis_psql_part(a int) PARTITION BY RANGE (a);
+CREATE TABLE test_invis_psql_part_p1
+    PARTITION OF test_invis_psql_part FOR VALUES FROM (0) TO (100);
+CREATE INDEX test_invis_psql_part_idx
+    ON ONLY test_invis_psql_part(a) INVISIBLE;
+SELECT pg_get_indexdef('test_invis_psql_part_idx'::regclass);
+                                                pg_get_indexdef                                                 
+----------------------------------------------------------------------------------------------------------------
+ CREATE INDEX test_invis_psql_part_idx ON ONLY schema_to_reindex.test_invis_psql_part USING btree (a) INVISIBLE
+(1 row)
+
+\d test_invis_psql_part
+Partitioned table "schema_to_reindex.test_invis_psql_part"
+ Column |  Type   | Collation | Nullable | Default 
+--------+---------+-----------+----------+---------
+ a      | integer |           |          | 
+Partition key: RANGE (a)
+Indexes:
+    "test_invis_psql_part_idx" btree (a), invisible INVALID
+Number of partitions: 1 (Use \d+ to list them.)
+
+DROP TABLE test_invis_psql_part;
+-- Cleanup
+DROP TABLE test_invisible_index;
+DROP TABLE test_invisible_plan_cache;
+DROP TABLE test_invisible_dml;
+DROP TABLE test_invisible_pk;
+DROP TABLE test_invisible_reindex;
+DROP TABLE test_invisible_alter_type;
+DROP TABLE test_invis_expr;
+--
+-- Index visibility on partitioned tables / partitioned indexes
+--
+-- Group P1: leaf partition index invisibility
+CREATE TABLE test_part_invisible_leaf
+(
+    a int,
+    b text
+) PARTITION BY RANGE (a);
+CREATE TABLE test_part_invisible_leaf_1
+    PARTITION OF test_part_invisible_leaf
+    FOR VALUES FROM (0) TO (1000);
+CREATE TABLE test_part_invisible_leaf_2
+    PARTITION OF test_part_invisible_leaf
+    FOR VALUES FROM (1000) TO (2000);
+INSERT INTO test_part_invisible_leaf
+    SELECT i, md5(i::text)
+    FROM generate_series(1, 1999) i;
+CREATE INDEX test_part_invisible_leaf_1_a_idx
+    ON test_part_invisible_leaf_1(a);
+CREATE INDEX test_part_invisible_leaf_2_a_idx
+    ON test_part_invisible_leaf_2(a);
+ANALYZE test_part_invisible_leaf;
+ALTER INDEX test_part_invisible_leaf_1_a_idx INVISIBLE;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_leaf%'
+ORDER BY c.relname;
+             relname              | indisvisible 
+----------------------------------+--------------
+ test_part_invisible_leaf_1_a_idx | f
+ test_part_invisible_leaf_2_a_idx | t
+(2 rows)
+
+-- The pt1 partition has no usable index, so a sequential scan is correct.
+-- Force the visible side (the pt2 partition lookup, and after VISIBLE the
+-- pt1 lookup) to use the index, regardless of cost.
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_part_invisible_leaf WHERE a = 42;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Seq Scan on test_part_invisible_leaf_1 test_part_invisible_leaf
+   Filter: (a = 42)
+(2 rows)
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_part_invisible_leaf WHERE a = 1042;
+                                                QUERY PLAN                                                
+----------------------------------------------------------------------------------------------------------
+ Index Scan using test_part_invisible_leaf_2_a_idx on test_part_invisible_leaf_2 test_part_invisible_leaf
+   Index Cond: (a = 1042)
+(2 rows)
+
+ALTER INDEX test_part_invisible_leaf_1_a_idx VISIBLE;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_part_invisible_leaf WHERE a = 42;
+                                                QUERY PLAN                                                
+----------------------------------------------------------------------------------------------------------
+ Index Scan using test_part_invisible_leaf_1_a_idx on test_part_invisible_leaf_1 test_part_invisible_leaf
+   Index Cond: (a = 42)
+(2 rows)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE test_part_invisible_leaf;
+-- Group P2 / P3 / P4: parent partitioned index invisibility, catalog state,
+-- unrelated indexes left alone
+CREATE TABLE test_part_invisible_parent
+(
+    a int,
+    b text
+) PARTITION BY RANGE (a);
+CREATE TABLE test_part_invisible_parent_1
+    PARTITION OF test_part_invisible_parent
+    FOR VALUES FROM (0) TO (1000);
+CREATE TABLE test_part_invisible_parent_2
+    PARTITION OF test_part_invisible_parent
+    FOR VALUES FROM (1000) TO (2000);
+INSERT INTO test_part_invisible_parent
+    SELECT i, md5(i::text)
+    FROM generate_series(1, 1999) i;
+CREATE INDEX test_part_invisible_parent_a_idx
+    ON test_part_invisible_parent(a);
+CREATE INDEX test_part_invisible_parent_b_idx
+    ON test_part_invisible_parent(b);
+ANALYZE test_part_invisible_parent;
+ALTER INDEX test_part_invisible_parent_a_idx INVISIBLE;
+-- P3: parent and every attached child index in the 'a' hierarchy invisible;
+-- P4: the 'b' hierarchy must remain visible.  Identify each hierarchy by
+-- the parent index's OID rather than by name pattern.
+WITH parents(oid, relname) AS (
+    SELECT c.oid, c.relname
+      FROM pg_class c
+     WHERE c.relname IN ('test_part_invisible_parent_a_idx',
+                         'test_part_invisible_parent_b_idx')
+)
+SELECT c.relname, i.indisvisible
+FROM pg_index i
+JOIN pg_class c ON c.oid = i.indexrelid
+LEFT JOIN pg_inherits inh ON inh.inhrelid = c.oid
+WHERE c.oid IN (SELECT oid FROM parents)
+   OR inh.inhparent IN (SELECT oid FROM parents)
+ORDER BY c.relname;
+              relname               | indisvisible 
+------------------------------------+--------------
+ test_part_invisible_parent_1_a_idx | f
+ test_part_invisible_parent_1_b_idx | t
+ test_part_invisible_parent_2_a_idx | f
+ test_part_invisible_parent_2_b_idx | t
+ test_part_invisible_parent_a_idx   | f
+ test_part_invisible_parent_b_idx   | t
+(6 rows)
+
+-- P2: planner must avoid the (now invisible) child indexes
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_part_invisible_parent WHERE a = 42;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on test_part_invisible_parent_1 test_part_invisible_parent
+   Filter: (a = 42)
+(2 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_part_invisible_parent WHERE a = 1042;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on test_part_invisible_parent_2 test_part_invisible_parent
+   Filter: (a = 1042)
+(2 rows)
+
+ALTER INDEX test_part_invisible_parent_a_idx VISIBLE;
+WITH parents(oid) AS (
+    SELECT c.oid FROM pg_class c
+     WHERE c.relname = 'test_part_invisible_parent_a_idx'
+)
+SELECT c.relname, i.indisvisible
+FROM pg_index i
+JOIN pg_class c ON c.oid = i.indexrelid
+LEFT JOIN pg_inherits inh ON inh.inhrelid = c.oid
+WHERE c.oid IN (SELECT oid FROM parents)
+   OR inh.inhparent IN (SELECT oid FROM parents)
+ORDER BY c.relname;
+              relname               | indisvisible 
+------------------------------------+--------------
+ test_part_invisible_parent_1_a_idx | t
+ test_part_invisible_parent_2_a_idx | t
+ test_part_invisible_parent_a_idx   | t
+(3 rows)
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_part_invisible_parent WHERE a = 42;
+                                                   QUERY PLAN                                                   
+----------------------------------------------------------------------------------------------------------------
+ Index Scan using test_part_invisible_parent_1_a_idx on test_part_invisible_parent_1 test_part_invisible_parent
+   Index Cond: (a = 42)
+(2 rows)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+-- Group P5: multi-level partitioning
+CREATE TABLE test_part_invisible_multi
+(
+    a int,
+    b int,
+    c text
+) PARTITION BY RANGE (a);
+CREATE TABLE test_part_invisible_multi_1
+    PARTITION OF test_part_invisible_multi
+    FOR VALUES FROM (0) TO (1000)
+    PARTITION BY RANGE (b);
+CREATE TABLE test_part_invisible_multi_1_1
+    PARTITION OF test_part_invisible_multi_1
+    FOR VALUES FROM (0) TO (1000);
+CREATE TABLE test_part_invisible_multi_1_2
+    PARTITION OF test_part_invisible_multi_1
+    FOR VALUES FROM (1000) TO (2000);
+CREATE INDEX test_part_invisible_multi_a_idx
+    ON test_part_invisible_multi(a);
+ALTER INDEX test_part_invisible_multi_a_idx INVISIBLE;
+SELECT c.relname, c.relkind, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_multi%'
+ORDER BY c.relname;
+               relname               | relkind | indisvisible 
+-------------------------------------+---------+--------------
+ test_part_invisible_multi_1_1_a_idx | i       | f
+ test_part_invisible_multi_1_2_a_idx | i       | f
+ test_part_invisible_multi_1_a_idx   | I       | f
+ test_part_invisible_multi_a_idx     | I       | f
+(4 rows)
+
+ALTER INDEX test_part_invisible_multi_a_idx VISIBLE;
+SELECT c.relname, c.relkind, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_multi%'
+ORDER BY c.relname;
+               relname               | relkind | indisvisible 
+-------------------------------------+---------+--------------
+ test_part_invisible_multi_1_1_a_idx | i       | t
+ test_part_invisible_multi_1_2_a_idx | i       | t
+ test_part_invisible_multi_1_a_idx   | I       | t
+ test_part_invisible_multi_a_idx     | I       | t
+(4 rows)
+
+DROP TABLE test_part_invisible_multi;
+-- Group P6: prepared statement / plan cache invalidation
+SET plan_cache_mode = force_generic_plan;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+PREPARE q_part_invisible(int) AS
+    SELECT * FROM test_part_invisible_parent WHERE a = $1;
+EXPLAIN (COSTS OFF) EXECUTE q_part_invisible(42);
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
+ Append
+   Subplans Removed: 1
+   ->  Index Scan using test_part_invisible_parent_1_a_idx on test_part_invisible_parent_1
+         Index Cond: (a = $1)
+(4 rows)
+
+ALTER INDEX test_part_invisible_parent_a_idx INVISIBLE;
+EXPLAIN (COSTS OFF) EXECUTE q_part_invisible(42);
+                   QUERY PLAN                   
+------------------------------------------------
+ Append
+   Subplans Removed: 1
+   ->  Seq Scan on test_part_invisible_parent_1
+         Disabled: true
+         Filter: (a = $1)
+(5 rows)
+
+ALTER INDEX test_part_invisible_parent_a_idx VISIBLE;
+EXPLAIN (COSTS OFF) EXECUTE q_part_invisible(42);
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
+ Append
+   Subplans Removed: 1
+   ->  Index Scan using test_part_invisible_parent_1_a_idx on test_part_invisible_parent_1
+         Index Cond: (a = $1)
+(4 rows)
+
+DEALLOCATE q_part_invisible;
+RESET plan_cache_mode;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE test_part_invisible_parent;
+-- Group P7: DML maintenance and PRIMARY KEY enforcement
+CREATE TABLE test_part_invisible_unique
+(
+    a int,
+    b int,
+    PRIMARY KEY (a)
+) PARTITION BY RANGE (a);
+CREATE TABLE test_part_invisible_unique_1
+    PARTITION OF test_part_invisible_unique
+    FOR VALUES FROM (0) TO (1000);
+CREATE TABLE test_part_invisible_unique_2
+    PARTITION OF test_part_invisible_unique
+    FOR VALUES FROM (1000) TO (2000);
+INSERT INTO test_part_invisible_unique VALUES (1, 10), (1500, 20);
+ALTER INDEX test_part_invisible_unique_pkey INVISIBLE;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_unique%pkey'
+ORDER BY c.relname;
+              relname              | indisvisible 
+-----------------------------------+--------------
+ test_part_invisible_unique_1_pkey | f
+ test_part_invisible_unique_2_pkey | f
+ test_part_invisible_unique_pkey   | f
+(3 rows)
+
+DO $$
+BEGIN
+    BEGIN
+        INSERT INTO test_part_invisible_unique VALUES (1, 99);
+        RAISE EXCEPTION 'expected unique violation was not raised';
+    EXCEPTION WHEN unique_violation THEN
+        RAISE NOTICE 'unique violation correctly raised';
+    END;
+END $$;
+NOTICE:  unique violation correctly raised
+DROP TABLE test_part_invisible_unique;
+-- Group P9: REINDEX preserves partitioned-index visibility
+CREATE TABLE test_part_invisible_reindex(a int) PARTITION BY RANGE (a);
+CREATE TABLE test_part_invisible_reindex_1
+    PARTITION OF test_part_invisible_reindex
+    FOR VALUES FROM (0) TO (1000);
+CREATE TABLE test_part_invisible_reindex_2
+    PARTITION OF test_part_invisible_reindex
+    FOR VALUES FROM (1000) TO (2000);
+CREATE INDEX test_part_invisible_reindex_a_idx
+    ON test_part_invisible_reindex(a) INVISIBLE;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_reindex%'
+ORDER BY c.relname;
+               relname               | indisvisible 
+-------------------------------------+--------------
+ test_part_invisible_reindex_1_a_idx | f
+ test_part_invisible_reindex_2_a_idx | f
+ test_part_invisible_reindex_a_idx   | f
+(3 rows)
+
+REINDEX INDEX test_part_invisible_reindex_a_idx;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_reindex%'
+ORDER BY c.relname;
+               relname               | indisvisible 
+-------------------------------------+--------------
+ test_part_invisible_reindex_1_a_idx | f
+ test_part_invisible_reindex_2_a_idx | f
+ test_part_invisible_reindex_a_idx   | f
+(3 rows)
+
+-- direct REINDEX of a leaf child partition index
+REINDEX INDEX CONCURRENTLY test_part_invisible_reindex_1_a_idx;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_reindex%'
+ORDER BY c.relname;
+               relname               | indisvisible 
+-------------------------------------+--------------
+ test_part_invisible_reindex_1_a_idx | f
+ test_part_invisible_reindex_2_a_idx | f
+ test_part_invisible_reindex_a_idx   | f
+(3 rows)
+
+DROP TABLE test_part_invisible_reindex;
+-- Group P10: CREATE INDEX ... INVISIBLE on partitioned table propagates
+CREATE TABLE test_part_create_invisible(a int, b int) PARTITION BY RANGE (a);
+CREATE TABLE test_part_create_invisible_1
+    PARTITION OF test_part_create_invisible
+    FOR VALUES FROM (0) TO (1000);
+CREATE TABLE test_part_create_invisible_2
+    PARTITION OF test_part_create_invisible
+    FOR VALUES FROM (1000) TO (2000);
+CREATE INDEX test_part_create_invisible_a_idx
+    ON test_part_create_invisible(a) INVISIBLE;
+CREATE INDEX test_part_create_visible_b_idx
+    ON test_part_create_invisible(b) VISIBLE;
+-- Identify each partitioned-index hierarchy by its parent's OID, then
+-- include the parent itself, so the test never matches the wrong index
+-- through a name pattern.
+WITH parent(oid, relname) AS (
+    SELECT c.oid, c.relname
+      FROM pg_class c
+     WHERE c.relname IN ('test_part_create_invisible_a_idx',
+                         'test_part_create_visible_b_idx')
+)
+SELECT c.relname, i.indisvisible
+FROM pg_index i
+JOIN pg_class c ON c.oid = i.indexrelid
+LEFT JOIN pg_inherits inh ON inh.inhrelid = c.oid
+WHERE c.oid IN (SELECT oid FROM parent)
+   OR inh.inhparent IN (SELECT oid FROM parent)
+ORDER BY c.relname;
+              relname               | indisvisible 
+------------------------------------+--------------
+ test_part_create_invisible_1_a_idx | f
+ test_part_create_invisible_1_b_idx | t
+ test_part_create_invisible_2_a_idx | f
+ test_part_create_invisible_2_b_idx | t
+ test_part_create_invisible_a_idx   | f
+ test_part_create_visible_b_idx     | t
+(6 rows)
+
+ALTER INDEX test_part_create_invisible_a_idx VISIBLE;
+WITH parent(oid) AS (
+    SELECT c.oid
+      FROM pg_class c
+     WHERE c.relname = 'test_part_create_invisible_a_idx'
+)
+SELECT c.relname, i.indisvisible
+FROM pg_index i
+JOIN pg_class c ON c.oid = i.indexrelid
+LEFT JOIN pg_inherits inh ON inh.inhrelid = c.oid
+WHERE c.oid IN (SELECT oid FROM parent)
+   OR inh.inhparent IN (SELECT oid FROM parent)
+ORDER BY c.relname;
+              relname               | indisvisible 
+------------------------------------+--------------
+ test_part_create_invisible_1_a_idx | t
+ test_part_create_invisible_2_a_idx | t
+ test_part_create_invisible_a_idx   | t
+(3 rows)
+
+DROP TABLE test_part_create_invisible;
+-- Group P11: no-op ALTER INDEX visibility commands are accepted.
+-- Re-issuing the same value must succeed without error.  The
+-- indcheckxmin safety check performed at planning time is existing
+-- PostgreSQL behavior: it compares the pg_index tuple xmin against
+-- TransactionXmin and skips the index for transactions that might
+-- see broken HOT chains, regardless of indisvisible.  ALTER INDEX
+-- ... VISIBLE only restores catalog visibility; it does not bypass
+-- that check.  The check is covered by existing code-path review
+-- and cannot be reproduced deterministically from regression SQL
+-- without directly mutating pg_index, so no SQL test is added here.
+CREATE TABLE test_part_invisible_noop(a int);
+CREATE INDEX test_part_invisible_noop_idx ON test_part_invisible_noop(a);
+ALTER INDEX test_part_invisible_noop_idx INVISIBLE;
+ALTER INDEX test_part_invisible_noop_idx INVISIBLE; -- no-op, must succeed
+ALTER INDEX test_part_invisible_noop_idx VISIBLE;
+ALTER INDEX test_part_invisible_noop_idx VISIBLE;   -- no-op, must succeed
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_part_invisible_noop_idx'::regclass;
+ indisvisible 
+--------------
+ t
+(1 row)
+
+DROP TABLE test_part_invisible_noop;
+-- Group P12: ALTER INDEX ... ATTACH PARTITION does not rewrite the child
+-- index's pg_index.indisvisible.  The attached child keeps whatever
+-- visibility it already had; alignment with the partitioned parent must
+-- come from a later recursive ALTER INDEX on the parent.
+-- Case 1: invisible parent, visible child
+CREATE TABLE attach_vis_parent (
+    a int NOT NULL,
+    b text
+) PARTITION BY RANGE (a);
+CREATE TABLE attach_vis_child (
+    a int NOT NULL,
+    b text
+);
+ALTER TABLE attach_vis_parent
+    ATTACH PARTITION attach_vis_child FOR VALUES FROM (0) TO (100);
+-- Build the parent partitioned index ON ONLY so it does not auto-create
+-- a child, then create the child index manually with a different
+-- visibility from the parent.
+CREATE INDEX attach_vis_parent_a_idx
+    ON ONLY attach_vis_parent(a) INVISIBLE;
+CREATE INDEX attach_vis_child_a_idx
+    ON attach_vis_child(a);
+-- Pre-attach: parent invisible, child visible.
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis_parent_a_idx', 'attach_vis_child_a_idx')
+ORDER BY c.relname;
+         relname         | indisvisible 
+-------------------------+--------------
+ attach_vis_child_a_idx  | t
+ attach_vis_parent_a_idx | f
+(2 rows)
+
+ALTER INDEX attach_vis_parent_a_idx
+    ATTACH PARTITION attach_vis_child_a_idx;
+-- ATTACH PARTITION must not rewrite the child's indisvisible: the child
+-- remains visible even though the parent is invisible.
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis_parent_a_idx', 'attach_vis_child_a_idx')
+ORDER BY c.relname;
+         relname         | indisvisible 
+-------------------------+--------------
+ attach_vis_child_a_idx  | t
+ attach_vis_parent_a_idx | f
+(2 rows)
+
+-- Recursive ALTER INDEX parent INVISIBLE aligns the hierarchy.  The
+-- parent is already invisible, but the recursion still propagates the
+-- new value to the attached child.
+ALTER INDEX attach_vis_parent_a_idx INVISIBLE;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis_parent_a_idx', 'attach_vis_child_a_idx')
+ORDER BY c.relname;
+         relname         | indisvisible 
+-------------------------+--------------
+ attach_vis_child_a_idx  | f
+ attach_vis_parent_a_idx | f
+(2 rows)
+
+-- Recursive ALTER INDEX parent VISIBLE flips the whole hierarchy back.
+ALTER INDEX attach_vis_parent_a_idx VISIBLE;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis_parent_a_idx', 'attach_vis_child_a_idx')
+ORDER BY c.relname;
+         relname         | indisvisible 
+-------------------------+--------------
+ attach_vis_child_a_idx  | t
+ attach_vis_parent_a_idx | t
+(2 rows)
+
+DROP TABLE attach_vis_parent;
+-- Case 2: visible parent, invisible child
+CREATE TABLE attach_vis2_parent (
+    a int NOT NULL,
+    b text
+) PARTITION BY RANGE (a);
+CREATE TABLE attach_vis2_child (
+    a int NOT NULL,
+    b text
+);
+ALTER TABLE attach_vis2_parent
+    ATTACH PARTITION attach_vis2_child FOR VALUES FROM (0) TO (100);
+CREATE INDEX attach_vis2_parent_a_idx
+    ON ONLY attach_vis2_parent(a);
+CREATE INDEX attach_vis2_child_a_idx
+    ON attach_vis2_child(a) INVISIBLE;
+-- Pre-attach: parent visible, child invisible.
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis2_parent_a_idx', 'attach_vis2_child_a_idx')
+ORDER BY c.relname;
+         relname          | indisvisible 
+--------------------------+--------------
+ attach_vis2_child_a_idx  | f
+ attach_vis2_parent_a_idx | t
+(2 rows)
+
+ALTER INDEX attach_vis2_parent_a_idx
+    ATTACH PARTITION attach_vis2_child_a_idx;
+-- ATTACH PARTITION must not rewrite the child's indisvisible: the child
+-- remains invisible even though the parent is visible.
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis2_parent_a_idx', 'attach_vis2_child_a_idx')
+ORDER BY c.relname;
+         relname          | indisvisible 
+--------------------------+--------------
+ attach_vis2_child_a_idx  | f
+ attach_vis2_parent_a_idx | t
+(2 rows)
+
+-- Recursive ALTER INDEX parent VISIBLE aligns the hierarchy.  The
+-- parent is already visible, but the recursion still propagates the
+-- new value to the attached child.
+ALTER INDEX attach_vis2_parent_a_idx VISIBLE;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis2_parent_a_idx', 'attach_vis2_child_a_idx')
+ORDER BY c.relname;
+         relname          | indisvisible 
+--------------------------+--------------
+ attach_vis2_child_a_idx  | t
+ attach_vis2_parent_a_idx | t
+(2 rows)
+
+DROP TABLE attach_vis2_parent;
 -- Failure for unauthorized user
 CREATE ROLE regress_reindexuser NOLOGIN;
 SET SESSION ROLE regress_reindexuser;
diff --git a/src/test/regress/sql/create_index.sql b/src/test/regress/sql/create_index.sql
index 82e4062a215..4dfc1bf672e 100644
--- a/src/test/regress/sql/create_index.sql
+++ b/src/test/regress/sql/create_index.sql
@@ -1500,6 +1500,783 @@ END;
 -- concurrently
 REINDEX SCHEMA CONCURRENTLY schema_to_reindex;
 
+--
+-- Index visibility (ALTER INDEX ... VISIBLE / INVISIBLE)
+--
+
+-- Group 1: basic planner visibility
+CREATE TABLE test_invisible_index(a int, b text);
+INSERT INTO test_invisible_index
+    SELECT i, md5(i::text)
+    FROM generate_series(1, 10000) i;
+CREATE INDEX test_invisible_index_a_idx ON test_invisible_index(a);
+ANALYZE test_invisible_index;
+
+SELECT indisvalid, indisready, indislive, indisvisible
+FROM pg_index WHERE indexrelid = 'test_invisible_index_a_idx'::regclass;
+
+-- Force a plan that uses the index when it is visible.  Invisible-index
+-- expectations below do not need to be stabilized: with no usable index
+-- the planner falls back to a sequential scan even with enable_seqscan
+-- and enable_bitmapscan turned off.
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invisible_index WHERE a = 42;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+
+ALTER INDEX test_invisible_index_a_idx INVISIBLE;
+SELECT indisvalid, indisready, indislive, indisvisible
+FROM pg_index WHERE indexrelid = 'test_invisible_index_a_idx'::regclass;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invisible_index WHERE a = 42;
+
+ALTER INDEX test_invisible_index_a_idx VISIBLE;
+SELECT indisvalid, indisready, indislive, indisvisible
+FROM pg_index WHERE indexrelid = 'test_invisible_index_a_idx'::regclass;
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invisible_index WHERE a = 42;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+
+-- Group 2: CREATE INDEX ... INVISIBLE
+CREATE INDEX test_invisible_index_b_idx
+    ON test_invisible_index(b) INVISIBLE;
+ANALYZE test_invisible_index;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_index_b_idx'::regclass;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invisible_index WHERE b = md5('42');
+
+ALTER INDEX test_invisible_index_b_idx VISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_index_b_idx'::regclass;
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invisible_index WHERE b = md5('42');
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+
+-- Default visibility for plain CREATE INDEX
+CREATE INDEX test_invisible_index_default_idx ON test_invisible_index(a);
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_index_default_idx'::regclass;
+
+-- Explicit VISIBLE keyword
+CREATE INDEX test_invisible_index_visible_idx
+    ON test_invisible_index(a) VISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_index_visible_idx'::regclass;
+
+DROP INDEX test_invisible_index_default_idx;
+DROP INDEX test_invisible_index_visible_idx;
+
+-- Group 3: prepared statement / plan cache invalidation
+CREATE TABLE test_invisible_plan_cache(a int, b text);
+INSERT INTO test_invisible_plan_cache
+    SELECT i, md5(i::text)
+    FROM generate_series(1, 10000) i;
+CREATE INDEX test_invisible_plan_cache_a_idx
+    ON test_invisible_plan_cache(a);
+ANALYZE test_invisible_plan_cache;
+
+SET plan_cache_mode = force_generic_plan;
+-- Force the visible-side plans to use the index regardless of cost.  The
+-- invisible-side plan still drops to a sequential scan, since no usable
+-- index exists.
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+
+PREPARE q_invisible_cache(int) AS
+    SELECT * FROM test_invisible_plan_cache WHERE a = $1;
+
+EXPLAIN (COSTS OFF) EXECUTE q_invisible_cache(42);
+
+-- Visible -> invisible: cached plan must be replanned, no longer use the index
+ALTER INDEX test_invisible_plan_cache_a_idx INVISIBLE;
+EXPLAIN (COSTS OFF) EXECUTE q_invisible_cache(42);
+
+-- Invisible -> visible: cached plan must be replanned, can use the index again
+ALTER INDEX test_invisible_plan_cache_a_idx VISIBLE;
+EXPLAIN (COSTS OFF) EXECUTE q_invisible_cache(42);
+
+DEALLOCATE q_invisible_cache;
+RESET plan_cache_mode;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+
+-- Group 4: DML still maintains invisible indexes
+CREATE TABLE test_invisible_dml(a int, b int);
+CREATE UNIQUE INDEX test_invisible_dml_a_idx
+    ON test_invisible_dml(a) INVISIBLE;
+
+INSERT INTO test_invisible_dml VALUES (1, 10);
+INSERT INTO test_invisible_dml VALUES (2, 20);
+
+-- uniqueness must still be enforced while invisible
+DO $$
+BEGIN
+    BEGIN
+        INSERT INTO test_invisible_dml VALUES (1, 30);
+        RAISE EXCEPTION 'expected unique violation was not raised';
+    EXCEPTION WHEN unique_violation THEN
+        RAISE NOTICE 'unique violation correctly raised';
+    END;
+END $$;
+
+ALTER INDEX test_invisible_dml_a_idx VISIBLE;
+ANALYZE test_invisible_dml;
+
+-- After visibility flips back, the index is usable, proving it was
+-- maintained while invisible.
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invisible_dml WHERE a = 1;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+
+-- Group 5: primary key / constraint-backed indexes
+CREATE TABLE test_invisible_pk(a int PRIMARY KEY, b int);
+INSERT INTO test_invisible_pk VALUES (1, 10), (2, 20);
+
+ALTER INDEX test_invisible_pk_pkey INVISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_pk_pkey'::regclass;
+
+DO $$
+BEGIN
+    BEGIN
+        INSERT INTO test_invisible_pk VALUES (1, 30);
+        RAISE EXCEPTION 'expected unique violation was not raised';
+    EXCEPTION WHEN unique_violation THEN
+        RAISE NOTICE 'unique violation correctly raised';
+    END;
+END $$;
+
+\d test_invisible_pk
+
+ALTER INDEX test_invisible_pk_pkey VISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_pk_pkey'::regclass;
+
+-- Group 6: pg_get_indexdef deparse
+ALTER INDEX test_invisible_index_b_idx INVISIBLE;
+SELECT pg_get_indexdef('test_invisible_index_b_idx'::regclass);
+ALTER INDEX test_invisible_index_b_idx VISIBLE;
+SELECT pg_get_indexdef('test_invisible_index_b_idx'::regclass);
+
+-- Group 7: psql \d annotations.  Both \d <table> and \d <indexname>
+-- render invisible indexes using the descriptive ", invisible" style.
+ALTER INDEX test_invisible_index_b_idx INVISIBLE;
+\d test_invisible_index
+\d test_invisible_index_b_idx
+ALTER INDEX test_invisible_index_b_idx VISIBLE;
+\d test_invisible_index_b_idx
+
+-- Group 8: REINDEX preserves visibility
+CREATE TABLE test_invisible_reindex(a int);
+CREATE INDEX test_invisible_reindex_idx
+    ON test_invisible_reindex(a) INVISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_reindex_idx'::regclass;
+
+REINDEX INDEX test_invisible_reindex_idx;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_reindex_idx'::regclass;
+
+REINDEX INDEX CONCURRENTLY test_invisible_reindex_idx;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_reindex_idx'::regclass;
+
+ALTER INDEX test_invisible_reindex_idx VISIBLE;
+REINDEX INDEX CONCURRENTLY test_invisible_reindex_idx;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_reindex_idx'::regclass;
+
+-- Group 9: ALTER TABLE ALTER COLUMN TYPE rebuild path
+CREATE TABLE test_invisible_alter_type(a int);
+CREATE INDEX test_invisible_alter_type_idx
+    ON test_invisible_alter_type(a) INVISIBLE;
+ALTER TABLE test_invisible_alter_type
+    ALTER COLUMN a TYPE bigint;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invisible_alter_type_idx'::regclass;
+
+-- Group 10: expression index visibility and ANALYZE behavior.
+-- Confirm that (a) the planner ignores an invisible expression index
+-- and uses it once visible, and (b) ANALYZE still collects statistics
+-- for an invisible expression index, since invisibility is a planner
+-- hint only and does not stop other catalog-level work.
+CREATE TABLE test_invis_expr(a int, b int);
+INSERT INTO test_invis_expr
+    SELECT i, i * 2
+    FROM generate_series(1, 1000) AS i;
+CREATE INDEX test_invis_expr_idx
+    ON test_invis_expr ((a + b)) INVISIBLE;
+ANALYZE test_invis_expr;
+-- ANALYZE collected statistics on the expression-index attribute even
+-- though the index is invisible.
+SELECT COUNT(*) > 0 AS has_expr_stats
+FROM pg_statistic
+WHERE starelid = 'test_invis_expr_idx'::regclass;
+-- Planner ignores the invisible expression index.
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invis_expr WHERE (a + b) = 30;
+-- Made visible: planner picks the expression index back up.
+ALTER INDEX test_invis_expr_idx VISIBLE;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invis_expr WHERE (a + b) = 30;
+-- Made invisible again: planner falls back to a sequential scan.
+ALTER INDEX test_invis_expr_idx INVISIBLE;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invis_expr WHERE (a + b) = 30;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+
+-- Group 11: UNIQUE constraint-backed invisible index.
+-- A UNIQUE constraint declared inline gets a backing index (named
+-- <table>_<column>_key by default).  Marking that index invisible must
+-- hide it from the planner but must NOT disable the UNIQUE constraint:
+-- duplicate inserts must still be rejected.
+CREATE TABLE test_invis_unique_constraint
+(
+    a int,
+    b text,
+    UNIQUE (a)
+);
+INSERT INTO test_invis_unique_constraint
+    SELECT i, md5(i::text)
+    FROM generate_series(1, 1000) AS i;
+ANALYZE test_invis_unique_constraint;
+ALTER INDEX test_invis_unique_constraint_a_key INVISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invis_unique_constraint_a_key'::regclass;
+-- Planner should not use the invisible unique backing index.
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_invis_unique_constraint WHERE a = 1;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+-- Constraint enforcement still works.  Wrap the duplicate insert in a
+-- DO block so the regression output stays stable, mirroring Group 5.
+DO $$
+BEGIN
+    BEGIN
+        INSERT INTO test_invis_unique_constraint VALUES (1, 'duplicate');
+        RAISE EXCEPTION 'expected unique violation was not raised';
+    EXCEPTION WHEN unique_violation THEN
+        RAISE NOTICE 'unique violation correctly raised';
+    END;
+END $$;
+ALTER INDEX test_invis_unique_constraint_a_key VISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invis_unique_constraint_a_key'::regclass;
+DROP TABLE test_invis_unique_constraint;
+
+-- Group 12: ALTER TABLE ... ADD CONSTRAINT ... USING INDEX with an
+-- already-invisible unique index.  Promoting an invisible index into a
+-- constraint must preserve indisvisible = false; indisvalid /
+-- indisready / indislive remain true; uniqueness must be enforced.
+CREATE TABLE test_invis_using_idx
+(
+    a int NOT NULL,
+    b text
+);
+CREATE UNIQUE INDEX test_invis_using_idx_a_idx
+    ON test_invis_using_idx(a) INVISIBLE;
+SELECT indisvisible, indisvalid, indisready, indislive FROM pg_index
+WHERE indexrelid = 'test_invis_using_idx_a_idx'::regclass;
+-- Promote: ADD CONSTRAINT USING INDEX renames the index to the
+-- constraint name.  The renamed index must still be invisible.
+ALTER TABLE test_invis_using_idx
+    ADD CONSTRAINT test_invis_using_idx_a_key
+    UNIQUE USING INDEX test_invis_using_idx_a_idx;
+SELECT c.relname, i.indisvisible, i.indisvalid, i.indisready, i.indislive
+FROM pg_index i
+JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('test_invis_using_idx_a_idx',
+                    'test_invis_using_idx_a_key')
+ORDER BY c.relname;
+-- Constraint enforcement still works on the renamed, still-invisible
+-- backing index.
+INSERT INTO test_invis_using_idx VALUES (1, 'one');
+DO $$
+BEGIN
+    BEGIN
+        INSERT INTO test_invis_using_idx VALUES (1, 'duplicate');
+        RAISE EXCEPTION 'expected unique violation was not raised';
+    EXCEPTION WHEN unique_violation THEN
+        RAISE NOTICE 'unique violation correctly raised';
+    END;
+END $$;
+-- Flip the renamed constraint-backed index visible.
+ALTER INDEX test_invis_using_idx_a_key VISIBLE;
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_invis_using_idx_a_key'::regclass;
+DROP TABLE test_invis_using_idx;
+
+-- Group 13: psql \d INVISIBLE-suffix stripping on non-trivial index shapes.
+-- pg_get_indexdef() unconditionally appends " INVISIBLE" after any
+-- WITH/TABLESPACE/WHERE clause for invisible indexes; describe.c strips
+-- that suffix and renders the descriptive ", invisible" annotation
+-- instead.  Exercise that path against an expression index, a partial
+-- index, and a partitioned (ON ONLY) index so the strip-and-relabel
+-- logic is verified for shapes whose deparse output is non-trivial.
+CREATE TABLE test_invis_psql(a int, b int);
+CREATE INDEX test_invis_psql_expr_idx
+    ON test_invis_psql ((a + b)) INVISIBLE;
+CREATE INDEX test_invis_psql_partial_idx
+    ON test_invis_psql (a) WHERE a > 0;
+ALTER INDEX test_invis_psql_partial_idx INVISIBLE;
+-- pg_get_indexdef must show " INVISIBLE" at the very end.
+SELECT pg_get_indexdef('test_invis_psql_expr_idx'::regclass);
+SELECT pg_get_indexdef('test_invis_psql_partial_idx'::regclass);
+-- \d <table> shows the descriptive ", invisible" annotation, not the
+-- raw " INVISIBLE" SQL keyword.
+\d test_invis_psql
+DROP TABLE test_invis_psql;
+
+-- Partitioned (ON ONLY) parent index whose deparse includes INVISIBLE.
+CREATE TABLE test_invis_psql_part(a int) PARTITION BY RANGE (a);
+CREATE TABLE test_invis_psql_part_p1
+    PARTITION OF test_invis_psql_part FOR VALUES FROM (0) TO (100);
+CREATE INDEX test_invis_psql_part_idx
+    ON ONLY test_invis_psql_part(a) INVISIBLE;
+SELECT pg_get_indexdef('test_invis_psql_part_idx'::regclass);
+\d test_invis_psql_part
+DROP TABLE test_invis_psql_part;
+
+-- Cleanup
+DROP TABLE test_invisible_index;
+DROP TABLE test_invisible_plan_cache;
+DROP TABLE test_invisible_dml;
+DROP TABLE test_invisible_pk;
+DROP TABLE test_invisible_reindex;
+DROP TABLE test_invisible_alter_type;
+DROP TABLE test_invis_expr;
+
+--
+-- Index visibility on partitioned tables / partitioned indexes
+--
+
+-- Group P1: leaf partition index invisibility
+CREATE TABLE test_part_invisible_leaf
+(
+    a int,
+    b text
+) PARTITION BY RANGE (a);
+CREATE TABLE test_part_invisible_leaf_1
+    PARTITION OF test_part_invisible_leaf
+    FOR VALUES FROM (0) TO (1000);
+CREATE TABLE test_part_invisible_leaf_2
+    PARTITION OF test_part_invisible_leaf
+    FOR VALUES FROM (1000) TO (2000);
+INSERT INTO test_part_invisible_leaf
+    SELECT i, md5(i::text)
+    FROM generate_series(1, 1999) i;
+CREATE INDEX test_part_invisible_leaf_1_a_idx
+    ON test_part_invisible_leaf_1(a);
+CREATE INDEX test_part_invisible_leaf_2_a_idx
+    ON test_part_invisible_leaf_2(a);
+ANALYZE test_part_invisible_leaf;
+
+ALTER INDEX test_part_invisible_leaf_1_a_idx INVISIBLE;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_leaf%'
+ORDER BY c.relname;
+
+-- The pt1 partition has no usable index, so a sequential scan is correct.
+-- Force the visible side (the pt2 partition lookup, and after VISIBLE the
+-- pt1 lookup) to use the index, regardless of cost.
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_part_invisible_leaf WHERE a = 42;
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_part_invisible_leaf WHERE a = 1042;
+
+ALTER INDEX test_part_invisible_leaf_1_a_idx VISIBLE;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_part_invisible_leaf WHERE a = 42;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+
+DROP TABLE test_part_invisible_leaf;
+
+-- Group P2 / P3 / P4: parent partitioned index invisibility, catalog state,
+-- unrelated indexes left alone
+CREATE TABLE test_part_invisible_parent
+(
+    a int,
+    b text
+) PARTITION BY RANGE (a);
+CREATE TABLE test_part_invisible_parent_1
+    PARTITION OF test_part_invisible_parent
+    FOR VALUES FROM (0) TO (1000);
+CREATE TABLE test_part_invisible_parent_2
+    PARTITION OF test_part_invisible_parent
+    FOR VALUES FROM (1000) TO (2000);
+INSERT INTO test_part_invisible_parent
+    SELECT i, md5(i::text)
+    FROM generate_series(1, 1999) i;
+CREATE INDEX test_part_invisible_parent_a_idx
+    ON test_part_invisible_parent(a);
+CREATE INDEX test_part_invisible_parent_b_idx
+    ON test_part_invisible_parent(b);
+ANALYZE test_part_invisible_parent;
+
+ALTER INDEX test_part_invisible_parent_a_idx INVISIBLE;
+
+-- P3: parent and every attached child index in the 'a' hierarchy invisible;
+-- P4: the 'b' hierarchy must remain visible.  Identify each hierarchy by
+-- the parent index's OID rather than by name pattern.
+WITH parents(oid, relname) AS (
+    SELECT c.oid, c.relname
+      FROM pg_class c
+     WHERE c.relname IN ('test_part_invisible_parent_a_idx',
+                         'test_part_invisible_parent_b_idx')
+)
+SELECT c.relname, i.indisvisible
+FROM pg_index i
+JOIN pg_class c ON c.oid = i.indexrelid
+LEFT JOIN pg_inherits inh ON inh.inhrelid = c.oid
+WHERE c.oid IN (SELECT oid FROM parents)
+   OR inh.inhparent IN (SELECT oid FROM parents)
+ORDER BY c.relname;
+
+-- P2: planner must avoid the (now invisible) child indexes
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_part_invisible_parent WHERE a = 42;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_part_invisible_parent WHERE a = 1042;
+
+ALTER INDEX test_part_invisible_parent_a_idx VISIBLE;
+
+WITH parents(oid) AS (
+    SELECT c.oid FROM pg_class c
+     WHERE c.relname = 'test_part_invisible_parent_a_idx'
+)
+SELECT c.relname, i.indisvisible
+FROM pg_index i
+JOIN pg_class c ON c.oid = i.indexrelid
+LEFT JOIN pg_inherits inh ON inh.inhrelid = c.oid
+WHERE c.oid IN (SELECT oid FROM parents)
+   OR inh.inhparent IN (SELECT oid FROM parents)
+ORDER BY c.relname;
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM test_part_invisible_parent WHERE a = 42;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+
+-- Group P5: multi-level partitioning
+CREATE TABLE test_part_invisible_multi
+(
+    a int,
+    b int,
+    c text
+) PARTITION BY RANGE (a);
+CREATE TABLE test_part_invisible_multi_1
+    PARTITION OF test_part_invisible_multi
+    FOR VALUES FROM (0) TO (1000)
+    PARTITION BY RANGE (b);
+CREATE TABLE test_part_invisible_multi_1_1
+    PARTITION OF test_part_invisible_multi_1
+    FOR VALUES FROM (0) TO (1000);
+CREATE TABLE test_part_invisible_multi_1_2
+    PARTITION OF test_part_invisible_multi_1
+    FOR VALUES FROM (1000) TO (2000);
+CREATE INDEX test_part_invisible_multi_a_idx
+    ON test_part_invisible_multi(a);
+
+ALTER INDEX test_part_invisible_multi_a_idx INVISIBLE;
+SELECT c.relname, c.relkind, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_multi%'
+ORDER BY c.relname;
+
+ALTER INDEX test_part_invisible_multi_a_idx VISIBLE;
+SELECT c.relname, c.relkind, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_multi%'
+ORDER BY c.relname;
+
+DROP TABLE test_part_invisible_multi;
+
+-- Group P6: prepared statement / plan cache invalidation
+SET plan_cache_mode = force_generic_plan;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+
+PREPARE q_part_invisible(int) AS
+    SELECT * FROM test_part_invisible_parent WHERE a = $1;
+EXPLAIN (COSTS OFF) EXECUTE q_part_invisible(42);
+
+ALTER INDEX test_part_invisible_parent_a_idx INVISIBLE;
+EXPLAIN (COSTS OFF) EXECUTE q_part_invisible(42);
+
+ALTER INDEX test_part_invisible_parent_a_idx VISIBLE;
+EXPLAIN (COSTS OFF) EXECUTE q_part_invisible(42);
+
+DEALLOCATE q_part_invisible;
+RESET plan_cache_mode;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+
+DROP TABLE test_part_invisible_parent;
+
+-- Group P7: DML maintenance and PRIMARY KEY enforcement
+CREATE TABLE test_part_invisible_unique
+(
+    a int,
+    b int,
+    PRIMARY KEY (a)
+) PARTITION BY RANGE (a);
+CREATE TABLE test_part_invisible_unique_1
+    PARTITION OF test_part_invisible_unique
+    FOR VALUES FROM (0) TO (1000);
+CREATE TABLE test_part_invisible_unique_2
+    PARTITION OF test_part_invisible_unique
+    FOR VALUES FROM (1000) TO (2000);
+INSERT INTO test_part_invisible_unique VALUES (1, 10), (1500, 20);
+
+ALTER INDEX test_part_invisible_unique_pkey INVISIBLE;
+
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_unique%pkey'
+ORDER BY c.relname;
+
+DO $$
+BEGIN
+    BEGIN
+        INSERT INTO test_part_invisible_unique VALUES (1, 99);
+        RAISE EXCEPTION 'expected unique violation was not raised';
+    EXCEPTION WHEN unique_violation THEN
+        RAISE NOTICE 'unique violation correctly raised';
+    END;
+END $$;
+
+DROP TABLE test_part_invisible_unique;
+
+-- Group P9: REINDEX preserves partitioned-index visibility
+CREATE TABLE test_part_invisible_reindex(a int) PARTITION BY RANGE (a);
+CREATE TABLE test_part_invisible_reindex_1
+    PARTITION OF test_part_invisible_reindex
+    FOR VALUES FROM (0) TO (1000);
+CREATE TABLE test_part_invisible_reindex_2
+    PARTITION OF test_part_invisible_reindex
+    FOR VALUES FROM (1000) TO (2000);
+CREATE INDEX test_part_invisible_reindex_a_idx
+    ON test_part_invisible_reindex(a) INVISIBLE;
+
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_reindex%'
+ORDER BY c.relname;
+
+REINDEX INDEX test_part_invisible_reindex_a_idx;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_reindex%'
+ORDER BY c.relname;
+
+-- direct REINDEX of a leaf child partition index
+REINDEX INDEX CONCURRENTLY test_part_invisible_reindex_1_a_idx;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname LIKE 'test_part_invisible_reindex%'
+ORDER BY c.relname;
+
+DROP TABLE test_part_invisible_reindex;
+
+-- Group P10: CREATE INDEX ... INVISIBLE on partitioned table propagates
+CREATE TABLE test_part_create_invisible(a int, b int) PARTITION BY RANGE (a);
+CREATE TABLE test_part_create_invisible_1
+    PARTITION OF test_part_create_invisible
+    FOR VALUES FROM (0) TO (1000);
+CREATE TABLE test_part_create_invisible_2
+    PARTITION OF test_part_create_invisible
+    FOR VALUES FROM (1000) TO (2000);
+
+CREATE INDEX test_part_create_invisible_a_idx
+    ON test_part_create_invisible(a) INVISIBLE;
+CREATE INDEX test_part_create_visible_b_idx
+    ON test_part_create_invisible(b) VISIBLE;
+
+-- Identify each partitioned-index hierarchy by its parent's OID, then
+-- include the parent itself, so the test never matches the wrong index
+-- through a name pattern.
+WITH parent(oid, relname) AS (
+    SELECT c.oid, c.relname
+      FROM pg_class c
+     WHERE c.relname IN ('test_part_create_invisible_a_idx',
+                         'test_part_create_visible_b_idx')
+)
+SELECT c.relname, i.indisvisible
+FROM pg_index i
+JOIN pg_class c ON c.oid = i.indexrelid
+LEFT JOIN pg_inherits inh ON inh.inhrelid = c.oid
+WHERE c.oid IN (SELECT oid FROM parent)
+   OR inh.inhparent IN (SELECT oid FROM parent)
+ORDER BY c.relname;
+
+ALTER INDEX test_part_create_invisible_a_idx VISIBLE;
+
+WITH parent(oid) AS (
+    SELECT c.oid
+      FROM pg_class c
+     WHERE c.relname = 'test_part_create_invisible_a_idx'
+)
+SELECT c.relname, i.indisvisible
+FROM pg_index i
+JOIN pg_class c ON c.oid = i.indexrelid
+LEFT JOIN pg_inherits inh ON inh.inhrelid = c.oid
+WHERE c.oid IN (SELECT oid FROM parent)
+   OR inh.inhparent IN (SELECT oid FROM parent)
+ORDER BY c.relname;
+
+DROP TABLE test_part_create_invisible;
+
+-- Group P11: no-op ALTER INDEX visibility commands are accepted.
+-- Re-issuing the same value must succeed without error.  The
+-- indcheckxmin safety check performed at planning time is existing
+-- PostgreSQL behavior: it compares the pg_index tuple xmin against
+-- TransactionXmin and skips the index for transactions that might
+-- see broken HOT chains, regardless of indisvisible.  ALTER INDEX
+-- ... VISIBLE only restores catalog visibility; it does not bypass
+-- that check.  The check is covered by existing code-path review
+-- and cannot be reproduced deterministically from regression SQL
+-- without directly mutating pg_index, so no SQL test is added here.
+CREATE TABLE test_part_invisible_noop(a int);
+CREATE INDEX test_part_invisible_noop_idx ON test_part_invisible_noop(a);
+ALTER INDEX test_part_invisible_noop_idx INVISIBLE;
+ALTER INDEX test_part_invisible_noop_idx INVISIBLE; -- no-op, must succeed
+ALTER INDEX test_part_invisible_noop_idx VISIBLE;
+ALTER INDEX test_part_invisible_noop_idx VISIBLE;   -- no-op, must succeed
+SELECT indisvisible FROM pg_index
+WHERE indexrelid = 'test_part_invisible_noop_idx'::regclass;
+DROP TABLE test_part_invisible_noop;
+
+-- Group P12: ALTER INDEX ... ATTACH PARTITION does not rewrite the child
+-- index's pg_index.indisvisible.  The attached child keeps whatever
+-- visibility it already had; alignment with the partitioned parent must
+-- come from a later recursive ALTER INDEX on the parent.
+
+-- Case 1: invisible parent, visible child
+CREATE TABLE attach_vis_parent (
+    a int NOT NULL,
+    b text
+) PARTITION BY RANGE (a);
+CREATE TABLE attach_vis_child (
+    a int NOT NULL,
+    b text
+);
+ALTER TABLE attach_vis_parent
+    ATTACH PARTITION attach_vis_child FOR VALUES FROM (0) TO (100);
+
+-- Build the parent partitioned index ON ONLY so it does not auto-create
+-- a child, then create the child index manually with a different
+-- visibility from the parent.
+CREATE INDEX attach_vis_parent_a_idx
+    ON ONLY attach_vis_parent(a) INVISIBLE;
+CREATE INDEX attach_vis_child_a_idx
+    ON attach_vis_child(a);
+
+-- Pre-attach: parent invisible, child visible.
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis_parent_a_idx', 'attach_vis_child_a_idx')
+ORDER BY c.relname;
+
+ALTER INDEX attach_vis_parent_a_idx
+    ATTACH PARTITION attach_vis_child_a_idx;
+
+-- ATTACH PARTITION must not rewrite the child's indisvisible: the child
+-- remains visible even though the parent is invisible.
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis_parent_a_idx', 'attach_vis_child_a_idx')
+ORDER BY c.relname;
+
+-- Recursive ALTER INDEX parent INVISIBLE aligns the hierarchy.  The
+-- parent is already invisible, but the recursion still propagates the
+-- new value to the attached child.
+ALTER INDEX attach_vis_parent_a_idx INVISIBLE;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis_parent_a_idx', 'attach_vis_child_a_idx')
+ORDER BY c.relname;
+
+-- Recursive ALTER INDEX parent VISIBLE flips the whole hierarchy back.
+ALTER INDEX attach_vis_parent_a_idx VISIBLE;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis_parent_a_idx', 'attach_vis_child_a_idx')
+ORDER BY c.relname;
+
+DROP TABLE attach_vis_parent;
+
+-- Case 2: visible parent, invisible child
+CREATE TABLE attach_vis2_parent (
+    a int NOT NULL,
+    b text
+) PARTITION BY RANGE (a);
+CREATE TABLE attach_vis2_child (
+    a int NOT NULL,
+    b text
+);
+ALTER TABLE attach_vis2_parent
+    ATTACH PARTITION attach_vis2_child FOR VALUES FROM (0) TO (100);
+
+CREATE INDEX attach_vis2_parent_a_idx
+    ON ONLY attach_vis2_parent(a);
+CREATE INDEX attach_vis2_child_a_idx
+    ON attach_vis2_child(a) INVISIBLE;
+
+-- Pre-attach: parent visible, child invisible.
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis2_parent_a_idx', 'attach_vis2_child_a_idx')
+ORDER BY c.relname;
+
+ALTER INDEX attach_vis2_parent_a_idx
+    ATTACH PARTITION attach_vis2_child_a_idx;
+
+-- ATTACH PARTITION must not rewrite the child's indisvisible: the child
+-- remains invisible even though the parent is visible.
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis2_parent_a_idx', 'attach_vis2_child_a_idx')
+ORDER BY c.relname;
+
+-- Recursive ALTER INDEX parent VISIBLE aligns the hierarchy.  The
+-- parent is already visible, but the recursion still propagates the
+-- new value to the attached child.
+ALTER INDEX attach_vis2_parent_a_idx VISIBLE;
+SELECT c.relname, i.indisvisible
+FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
+WHERE c.relname IN ('attach_vis2_parent_a_idx', 'attach_vis2_child_a_idx')
+ORDER BY c.relname;
+
+DROP TABLE attach_vis2_parent;
+
 -- Failure for unauthorized user
 CREATE ROLE regress_reindexuser NOLOGIN;
 SET SESSION ROLE regress_reindexuser;
