From 8be4d5a0d9e89c2c3ec478331f5af7e371d48a7c Mon Sep 17 00:00:00 2001
From: Vignesh C <c_vigneshwaran@apple.com>
Date: Mon, 10 Aug 2026 21:22:15 +0530
Subject: [PATCH v26 1/3] Support EXCEPT clause for schema-level publications

Extend table exclusion support in publications to allow specific
tables to be excluded from schema-level publications using an
EXCEPT clause in CREATE PUBLICATION.

Supported syntax:
CREATE PUBLICATION pub FOR TABLES IN SCHEMA s EXCEPT (TABLE t1,...);

This reuses the existing opt_pub_except_clause grammar and stores
excluded tables as pg_publication_rel entries with prexcept = true,
requiring no catalog changes.

NOTES
 - Each EXCEPT clause applies only to its own schema; cross-schema
   references are rejected.
 - A schema may appear only once with an EXCEPT clause.
 - A table cannot be both explicitly published and excluded in the
   same statement.
 - Only partition roots may be excluded. Excluding a root excludes the
   entire partition tree, including partitions in other published schemas.
   Individual partitions cannot be added while their root is excluded, and
   ATTACH PARTITION is rejected if the attached table is excluded.
 - ALTER TABLE ... SET SCHEMA removes schema-scoped exclusions, matching
   existing schema-publication behavior.
 - psql describe output and tab completion are updated to support the new syntax.
---
 src/backend/catalog/pg_publication.c        | 237 ++++++++++++++---
 src/backend/commands/publicationcmds.c      | 254 +++++++++++++++++-
 src/backend/commands/tablecmds.c            |  24 +-
 src/backend/parser/gram.y                   |  27 +-
 src/backend/replication/pgoutput/pgoutput.c |  37 ++-
 src/backend/utils/cache/relcache.c          |  26 +-
 src/bin/psql/describe.c                     |  44 +++-
 src/bin/psql/tab-complete.in.c              |  35 ++-
 src/include/catalog/pg_publication.h        |   1 +
 src/include/commands/publicationcmds.h      |   2 +
 src/include/nodes/parsenodes.h              |   2 +
 src/test/regress/expected/publication.out   | 278 +++++++++++++++++++-
 src/test/regress/sql/publication.sql        | 178 ++++++++++++-
 src/test/subscription/t/037_except.pl       | 261 +++++++++++++++++-
 14 files changed, 1319 insertions(+), 87 deletions(-)

diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 12af7d15536..9d8824f92e4 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -54,7 +54,7 @@ typedef struct
  * error if not.
  */
 static void
-check_publication_add_relation(PublicationRelInfo *pri)
+check_publication_add_relation(PublicationRelInfo *pri, Oid pubid)
 {
 	Relation	targetrel = pri->relation;
 	const char *relname;
@@ -71,12 +71,51 @@ check_publication_add_relation(PublicationRelInfo *pri)
 		errormsg = gettext_noop("cannot add relation \"%s\" to publication");
 	}
 
-	/* If in EXCEPT clause, must be root partitioned table */
-	if (pri->except && targetrel->rd_rel->relispartition)
-		ereport(ERROR,
-				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-				 errmsg(errormsg, relname),
-				 errdetail("This operation is not supported for individual partitions.")));
+	/*
+	 * A partition cannot be added to a publication if its partition root is
+	 * excluded by the publication. We follow the rule that excluding a
+	 * partition root means that the entire partition tree is excluded. Thus,
+	 * check whether the root is in the EXCEPT clause and reject adding the
+	 * individual partition to the publication.
+	 */
+	if (targetrel->rd_rel->relispartition)
+	{
+		List	   *ancestors;
+
+		if (pri->except)
+		{
+			/* If in EXCEPT clause, must be root partitioned table */
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg(errormsg, relname),
+					 errdetail("This operation is not supported for individual partitions.")));
+		}
+
+		/*
+		 * ancestors is NIL for a partition with a pending DETACH
+		 * CONCURRENTLY, in which case there is no root to check.
+		 */
+		ancestors = get_partition_ancestors(RelationGetRelid(targetrel));
+		if (ancestors != NIL)
+		{
+			Oid			root = llast_oid(ancestors);
+			bool		root_except;
+
+			if (CheckPublicationRelEntry(pubid, root, &root_except) && root_except)
+				ereport(ERROR,
+						errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						errmsg("cannot add partition \"%s\" to publication \"%s\"",
+							   RelationGetQualifiedRelationName(targetrel),
+							   get_publication_name(pubid, false)),
+						errdetail("Partition root \"%s\" is named in the publication's EXCEPT clause for schema \"%s\".",
+								  quote_qualified_identifier(get_namespace_name(get_rel_namespace(root)),
+															 get_rel_name(root)),
+								  get_namespace_name(get_rel_namespace(root))),
+						errhint("Change the EXCEPT clause using ALTER PUBLICATION ... SET TABLES IN SCHEMA ... EXCEPT."));
+		}
+
+		list_free(ancestors);
+	}
 
 	/* Must be a regular or partitioned table */
 	if (RelationGetForm(targetrel)->relkind != RELKIND_RELATION &&
@@ -320,8 +359,7 @@ is_schema_publication(Oid pubid)
 }
 
 /*
- * Returns true if the publication has explicitly included relation (i.e.,
- * not marked as EXCEPT).
+ * Returns true if the publication has a FOR TABLE clause.
  */
 bool
 is_table_publication(Oid pubid)
@@ -341,19 +379,23 @@ is_table_publication(Oid pubid)
 	scan = systable_beginscan(pubrelsrel,
 							  PublicationRelPrpubidIndexId,
 							  true, NULL, 1, &scankey);
-	tup = systable_getnext(scan);
-	if (HeapTupleIsValid(tup))
-	{
-		Form_pg_publication_rel pubrel;
 
-		pubrel = (Form_pg_publication_rel) GETSTRUCT(tup);
-
-		/*
-		 * For any publication, pg_publication_rel contains either only EXCEPT
-		 * entries or only explicitly included tables. Therefore, examining
-		 * the first tuple is sufficient to determine table inclusion.
-		 */
-		result = !pubrel->prexcept;
+	/*
+	 * A publication can have both explicitly included (prexcept = false) and
+	 * EXCEPT (prexcept = true) rows for the same pubid. For example:
+	 *
+	 * CREATE PUBLICATION pub1 FOR TABLE s1.t1, TABLES IN SCHEMA s2 EXCEPT
+	 * (TABLE s2.t1);
+	 *
+	 * Scan until we find an explicitly included row.
+	 */
+	while (HeapTupleIsValid(tup = systable_getnext(scan)))
+	{
+		if (!((Form_pg_publication_rel) GETSTRUCT(tup))->prexcept)
+		{
+			result = true;
+			break;
+		}
 	}
 
 	systable_endscan(scan);
@@ -362,6 +404,27 @@ is_table_publication(Oid pubid)
 	return result;
 }
 
+/*
+ * Check whether a pg_publication_rel row exists for (pubid, relid); if so,
+ * set *is_except to its prexcept flag.
+ */
+bool
+CheckPublicationRelEntry(Oid pubid, Oid relid, bool *is_except)
+{
+	HeapTuple	tup;
+
+	tup = SearchSysCache2(PUBLICATIONRELMAP,
+						  ObjectIdGetDatum(relid),
+						  ObjectIdGetDatum(pubid));
+	if (!HeapTupleIsValid(tup))
+		return false;
+
+	*is_except = ((Form_pg_publication_rel) GETSTRUCT(tup))->prexcept;
+	ReleaseSysCache(tup);
+
+	return true;
+}
+
 /*
  * Returns true if the relation has column list associated with the
  * publication, false otherwise.
@@ -457,11 +520,26 @@ GetPubPartitionOptionRelations(List *result, PublicationPartOpt pub_partopt,
  * ancestor is at the end of the list.
  */
 Oid
-GetTopMostAncestorInPublication(Oid puboid, List *ancestors, int *ancestor_level)
+GetTopMostAncestorInPublication(Oid puboid, List *ancestors,
+								int *ancestor_level)
 {
 	ListCell   *lc;
 	Oid			topmost_relid = InvalidOid;
 	int			level = 0;
+	bool		root_except;
+
+	if (ancestors == NIL)
+		return InvalidOid;
+
+	/*
+	 * If the partition root is excluded from this publication via an EXCEPT
+	 * clause, the partition is not published through this publication, so
+	 * return InvalidOid.  Since only partition roots can appear in an EXCEPT
+	 * clause, the root is the only ancestor worth a catalog lookup.
+	 */
+	if (CheckPublicationRelEntry(puboid, llast_oid(ancestors), &root_except) &&
+		root_except)
+		return InvalidOid;
 
 	/*
 	 * Find the "topmost" ancestor that is in this publication.
@@ -548,6 +626,7 @@ publication_add_relation(Oid pubid, PublicationRelInfo *pri,
 	List	   *relids = NIL;
 	int			i;
 	bool		inval_except_table;
+	bool		is_except;
 
 	rel = table_open(PublicationRelRelationId, RowExclusiveLock);
 
@@ -556,21 +635,30 @@ publication_add_relation(Oid pubid, PublicationRelInfo *pri,
 	 * duplicates, it's here just to provide nicer error message in common
 	 * case. The real protection is the unique key on the catalog.
 	 */
-	if (SearchSysCacheExists2(PUBLICATIONRELMAP, ObjectIdGetDatum(relid),
-							  ObjectIdGetDatum(pubid)))
+	if (CheckPublicationRelEntry(pubid, relid, &is_except))
 	{
 		table_close(rel, RowExclusiveLock);
 
 		if (if_not_exists)
 			return InvalidObjectAddress;
 
-		ereport(ERROR,
-				(errcode(ERRCODE_DUPLICATE_OBJECT),
-				 errmsg("relation \"%s\" is already a member of publication \"%s\"",
-						RelationGetRelationName(targetrel), pub->name)));
+		if (is_except)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("cannot add table \"%s\" to publication \"%s\"",
+							RelationGetQualifiedRelationName(targetrel),
+							pub->name),
+					 errdetail("The table is named in the publication's EXCEPT clause for schema \"%s\".",
+							   get_namespace_name(RelationGetNamespace(targetrel))),
+					 errhint("Change the EXCEPT clause using ALTER PUBLICATION ... SET TABLES IN SCHEMA ... EXCEPT.")));
+		else
+			ereport(ERROR,
+					(errcode(ERRCODE_DUPLICATE_OBJECT),
+					 errmsg("relation \"%s\" is already a member of publication \"%s\"",
+							RelationGetRelationName(targetrel), pub->name)));
 	}
 
-	check_publication_add_relation(pri);
+	check_publication_add_relation(pri, pubid);
 
 	/* Validate and translate column names into a Bitmapset of attnums. */
 	attnums = pub_collist_validate(pri->relation, pri->columns);
@@ -996,12 +1084,13 @@ GetIncludedPublicationRelations(Oid pubid, PublicationPartOpt pub_partopt)
  * Gets list of table oids that were specified in the EXCEPT clause for a
  * publication.
  *
- * This should only be used FOR ALL TABLES publications.
+ * This is used for FOR ALL TABLES and FOR TABLES IN SCHEMA publications,
+ * both of which support EXCEPT TABLE.
  */
 List *
 GetExcludedPublicationTables(Oid pubid, PublicationPartOpt pub_partopt)
 {
-	Assert(GetPublication(pubid)->alltables);
+	Assert(GetPublication(pubid)->alltables || is_schema_publication(pubid));
 
 	return get_publication_relations(pubid, pub_partopt, true);
 }
@@ -1246,22 +1335,63 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
 
 /*
  * Gets the list of all relations published by FOR TABLES IN SCHEMA
- * publication.
+ * publication, excluding any tables listed in EXCEPT clauses.
  */
 List *
 GetAllSchemaPublicationRelations(Oid pubid, PublicationPartOpt pub_partopt)
 {
 	List	   *result = NIL;
 	List	   *pubschemalist = GetPublicationSchemas(pubid);
+	List	   *except_relids = NIL;
 	ListCell   *cell;
 
+	/* get the list of tables excluded via EXCEPT TABLE for this publication */
+	if (pubschemalist != NIL)
+		except_relids = GetExcludedPublicationTables(pubid, pub_partopt);
+
 	foreach(cell, pubschemalist)
 	{
 		Oid			schemaid = lfirst_oid(cell);
 		List	   *schemaRels = NIL;
 
 		schemaRels = GetSchemaPublicationRelations(schemaid, pub_partopt);
-		result = list_concat(result, schemaRels);
+
+		if (except_relids != NIL)
+		{
+			/* filter out any tables that appear in the EXCEPT list */
+			foreach_oid(relid, schemaRels)
+			{
+				bool		excluded = list_member_oid(except_relids, relid);
+
+				/*
+				 * A partition whose root is excluded from the publication is
+				 * also excluded, even if the partition itself lives in a
+				 * different (included) schema.  Only the topmost root of a
+				 * partition hierarchy can ever appear in EXCEPT (see
+				 * check_publication_add_relation()), so it's enough to check
+				 * the last element of the ancestors list, rather than walking
+				 * the whole chain.
+				 */
+				if (!excluded && get_rel_relispartition(relid))
+				{
+					List	   *ancestors = get_partition_ancestors(relid);
+
+					if (ancestors != NIL)
+					{
+						excluded = list_member_oid(except_relids,
+												   llast_oid(ancestors));
+						list_free(ancestors);
+					}
+				}
+
+				if (!excluded)
+					result = lappend_oid(result, relid);
+			}
+
+			list_free(schemaRels);
+		}
+		else
+			result = list_concat(result, schemaRels);
 	}
 
 	return result;
@@ -1338,6 +1468,7 @@ is_table_publishable_in_publication(Oid relid, Publication *pub)
 {
 	bool		relispartition;
 	List	   *ancestors = NIL;
+	bool		is_except;
 
 	/*
 	 * For non-pubviaroot publications, a partitioned table is never the
@@ -1385,6 +1516,10 @@ is_table_publishable_in_publication(Oid relid, Publication *pub)
 	 */
 
 	/*
+	 * If the partition root is excluded via the EXCEPT clause, the partition
+	 * is never considered to be published through this publication.  Check
+	 * this before checking whether an ancestor is published.
+	 *
 	 * If an ancestor is published, the partition's status depends on
 	 * publish_via_partition_root value.
 	 *
@@ -1394,20 +1529,38 @@ is_table_publishable_in_publication(Oid relid, Publication *pub)
 	 * If it's false, the partition is covered by its ancestor's presence in
 	 * the publication, it should be included (return true).
 	 */
-	if (relispartition &&
-		OidIsValid(GetTopMostAncestorInPublication(pub->oid, ancestors, NULL)))
-		return !pub->pubviaroot;
+	if (relispartition)
+	{
+		/*
+		 * ancestors can be NIL for a partition whose DETACH ... CONCURRENTLY
+		 * is pending, in which case there is no ancestor to check.
+		 */
+		if (ancestors != NIL &&
+			CheckPublicationRelEntry(pub->oid, llast_oid(ancestors), &is_except) &&
+			is_except)
+			return false;
+
+		if (OidIsValid(GetTopMostAncestorInPublication(pub->oid, ancestors, NULL)))
+			return !pub->pubviaroot;
+	}
 
 	/*
 	 * Check whether the table is explicitly published via pg_publication_rel
 	 * or pg_publication_namespace.
+	 *
+	 * A pg_publication_rel row with prexcept=true means the table is
+	 * explicitly excluded via EXCEPT and must not be reported as published,
+	 * even if its schema is otherwise included.  A row with prexcept=false
+	 * means it is explicitly included.  If no pg_publication_rel row exists,
+	 * the table is published iff its schema appears in
+	 * pg_publication_namespace.
 	 */
-	return (SearchSysCacheExists2(PUBLICATIONRELMAP,
-								  ObjectIdGetDatum(relid),
-								  ObjectIdGetDatum(pub->oid)) ||
-			SearchSysCacheExists2(PUBLICATIONNAMESPACEMAP,
-								  ObjectIdGetDatum(get_rel_namespace(relid)),
-								  ObjectIdGetDatum(pub->oid)));
+	if (CheckPublicationRelEntry(pub->oid, relid, &is_except))
+		return !is_except;
+	else
+		return SearchSysCacheExists2(PUBLICATIONNAMESPACEMAP,
+									 ObjectIdGetDatum(get_rel_namespace(relid)),
+									 ObjectIdGetDatum(pub->oid));
 }
 
 /*
diff --git a/src/backend/commands/publicationcmds.c b/src/backend/commands/publicationcmds.c
index 440adb356ad..8c39950bcda 100644
--- a/src/backend/commands/publicationcmds.c
+++ b/src/backend/commands/publicationcmds.c
@@ -22,6 +22,7 @@
 #include "catalog/namespace.h"
 #include "catalog/objectaccess.h"
 #include "catalog/objectaddress.h"
+#include "catalog/partition.h"
 #include "catalog/pg_database.h"
 #include "catalog/pg_inherits.h"
 #include "catalog/pg_namespace.h"
@@ -71,6 +72,11 @@ static void PublicationAddSchemas(Oid pubid, List *schemas, bool if_not_exists,
 								  AlterPublicationStmt *stmt);
 static void PublicationDropSchemas(Oid pubid, List *schemas, bool missing_ok);
 static char defGetGeneratedColsOption(DefElem *def);
+static void CheckExceptNotInTableList(List *except_rels, List *explicitrelids);
+static void ProcessSchemaExceptTables(Oid schemaid, List *except_tables,
+									  ParseState *pstate, List **schemas,
+									  List **schemas_with_except,
+									  List **except_pubtables);
 
 
 static void
@@ -175,16 +181,87 @@ parse_publication_options(ParseState *pstate,
 	}
 }
 
+/*
+ * Also rejects a schema being mentioned more than once with an EXCEPT
+ * clause, even if the EXCEPT clauses are identical — much like
+ * OpenTableList() rejects "FOR TABLE t1(a), t1(a)" despite the column
+ * lists matching. A schema can still be mentioned multiple times, just
+ * not more than once with EXCEPT.
+ *
+ * Qualify unqualified EXCEPT table names with the given schema (rejecting
+ * any explicitly qualified with a different schema), and append them to
+ * *except_pubtables.
+ *
+ * schemaid: OID of the schema for this TABLES IN SCHEMA mention.
+ * except_tables: the EXCEPT list (or NIL) attached to this mention.
+ * pstate: parse state of the statement, used to report the error position of
+ * an offending EXCEPT entry.
+ * *schemas: accumulates all schema OIDs seen so far in this statement.
+ * *schemas_with_except: This is a subset of *schemas. Tracks which of
+ * the seen schemas of this statement had an EXCEPT clause.
+ * *except_pubtables: accumulates the (now schema-qualified) EXCEPT table
+ * entries across the whole statement.
+ */
+static void
+ProcessSchemaExceptTables(Oid schemaid, List *except_tables,
+						  ParseState *pstate, List **schemas,
+						  List **schemas_with_except, List **except_pubtables)
+{
+	char	   *schema_name = get_namespace_name(schemaid);
+	bool		schema_has_except = (except_tables != NIL);
+
+	/*
+	 * A repeating schema is only a problem if this mention has EXCEPT, or an
+	 * earlier mention of the same schema did.
+	 */
+	if (list_member_oid(*schemas, schemaid))
+	{
+		if (schema_has_except ||
+			list_member_oid(*schemas_with_except, schemaid))
+			ereport(ERROR,
+					errcode(ERRCODE_DUPLICATE_OBJECT),
+					errmsg("conflicting or redundant EXCEPT clauses for schema \"%s\"",
+						   schema_name));
+	}
+
+	if (schema_has_except)
+		*schemas_with_except = lappend_oid(*schemas_with_except, schemaid);
+
+	/* Filter out duplicates if the user specifies "sch1, sch1" */
+	*schemas = list_append_unique_oid(*schemas, schemaid);
+
+	if (!schema_has_except)
+		return;
+
+	foreach_ptr(PublicationObjSpec, eobj, except_tables)
+	{
+		RangeVar   *relation = eobj->pubtable->relation;
+
+		if (relation->schemaname == NULL)
+			relation->schemaname = schema_name;
+		else if (strcmp(relation->schemaname, schema_name) != 0)
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
+					errmsg("table \"%s\" in EXCEPT clause does not belong to schema \"%s\"",
+						   quote_qualified_identifier(relation->schemaname, relation->relname),
+						   schema_name),
+					parser_errposition(pstate, eobj->location));
+
+		*except_pubtables = lappend(*except_pubtables, eobj->pubtable);
+	}
+}
+
 /*
  * Convert the PublicationObjSpecType list into schema oid list and
  * PublicationTable list.
  */
 static void
 ObjectsInPublicationToOids(List *pubobjspec_list, ParseState *pstate,
-						   List **rels, List **exceptrels, List **schemas)
+						   List **rels, List **except_pubtables, List **schemas)
 {
 	ListCell   *cell;
 	PublicationObjSpec *pubobj;
+	List	   *schemas_with_except = NIL;
 
 	if (!pubobjspec_list)
 		return;
@@ -200,7 +277,7 @@ ObjectsInPublicationToOids(List *pubobjspec_list, ParseState *pstate,
 		{
 			case PUBLICATIONOBJ_EXCEPT_TABLE:
 				pubobj->pubtable->except = true;
-				*exceptrels = lappend(*exceptrels, pubobj->pubtable);
+				*except_pubtables = lappend(*except_pubtables, pubobj->pubtable);
 				break;
 			case PUBLICATIONOBJ_TABLE:
 				pubobj->pubtable->except = false;
@@ -209,8 +286,10 @@ ObjectsInPublicationToOids(List *pubobjspec_list, ParseState *pstate,
 			case PUBLICATIONOBJ_TABLES_IN_SCHEMA:
 				schemaid = get_namespace_oid(pubobj->name, false);
 
-				/* Filter out duplicates if user specifies "sch1, sch1" */
-				*schemas = list_append_unique_oid(*schemas, schemaid);
+				ProcessSchemaExceptTables(schemaid, pubobj->except_tables,
+										  pstate, schemas,
+										  &schemas_with_except,
+										  except_pubtables);
 				break;
 			case PUBLICATIONOBJ_TABLES_IN_CUR_SCHEMA:
 				search_path = fetch_search_path(false);
@@ -222,8 +301,10 @@ ObjectsInPublicationToOids(List *pubobjspec_list, ParseState *pstate,
 				schemaid = linitial_oid(search_path);
 				list_free(search_path);
 
-				/* Filter out duplicates if user specifies "sch1, sch1" */
-				*schemas = list_append_unique_oid(*schemas, schemaid);
+				ProcessSchemaExceptTables(schemaid, pubobj->except_tables,
+										  pstate, schemas,
+										  &schemas_with_except,
+										  except_pubtables);
 				break;
 			default:
 				/* shouldn't happen */
@@ -770,6 +851,68 @@ TransformPubWhereClauses(List *tables, const char *queryString,
 	}
 }
 
+/*
+ * Check that a table is not both excluded and published in the same DDL.
+ *
+ * Both checks are performed here, on the lists supplied by the statement,
+ * because at this point the catalog cannot answer either question. The
+ * corresponding checks in publication_add_relation() and
+ * check_publication_add_relation() rely on pg_publication_rel entries and
+ * ancestor lookups, neither of which is sufficient in the cases below:
+ *
+ * - CreatePublication() inserts the explicit tables and the EXCEPT tables in
+ *   a single command, and rows inserted by the current command are not
+ *   visible to later lookups within the same command.
+ *
+ * - In the partition case, the excluded root's row might not yet exist
+ *   because the root may be added to the EXCEPT list later. Detecting such
+ *   contradictions when adding the root to the EXCEPT clause would require
+ *   traversing downward through the partition hierarchy, whereas the
+ *   existing ancestor lookups only traverse upward from the relation being
+ *   added.
+ */
+static void
+CheckExceptNotInTableList(List *except_rels, List *explicitrelids)
+{
+	foreach_oid(explicitrelid, explicitrelids)
+	{
+		Oid			root = InvalidOid;
+
+		if (get_rel_relispartition(explicitrelid))
+		{
+			List	   *ancestors = get_partition_ancestors(explicitrelid);
+
+			/*
+			 * ancestors is NIL for a partition with a pending DETACH
+			 * CONCURRENTLY.
+			 */
+			if (ancestors != NIL)
+				root = llast_oid(ancestors);
+			list_free(ancestors);
+		}
+
+		foreach_ptr(PublicationRelInfo, pri, except_rels)
+		{
+			Oid			exceptrelid = RelationGetRelid(pri->relation);
+
+			if (exceptrelid == explicitrelid)
+				ereport(ERROR,
+						errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						errmsg("table \"%s\" cannot be both published and excluded",
+							   RelationGetQualifiedRelationName(pri->relation)));
+
+			if (OidIsValid(root) && exceptrelid == root)
+				ereport(ERROR,
+						(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						 errmsg("partition \"%s\" cannot be both published and excluded",
+								quote_qualified_identifier(get_namespace_name(get_rel_namespace(explicitrelid)),
+														   get_rel_name(explicitrelid))),
+						 errdetail("Partition root \"%s\" is named in the publication's EXCEPT clause for schema \"%s\".",
+								   RelationGetQualifiedRelationName(pri->relation),
+								   get_namespace_name(RelationGetNamespace(pri->relation)))));
+		}
+	}
+}
 
 /*
  * Given a list of tables that are going to be added to a publication,
@@ -849,7 +992,7 @@ CreatePublication(ParseState *pstate, CreatePublicationStmt *stmt)
 	char		publish_generated_columns;
 	AclResult	aclresult;
 	List	   *relations = NIL;
-	List	   *exceptrelations = NIL;
+	List	   *except_pubtables = NIL;
 	List	   *schemaidlist = NIL;
 
 	/* must have CREATE privilege on database */
@@ -936,16 +1079,16 @@ CreatePublication(ParseState *pstate, CreatePublicationStmt *stmt)
 
 	/* Associate objects with the publication. */
 	ObjectsInPublicationToOids(stmt->pubobjects, pstate, &relations,
-							   &exceptrelations, &schemaidlist);
+							   &except_pubtables, &schemaidlist);
 
 	if (stmt->for_all_tables)
 	{
 		/* Process EXCEPT table list */
-		if (exceptrelations != NIL)
+		if (except_pubtables != NIL)
 		{
 			List	   *rels;
 
-			rels = OpenTableList(exceptrelations);
+			rels = OpenTableList(except_pubtables);
 			PublicationAddTables(puboid, rels, true, NULL);
 			CloseTableList(rels);
 		}
@@ -959,6 +1102,11 @@ CreatePublication(ParseState *pstate, CreatePublicationStmt *stmt)
 	}
 	else if (!stmt->for_all_sequences)
 	{
+		List	   *explicitrelids = NIL;
+
+		/* EXCEPT tables here always belong to a schema in schemaidlist */
+		Assert(except_pubtables == NIL || schemaidlist != NIL);
+
 		/* FOR TABLES IN SCHEMA requires superuser */
 		if (schemaidlist != NIL && !superuser())
 			ereport(ERROR,
@@ -978,6 +1126,20 @@ CreatePublication(ParseState *pstate, CreatePublicationStmt *stmt)
 									   publish_via_partition_root);
 
 			PublicationAddTables(puboid, rels, true, NULL);
+
+			/*
+			 * Collect explicit table OIDs now, before we close the relation
+			 * list, so that the except-table validation for pub-included
+			 * schemas below can check for contradictions without relying on a
+			 * catalog scan that might not yet see the just-inserted rows.
+			 */
+			if (except_pubtables != NIL)
+			{
+				foreach_ptr(PublicationRelInfo, pri, rels)
+					explicitrelids = lappend_oid(explicitrelids,
+												 RelationGetRelid(pri->relation));
+			}
+
 			CloseTableList(rels);
 		}
 
@@ -989,6 +1151,22 @@ CreatePublication(ParseState *pstate, CreatePublicationStmt *stmt)
 			 */
 			LockSchemaList(schemaidlist);
 			PublicationAddSchemas(puboid, schemaidlist, true, NULL);
+
+			if (except_pubtables != NIL)
+			{
+				List	   *except_rels;
+
+				except_rels = OpenTableList(except_pubtables);
+
+				/*
+				 * Validate that a table is not both explicitly included and
+				 * excluded by the schema's EXCEPT clause.
+				 */
+				CheckExceptNotInTableList(except_rels, explicitrelids);
+
+				PublicationAddTables(puboid, except_rels, true, NULL);
+				CloseTableList(except_rels);
+			}
 		}
 	}
 
@@ -1829,6 +2007,62 @@ RemovePublicationSchemaById(Oid psoid)
 	table_close(rel, RowExclusiveLock);
 }
 
+/*
+ * Remove any EXCEPT clause entries for a relation from schema publications.
+ * Called when a table changes schema (ALTER TABLE ... SET SCHEMA), so that
+ * a schema-scoped exclusion does not silently follow the table to its new
+ * schema.
+ */
+void
+RemoveSchemaPubExceptForRel(Oid relid, Oid oldNspOid, Oid newNspOid)
+{
+	List	   *pubids;
+
+	/*
+	 * If the table is not actually moving to a different schema (no-op ALTER
+	 * TABLE ... SET SCHEMA <current_schema>), there is nothing to do.
+	 */
+	if (oldNspOid == newNspOid)
+		return;
+
+	pubids = GetRelationExcludedPublications(relid);
+
+	foreach_oid(pubid, pubids)
+	{
+		Oid			proid;
+
+		/*
+		 * This problem does not apply to FOR ALL TABLES publications, because
+		 * their EXCEPT clause is publication-scoped, not schema-scoped: the
+		 * exclusion should persist regardless of what schema the table is in.
+		 */
+		if (!is_schema_publication(pubid))
+			continue;
+
+		proid = GetSysCacheOid2(PUBLICATIONRELMAP,
+								Anum_pg_publication_rel_oid,
+								ObjectIdGetDatum(relid),
+								ObjectIdGetDatum(pubid));
+		if (OidIsValid(proid))
+		{
+			ObjectAddress obj;
+
+			ObjectAddressSet(obj, PublicationRelRelationId, proid);
+
+			ereport(DEBUG2,
+					errmsg_internal("auto-remove exclusion of table \"%s.%s\" from publication \"%s\": table moved to schema \"%s\"",
+									get_namespace_name(oldNspOid),
+									get_rel_name(relid),
+									get_publication_name(pubid, false),
+									get_namespace_name(newNspOid)));
+
+			performDeletion(&obj, DROP_CASCADE, 0);
+		}
+	}
+
+	list_free(pubids);
+}
+
 /*
  * Open relations specified by a PublicationTable list.
  * The returned tables are locked in ShareUpdateExclusiveLock mode in order to
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 6857315c071..d6620046b51 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -49,6 +49,7 @@
 #include "catalog/pg_opclass.h"
 #include "catalog/pg_policy.h"
 #include "catalog/pg_proc.h"
+#include "catalog/pg_publication.h"
 #include "catalog/pg_publication_rel.h"
 #include "catalog/pg_rewrite.h"
 #include "catalog/pg_statistic_ext.h"
@@ -63,6 +64,7 @@
 #include "commands/event_trigger.h"
 #include "commands/extension.h"
 #include "commands/repack.h"
+#include "commands/publicationcmds.h"
 #include "commands/sequence.h"
 #include "commands/tablecmds.h"
 #include "commands/tablespace.h"
@@ -19781,6 +19783,16 @@ AlterTableNamespaceInternal(Relation rel, Oid oldNspOid, Oid nspOid,
 	AlterConstraintNamespaces(RelationGetRelid(rel), oldNspOid, nspOid,
 							  false, objsMoved);
 
+	/*
+	 * Remove any EXCEPT clause entries for this relation from schema
+	 * publications.  A schema-scoped exclusion is no longer meaningful once
+	 * the table moves to a different schema.
+	 */
+	if (rel->rd_rel->relkind == RELKIND_RELATION ||
+		rel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
+		RemoveSchemaPubExceptForRel(RelationGetRelid(rel), oldNspOid,
+									nspOid);
+
 	table_close(classRel, RowExclusiveLock);
 }
 
@@ -21114,6 +21126,7 @@ ATExecAttachPartition(List **wqueue, Relation rel, PartitionCmd *cmd,
 	if (exceptpuboids != NIL)
 	{
 		bool		first = true;
+		bool		has_alltables_pub = false;
 		StringInfoData pubnames;
 
 		initStringInfo(&pubnames);
@@ -21127,17 +21140,22 @@ ATExecAttachPartition(List **wqueue, Relation rel, PartitionCmd *cmd,
 			else
 				appendStringInfo(&pubnames, _(", \"%s\""), pubname);
 			first = false;
+
+			if (GetPublication(pubid)->alltables)
+				has_alltables_pub = true;
 		}
 
 		ereport(ERROR,
 				errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
-				errmsg_plural("cannot attach table \"%s\" as partition because it is referenced in publication %s EXCEPT clause",
-							  "cannot attach table \"%s\" as partition because it is referenced in publications %s EXCEPT clause",
+				errmsg_plural("cannot attach table \"%s\" as partition because it is named in EXCEPT clause of publication %s",
+							  "cannot attach table \"%s\" as partition because it is named in EXCEPT clause of publications %s",
 							  list_length(exceptpuboids),
 							  RelationGetRelationName(attachrel),
 							  pubnames.data),
 				errdetail("The publication EXCEPT clause cannot contain tables that are partitions."),
-				errhint("Change the publication's EXCEPT clause using ALTER PUBLICATION ... SET ALL TABLES."));
+				errhint("%s", has_alltables_pub ?
+						_("Change the EXCEPT clause using ALTER PUBLICATION ... SET ALL TABLES ... EXCEPT.") :
+						_("Change the EXCEPT clause using ALTER PUBLICATION ... SET TABLES IN SCHEMA ... EXCEPT.")));
 	}
 
 	list_free(exceptpuboids);
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 17035fb4d15..14520a5ccab 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -58,6 +58,7 @@
 #include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
 #include "parser/parser.h"
+#include "utils/builtins.h"
 #include "utils/datetime.h"
 #include "utils/xml.h"
 
@@ -11282,7 +11283,7 @@ AlterOwnerStmt: ALTER AGGREGATE aggregate_with_argtypes OWNER TO RoleSpec
  * pub_obj is one of:
  *
  *		TABLE table [, ...]
- *		TABLES IN SCHEMA schema [, ...]
+ *		TABLES IN SCHEMA schema [EXCEPT (TABLE table [, ...] )] [, ...]
  *
  *****************************************************************************/
 
@@ -11342,23 +11343,26 @@ PublicationObjSpec:
 					$$->pubtable->columns = $3;
 					$$->pubtable->whereClause = $4;
 				}
-			| TABLES IN_P SCHEMA ColId
+			| TABLES IN_P SCHEMA ColId opt_pub_except_clause
 				{
 					$$ = makeNode(PublicationObjSpec);
 					$$->pubobjtype = PUBLICATIONOBJ_TABLES_IN_SCHEMA;
 					$$->name = $4;
+					$$->except_tables = $5;
 					$$->location = @4;
 				}
-			| TABLES IN_P SCHEMA CURRENT_SCHEMA
+			| TABLES IN_P SCHEMA CURRENT_SCHEMA opt_pub_except_clause
 				{
 					$$ = makeNode(PublicationObjSpec);
 					$$->pubobjtype = PUBLICATIONOBJ_TABLES_IN_CUR_SCHEMA;
+					$$->except_tables = $5;
 					$$->location = @4;
 				}
-			| ColId opt_column_list OptWhereClause
+			| ColId opt_column_list OptWhereClause opt_pub_except_clause
 				{
 					$$ = makeNode(PublicationObjSpec);
 					$$->pubobjtype = PUBLICATIONOBJ_CONTINUATION;
+					$$->except_tables = $4;
 					/*
 					 * If either a row filter or column list is specified, create
 					 * a PublicationTable object.
@@ -11402,10 +11406,11 @@ PublicationObjSpec:
 					$$->pubtable->columns = $2;
 					$$->pubtable->whereClause = $3;
 				}
-			| CURRENT_SCHEMA
+			| CURRENT_SCHEMA opt_pub_except_clause
 				{
 					$$ = makeNode(PublicationObjSpec);
 					$$->pubobjtype = PUBLICATIONOBJ_CONTINUATION;
+					$$->except_tables = $2;
 					$$->location = @1;
 				}
 				;
@@ -20867,6 +20872,11 @@ preprocess_pub_all_objtype_list(List *all_objects_list, List **pubobjects,
 /*
  * Process pubobjspec_list to check for errors in any of the objects and
  * convert PUBLICATIONOBJ_CONTINUATION into appropriate PublicationObjSpecType.
+ *
+ * The except_tables attached to TABLES IN SCHEMA nodes are left in place here;
+ * ObjectsInPublicationToOids() qualifies their names, validates schema
+ * membership, and merges the qualified tables into its except_pubtables
+ * output list once the schema OID is known.
  */
 static void
 preprocess_pubobj_list(List *pubobjspec_list, core_yyscan_t yyscanner)
@@ -20895,6 +20905,13 @@ preprocess_pubobj_list(List *pubobjspec_list, core_yyscan_t yyscanner)
 
 		if (pubobj->pubobjtype == PUBLICATIONOBJ_TABLE)
 		{
+			/* EXCEPT is not valid for table objects */
+			if (pubobj->except_tables != NIL)
+				ereport(ERROR,
+						errcode(ERRCODE_SYNTAX_ERROR),
+						errmsg("EXCEPT is not allowed for TABLE publication objects"),
+						parser_errposition(pubobj->location));
+
 			/* relation name or pubtable must be set for this type of object */
 			if (!pubobj->name && !pubobj->pubtable)
 				ereport(ERROR,
diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 0afdb1432ca..ee2b997f82d 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -2281,10 +2281,41 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
 					}
 				}
 
-				if (list_member_oid(pubids, pub->oid) ||
-					list_member_oid(schemaPubids, pub->oid) ||
-					ancestor_published)
+				if (list_member_oid(pubids, pub->oid) || ancestor_published)
 					publish = true;
+				else if (list_member_oid(schemaPubids, pub->oid))
+				{
+					Oid			root_relid;
+					bool		root_is_except = false;
+
+					/*
+					 * schemaPubids is the list of publications that include
+					 * relid's own schema, independent of the ancestor walk
+					 * above -- a partition can live in a different schema
+					 * than its root.  Still need to check for exclusion here,
+					 * using the top-most ancestor since only a root
+					 * (non-partition) table can appear in an EXCEPT clause.
+					 */
+					if (am_partition)
+					{
+						List	   *ancestors = get_partition_ancestors(relid);
+
+						/*
+						 * ancestors is NIL for a partition with a pending
+						 * DETACH CONCURRENTLY;
+						 */
+						root_relid = (ancestors == NIL) ? relid :
+							llast_oid(ancestors);
+						list_free(ancestors);
+					}
+					else
+						root_relid = relid;
+
+					CheckPublicationRelEntry(pub->oid, root_relid, &root_is_except);
+
+					if (!root_is_except)
+						publish = true;
+				}
 			}
 
 			/*
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 19c4ff6e75e..e4eb5e6d885 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -5807,7 +5807,6 @@ RelationBuildPublicationDesc(Relation relation, PublicationDesc *pubdesc)
 {
 	List	   *puboids = NIL;
 	List	   *exceptpuboids = NIL;
-	List	   *alltablespuboids;
 	ListCell   *lc;
 	MemoryContext oldcxt;
 	Oid			schemaid;
@@ -5851,12 +5850,9 @@ RelationBuildPublicationDesc(Relation relation, PublicationDesc *pubdesc)
 
 	if (relation->rd_rel->relispartition)
 	{
-		Oid			last_ancestor_relid;
-
-		/* Add publications that the ancestors are in too. */
 		ancestors = get_partition_ancestors(relid);
-		last_ancestor_relid = llast_oid(ancestors);
 
+		/* Add publications that the ancestors are in too. */
 		foreach(lc, ancestors)
 		{
 			Oid			ancestor = lfirst_oid(lc);
@@ -5869,11 +5865,13 @@ RelationBuildPublicationDesc(Relation relation, PublicationDesc *pubdesc)
 		}
 
 		/*
-		 * Only the top-most ancestor can appear in the EXCEPT clause.
-		 * Therefore, for a partition, exclusion must be evaluated at the
-		 * top-most ancestor.
+		 * Only the topmost root of a partition hierarchy can appear in an
+		 * EXCEPT clause, so that is where exclusion has to be evaluated.
+		 * ancestors is NIL for a partition with a pending DETACH
+		 * CONCURRENTLY, in which case there is no root to consult.
 		 */
-		exceptpuboids = GetRelationExcludedPublications(last_ancestor_relid);
+		if (ancestors != NIL)
+			exceptpuboids = GetRelationExcludedPublications(llast_oid(ancestors));
 	}
 	else
 	{
@@ -5884,10 +5882,12 @@ RelationBuildPublicationDesc(Relation relation, PublicationDesc *pubdesc)
 		exceptpuboids = GetRelationExcludedPublications(relid);
 	}
 
-	alltablespuboids = GetAllTablesPublications();
-	puboids = list_concat_unique_oid(puboids,
-									 list_difference_oid(alltablespuboids,
-														 exceptpuboids));
+	puboids = list_concat_unique_oid(puboids, GetAllTablesPublications());
+
+	/* Ignore the publications that exclude this relation. */
+	if (exceptpuboids != NIL)
+		puboids = list_difference_oid(puboids, exceptpuboids);
+
 	foreach(lc, puboids)
 	{
 		Oid			pubid = lfirst_oid(lc);
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index ad9c8affb4f..bb980fc1c91 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -3036,7 +3036,29 @@ describeOneTableDetails(const char *schemaname,
 							  "FROM pg_catalog.pg_publication p\n"
 							  "     JOIN pg_catalog.pg_publication_namespace pn ON p.oid = pn.pnpubid\n"
 							  "     JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.pnnspid\n"
-							  "WHERE pc.oid ='%s' and pg_catalog.pg_relation_is_publishable('%s')\n"
+							  "WHERE pc.oid ='%s' and pg_catalog.pg_relation_is_publishable('%s')\n",
+							  oid, oid);
+
+			if (pset.sversion >= 200000)
+			{
+				/*
+				 * Publishing the schema does not publish a table named in the
+				 * publication's EXCEPT clause, nor a partition whose root is
+				 * named there, since excluding a root excludes the whole
+				 * partition tree.  Unlike the FOR ALL TABLES case below, a
+				 * schema publication can have both kinds of
+				 * pg_publication_rel row, so prexcept has to be tested.
+				 */
+				appendPQExpBuffer(&buf,
+								  "     AND NOT EXISTS (\n"
+								  "     SELECT 1\n"
+								  "     FROM pg_catalog.pg_publication_rel pr\n"
+								  "     WHERE pr.prpubid = p.oid AND pr.prexcept AND\n"
+								  "     (pr.prrelid = '%s' OR pr.prrelid = pg_catalog.pg_partition_root('%s')))\n",
+								  oid, oid);
+			}
+
+			appendPQExpBuffer(&buf,
 							  "UNION\n"
 							  "SELECT pubname\n"
 							  "     , pg_catalog.pg_get_expr(pr.prqual, c.oid)\n"
@@ -3060,7 +3082,7 @@ describeOneTableDetails(const char *schemaname,
 							  "     FROM pg_catalog.pg_publication_namespace pn\n"
 							  "     WHERE pn.pnpubid = p.oid\n"
 							  "       AND pn.pnnspid = c.relnamespace)\n",
-							  oid, oid, oid);
+							  oid);
 
 			if (pset.sversion >= 190000)
 			{
@@ -6839,6 +6861,24 @@ describePublications(const char *pattern)
 				if (!addFooterToPublicationDesc(&buf, _("Tables from schemas:"),
 												true, &cont))
 					goto error_return;
+
+				if (pset.sversion >= 200000)
+				{
+					/*
+					 * Get tables in the EXCEPT clause for this schema
+					 * publication.
+					 */
+					printfPQExpBuffer(&buf,
+									  "SELECT concat(c.relnamespace::regnamespace, '.', c.relname)\n"
+									  "FROM pg_catalog.pg_class c\n"
+									  "     JOIN pg_catalog.pg_publication_rel pr ON c.oid = pr.prrelid\n"
+									  "WHERE pr.prpubid = '%s'\n"
+									  "  AND pr.prexcept\n"
+									  "ORDER BY 1", pubid);
+					if (!addFooterToPublicationDesc(&buf, _("Except tables:"),
+													true, &cont))
+						goto error_return;
+				}
 			}
 		}
 		else
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 190fff7ea0e..f069e2654cc 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -1065,6 +1065,24 @@ static const SchemaQuery Query_for_trigger_of_table = {
 "SELECT nspname FROM pg_catalog.pg_namespace "\
 " WHERE nspname LIKE '%s'"
 
+#define Query_for_list_of_tables_in_schema \
+"SELECT n.nspname || '.' || c.relname "\
+"  FROM pg_catalog.pg_class c "\
+"       JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace "\
+" WHERE c.relkind IN (" CppAsString2(RELKIND_RELATION) ", " \
+						CppAsString2(RELKIND_PARTITIONED_TABLE) ") "\
+"   AND (n.nspname || '.' || c.relname) LIKE '%s' "\
+"   AND n.nspname = '%s'"
+
+#define Query_for_list_of_tables_in_current_schema \
+"SELECT c.relname "\
+"  FROM pg_catalog.pg_class c "\
+"       JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace "\
+" WHERE c.relkind IN (" CppAsString2(RELKIND_RELATION) ", " \
+						CppAsString2(RELKIND_PARTITIONED_TABLE) ") "\
+"   AND c.relname LIKE '%s' "\
+"   AND n.nspname = pg_catalog.current_schema()"
+
 /* Use COMPLETE_WITH_QUERY_VERBATIM with these queries for GUC names: */
 #define Query_for_list_of_alter_system_set_vars \
 "SELECT pg_catalog.lower(name) FROM pg_catalog.pg_settings "\
@@ -3777,8 +3795,21 @@ match_previous_words(int pattern_id,
 		COMPLETE_WITH_QUERY_PLUS(Query_for_list_of_schemas
 								 " AND nspname NOT LIKE E'pg\\\\_%%'",
 								 "CURRENT_SCHEMA");
-	else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "TABLES", "IN", "SCHEMA", MatchAny) && (!ends_with(prev_wd, ',')))
-		COMPLETE_WITH("WITH (");
+	else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "TABLES", "IN", "SCHEMA", MatchAny) && !ends_with(prev_wd, ','))
+		COMPLETE_WITH("EXCEPT ( TABLE", "WITH (");
+	else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT"))
+		COMPLETE_WITH("( TABLE");
+	else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT", "("))
+		COMPLETE_WITH("TABLE");
+	else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "TABLES", "IN", "SCHEMA", "CURRENT_SCHEMA", "EXCEPT", "(", "TABLE"))
+		COMPLETE_WITH_QUERY_VERBATIM(Query_for_list_of_tables_in_current_schema);
+	else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT", "(", "TABLE"))
+	{
+		set_completion_reference(prev4_wd);
+		COMPLETE_WITH_QUERY_VERBATIM(Query_for_list_of_tables_in_schema);
+	}
+	else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT", "(", "TABLE", MatchAnyN) && !ends_with(prev_wd, ','))
+		COMPLETE_WITH(")");
 	/* Complete "CREATE PUBLICATION <name> [...] WITH" */
 	else if (Matches("CREATE", "PUBLICATION", MatchAnyN, "WITH", "("))
 		COMPLETE_WITH("publish", "publish_generated_columns", "publish_via_partition_root");
diff --git a/src/include/catalog/pg_publication.h b/src/include/catalog/pg_publication.h
index 89b4bb14f62..d241b781ccc 100644
--- a/src/include/catalog/pg_publication.h
+++ b/src/include/catalog/pg_publication.h
@@ -192,6 +192,7 @@ extern List *GetPubPartitionOptionRelations(List *result,
 											Oid relid);
 extern Oid	GetTopMostAncestorInPublication(Oid puboid, List *ancestors,
 											int *ancestor_level);
+extern bool CheckPublicationRelEntry(Oid pubid, Oid relid, bool *is_except);
 
 extern bool is_publishable_relation(Relation rel);
 extern bool is_schema_publication(Oid pubid);
diff --git a/src/include/commands/publicationcmds.h b/src/include/commands/publicationcmds.h
index 4cf45c17cc5..fb1703aa675 100644
--- a/src/include/commands/publicationcmds.h
+++ b/src/include/commands/publicationcmds.h
@@ -27,6 +27,8 @@ extern void AlterPublication(ParseState *pstate, AlterPublicationStmt *stmt);
 extern void RemovePublicationById(Oid pubid);
 extern void RemovePublicationRelById(Oid proid);
 extern void RemovePublicationSchemaById(Oid psoid);
+extern void RemoveSchemaPubExceptForRel(Oid relid, Oid oldNspOid,
+										Oid newNspOid);
 
 extern ObjectAddress AlterPublicationOwner(const char *name, Oid newOwnerId);
 extern void AlterPublicationOwner_oid(Oid pubid, Oid newOwnerId);
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 8a9df884276..01fa90b9186 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -4531,6 +4531,8 @@ typedef struct PublicationObjSpec
 	PublicationObjSpecType pubobjtype;	/* type of this publication object */
 	char	   *name;
 	PublicationTable *pubtable;
+	List	   *except_tables;	/* tables specified in the EXCEPT clause (for
+								 * TABLES IN SCHEMA) */
 	ParseLoc	location;		/* token location, or -1 if unknown */
 } PublicationObjSpec;
 
diff --git a/src/test/regress/expected/publication.out b/src/test/regress/expected/publication.out
index 4f21462cc17..c78a4a15696 100644
--- a/src/test/regress/expected/publication.out
+++ b/src/test/regress/expected/publication.out
@@ -279,6 +279,12 @@ CREATE PUBLICATION testpub_foralltables_excepttable2 FOR ALL TABLES EXCEPT (test
 ERROR:  syntax error at or near "testpub_tbl1"
 LINE 1: ..._foralltables_excepttable2 FOR ALL TABLES EXCEPT (testpub_tb...
                                                              ^
+-- fail - EXCEPT is not allowed for FOR TABLE publications
+CREATE PUBLICATION testpub_except_err
+    FOR TABLE testpub_tbl1, testpub_tbl2 EXCEPT (TABLE testpub_tbl3);
+ERROR:  EXCEPT is not allowed for TABLE publication objects
+LINE 2:     FOR TABLE testpub_tbl1, testpub_tbl2 EXCEPT (TABLE testp...
+                                    ^
 ---------------------------------------------
 -- SET ALL TABLES/SEQUENCES
 ---------------------------------------------
@@ -473,13 +479,186 @@ CREATE TABLE tab_main (a int) PARTITION BY RANGE(a);
 -- Attaching a partition is not allowed if the partitioned table appears in a
 -- publication's EXCEPT clause.
 ALTER TABLE tab_main ATTACH PARTITION testpub_root FOR VALUES FROM (0) TO (200);
-ERROR:  cannot attach table "testpub_root" as partition because it is referenced in publication "testpub8" EXCEPT clause
+ERROR:  cannot attach table "testpub_root" as partition because it is named in EXCEPT clause of publication "testpub8"
 DETAIL:  The publication EXCEPT clause cannot contain tables that are partitions.
-HINT:  Change the publication's EXCEPT clause using ALTER PUBLICATION ... SET ALL TABLES.
+HINT:  Change the EXCEPT clause using ALTER PUBLICATION ... SET ALL TABLES ... EXCEPT.
 RESET client_min_messages;
 DROP TABLE testpub_root, testpub_part1, tab_main;
 DROP PUBLICATION testpub8;
---- Tests for publications with SEQUENCES
+---------------------------------------------
+-- EXCEPT tests for TABLES IN SCHEMA
+---------------------------------------------
+SET client_min_messages = 'ERROR';
+-- Create tables in pub_test for these tests
+CREATE TABLE pub_test.testpub_tbl_s1 (a int primary key, b text);
+CREATE TABLE pub_test.testpub_tbl_s2 (x int primary key, y text);
+-- Create same-named tables in public to verify unqualified EXCEPT entries
+-- are qualified with the named schema, not public
+CREATE TABLE testpub_nopk (foo int, bar int);
+CREATE TABLE testpub_tbl_s1 (a int primary key, b text);
+-- Basic: exclude one table from a schema publication
+CREATE PUBLICATION testpub_schema_except1
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1);
+\dRp+ testpub_schema_except1
+                                                      Publication testpub_schema_except1
+          Owner           | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root | Description 
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------+-------------
+ regress_publication_user | f          | f             | t       | t       | t       | t         | none              | f        | 
+Tables from schemas:
+    "pub_test"
+Except tables:
+    "pub_test.testpub_tbl_s1"
+
+-- Exclude multiple tables using unqualified names; same-named tables exist in
+-- public to confirm unqualified names resolve to pub_test, not public
+CREATE PUBLICATION testpub_schema_except2
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE testpub_nopk, testpub_tbl_s1);
+\dRp+ testpub_schema_except2
+                                                      Publication testpub_schema_except2
+          Owner           | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root | Description 
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------+-------------
+ regress_publication_user | f          | f             | t       | t       | t       | t         | none              | f        | 
+Tables from schemas:
+    "pub_test"
+Except tables:
+    "pub_test.testpub_nopk"
+    "pub_test.testpub_tbl_s1"
+
+-- fail: EXCEPT table belongs to a different schema
+CREATE PUBLICATION testpub_except_wrongschema
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE public.testpub_tbl1);
+ERROR:  table "public.testpub_tbl1" in EXCEPT clause does not belong to schema "pub_test"
+LINE 2:     FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE public.testp...
+                                                        ^
+-- fail: cross-schema EXCEPT not allowed; each EXCEPT is bound to its immediate schema
+CREATE PUBLICATION testpub_except_crossschema
+    FOR TABLES IN SCHEMA pub_test, public EXCEPT (TABLE pub_test.testpub_tbl_s1, public.testpub_tbl1);
+ERROR:  table "pub_test.testpub_tbl_s1" in EXCEPT clause does not belong to schema "public"
+LINE 2: ...R TABLES IN SCHEMA pub_test, public EXCEPT (TABLE pub_test.t...
+                                                             ^
+-- Multiple schemas each with their own EXCEPT clause
+CREATE PUBLICATION testpub_schema_except_multi
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1),
+                  public EXCEPT (TABLE testpub_tbl1);
+\dRp+ testpub_schema_except_multi
+                                                   Publication testpub_schema_except_multi
+          Owner           | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root | Description 
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------+-------------
+ regress_publication_user | f          | f             | t       | t       | t       | t         | none              | f        | 
+Tables from schemas:
+    "pub_test"
+    "public"
+Except tables:
+    "pub_test.testpub_tbl_s1"
+    "public.testpub_tbl1"
+
+-- fail: same schema repeated with same EXCEPT clauses
+CREATE PUBLICATION testpub_schema_except_conflict
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1),
+                  pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1);
+ERROR:  conflicting or redundant EXCEPT clauses for schema "pub_test"
+-- fail: same schema repeated with conflicting or no EXCEPT clauses
+CREATE PUBLICATION testpub_schema_except_conflict2
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1),
+                  pub_test EXCEPT (TABLE pub_test.testpub_tbl_s2),
+                  pub_test;
+ERROR:  conflicting or redundant EXCEPT clauses for schema "pub_test"
+-- ALTER TABLE ... SET SCHEMA on a table excluded by a schema publication:
+-- the schema-scoped exclusion is no longer meaningful once the table moves
+-- out of its schema, so the exclusion is auto-removed.
+CREATE PUBLICATION testpub_schema_except_setsch
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s2);
+\dRp+ testpub_schema_except_setsch
+                                                   Publication testpub_schema_except_setsch
+          Owner           | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root | Description 
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------+-------------
+ regress_publication_user | f          | f             | t       | t       | t       | t         | none              | f        | 
+Tables from schemas:
+    "pub_test"
+Except tables:
+    "pub_test.testpub_tbl_s2"
+
+ALTER TABLE pub_test.testpub_tbl_s2 SET SCHEMA public;
+\dRp+ testpub_schema_except_setsch
+                                                   Publication testpub_schema_except_setsch
+          Owner           | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root | Description 
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------+-------------
+ regress_publication_user | f          | f             | t       | t       | t       | t         | none              | f        | 
+Tables from schemas:
+    "pub_test"
+
+DROP PUBLICATION testpub_schema_except_setsch;
+-- Restore for further tests
+ALTER TABLE public.testpub_tbl_s2 SET SCHEMA pub_test;
+-- fail: table appears in both the explicit table list and the EXCEPT clause
+CREATE PUBLICATION testpub_except_conflict
+    FOR TABLE pub_test.testpub_tbl_s1, TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1);
+ERROR:  table "pub_test.testpub_tbl_s1" cannot be both published and excluded
+-- fail: nonexistent table in EXCEPT clause
+CREATE PUBLICATION testpub_except_norel
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.nonexistent_table);
+ERROR:  relation "pub_test.nonexistent_table" does not exist
+-- fail: partition cannot appear in EXCEPT clause; only root tables are allowed
+CREATE TABLE pub_test.testpub_parted_s (a int) PARTITION BY LIST (a);
+CREATE TABLE pub_test.testpub_part_s PARTITION OF pub_test.testpub_parted_s FOR VALUES IN (1);
+CREATE PUBLICATION testpub_except_partition
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_part_s);
+ERROR:  cannot specify relation "pub_test.testpub_part_s" in the publication EXCEPT clause
+DETAIL:  This operation is not supported for individual partitions.
+-- fail: TABLE keyword is required for the first entry in the EXCEPT clause
+CREATE PUBLICATION testpub_except_nokw
+    FOR TABLES IN SCHEMA pub_test EXCEPT (testpub_nopk);
+ERROR:  syntax error at or near "testpub_nopk"
+LINE 2:     FOR TABLES IN SCHEMA pub_test EXCEPT (testpub_nopk);
+                                                  ^
+-- Check that a table excluded from a schema publication is reported only as
+-- excluded and not as published.
+\d pub_test.testpub_nopk
+           Table "pub_test.testpub_nopk"
+ Column |  Type   | Collation | Nullable | Default 
+--------+---------+-----------+----------+---------
+ foo    | integer |           |          | 
+ bar    | integer |           |          | 
+Included in publications:
+    "testpub_schema_except1"
+    "testpub_schema_except_multi"
+Excluded from publications:
+    "testpub_schema_except2"
+
+-- fail: ADD TABLE for a partition is rejected when a partition ancestor
+-- is currently in the publication's EXCEPT list.
+CREATE PUBLICATION testpub_except_ancestor
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_parted_s);
+ALTER PUBLICATION testpub_except_ancestor ADD TABLE pub_test.testpub_part_s;
+ERROR:  cannot add partition "pub_test.testpub_part_s" to publication "testpub_except_ancestor"
+DETAIL:  Partition root "pub_test.testpub_parted_s" is named in the publication's EXCEPT clause for schema "pub_test".
+HINT:  Change the EXCEPT clause using ALTER PUBLICATION ... SET TABLES IN SCHEMA ... EXCEPT.
+DROP PUBLICATION testpub_except_ancestor;
+-- fail: same contradiction (explicit partition + EXCEPT-ed ancestor), but
+-- given in a single statement instead of two separate ones
+CREATE PUBLICATION testpub_except_ancestor_same_create
+    FOR TABLE pub_test.testpub_part_s,
+       TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_parted_s);
+ERROR:  partition "pub_test.testpub_part_s" cannot be both published and excluded
+DETAIL:  Partition root "pub_test.testpub_parted_s" is named in the publication's EXCEPT clause for schema "pub_test".
+-- fail: same contradiction as above, but the partition lives in a different
+-- schema (public) than its EXCEPT-ed root (pub_test)
+CREATE TABLE public.testpub_part_s2 PARTITION OF pub_test.testpub_parted_s FOR VALUES IN (2);
+CREATE PUBLICATION testpub_except_ancestor_cross_schema
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_parted_s),
+       TABLE public.testpub_part_s2;
+ERROR:  partition "public.testpub_part_s2" cannot be both published and excluded
+DETAIL:  Partition root "pub_test.testpub_parted_s" is named in the publication's EXCEPT clause for schema "pub_test".
+DROP TABLE public.testpub_part_s2;
+-- Cleanup
+RESET client_min_messages;
+DROP TABLE pub_test.testpub_tbl_s1, pub_test.testpub_tbl_s2;
+DROP TABLE pub_test.testpub_parted_s CASCADE;
+DROP TABLE testpub_nopk, testpub_tbl_s1;
+DROP PUBLICATION testpub_schema_except1, testpub_schema_except2, testpub_schema_except_multi;
+---------------------------------------------
+-- Tests for publications with SEQUENCES
+---------------------------------------------
 CREATE SEQUENCE regress_pub_seq0;
 CREATE SEQUENCE pub_test.regress_pub_seq1;
 -- FOR ALL SEQUENCES
@@ -1970,6 +2149,54 @@ ALTER PUBLICATION testpub1_forschema ADD TABLES IN SCHEMA foo, bar (a, b);
 ERROR:  column specification not allowed for schema
 LINE 1: ...TION testpub1_forschema ADD TABLES IN SCHEMA foo, bar (a, b)...
                                                              ^
+-- EXCEPT clause with CURRENT_SCHEMA: cross-schema entry must be rejected
+SET search_path = pub_test1;
+-- qualified name from wrong schema -> error
+CREATE PUBLICATION testpub_cursch_except FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE pub_test2.tbl1);
+ERROR:  table "pub_test2.tbl1" in EXCEPT clause does not belong to schema "pub_test1"
+LINE 1: ...FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE pub_test2....
+                                                             ^
+-- unqualified name implicitly qualified with current schema (pub_test1.tbl)
+SET client_min_messages = 'ERROR';
+CREATE PUBLICATION testpub_cursch_except FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE tbl);
+RESET client_min_messages;
+\dRp+ testpub_cursch_except
+                                                      Publication testpub_cursch_except
+          Owner           | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root | Description 
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------+-------------
+ regress_publication_user | f          | f             | t       | t       | t       | t         | none              | f        | 
+Tables from schemas:
+    "pub_test1"
+Except tables:
+    "pub_test1.tbl"
+
+DROP PUBLICATION testpub_cursch_except;
+-- succeeds: CURRENT_SCHEMA and pub_test1 (same schema)
+SET client_min_messages = 'ERROR';
+CREATE PUBLICATION testpub_cursch_named_same
+    FOR TABLES IN SCHEMA CURRENT_SCHEMA, pub_test1;
+RESET client_min_messages;
+\dRp+ testpub_cursch_named_same
+                                                    Publication testpub_cursch_named_same
+          Owner           | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root | Description 
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------+-------------
+ regress_publication_user | f          | f             | t       | t       | t       | t         | none              | f        | 
+Tables from schemas:
+    "pub_test1"
+
+DROP PUBLICATION testpub_cursch_named_same;
+-- fail: CURRENT_SCHEMA and pub_test1 (same schema) have
+-- conflicting EXCEPT clauses
+CREATE PUBLICATION testpub_cursch_named_conflict
+    FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE tbl),
+                  pub_test1 EXCEPT (TABLE tbl1);
+ERROR:  conflicting or redundant EXCEPT clauses for schema "pub_test1"
+-- fail: two CURRENT_SCHEMA mentions with conflicting EXCEPT clauses
+CREATE PUBLICATION testpub_cursch_cursch_conflict
+    FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE tbl),
+                  CURRENT_SCHEMA EXCEPT (TABLE tbl1);
+ERROR:  conflicting or redundant EXCEPT clauses for schema "pub_test1"
+RESET search_path;
 -- cleanup pub_test1 schema for invalidation tests
 ALTER PUBLICATION testpub2_forschema DROP TABLES IN SCHEMA pub_test1;
 DROP PUBLICATION testpub3_forschema, testpub4_forschema, testpub5_forschema, testpub6_forschema, testpub_fortable;
@@ -2321,6 +2548,7 @@ DROP ROLE regress_publication_user_dummy;
 -- Test pg_get_publication_tables(text[], oid) function
 CREATE SCHEMA gpt_test_sch;
 CREATE TABLE gpt_test_sch.tbl_sch (id int);
+CREATE TABLE gpt_test_sch.tbl_sch2 (id int);
 CREATE TABLE tbl_normal (id int);
 CREATE TABLE tbl_parent (id1 int, id2 int, id3 int) PARTITION BY RANGE (id1);
 CREATE TABLE tbl_part1 PARTITION OF tbl_parent FOR VALUES FROM (1) TO (10);
@@ -2331,6 +2559,7 @@ CREATE PUBLICATION pub_all_no_viaroot FOR ALL TABLES WITH (publish_via_partition
 CREATE PUBLICATION pub_all_except FOR ALL TABLES EXCEPT (TABLE tbl_parent, gpt_test_sch.tbl_sch) WITH (publish_via_partition_root = true);
 CREATE PUBLICATION pub_all_except_no_viaroot FOR ALL TABLES EXCEPT (TABLE tbl_parent, gpt_test_sch.tbl_sch) WITH (publish_via_partition_root = false);
 CREATE PUBLICATION pub_schema FOR TABLES IN SCHEMA gpt_test_sch;
+CREATE PUBLICATION pub_schema_except FOR TABLES IN SCHEMA gpt_test_sch EXCEPT (TABLE gpt_test_sch.tbl_sch);
 CREATE PUBLICATION pub_normal FOR TABLE tbl_normal WHERE (id < 10);
 CREATE PUBLICATION pub_part_leaf FOR TABLE tbl_part1 WITH (publish_via_partition_root = false);
 CREATE PUBLICATION pub_part_parent FOR TABLE tbl_parent (id1, id2) WHERE (id1 = 10) WITH (publish_via_partition_root = true);
@@ -2482,6 +2711,44 @@ SELECT * FROM test_gpt(ARRAY['pub_all_except_no_viaroot'], 'tbl_part1'); -- no r
 ---------+---------+-------+------
 (0 rows)
 
+-- test for EXCEPT clause with schema publication
+SELECT * FROM test_gpt(ARRAY['pub_schema_except'], 'gpt_test_sch.tbl_sch'); -- no result (excluded)
+ pubname | relname | attrs | qual 
+---------+---------+-------+------
+(0 rows)
+
+SELECT * FROM test_gpt(ARRAY['pub_schema_except'], 'gpt_test_sch.tbl_sch2'); -- one row (included via schema)
+      pubname      | relname  | attrs | qual 
+-------------------+----------+-------+------
+ pub_schema_except | tbl_sch2 | 1     | 
+(1 row)
+
+-- test for EXCEPT clause with schema publication, where the excluded root's
+-- partition lives in a different schema that is separately, fully included
+-- (no EXCEPT) in the same publication.  The root's exclusion must cascade to
+-- the partition regardless of the partition's own schema membership.
+CREATE SCHEMA gpt_cross_sch1;
+CREATE SCHEMA gpt_cross_sch2;
+CREATE TABLE gpt_cross_sch1.croot (id int) PARTITION BY RANGE (id);
+CREATE TABLE gpt_cross_sch2.cpart1 PARTITION OF gpt_cross_sch1.croot FOR VALUES FROM (1) TO (10);
+SET client_min_messages = 'ERROR';
+CREATE PUBLICATION pub_cross_schema_except FOR TABLES IN SCHEMA gpt_cross_sch1 EXCEPT (TABLE gpt_cross_sch1.croot), TABLES IN SCHEMA gpt_cross_sch2;
+RESET client_min_messages;
+SELECT * FROM test_gpt(ARRAY['pub_cross_schema_except'], 'gpt_cross_sch1.croot'); -- no result (excluded)
+ pubname | relname | attrs | qual 
+---------+---------+-------+------
+(0 rows)
+
+-- no result (excluded via cascading root exclusion, even though cpart1's own
+-- schema gpt_cross_sch2 is separately, fully included with no EXCEPT)
+SELECT * FROM test_gpt(ARRAY['pub_cross_schema_except'], 'gpt_cross_sch2.cpart1');
+ pubname | relname | attrs | qual 
+---------+---------+-------+------
+(0 rows)
+
+DROP PUBLICATION pub_cross_schema_except;
+DROP TABLE gpt_cross_sch2.cpart1, gpt_cross_sch1.croot;
+DROP SCHEMA gpt_cross_sch1, gpt_cross_sch2;
 -- two rows with different row filter
 SELECT * FROM test_gpt(ARRAY['pub_all', 'pub_normal'], 'tbl_normal');
   pubname   |  relname   | attrs |   qual    
@@ -2534,6 +2801,7 @@ DROP PUBLICATION pub_all_no_viaroot;
 DROP PUBLICATION pub_all_except;
 DROP PUBLICATION pub_all_except_no_viaroot;
 DROP PUBLICATION pub_schema;
+DROP PUBLICATION pub_schema_except;
 DROP PUBLICATION pub_normal;
 DROP PUBLICATION pub_part_leaf;
 DROP PUBLICATION pub_part_parent;
@@ -2542,7 +2810,9 @@ DROP PUBLICATION pub_part_parent_child;
 DROP VIEW gpt_test_view;
 DROP TABLE tbl_normal, tbl_parent, tbl_part1;
 DROP SCHEMA gpt_test_sch CASCADE;
-NOTICE:  drop cascades to table gpt_test_sch.tbl_sch
+NOTICE:  drop cascades to 2 other objects
+DETAIL:  drop cascades to table gpt_test_sch.tbl_sch
+drop cascades to table gpt_test_sch.tbl_sch2
 -- stage objects for pg_dump tests
 CREATE SCHEMA pubme CREATE TABLE t0 (c int, d int) CREATE TABLE t1 (c int);
 CREATE SCHEMA pubme2 CREATE TABLE t0 (c int, d int);
diff --git a/src/test/regress/sql/publication.sql b/src/test/regress/sql/publication.sql
index fac54b02e27..dcdc1eb5df9 100644
--- a/src/test/regress/sql/publication.sql
+++ b/src/test/regress/sql/publication.sql
@@ -125,6 +125,9 @@ CREATE PUBLICATION testpub_foralltables_excepttable1 FOR ALL TABLES EXCEPT (TABL
 \d testpub_tbl1
 -- fail - first table in the EXCEPT list should use TABLE keyword
 CREATE PUBLICATION testpub_foralltables_excepttable2 FOR ALL TABLES EXCEPT (testpub_tbl1, testpub_tbl2);
+-- fail - EXCEPT is not allowed for FOR TABLE publications
+CREATE PUBLICATION testpub_except_err
+    FOR TABLE testpub_tbl1, testpub_tbl2 EXCEPT (TABLE testpub_tbl3);
 
 ---------------------------------------------
 -- SET ALL TABLES/SEQUENCES
@@ -222,7 +225,119 @@ RESET client_min_messages;
 DROP TABLE testpub_root, testpub_part1, tab_main;
 DROP PUBLICATION testpub8;
 
---- Tests for publications with SEQUENCES
+---------------------------------------------
+-- EXCEPT tests for TABLES IN SCHEMA
+---------------------------------------------
+SET client_min_messages = 'ERROR';
+-- Create tables in pub_test for these tests
+CREATE TABLE pub_test.testpub_tbl_s1 (a int primary key, b text);
+CREATE TABLE pub_test.testpub_tbl_s2 (x int primary key, y text);
+-- Create same-named tables in public to verify unqualified EXCEPT entries
+-- are qualified with the named schema, not public
+CREATE TABLE testpub_nopk (foo int, bar int);
+CREATE TABLE testpub_tbl_s1 (a int primary key, b text);
+
+-- Basic: exclude one table from a schema publication
+CREATE PUBLICATION testpub_schema_except1
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1);
+\dRp+ testpub_schema_except1
+
+-- Exclude multiple tables using unqualified names; same-named tables exist in
+-- public to confirm unqualified names resolve to pub_test, not public
+CREATE PUBLICATION testpub_schema_except2
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE testpub_nopk, testpub_tbl_s1);
+\dRp+ testpub_schema_except2
+
+-- fail: EXCEPT table belongs to a different schema
+CREATE PUBLICATION testpub_except_wrongschema
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE public.testpub_tbl1);
+
+-- fail: cross-schema EXCEPT not allowed; each EXCEPT is bound to its immediate schema
+CREATE PUBLICATION testpub_except_crossschema
+    FOR TABLES IN SCHEMA pub_test, public EXCEPT (TABLE pub_test.testpub_tbl_s1, public.testpub_tbl1);
+
+-- Multiple schemas each with their own EXCEPT clause
+CREATE PUBLICATION testpub_schema_except_multi
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1),
+                  public EXCEPT (TABLE testpub_tbl1);
+\dRp+ testpub_schema_except_multi
+
+-- fail: same schema repeated with same EXCEPT clauses
+CREATE PUBLICATION testpub_schema_except_conflict
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1),
+                  pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1);
+
+-- fail: same schema repeated with conflicting or no EXCEPT clauses
+CREATE PUBLICATION testpub_schema_except_conflict2
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1),
+                  pub_test EXCEPT (TABLE pub_test.testpub_tbl_s2),
+                  pub_test;
+
+-- ALTER TABLE ... SET SCHEMA on a table excluded by a schema publication:
+-- the schema-scoped exclusion is no longer meaningful once the table moves
+-- out of its schema, so the exclusion is auto-removed.
+CREATE PUBLICATION testpub_schema_except_setsch
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s2);
+\dRp+ testpub_schema_except_setsch
+ALTER TABLE pub_test.testpub_tbl_s2 SET SCHEMA public;
+\dRp+ testpub_schema_except_setsch
+DROP PUBLICATION testpub_schema_except_setsch;
+-- Restore for further tests
+ALTER TABLE public.testpub_tbl_s2 SET SCHEMA pub_test;
+
+-- fail: table appears in both the explicit table list and the EXCEPT clause
+CREATE PUBLICATION testpub_except_conflict
+    FOR TABLE pub_test.testpub_tbl_s1, TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1);
+
+-- fail: nonexistent table in EXCEPT clause
+CREATE PUBLICATION testpub_except_norel
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.nonexistent_table);
+
+-- fail: partition cannot appear in EXCEPT clause; only root tables are allowed
+CREATE TABLE pub_test.testpub_parted_s (a int) PARTITION BY LIST (a);
+CREATE TABLE pub_test.testpub_part_s PARTITION OF pub_test.testpub_parted_s FOR VALUES IN (1);
+CREATE PUBLICATION testpub_except_partition
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_part_s);
+
+-- fail: TABLE keyword is required for the first entry in the EXCEPT clause
+CREATE PUBLICATION testpub_except_nokw
+    FOR TABLES IN SCHEMA pub_test EXCEPT (testpub_nopk);
+
+-- Check that a table excluded from a schema publication is reported only as
+-- excluded and not as published.
+\d pub_test.testpub_nopk
+
+-- fail: ADD TABLE for a partition is rejected when a partition ancestor
+-- is currently in the publication's EXCEPT list.
+CREATE PUBLICATION testpub_except_ancestor
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_parted_s);
+ALTER PUBLICATION testpub_except_ancestor ADD TABLE pub_test.testpub_part_s;
+DROP PUBLICATION testpub_except_ancestor;
+
+-- fail: same contradiction (explicit partition + EXCEPT-ed ancestor), but
+-- given in a single statement instead of two separate ones
+CREATE PUBLICATION testpub_except_ancestor_same_create
+    FOR TABLE pub_test.testpub_part_s,
+       TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_parted_s);
+
+-- fail: same contradiction as above, but the partition lives in a different
+-- schema (public) than its EXCEPT-ed root (pub_test)
+CREATE TABLE public.testpub_part_s2 PARTITION OF pub_test.testpub_parted_s FOR VALUES IN (2);
+CREATE PUBLICATION testpub_except_ancestor_cross_schema
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_parted_s),
+       TABLE public.testpub_part_s2;
+DROP TABLE public.testpub_part_s2;
+
+-- Cleanup
+RESET client_min_messages;
+DROP TABLE pub_test.testpub_tbl_s1, pub_test.testpub_tbl_s2;
+DROP TABLE pub_test.testpub_parted_s CASCADE;
+DROP TABLE testpub_nopk, testpub_tbl_s1;
+DROP PUBLICATION testpub_schema_except1, testpub_schema_except2, testpub_schema_except_multi;
+
+---------------------------------------------
+-- Tests for publications with SEQUENCES
+---------------------------------------------
 CREATE SEQUENCE regress_pub_seq0;
 CREATE SEQUENCE pub_test.regress_pub_seq1;
 
@@ -1194,6 +1309,38 @@ ALTER PUBLICATION testpub1_forschema SET TABLES IN SCHEMA pub_test1, pub_test1;
 ALTER PUBLICATION testpub1_forschema ADD TABLES IN SCHEMA foo (a, b);
 ALTER PUBLICATION testpub1_forschema ADD TABLES IN SCHEMA foo, bar (a, b);
 
+-- EXCEPT clause with CURRENT_SCHEMA: cross-schema entry must be rejected
+SET search_path = pub_test1;
+-- qualified name from wrong schema -> error
+CREATE PUBLICATION testpub_cursch_except FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE pub_test2.tbl1);
+-- unqualified name implicitly qualified with current schema (pub_test1.tbl)
+SET client_min_messages = 'ERROR';
+CREATE PUBLICATION testpub_cursch_except FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE tbl);
+RESET client_min_messages;
+\dRp+ testpub_cursch_except
+DROP PUBLICATION testpub_cursch_except;
+
+-- succeeds: CURRENT_SCHEMA and pub_test1 (same schema)
+SET client_min_messages = 'ERROR';
+CREATE PUBLICATION testpub_cursch_named_same
+    FOR TABLES IN SCHEMA CURRENT_SCHEMA, pub_test1;
+RESET client_min_messages;
+\dRp+ testpub_cursch_named_same
+DROP PUBLICATION testpub_cursch_named_same;
+
+-- fail: CURRENT_SCHEMA and pub_test1 (same schema) have
+-- conflicting EXCEPT clauses
+CREATE PUBLICATION testpub_cursch_named_conflict
+    FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE tbl),
+                  pub_test1 EXCEPT (TABLE tbl1);
+
+-- fail: two CURRENT_SCHEMA mentions with conflicting EXCEPT clauses
+CREATE PUBLICATION testpub_cursch_cursch_conflict
+    FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE tbl),
+                  CURRENT_SCHEMA EXCEPT (TABLE tbl1);
+
+RESET search_path;
+
 -- cleanup pub_test1 schema for invalidation tests
 ALTER PUBLICATION testpub2_forschema DROP TABLES IN SCHEMA pub_test1;
 DROP PUBLICATION testpub3_forschema, testpub4_forschema, testpub5_forschema, testpub6_forschema, testpub_fortable;
@@ -1448,6 +1595,7 @@ DROP ROLE regress_publication_user_dummy;
 -- Test pg_get_publication_tables(text[], oid) function
 CREATE SCHEMA gpt_test_sch;
 CREATE TABLE gpt_test_sch.tbl_sch (id int);
+CREATE TABLE gpt_test_sch.tbl_sch2 (id int);
 CREATE TABLE tbl_normal (id int);
 CREATE TABLE tbl_parent (id1 int, id2 int, id3 int) PARTITION BY RANGE (id1);
 CREATE TABLE tbl_part1 PARTITION OF tbl_parent FOR VALUES FROM (1) TO (10);
@@ -1459,6 +1607,7 @@ CREATE PUBLICATION pub_all_no_viaroot FOR ALL TABLES WITH (publish_via_partition
 CREATE PUBLICATION pub_all_except FOR ALL TABLES EXCEPT (TABLE tbl_parent, gpt_test_sch.tbl_sch) WITH (publish_via_partition_root = true);
 CREATE PUBLICATION pub_all_except_no_viaroot FOR ALL TABLES EXCEPT (TABLE tbl_parent, gpt_test_sch.tbl_sch) WITH (publish_via_partition_root = false);
 CREATE PUBLICATION pub_schema FOR TABLES IN SCHEMA gpt_test_sch;
+CREATE PUBLICATION pub_schema_except FOR TABLES IN SCHEMA gpt_test_sch EXCEPT (TABLE gpt_test_sch.tbl_sch);
 CREATE PUBLICATION pub_normal FOR TABLE tbl_normal WHERE (id < 10);
 CREATE PUBLICATION pub_part_leaf FOR TABLE tbl_part1 WITH (publish_via_partition_root = false);
 CREATE PUBLICATION pub_part_parent FOR TABLE tbl_parent (id1, id2) WHERE (id1 = 10) WITH (publish_via_partition_root = true);
@@ -1515,6 +1664,32 @@ SELECT * FROM test_gpt(ARRAY['pub_all_except_no_viaroot'], 'gpt_test_sch.tbl_sch
 SELECT * FROM test_gpt(ARRAY['pub_all_except_no_viaroot'], 'tbl_parent'); -- no result (excluded)
 SELECT * FROM test_gpt(ARRAY['pub_all_except_no_viaroot'], 'tbl_part1'); -- no result
 
+-- test for EXCEPT clause with schema publication
+SELECT * FROM test_gpt(ARRAY['pub_schema_except'], 'gpt_test_sch.tbl_sch'); -- no result (excluded)
+SELECT * FROM test_gpt(ARRAY['pub_schema_except'], 'gpt_test_sch.tbl_sch2'); -- one row (included via schema)
+
+-- test for EXCEPT clause with schema publication, where the excluded root's
+-- partition lives in a different schema that is separately, fully included
+-- (no EXCEPT) in the same publication.  The root's exclusion must cascade to
+-- the partition regardless of the partition's own schema membership.
+CREATE SCHEMA gpt_cross_sch1;
+CREATE SCHEMA gpt_cross_sch2;
+CREATE TABLE gpt_cross_sch1.croot (id int) PARTITION BY RANGE (id);
+CREATE TABLE gpt_cross_sch2.cpart1 PARTITION OF gpt_cross_sch1.croot FOR VALUES FROM (1) TO (10);
+
+SET client_min_messages = 'ERROR';
+CREATE PUBLICATION pub_cross_schema_except FOR TABLES IN SCHEMA gpt_cross_sch1 EXCEPT (TABLE gpt_cross_sch1.croot), TABLES IN SCHEMA gpt_cross_sch2;
+RESET client_min_messages;
+
+SELECT * FROM test_gpt(ARRAY['pub_cross_schema_except'], 'gpt_cross_sch1.croot'); -- no result (excluded)
+-- no result (excluded via cascading root exclusion, even though cpart1's own
+-- schema gpt_cross_sch2 is separately, fully included with no EXCEPT)
+SELECT * FROM test_gpt(ARRAY['pub_cross_schema_except'], 'gpt_cross_sch2.cpart1');
+
+DROP PUBLICATION pub_cross_schema_except;
+DROP TABLE gpt_cross_sch2.cpart1, gpt_cross_sch1.croot;
+DROP SCHEMA gpt_cross_sch1, gpt_cross_sch2;
+
 -- two rows with different row filter
 SELECT * FROM test_gpt(ARRAY['pub_all', 'pub_normal'], 'tbl_normal');
 
@@ -1543,6 +1718,7 @@ DROP PUBLICATION pub_all_no_viaroot;
 DROP PUBLICATION pub_all_except;
 DROP PUBLICATION pub_all_except_no_viaroot;
 DROP PUBLICATION pub_schema;
+DROP PUBLICATION pub_schema_except;
 DROP PUBLICATION pub_normal;
 DROP PUBLICATION pub_part_leaf;
 DROP PUBLICATION pub_part_parent;
diff --git a/src/test/subscription/t/037_except.pl b/src/test/subscription/t/037_except.pl
index 43b51c8ff71..7e11061a594 100644
--- a/src/test/subscription/t/037_except.pl
+++ b/src/test/subscription/t/037_except.pl
@@ -24,14 +24,17 @@ my $result;
 
 sub test_except_root_partition
 {
-	my ($pubviaroot) = @_;
+	my ($pubviaroot, $pubsql) = @_;
+	$pubsql //=
+	  "CREATE PUBLICATION tap_pub_part FOR ALL TABLES EXCEPT (TABLE root1)";
+	$pubsql .= " WITH (publish_via_partition_root = $pubviaroot)";
 
 	# If the root partitioned table is in the EXCEPT clause, all its
 	# partitions are excluded from publication, regardless of the
 	# publish_via_partition_root setting.
 	$node_publisher->safe_psql(
 		'postgres', qq(
-		CREATE PUBLICATION tap_pub_part FOR ALL TABLES EXCEPT (TABLE root1) WITH (publish_via_partition_root = $pubviaroot);
+		$pubsql;
 		INSERT INTO root1 VALUES (1), (101);
 	));
 	$node_subscriber->safe_psql('postgres',
@@ -223,6 +226,206 @@ $node_subscriber->safe_psql(
 test_except_root_partition('false');
 test_except_root_partition('true');
 
+# Same validation using TABLES IN SCHEMA instead of FOR ALL TABLES.
+my $schema_pub =
+  "CREATE PUBLICATION tap_pub_part FOR TABLES IN SCHEMA public EXCEPT (TABLE public.root1)";
+test_except_root_partition('false', $schema_pub);
+test_except_root_partition('true', $schema_pub);
+
+# ============================================
+# EXCEPT test cases for TABLES IN SCHEMA
+# ============================================
+
+# Create a dedicated schema with two tables: one to be published and one to be
+# excluded.  Also create inherited tables to verify ONLY semantics.
+$node_publisher->safe_psql(
+	'postgres', qq(
+	CREATE SCHEMA sch1;
+	CREATE TABLE sch1.tab_published AS SELECT generate_series(1,5) AS a;
+	CREATE TABLE sch1.tab_excluded AS SELECT generate_series(1,5) AS a;
+	CREATE TABLE sch1.parent (a int);
+	CREATE TABLE sch1.child (b int) INHERITS (sch1.parent);
+));
+
+$node_subscriber->safe_psql(
+	'postgres', qq(
+	CREATE SCHEMA sch1;
+	CREATE TABLE sch1.tab_published (a int);
+	CREATE TABLE sch1.tab_excluded (a int);
+	CREATE TABLE sch1.parent (a int);
+	CREATE TABLE sch1.child (b int) INHERITS (sch1.parent);
+));
+
+# Basic test: initial sync respects EXCEPT.
+$node_publisher->safe_psql('postgres',
+	"CREATE PUBLICATION sch_pub FOR TABLES IN SCHEMA sch1 EXCEPT (TABLE sch1.tab_excluded)"
+);
+$node_subscriber->safe_psql('postgres',
+	"CREATE SUBSCRIPTION sch_sub CONNECTION '$publisher_connstr' PUBLICATION sch_pub"
+);
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'sch_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres',
+	"SELECT count(*) FROM sch1.tab_published");
+is($result, qq(5),
+	'TABLES IN SCHEMA EXCEPT: initial sync copies included table');
+$result =
+  $node_subscriber->safe_psql('postgres',
+	"SELECT count(*) FROM sch1.tab_excluded");
+is($result, qq(0),
+	'TABLES IN SCHEMA EXCEPT: initial sync skips excluded table');
+
+# DML: only the included table should be replicated.
+$node_publisher->safe_psql(
+	'postgres', qq(
+	INSERT INTO sch1.tab_published VALUES (6);
+	INSERT INTO sch1.tab_excluded VALUES (6);
+));
+$node_publisher->wait_for_catchup('sch_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres',
+	"SELECT count(*) FROM sch1.tab_published");
+is($result, qq(6),
+	'TABLES IN SCHEMA EXCEPT: DML on included table is replicated');
+$result =
+  $node_subscriber->safe_psql('postgres',
+	"SELECT count(*) FROM sch1.tab_excluded");
+is($result, qq(0),
+	'TABLES IN SCHEMA EXCEPT: DML on excluded table is not replicated');
+
+$node_subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION sch_sub');
+$node_publisher->safe_psql('postgres', 'DROP PUBLICATION sch_pub');
+
+# Inherited tables: excluding the parent (without ONLY) also excludes the child.
+$node_publisher->safe_psql('postgres',
+	"CREATE PUBLICATION sch_pub FOR TABLES IN SCHEMA sch1 EXCEPT (TABLE sch1.parent)"
+);
+$node_subscriber->safe_psql('postgres',
+	"CREATE SUBSCRIPTION sch_sub CONNECTION '$publisher_connstr' PUBLICATION sch_pub"
+);
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'sch_sub');
+
+$node_publisher->safe_psql('postgres',
+	"INSERT INTO sch1.child VALUES (generate_series(1,5), generate_series(1,5))"
+);
+$node_publisher->wait_for_catchup('sch_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM sch1.child");
+is($result, qq(0),
+	'TABLES IN SCHEMA EXCEPT: excluding parent (without ONLY) also excludes child'
+);
+
+$node_subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION sch_sub');
+$node_publisher->safe_psql('postgres', 'DROP PUBLICATION sch_pub');
+
+# Test that EXCEPT (TABLE ONLY parent) excludes only the parent itself, not its
+# child.  Truncate child first so rows from the previous test are not copied by
+# the initial table sync of the next subscription.
+$node_publisher->safe_psql('postgres', 'TRUNCATE sch1.child');
+$node_subscriber->safe_psql('postgres', 'TRUNCATE sch1.child');
+$node_publisher->safe_psql('postgres',
+	"CREATE PUBLICATION sch_pub FOR TABLES IN SCHEMA sch1 EXCEPT (TABLE ONLY sch1.parent)"
+);
+$node_subscriber->safe_psql('postgres',
+	"CREATE SUBSCRIPTION sch_sub CONNECTION '$publisher_connstr' PUBLICATION sch_pub"
+);
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'sch_sub');
+
+$node_publisher->safe_psql('postgres',
+	"INSERT INTO sch1.child VALUES (generate_series(1,5), generate_series(1,5))"
+);
+$node_publisher->wait_for_catchup('sch_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM sch1.child");
+is($result, qq(5),
+	'TABLES IN SCHEMA EXCEPT: ONLY parent in EXCEPT does not exclude child');
+
+$node_subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION sch_sub');
+$node_publisher->safe_psql('postgres', 'DROP PUBLICATION sch_pub');
+
+# Cleanup schema tables before the multi-publication section.
+$node_publisher->safe_psql('postgres', 'DROP SCHEMA sch1 CASCADE');
+$node_subscriber->safe_psql('postgres', 'DROP SCHEMA sch1 CASCADE');
+
+# ============================================
+# EXCEPT test cases for TABLES IN SCHEMA with a cross-schema partition
+# ============================================
+
+# A partition can live in a different schema than its partitioned root.  If
+# the root is excluded via EXCEPT under its own schema's clause, the
+# exclusion must cascade to all its partitions, even when the partition's
+# own schema is separately, fully included (no EXCEPT) in the same
+# publication.
+$node_publisher->safe_psql(
+	'postgres', qq(
+	CREATE SCHEMA csch1;
+	CREATE SCHEMA csch2;
+	CREATE TABLE csch1.croot(a int) PARTITION BY RANGE(a);
+	CREATE TABLE csch2.cpart1 PARTITION OF csch1.croot FOR VALUES FROM (0) TO (100);
+	INSERT INTO csch1.croot VALUES (generate_series(1,5));
+));
+
+$node_subscriber->safe_psql(
+	'postgres', qq(
+	CREATE SCHEMA csch1;
+	CREATE SCHEMA csch2;
+	CREATE TABLE csch1.croot(a int);
+	CREATE TABLE csch2.cpart1(a int);
+));
+
+$node_publisher->safe_psql('postgres',
+	"CREATE PUBLICATION cross_sch_pub FOR TABLES IN SCHEMA csch1 EXCEPT (TABLE csch1.croot), TABLES IN SCHEMA csch2 WITH (publish_via_partition_root = false)"
+);
+$node_subscriber->safe_psql('postgres',
+	"CREATE SUBSCRIPTION cross_sch_sub CONNECTION '$publisher_connstr' PUBLICATION cross_sch_pub"
+);
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'cross_sch_sub');
+
+# Baseline: the root itself, pre-populated with rows before the subscription
+# was created, is excluded by its own schema's EXCEPT clause -- initial sync
+# must not have copied it.
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM csch1.croot");
+is($result, qq(0),
+	'TABLES IN SCHEMA EXCEPT: cross-schema partition root is excluded by initial sync'
+);
+
+# Regression case: the partition's own schema (csch2) is separately, fully
+# included with no EXCEPT, but the root's exclusion must still cascade to it
+# -- initial sync must not have copied the pre-existing rows routed into this
+# partition via the root.
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM csch2.cpart1");
+is($result, qq(0),
+	'TABLES IN SCHEMA EXCEPT: cross-schema partition is excluded by initial sync via cascading root exclusion'
+);
+
+# Insert distinct, identifiable data directly into the partition and verify
+# it is not replicated either.
+$node_publisher->safe_psql('postgres',
+	"INSERT INTO csch2.cpart1 VALUES (generate_series(1,5))");
+$node_publisher->wait_for_catchup('cross_sch_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM csch1.croot");
+is($result, qq(0),
+	'TABLES IN SCHEMA EXCEPT: cross-schema partition root remains excluded after DML'
+);
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM csch2.cpart1");
+is($result, qq(0),
+	'TABLES IN SCHEMA EXCEPT: cross-schema partition remains excluded after DML via cascading root exclusion'
+);
+
+$node_subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION cross_sch_sub');
+$node_publisher->safe_psql('postgres', 'DROP PUBLICATION cross_sch_pub');
+$node_publisher->safe_psql('postgres', 'DROP SCHEMA csch1, csch2 CASCADE');
+$node_subscriber->safe_psql('postgres', 'DROP SCHEMA csch1, csch2 CASCADE');
+
 # ============================================
 # Test when a subscription is subscribing to multiple publications
 # ============================================
@@ -254,6 +457,7 @@ $node_publisher->safe_psql(
 	DROP PUBLICATION tap_pub2;
 	TRUNCATE tab1;
 ));
+$node_subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION tap_sub');
 $node_subscriber->safe_psql('postgres', qq(TRUNCATE tab1));
 
 # OK when a table is excluded by pub1 EXCEPT clause, but it is included by pub2
@@ -282,6 +486,59 @@ $node_subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION tap_sub');
 $node_publisher->safe_psql('postgres', 'DROP PUBLICATION tap_pub1');
 $node_publisher->safe_psql('postgres', 'DROP PUBLICATION tap_pub2');
 
+# OK when a table is excluded by the EXCEPT clause of one schema publication,
+# but it is included by another publication that publishes the same schema in
+# full. Inclusion wins, so the table is replicated.
+$node_publisher->safe_psql(
+	'postgres', qq(
+	CREATE SCHEMA msch;
+	CREATE TABLE msch.tab_excl (a int);
+	INSERT INTO msch.tab_excl VALUES (1);
+	CREATE PUBLICATION tap_pub_sch_with_except
+	  FOR TABLES IN SCHEMA msch EXCEPT (TABLE msch.tab_excl);
+	CREATE PUBLICATION tap_pub_sch_without_except FOR TABLES IN SCHEMA msch;
+));
+
+$node_subscriber->safe_psql(
+	'postgres', qq(
+	CREATE SCHEMA msch;
+	CREATE TABLE msch.tab_excl (a int);
+));
+
+$node_subscriber->safe_psql('postgres',
+	"CREATE SUBSCRIPTION tap_sub_multi CONNECTION '$publisher_connstr' PUBLICATION tap_pub_sch_with_except, tap_pub_sch_without_except"
+);
+$node_subscriber->wait_for_subscription_sync($node_publisher,
+	'tap_sub_multi');
+
+# The initial table sync must copy the excluded table too, because
+# tap_pub_sch_without_except publishes it.
+$result =
+  $node_subscriber->safe_psql('postgres',
+	"SELECT count(*) FROM msch.tab_excl");
+is($result, qq(1),
+	'initial sync copies a table excluded by one publication but included by another'
+);
+
+$node_publisher->safe_psql(
+	'postgres', qq(
+	INSERT INTO msch.tab_excl VALUES (2);
+));
+$node_publisher->wait_for_catchup('tap_sub_multi');
+
+$result =
+  $node_subscriber->safe_psql('postgres',
+	"SELECT count(*) FROM msch.tab_excl");
+is($result, qq(2),
+	'DML on a table excluded by one publication but included by another is replicated'
+);
+
+$node_subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION tap_sub_multi');
+$node_publisher->safe_psql('postgres', 'DROP PUBLICATION tap_pub_sch_with_except');
+$node_publisher->safe_psql('postgres', 'DROP PUBLICATION tap_pub_sch_without_except');
+$node_publisher->safe_psql('postgres', 'DROP SCHEMA msch CASCADE');
+$node_subscriber->safe_psql('postgres', 'DROP SCHEMA msch CASCADE');
+
 $node_publisher->stop('fast');
 
 done_testing();
-- 
2.55.0

