Subject: Re: Extension security improvement: Add support for extensions with an owned schema

From: "Jelte Fennema-Nio" <[email-masked]>
Date: Wed, 11 Feb 2026 00:32:29 +0100

On Wed Feb 11, 2026 at 12:19 AM CET, Jelte Fennema-Nio wrote:
> Added now in v8

Ugh, I didn't include some of the new files in the attached patch. Fixed
now with v9.
From 3e07020b4d64a09cf97e067736d173ca9b255033 Mon Sep 17 00:00:00 2001
From: Jelte Fennema-Nio <[email-masked]>
Date: Fri, 4 Oct 2024 22:34:51 +0200
Subject: [PATCH v9] Add support for extensions with an owned schema

Writing the sql migration scripts that are run by CREATE EXTENSION and
ALTER EXTENSION UPDATE are security minefields for extension authors.
One big reason for this is that search_path is set to the schema of the
extension while running these scripts, and thus if a user with lower
privileges can create functions or operators in that schema they can do
all kinds of search_path confusion attacks if not every function and
operator that is used in the script is schema qualified. While doing
such schema qualification is possible, it relies on the author to never
make a mistake in any of the sql files. And sadly humans have a tendency
to make mistakes.

This patch adds a new "owned_schema" option to the extension control
file that can be set to true to indicate that this extension wants to
own the schema in which it is installed. What that means is that the
schema should not exist before creating the extension, and will be
created during extension creation. This thus gives the extension author
an easy way to use a safe search_path, while still allowing all objects
to be grouped together in a schema. The implementation also has the
pleasant side effect that the schema will be automatically dropped when
the extension is dropped.
---
 doc/src/sgml/extend.sgml                      |  34 ++
 doc/src/sgml/ref/create_extension.sgml        |   6 +-
 src/backend/commands/extension.c              | 429 +++++++++++++-----
 src/backend/utils/adt/pg_upgrade_support.c    |  45 +-
 src/bin/pg_dump/pg_dump.c                     |  54 ++-
 src/bin/pg_dump/pg_dump.h                     |   1 +
 src/include/catalog/pg_extension.h            |   1 +
 src/include/catalog/pg_proc.dat               |   2 +-
 src/include/commands/extension.h              |   4 +-
 src/test/modules/test_extensions/Makefile     |  13 +-
 .../expected/test_extensions.out              | 157 +++++++
 src/test/modules/test_extensions/meson.build  |  11 +
 .../test_extensions/sql/test_extensions.sql   |  75 +++
 .../test_ext_owned_schema--1.0--1.1.sql       |   3 +
 .../test_ext_owned_schema--1.0.sql            |   2 +
 .../test_ext_owned_schema.control             |   5 +
 ...ext_owned_schema_nosuperuser--1.0--1.1.sql |   5 +
 ...test_ext_owned_schema_nosuperuser--1.0.sql |   3 +
 .../test_ext_owned_schema_nosuperuser.control |   5 +
 ...test_ext_owned_schema_relocatable--1.0.sql |   2 +
 .../test_ext_owned_schema_relocatable.control |   4 +
 ...est_ext_owned_schema_trusted--1.0--1.1.sql |   3 +
 .../test_ext_owned_schema_trusted--1.0.sql    |   3 +
 .../test_ext_owned_schema_trusted.control     |   5 +
 src/test/modules/test_pg_dump/t/001_base.pl   |  32 ++
 25 files changed, 761 insertions(+), 143 deletions(-)
 create mode 100644 src/test/modules/test_extensions/test_ext_owned_schema--1.0--1.1.sql
 create mode 100644 src/test/modules/test_extensions/test_ext_owned_schema--1.0.sql
 create mode 100644 src/test/modules/test_extensions/test_ext_owned_schema.control
 create mode 100644 src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0--1.1.sql
 create mode 100644 src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0.sql
 create mode 100644 src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser.control
 create mode 100644 src/test/modules/test_extensions/test_ext_owned_schema_relocatable--1.0.sql
 create mode 100644 src/test/modules/test_extensions/test_ext_owned_schema_relocatable.control
 create mode 100644 src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0--1.1.sql
 create mode 100644 src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0.sql
 create mode 100644 src/test/modules/test_extensions/test_ext_owned_schema_trusted.control

diff --git a/doc/src/sgml/extend.sgml b/doc/src/sgml/extend.sgml
index 63c5ec6d1eb..ddfb4ebfbf5 100644
--- a/doc/src/sgml/extend.sgml
+++ b/doc/src/sgml/extend.sgml
@@ -814,6 +814,40 @@ RETURNS anycompatible AS ...
       </listitem>
      </varlistentry>
 
+     <varlistentry id="extend-extensions-files-owned-schema">
+      <term><varname>owned_schema</varname> (<type>boolean</type>)</term>
+      <listitem>
+       <para>
+        An extension should set <firstterm>owned_schema</firstterm> to
+        <literal>true</literal> in its control file if the extension wants a
+        dedicated schema for its objects. Such a schema should not exist yet at
+        the time of extension creation, and will be created automatically by
+        <literal>CREATE EXTENSION</literal>. The default is
+        <literal>false</literal>, i.e., the extension can be installed into an
+        existing schema.
+       </para>
+       <para>
+        Having a schema owned by the extension can make it much easier to
+        reason about possible <literal>search_path</literal> injection attacks.
+        For instance with an owned schema, it is generally safe to set the
+        <literal>search_path</literal> of a <literal>SECURITY DEFINER</literal>
+        function to the schema of the extension. While without an owned schema
+        it might not be safe to do so, because a malicious user could insert
+        objects in that schema and thus <link
+        linkend="sql-createfunction-security"> cause malicious to be executed
+        as superuser</link>. Similarly, having an owned schema can make it safe
+        by default to execute general-purpose SQL in the extension script,
+        because the search_path now only contains trusted schemas. Without an
+        owned schema it's <link linkend="extend-extensions-security-scripts">
+        recommended to manually change the search_path</link>.
+       </para>
+       <para>
+        Apart from the security considerations, having an owned schema can help
+        prevent naming conflicts between objects of different extensions.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="extend-extensions-files-schema">
       <term><varname>schema</varname> (<type>string</type>)</term>
       <listitem>
diff --git a/doc/src/sgml/ref/create_extension.sgml b/doc/src/sgml/ref/create_extension.sgml
index 713abd9c494..ab125d56263 100644
--- a/doc/src/sgml/ref/create_extension.sgml
+++ b/doc/src/sgml/ref/create_extension.sgml
@@ -104,7 +104,11 @@ CREATE EXTENSION [ IF NOT EXISTS ] <replaceable class="parameter">extension_name
        <para>
         The name of the schema in which to install the extension's
         objects, given that the extension allows its contents to be
-        relocated.  The named schema must already exist.
+        relocated. Whether this schema should already exist or not depends on
+        the value of <literal>owned_schema</literal> in the extensions control
+        file: If it's <literal>true</literal>, the schema must
+        <emphasis>not</emphasis> exist; if it's <literal>false</literal> it
+        must exist.
         If not specified, and the extension's control file does not specify a
         schema either, the current default object creation schema is used.
        </para>
diff --git a/src/backend/commands/extension.c b/src/backend/commands/extension.c
index 81f24615d51..03b3de357bd 100644
--- a/src/backend/commands/extension.c
+++ b/src/backend/commands/extension.c
@@ -94,6 +94,8 @@ typedef struct ExtensionControlFile
 									 * MODULE_PATHNAME */
 	char	   *comment;		/* comment, if any */
 	char	   *schema;			/* target schema (allowed if !relocatable) */
+	bool		owned_schema;	/* if the schema should be owned by the
+								 * extension */
 	bool		relocatable;	/* is ALTER EXTENSION SET SCHEMA supported? */
 	bool		superuser;		/* must be superuser to install? */
 	bool		trusted;		/* allow becoming superuser on the fly? */
@@ -790,6 +792,14 @@ parse_extension_control_file(ExtensionControlFile *control,
 		{
 			control->schema = pstrdup(item->value);
 		}
+		else if (strcmp(item->name, "owned_schema") == 0)
+		{
+			if (!parse_bool(item->value, &control->owned_schema))
+				ereport(ERROR,
+						(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						 errmsg("parameter \"%s\" requires a Boolean value",
+								item->name)));
+		}
 		else if (strcmp(item->name, "relocatable") == 0)
 		{
 			if (!parse_bool(item->value, &control->relocatable))
@@ -1233,6 +1243,44 @@ extension_is_trusted(ExtensionControlFile *control)
 	return false;
 }
 
+/*
+ * Enforce superuser requirements for extension operations.
+ *
+ * Returns true if we should switch to superuser for trusted extensions.
+ * Throws an error if superuser is required but not available.
+ *
+ * This function should only be called after choosing the appropriate
+ * control file (including any secondary control files for updates).
+ */
+static bool
+check_extension_superuser_requirements(ExtensionControlFile *control,
+									   const char *from_version)
+{
+	if (control->superuser && !superuser())
+	{
+		if (extension_is_trusted(control))
+			return true;
+		else if (from_version == NULL)
+			ereport(ERROR,
+					(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+					 errmsg("permission denied to create extension \"%s\"",
+							control->name),
+					 control->trusted
+					 ? errhint("Must have CREATE privilege on current database to create this extension.")
+					 : errhint("Must be superuser to create this extension.")));
+		else
+			ereport(ERROR,
+					(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+					 errmsg("permission denied to update extension \"%s\"",
+							control->name),
+					 control->trusted
+					 ? errhint("Must have CREATE privilege on current database to update this extension.")
+					 : errhint("Must be superuser to update this extension.")));
+	}
+
+	return false;
+}
+
 /*
  * Execute the appropriate script file for installing or updating the extension
  *
@@ -1261,27 +1309,7 @@ execute_extension_script(Oid extensionOid, ExtensionControlFile *control,
 	 * here so that the control flags are correctly associated with the right
 	 * script(s) if they happen to be set in secondary control files.
 	 */
-	if (control->superuser && !superuser())
-	{
-		if (extension_is_trusted(control))
-			switch_to_superuser = true;
-		else if (from_version == NULL)
-			ereport(ERROR,
-					(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
-					 errmsg("permission denied to create extension \"%s\"",
-							control->name),
-					 control->trusted
-					 ? errhint("Must have CREATE privilege on current database to create this extension.")
-					 : errhint("Must be superuser to create this extension.")));
-		else
-			ereport(ERROR,
-					(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
-					 errmsg("permission denied to update extension \"%s\"",
-							control->name),
-					 control->trusted
-					 ? errhint("Must have CREATE privilege on current database to update this extension.")
-					 : errhint("Must be superuser to update this extension.")));
-	}
+	switch_to_superuser = check_extension_superuser_requirements(control, from_version);
 
 	filename = get_extension_script_filename(control, from_version, version);
 
@@ -1820,6 +1848,164 @@ find_install_path(List *evi_list, ExtensionVersionInfo *evi_target,
 	return evi_start;
 }
 
+/*
+ * Create a schema with the given name, as part of CREATE EXTENSION.
+ */
+static Oid
+CreateSchemaForExtension(char *schemaName)
+{
+
+	CreateSchemaStmt *csstmt = makeNode(CreateSchemaStmt);
+
+	csstmt->schemaname = schemaName;
+	csstmt->authrole = NULL;	/* will be created by current user */
+	csstmt->schemaElts = NIL;
+	csstmt->if_not_exists = false;
+	CreateSchemaCommand(csstmt, "(generated CREATE SCHEMA command)",
+						-1, -1);
+
+	/*
+	 * CreateSchemaCommand includes CommandCounterIncrement, so new schema is
+	 * now visible.
+	 */
+	return get_namespace_oid(schemaName, false);
+}
+
+/*
+ * Create an owned schema with the given name, as part of CREATE EXTENSION, and
+ * fails if the schema already exist.
+ */
+static Oid
+CreateOwnedSchemaForExtension(char *schemaName)
+{
+	/* Find or create the schema in case it does not exist. */
+	Oid			schemaOid = get_namespace_oid(schemaName, true);
+
+	if (OidIsValid(schemaOid))
+	{
+		ereport(ERROR,
+				(errcode(ERRCODE_DUPLICATE_SCHEMA),
+				 errmsg("schema \"%s\" already exists",
+						schemaName),
+				 errhint("Drop schema \"%s\" or specify another schema using CREATE EXTENSION ... SCHEMA ...", schemaName)));
+	}
+
+	return CreateSchemaForExtension(schemaName);
+}
+
+/*
+ * Gets or creates the schema than an extension should be created in.
+ *
+ * Returns the OID of the schema and updates schemaName to the name of the schema.
+ */
+static Oid
+GetOrCreateSchemaForExtension(char **schemaName, ExtensionControlFile *control, bool cascade)
+{
+	/*
+	 * The simplest case is when the user provides a schema name.
+	 */
+	if (*schemaName)
+	{
+		Oid			schemaOid;
+
+		if (!control->owned_schema)
+		{
+			/*
+			 * For non-owned schemas, this schema must already exist. We want
+			 * to check this now, so it fails even if we bail out of this
+			 * block due to the CASCADE logic.
+			 */
+			schemaOid = get_namespace_oid(*schemaName, false);
+		}
+
+		if (control->schema && strcmp(control->schema, *schemaName) != 0)
+		{
+			/*
+			 * The extension is not relocatable and the author gave us a
+			 * schema for it.
+			 *
+			 * Unless CASCADE parameter was given, it's an error to give a
+			 * schema different from control->schema if control->schema is
+			 * specified.
+			 */
+			if (!cascade)
+			{
+				ereport(ERROR,
+						(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+						 errmsg("extension \"%s\" must be installed in schema \"%s\"",
+								control->name,
+								control->schema),
+						 errhint("Do not specify SCHEMA when running CREATE EXTENSION for extension \"%s\"", control->name)));
+			}
+
+			/*
+			 * If the schema mismatches and CASCADE was given, we pretend the
+			 * user did not specify a schema and use the normal logic below to
+			 * get or create the schema from the control file.
+			 */
+		}
+		else if (control->owned_schema)
+		{
+			return CreateOwnedSchemaForExtension(*schemaName);
+		}
+		else
+		{
+			return schemaOid;
+		}
+	}
+
+	if (control->schema)
+	{
+		Oid			schemaOid;
+
+		*schemaName = control->schema;
+
+		if (control->owned_schema)
+		{
+			return CreateOwnedSchemaForExtension(control->schema);
+		}
+
+		/* Find or create the schema in case it does not exist. */
+		schemaOid = get_namespace_oid(control->schema, true);
+		if (OidIsValid(schemaOid))
+		{
+			return schemaOid;
+		}
+		return CreateSchemaForExtension(control->schema);
+	}
+
+	if (control->owned_schema)
+	{
+		ereport(ERROR,
+				(errcode(ERRCODE_UNDEFINED_SCHEMA),
+				 errmsg("no schema has been selected to create in")));
+	}
+
+	{
+		/*
+		 * Neither user nor author of the extension specified schema; use the
+		 * current default creation namespace, which is the first explicit
+		 * entry in the search_path.
+		 */
+		List	   *search_path = fetch_search_path(false);
+		Oid			schemaOid;
+
+		if (search_path == NIL) /* nothing valid in search_path? */
+			ereport(ERROR,
+					(errcode(ERRCODE_UNDEFINED_SCHEMA),
+					 errmsg("no schema has been selected to create in")));
+		schemaOid = linitial_oid(search_path);
+		*schemaName = get_namespace_name(schemaOid);
+		if (*schemaName == NULL)	/* recently-deleted namespace? */
+			ereport(ERROR,
+					(errcode(ERRCODE_UNDEFINED_SCHEMA),
+					 errmsg("no schema has been selected to create in")));
+
+		list_free(search_path);
+		return schemaOid;
+	}
+}
+
 /*
  * CREATE EXTENSION worker
  *
@@ -1849,6 +2035,9 @@ CreateExtensionInternal(char *extensionName,
 	Oid			extensionOid;
 	ObjectAddress address;
 	ListCell   *lc;
+	bool		switch_to_superuser = false;
+	Oid			save_userid = 0;
+	int			save_sec_context = 0;
 
 	/*
 	 * Read the primary control file.  Note we assume that it does not contain
@@ -1917,77 +2106,25 @@ CreateExtensionInternal(char *extensionName,
 	control = read_extension_aux_control_file(pcontrol, versionName);
 
 	/*
-	 * Determine the target schema to install the extension into
+	 * For trusted extensions with owned schemas, we need to create the schema
+	 * as superuser to ensure proper ownership.
 	 */
-	if (schemaName)
-	{
-		/* If the user is giving us the schema name, it must exist already. */
-		schemaOid = get_namespace_oid(schemaName, false);
-	}
-
-	if (control->schema != NULL)
+	if (control->owned_schema)
 	{
-		/*
-		 * The extension is not relocatable and the author gave us a schema
-		 * for it.
-		 *
-		 * Unless CASCADE parameter was given, it's an error to give a schema
-		 * different from control->schema if control->schema is specified.
-		 */
-		if (schemaName && strcmp(control->schema, schemaName) != 0 &&
-			!cascade)
-			ereport(ERROR,
-					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-					 errmsg("extension \"%s\" must be installed in schema \"%s\"",
-							control->name,
-							control->schema)));
-
-		/* Always use the schema from control file for current extension. */
-		schemaName = control->schema;
-
-		/* Find or create the schema in case it does not exist. */
-		schemaOid = get_namespace_oid(schemaName, true);
-
-		if (!OidIsValid(schemaOid))
+		switch_to_superuser = check_extension_superuser_requirements(control, NULL);
+		if (switch_to_superuser)
 		{
-			CreateSchemaStmt *csstmt = makeNode(CreateSchemaStmt);
-
-			csstmt->schemaname = schemaName;
-			csstmt->authrole = NULL;	/* will be created by current user */
-			csstmt->schemaElts = NIL;
-			csstmt->if_not_exists = false;
-			CreateSchemaCommand(csstmt, "(generated CREATE SCHEMA command)",
-								-1, -1);
-
-			/*
-			 * CreateSchemaCommand includes CommandCounterIncrement, so new
-			 * schema is now visible.
-			 */
-			schemaOid = get_namespace_oid(schemaName, false);
+			GetUserIdAndSecContext(&save_userid, &save_sec_context);
+			SetUserIdAndSecContext(BOOTSTRAP_SUPERUSERID,
+								   save_sec_context | SECURITY_LOCAL_USERID_CHANGE);
 		}
 	}
-	else if (!OidIsValid(schemaOid))
-	{
-		/*
-		 * Neither user nor author of the extension specified schema; use the
-		 * current default creation namespace, which is the first explicit
-		 * entry in the search_path.
-		 */
-		List	   *search_path = fetch_search_path(false);
 
-		if (search_path == NIL) /* nothing valid in search_path? */
-			ereport(ERROR,
-					(errcode(ERRCODE_UNDEFINED_SCHEMA),
-					 errmsg("no schema has been selected to create in")));
-		schemaOid = linitial_oid(search_path);
-		schemaName = get_namespace_name(schemaOid);
-		if (schemaName == NULL) /* recently-deleted namespace? */
-			ereport(ERROR,
-					(errcode(ERRCODE_UNDEFINED_SCHEMA),
-					 errmsg("no schema has been selected to create in")));
+	schemaOid = GetOrCreateSchemaForExtension(&schemaName, control, cascade);
 
-		list_free(search_path);
-	}
+	/* Restore authentication state after schema creation if needed */
+	if (switch_to_superuser)
+		SetUserIdAndSecContext(save_userid, save_sec_context);
 
 	/*
 	 * Make note if a temporary namespace has been accessed in this
@@ -2033,6 +2170,7 @@ CreateExtensionInternal(char *extensionName,
 	 */
 	address = InsertExtensionTuple(control->name, extowner,
 								   schemaOid, control->relocatable,
+								   control->owned_schema,
 								   versionName,
 								   PointerGetDatum(NULL),
 								   PointerGetDatum(NULL),
@@ -2238,7 +2376,8 @@ CreateExtension(ParseState *pstate, CreateExtensionStmt *stmt)
  */
 ObjectAddress
 InsertExtensionTuple(const char *extName, Oid extOwner,
-					 Oid schemaOid, bool relocatable, const char *extVersion,
+					 Oid schemaOid, bool relocatable, bool ownedSchema,
+					 const char *extVersion,
 					 Datum extConfig, Datum extCondition,
 					 List *requiredExtensions)
 {
@@ -2268,6 +2407,7 @@ InsertExtensionTuple(const char *extName, Oid extOwner,
 	values[Anum_pg_extension_extowner - 1] = ObjectIdGetDatum(extOwner);
 	values[Anum_pg_extension_extnamespace - 1] = ObjectIdGetDatum(schemaOid);
 	values[Anum_pg_extension_extrelocatable - 1] = BoolGetDatum(relocatable);
+	values[Anum_pg_extension_extownedschema - 1] = BoolGetDatum(ownedSchema);
 	values[Anum_pg_extension_extversion - 1] = CStringGetTextDatum(extVersion);
 
 	if (extConfig == PointerGetDatum(NULL))
@@ -2312,6 +2452,17 @@ InsertExtensionTuple(const char *extName, Oid extOwner,
 	record_object_address_dependencies(&myself, refobjs, DEPENDENCY_NORMAL);
 	free_object_addresses(refobjs);
 
+	if (ownedSchema)
+	{
+		ObjectAddress schemaAddress = {
+			.classId = NamespaceRelationId,
+			.objectId = schemaOid,
+		};
+
+		recordDependencyOn(&schemaAddress, &myself, DEPENDENCY_EXTENSION);
+	}
+
+
 	/* Post creation hook for new extension */
 	InvokeObjectPostCreateHook(ExtensionRelationId, extensionOid, 0);
 
@@ -3246,12 +3397,16 @@ extension_config_remove(Oid extensionoid, Oid tableoid)
 
 /*
  * Execute ALTER EXTENSION SET SCHEMA
+ *
+ * For owned schemas, this boils down to changing the name of its schema. For
+ * non-owned schemas this requires moving all the member objects into the new
+ * schema.
  */
 ObjectAddress
 AlterExtensionNamespace(const char *extensionName, const char *newschema, Oid *oldschema)
 {
 	Oid			extensionOid;
-	Oid			nspOid;
+	Oid			nspOid = InvalidOid;
 	Oid			oldNspOid;
 	AclResult	aclresult;
 	Relation	extRel;
@@ -3264,11 +3419,10 @@ AlterExtensionNamespace(const char *extensionName, const char *newschema, Oid *o
 	HeapTuple	depTup;
 	ObjectAddresses *objsMoved;
 	ObjectAddress extAddr;
+	bool		ownedSchema;
 
 	extensionOid = get_extension_oid(extensionName, false);
 
-	nspOid = LookupCreationNamespace(newschema);
-
 	/*
 	 * Permission check: must own extension.  Note that we don't bother to
 	 * check ownership of the individual member objects ...
@@ -3277,22 +3431,6 @@ AlterExtensionNamespace(const char *extensionName, const char *newschema, Oid *o
 		aclcheck_error(ACLCHECK_NOT_OWNER, OBJECT_EXTENSION,
 					   extensionName);
 
-	/* Permission check: must have creation rights in target namespace */
-	aclresult = object_aclcheck(NamespaceRelationId, nspOid, GetUserId(), ACL_CREATE);
-	if (aclresult != ACLCHECK_OK)
-		aclcheck_error(aclresult, OBJECT_SCHEMA, newschema);
-
-	/*
-	 * If the schema is currently a member of the extension, disallow moving
-	 * the extension into the schema.  That would create a dependency loop.
-	 */
-	if (getExtensionOfObject(NamespaceRelationId, nspOid) == extensionOid)
-		ereport(ERROR,
-				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
-				 errmsg("cannot move extension \"%s\" into schema \"%s\" "
-						"because the extension contains the schema",
-						extensionName, newschema)));
-
 	/* Locate the pg_extension tuple */
 	extRel = table_open(ExtensionRelationId, RowExclusiveLock);
 
@@ -3316,14 +3454,43 @@ AlterExtensionNamespace(const char *extensionName, const char *newschema, Oid *o
 
 	systable_endscan(extScan);
 
+	ownedSchema = extForm->extownedschema;
+
 	/*
-	 * If the extension is already in the target schema, just silently do
-	 * nothing.
+	 * For non-owned schemas, we should now evaluate if the target schema is a
+	 * valid target. For owned schemas, no such checks are needed, because
+	 * we'll simply rename the existing schema.
 	 */
-	if (extForm->extnamespace == nspOid)
+	if (!ownedSchema)
 	{
-		table_close(extRel, RowExclusiveLock);
-		return InvalidObjectAddress;
+		nspOid = LookupCreationNamespace(newschema);
+
+		/* Permission check: must have creation rights in target namespace */
+		aclresult = object_aclcheck(NamespaceRelationId, nspOid, GetUserId(), ACL_CREATE);
+		if (aclresult != ACLCHECK_OK)
+			aclcheck_error(aclresult, OBJECT_SCHEMA, newschema);
+
+		/*
+		 * If the schema is currently a member of the extension, disallow
+		 * moving the extension into the schema.  That would create a
+		 * dependency loop.
+		 */
+		if (getExtensionOfObject(NamespaceRelationId, nspOid) == extensionOid)
+			ereport(ERROR,
+					(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					 errmsg("cannot move extension \"%s\" into schema \"%s\" "
+							"because the extension contains the schema",
+							extensionName, newschema)));
+
+		/*
+		 * If the extension is already in the target schema, just silently do
+		 * nothing.
+		 */
+		if (extForm->extnamespace == nspOid)
+		{
+			table_close(extRel, RowExclusiveLock);
+			return InvalidObjectAddress;
+		}
 	}
 
 	/* Check extension is supposed to be relocatable */
@@ -3396,6 +3563,13 @@ AlterExtensionNamespace(const char *extensionName, const char *newschema, Oid *o
 			}
 		}
 
+		/*
+		 * We don't actually move any objects for owned schemas because we
+		 * simply rename the schema that these objects are already in.
+		 */
+		if (ownedSchema)
+			continue;
+
 		/*
 		 * Otherwise, ignore non-membership dependencies.  (Currently, the
 		 * only other case we could see here is a normal dependency from
@@ -3439,18 +3613,35 @@ AlterExtensionNamespace(const char *extensionName, const char *newschema, Oid *o
 
 	relation_close(depRel, AccessShareLock);
 
-	/* Now adjust pg_extension.extnamespace */
-	extForm->extnamespace = nspOid;
+	/* Now actually update the schema of the extension. */
+	if (ownedSchema)
+	{
+		/*
+		 * For owned schemas, we simply rename the schema. This means that we
+		 * don't need to update the extension its catalog entry, because the
+		 * oid of the schema will stay the same.
+		 */
+		RenameSchema(get_namespace_name(oldNspOid), newschema);
+		table_close(extRel, RowExclusiveLock);
+	}
+	else
+	{
+		/*
+		 * For non-owned schemas, we now have to update the extension's schema
+		 * entry, and also update the dependencies.
+		 */
+		extForm->extnamespace = nspOid;
 
-	CatalogTupleUpdate(extRel, &extTup->t_self, extTup);
+		CatalogTupleUpdate(extRel, &extTup->t_self, extTup);
 
-	table_close(extRel, RowExclusiveLock);
+		table_close(extRel, RowExclusiveLock);
 
-	/* update dependency to point to the new schema */
-	if (changeDependencyFor(ExtensionRelationId, extensionOid,
-							NamespaceRelationId, oldNspOid, nspOid) != 1)
-		elog(ERROR, "could not change schema dependency for extension %s",
-			 NameStr(extForm->extname));
+		/* update dependency to point to the new schema */
+		if (changeDependencyFor(ExtensionRelationId, extensionOid,
+								NamespaceRelationId, oldNspOid, nspOid) != 1)
+			elog(ERROR, "could not change schema dependency for extension %s",
+				 NameStr(extForm->extname));
+	}
 
 	InvokeObjectPostAlterHook(ExtensionRelationId, extensionOid, 0);
 
diff --git a/src/backend/utils/adt/pg_upgrade_support.c b/src/backend/utils/adt/pg_upgrade_support.c
index b505a6b4fee..a0dbf119de9 100644
--- a/src/backend/utils/adt/pg_upgrade_support.c
+++ b/src/backend/utils/adt/pg_upgrade_support.c
@@ -19,6 +19,7 @@
 #include "catalog/pg_subscription_rel.h"
 #include "catalog/pg_type.h"
 #include "commands/extension.h"
+#include "commands/schemacmds.h"
 #include "miscadmin.h"
 #include "replication/logical.h"
 #include "replication/logicallauncher.h"
@@ -185,12 +186,14 @@ Datum
 binary_upgrade_create_empty_extension(PG_FUNCTION_ARGS)
 {
 	text	   *extName;
-	text	   *schemaName;
+	char	   *schemaName;
 	bool		relocatable;
+	bool		ownedschema;
 	text	   *extVersion;
 	Datum		extConfig;
 	Datum		extCondition;
 	List	   *requiredExtensions;
+	Oid			schemaOid;
 
 	CHECK_IS_BINARY_UPGRADE;
 
@@ -198,28 +201,30 @@ binary_upgrade_create_empty_extension(PG_FUNCTION_ARGS)
 	if (PG_ARGISNULL(0) ||
 		PG_ARGISNULL(1) ||
 		PG_ARGISNULL(2) ||
-		PG_ARGISNULL(3))
+		PG_ARGISNULL(3) ||
+		PG_ARGISNULL(4))
 		elog(ERROR, "null argument to binary_upgrade_create_empty_extension is not allowed");
 
 	extName = PG_GETARG_TEXT_PP(0);
-	schemaName = PG_GETARG_TEXT_PP(1);
+	schemaName = text_to_cstring(PG_GETARG_TEXT_PP(1));
 	relocatable = PG_GETARG_BOOL(2);
-	extVersion = PG_GETARG_TEXT_PP(3);
+	ownedschema = PG_GETARG_BOOL(3);
+	extVersion = PG_GETARG_TEXT_PP(4);
 
-	if (PG_ARGISNULL(4))
+	if (PG_ARGISNULL(5))
 		extConfig = PointerGetDatum(NULL);
 	else
-		extConfig = PG_GETARG_DATUM(4);
+		extConfig = PG_GETARG_DATUM(5);
 
-	if (PG_ARGISNULL(5))
+	if (PG_ARGISNULL(6))
 		extCondition = PointerGetDatum(NULL);
 	else
-		extCondition = PG_GETARG_DATUM(5);
+		extCondition = PG_GETARG_DATUM(6);
 
 	requiredExtensions = NIL;
-	if (!PG_ARGISNULL(6))
+	if (!PG_ARGISNULL(7))
 	{
-		ArrayType  *textArray = PG_GETARG_ARRAYTYPE_P(6);
+		ArrayType  *textArray = PG_GETARG_ARRAYTYPE_P(7);
 		Datum	   *textDatums;
 		int			ndatums;
 		int			i;
@@ -234,10 +239,28 @@ binary_upgrade_create_empty_extension(PG_FUNCTION_ARGS)
 		}
 	}
 
+	if (ownedschema)
+	{
+		CreateSchemaStmt *csstmt = makeNode(CreateSchemaStmt);
+
+		csstmt->schemaname = schemaName;
+		csstmt->authrole = NULL;	/* will be created by current user */
+		csstmt->schemaElts = NIL;
+		csstmt->if_not_exists = false;
+		schemaOid = CreateSchemaCommand(csstmt, "(generated CREATE SCHEMA command)",
+										-1, -1);
+
+	}
+	else
+	{
+		schemaOid = get_namespace_oid(schemaName, false);
+	}
+
 	InsertExtensionTuple(text_to_cstring(extName),
 						 GetUserId(),
-						 get_namespace_oid(text_to_cstring(schemaName), false),
+						 schemaOid,
 						 relocatable,
+						 ownedschema,
 						 text_to_cstring(extVersion),
 						 extConfig,
 						 extCondition,
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 2c3754d020f..f27490fa825 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -1951,6 +1951,19 @@ checkExtensionMembership(DumpableObject *dobj, Archive *fout)
 	if (ext == NULL)
 		return false;
 
+	/*
+	 * If this is the "owned_schema" of the extension, then we don't want to
+	 * create it manually, because it gets created together with the
+	 * extension.
+	 */
+	if (dobj->objType == DO_NAMESPACE &&
+		ext->ownedschema && strcmp(ext->namespace, dobj->name) == 0)
+	{
+		NamespaceInfo *nsinfo = (NamespaceInfo *) dobj;
+
+		nsinfo->create = false;
+	}
+
 	dobj->ext_member = true;
 
 	/* Record dependency so that getDependencies needn't deal with that */
@@ -5951,7 +5964,7 @@ binary_upgrade_extension_member(PQExpBuffer upgrade_buffer,
 								const char *objname,
 								const char *objnamespace)
 {
-	DumpableObject *extobj = NULL;
+	ExtensionInfo *ext = NULL;
 	int			i;
 
 	if (!dobj->ext_member)
@@ -5965,19 +5978,33 @@ binary_upgrade_extension_member(PQExpBuffer upgrade_buffer,
 	 */
 	for (i = 0; i < dobj->nDeps; i++)
 	{
-		extobj = findObjectByDumpId(dobj->dependencies[i]);
+		DumpableObject *extobj = findObjectByDumpId(dobj->dependencies[i]);
+
 		if (extobj && extobj->objType == DO_EXTENSION)
+		{
+			ext = (ExtensionInfo *) extobj;
 			break;
-		extobj = NULL;
+		}
 	}
-	if (extobj == NULL)
+	if (ext == NULL)
 		pg_fatal("could not find parent extension for %s %s",
 				 objtype, objname);
 
+	/*
+	 * If the object is the "owned_schema" of the extension, we don't need to
+	 * add it to the extension because it was already made a member of the
+	 * extension when the extension was created.
+	 */
+	if (dobj->objType == DO_NAMESPACE &&
+		ext->ownedschema && strcmp(ext->namespace, dobj->name) == 0)
+	{
+		return;
+	}
+
 	appendPQExpBufferStr(upgrade_buffer,
 						 "\n-- For binary upgrade, handle extension membership the hard way\n");
 	appendPQExpBuffer(upgrade_buffer, "ALTER EXTENSION %s ADD %s ",
-					  fmtId(extobj->name),
+					  fmtId(ext->dobj.name),
 					  objtype);
 	if (objnamespace && *objnamespace)
 		appendPQExpBuffer(upgrade_buffer, "%s.", fmtId(objnamespace));
@@ -6134,6 +6161,7 @@ getExtensions(Archive *fout, int *numExtensions)
 	int			i_extname;
 	int			i_nspname;
 	int			i_extrelocatable;
+	int			i_extownedschema;
 	int			i_extversion;
 	int			i_extconfig;
 	int			i_extcondition;
@@ -6142,7 +6170,14 @@ getExtensions(Archive *fout, int *numExtensions)
 
 	appendPQExpBufferStr(query, "SELECT x.tableoid, x.oid, "
 						 "x.extname, n.nspname, x.extrelocatable, x.extversion, x.extconfig, x.extcondition "
-						 "FROM pg_extension x "
+		);
+
+	if (fout->remoteVersion >= 180000)
+		appendPQExpBufferStr(query, ", x.extownedschema ");
+	else
+		appendPQExpBufferStr(query, ", false AS extownedschema ");
+
+	appendPQExpBufferStr(query, "FROM pg_extension x "
 						 "JOIN pg_namespace n ON n.oid = x.extnamespace");
 
 	res = ExecuteSqlQuery(fout, query->data, PGRES_TUPLES_OK);
@@ -6158,6 +6193,7 @@ getExtensions(Archive *fout, int *numExtensions)
 	i_extname = PQfnumber(res, "extname");
 	i_nspname = PQfnumber(res, "nspname");
 	i_extrelocatable = PQfnumber(res, "extrelocatable");
+	i_extownedschema = PQfnumber(res, "extownedschema");
 	i_extversion = PQfnumber(res, "extversion");
 	i_extconfig = PQfnumber(res, "extconfig");
 	i_extcondition = PQfnumber(res, "extcondition");
@@ -6171,6 +6207,7 @@ getExtensions(Archive *fout, int *numExtensions)
 		extinfo[i].dobj.name = pg_strdup(PQgetvalue(res, i, i_extname));
 		extinfo[i].namespace = pg_strdup(PQgetvalue(res, i, i_nspname));
 		extinfo[i].relocatable = *(PQgetvalue(res, i, i_extrelocatable)) == 't';
+		extinfo[i].ownedschema = *(PQgetvalue(res, i, i_extownedschema)) == 't';
 		extinfo[i].extversion = pg_strdup(PQgetvalue(res, i, i_extversion));
 		extinfo[i].extconfig = pg_strdup(PQgetvalue(res, i, i_extconfig));
 		extinfo[i].extcondition = pg_strdup(PQgetvalue(res, i, i_extcondition));
@@ -11884,9 +11921,9 @@ dumpNamespace(Archive *fout, const NamespaceInfo *nspinfo)
 	{
 		/* see selectDumpableNamespace() */
 		appendPQExpBufferStr(delq,
-							 "-- *not* dropping schema, since initdb creates it\n");
+							 "-- *not* dropping schema, since initdb or CREATE EXTENSION creates it\n");
 		appendPQExpBufferStr(q,
-							 "-- *not* creating schema, since initdb creates it\n");
+							 "-- *not* creating schema, since initdb or CREATE EXTENSION creates it\n");
 	}
 
 	if (dopt->binary_upgrade)
@@ -11998,6 +12035,7 @@ dumpExtension(Archive *fout, const ExtensionInfo *extinfo)
 		appendStringLiteralAH(q, extinfo->namespace, fout);
 		appendPQExpBufferStr(q, ", ");
 		appendPQExpBuffer(q, "%s, ", extinfo->relocatable ? "true" : "false");
+		appendPQExpBuffer(q, "%s, ", extinfo->ownedschema ? "true" : "false");
 		appendStringLiteralAH(q, extinfo->extversion, fout);
 		appendPQExpBufferStr(q, ", ");
 
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 4c4b14e5fc7..ef74020d171 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -195,6 +195,7 @@ typedef struct _extensionInfo
 	DumpableObject dobj;
 	char	   *namespace;		/* schema containing extension's objects */
 	bool		relocatable;
+	bool		ownedschema;
 	char	   *extversion;
 	char	   *extconfig;		/* info about configuration tables */
 	char	   *extcondition;
diff --git a/src/include/catalog/pg_extension.h b/src/include/catalog/pg_extension.h
index 8ab5e3141d0..bbc79773e6e 100644
--- a/src/include/catalog/pg_extension.h
+++ b/src/include/catalog/pg_extension.h
@@ -34,6 +34,7 @@ CATALOG(pg_extension,3079,ExtensionRelationId)
 	Oid			extnamespace BKI_LOOKUP(pg_namespace);	/* namespace of
 														 * contained objects */
 	bool		extrelocatable; /* if true, allow ALTER EXTENSION SET SCHEMA */
+	bool		extownedschema; /* if true, schema is owned by extension */
 
 #ifdef CATALOG_VARLEN			/* variable-length fields start here */
 	/* extversion may never be null, but the others can be. */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 83f6501df38..ea0ef57b7e0 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -11805,7 +11805,7 @@
 { oid => '3591', descr => 'for use by pg_upgrade',
   proname => 'binary_upgrade_create_empty_extension', proisstrict => 'f',
   provolatile => 'v', proparallel => 'u', prorettype => 'void',
-  proargtypes => 'text text bool text _oid _text _text',
+  proargtypes => 'text text bool bool text _oid _text _text',
   prosrc => 'binary_upgrade_create_empty_extension' },
 { oid => '4083', descr => 'for use by pg_upgrade',
   proname => 'binary_upgrade_set_record_init_privs', provolatile => 'v',
diff --git a/src/include/commands/extension.h b/src/include/commands/extension.h
index 7a76bdebcfa..03166b75502 100644
--- a/src/include/commands/extension.h
+++ b/src/include/commands/extension.h
@@ -38,7 +38,9 @@ extern ObjectAddress CreateExtension(ParseState *pstate, CreateExtensionStmt *st
 extern void RemoveExtensionById(Oid extId);
 
 extern ObjectAddress InsertExtensionTuple(const char *extName, Oid extOwner,
-										  Oid schemaOid, bool relocatable, const char *extVersion,
+										  Oid schemaOid, bool relocatable,
+										  bool ownedSchema,
+										  const char *extVersion,
 										  Datum extConfig, Datum extCondition,
 										  List *requiredExtensions);
 
diff --git a/src/test/modules/test_extensions/Makefile b/src/test/modules/test_extensions/Makefile
index a3591bf3d2f..0a7cf692214 100644
--- a/src/test/modules/test_extensions/Makefile
+++ b/src/test/modules/test_extensions/Makefile
@@ -9,7 +9,9 @@ EXTENSION = test_ext1 test_ext2 test_ext3 test_ext4 test_ext5 test_ext6 \
             test_ext_extschema \
             test_ext_evttrig \
             test_ext_set_schema \
-            test_ext_req_schema1 test_ext_req_schema2 test_ext_req_schema3
+            test_ext_req_schema1 test_ext_req_schema2 test_ext_req_schema3 \
+            test_ext_owned_schema test_ext_owned_schema_nosuperuser \
+            test_ext_owned_schema_trusted test_ext_owned_schema_relocatable
 
 DATA = test_ext1--1.0.sql test_ext2--1.0.sql test_ext3--1.0.sql \
        test_ext4--1.0.sql test_ext5--1.0.sql test_ext6--1.0.sql \
@@ -25,7 +27,14 @@ DATA = test_ext1--1.0.sql test_ext2--1.0.sql test_ext3--1.0.sql \
        test_ext_set_schema--1.0.sql \
        test_ext_req_schema1--1.0.sql \
        test_ext_req_schema2--1.0.sql \
-       test_ext_req_schema3--1.0.sql
+       test_ext_req_schema3--1.0.sql \
+       test_ext_owned_schema--1.0.sql \
+       test_ext_owned_schema--1.0--1.1.sql \
+       test_ext_owned_schema_nosuperuser--1.0.sql \
+       test_ext_owned_schema_nosuperuser--1.0--1.1.sql \
+       test_ext_owned_schema_trusted--1.0.sql \
+       test_ext_owned_schema_trusted--1.0--1.1.sql \
+       test_ext_owned_schema_relocatable--1.0.sql
 
 REGRESS = test_extensions test_extdepend
 TAP_TESTS = 1
diff --git a/src/test/modules/test_extensions/expected/test_extensions.out b/src/test/modules/test_extensions/expected/test_extensions.out
index fdae52d6ab2..1820e76d746 100644
--- a/src/test/modules/test_extensions/expected/test_extensions.out
+++ b/src/test/modules/test_extensions/expected/test_extensions.out
@@ -9,6 +9,7 @@ CREATE EXTENSION test_ext1 SCHEMA test_ext;
 ERROR:  schema "test_ext" does not exist
 CREATE EXTENSION test_ext1 SCHEMA has$dollar;
 ERROR:  extension "test_ext1" must be installed in schema "test_ext1"
+HINT:  Do not specify SCHEMA when running CREATE EXTENSION for extension "test_ext1"
 -- finally success
 CREATE EXTENSION test_ext1 SCHEMA has$dollar CASCADE;
 NOTICE:  installing required extension "test_ext2"
@@ -667,3 +668,159 @@ SELECT test_s_dep.dep_req2();
 
 DROP EXTENSION test_ext_req_schema1 CASCADE;
 NOTICE:  drop cascades to extension test_ext_req_schema2
+--
+-- Test owned schema extensions
+--
+CREATE SCHEMA test_ext_owned_schema;
+-- Fails for an already existing schema to be provided
+CREATE EXTENSION test_ext_owned_schema SCHEMA test_ext_owned_schema;
+ERROR:  schema "test_ext_owned_schema" already exists
+HINT:  Drop schema "test_ext_owned_schema" or specify another schema using CREATE EXTENSION ... SCHEMA ...
+-- Fails because a different schema is set in control file
+CREATE EXTENSION test_ext_owned_schema SCHEMA test_schema;
+ERROR:  extension "test_ext_owned_schema" must be installed in schema "test_ext_owned_schema"
+HINT:  Do not specify SCHEMA when running CREATE EXTENSION for extension "test_ext_owned_schema"
+DROP SCHEMA test_ext_owned_schema;
+CREATE EXTENSION test_ext_owned_schema;
+\dx+ test_ext_owned_schema;
+Objects in extension "test_ext_owned_schema"
+           Object description            
+-----------------------------------------
+ function test_ext_owned_schema.owned1()
+ schema test_ext_owned_schema
+(2 rows)
+
+-- Test ALTER EXTENSION UPDATE with owned schema
+ALTER EXTENSION test_ext_owned_schema UPDATE TO '1.1';
+\dx+ test_ext_owned_schema;
+Objects in extension "test_ext_owned_schema"
+           Object description            
+-----------------------------------------
+ function test_ext_owned_schema.owned1()
+ function test_ext_owned_schema.owned2()
+ schema test_ext_owned_schema
+(3 rows)
+
+-- Verify that the owned schema is dropped together with the extension
+DROP EXTENSION test_ext_owned_schema;
+SELECT COUNT(*) FROM pg_namespace WHERE nspname = 'test_ext_owned_schema';
+ count 
+-------
+     0
+(1 row)
+
+-- Test drop behavior with non-extension objects in the owned schema
+CREATE EXTENSION test_ext_owned_schema;
+CREATE FUNCTION test_ext_owned_schema.non_ext_func() RETURNS int LANGUAGE SQL AS $$ SELECT 1 $$;
+-- Fails because of the non-extension object
+DROP EXTENSION test_ext_owned_schema;
+ERROR:  cannot drop extension test_ext_owned_schema because other objects depend on it
+DETAIL:  function test_ext_owned_schema.non_ext_func() depends on schema test_ext_owned_schema
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+-- Succeeds and cascades to the non-extension object
+DROP EXTENSION test_ext_owned_schema CASCADE;
+NOTICE:  drop cascades to function test_ext_owned_schema.non_ext_func()
+CREATE SCHEMA already_existing;
+-- Fails for an already existing schema to be provided
+CREATE EXTENSION test_ext_owned_schema_relocatable SCHEMA already_existing;
+ERROR:  schema "already_existing" already exists
+HINT:  Drop schema "already_existing" or specify another schema using CREATE EXTENSION ... SCHEMA ...
+-- Fails because no schema is set in control file
+CREATE EXTENSION test_ext_owned_schema_relocatable;
+ERROR:  no schema has been selected to create in
+CREATE EXTENSION test_ext_owned_schema_relocatable SCHEMA test_schema;
+\dx+ test_ext_owned_schema_relocatable
+Objects in extension "test_ext_owned_schema_relocatable"
+      Object description       
+-------------------------------
+ function test_schema.owned2()
+ schema test_schema
+(2 rows)
+
+-- Fails because schema already exists
+ALTER EXTENSION test_ext_owned_schema_relocatable SET SCHEMA already_existing;
+ERROR:  schema "already_existing" already exists
+ALTER EXTENSION test_ext_owned_schema_relocatable SET SCHEMA some_other_name;
+\dx+ test_ext_owned_schema_relocatable
+Objects in extension "test_ext_owned_schema_relocatable"
+        Object description         
+-----------------------------------
+ function some_other_name.owned2()
+ schema some_other_name
+(2 rows)
+
+DROP EXTENSION test_ext_owned_schema_relocatable;
+-- Verify the schema was dropped with the extension
+DROP SCHEMA some_other_name;
+ERROR:  schema "some_other_name" does not exist
+-- Test owned_schema + superuser=false extension
+CREATE USER test_ext_user;
+GRANT CREATE ON DATABASE regression_test_extensions TO test_ext_user;
+SET SESSION AUTHORIZATION test_ext_user;
+CREATE EXTENSION test_ext_owned_schema_nosuperuser;
+\dx+ test_ext_owned_schema_nosuperuser;
+Objects in extension "test_ext_owned_schema_nosuperuser"
+               Object description                
+-------------------------------------------------
+ function test_owned_schema_nosuperuser.owned1()
+ schema test_owned_schema_nosuperuser
+(2 rows)
+
+-- Check that schema is owned by the creating user (not bootstrap superuser)
+SELECT n.nspname, n.nspowner = current_user::regrole as owned_by_current_user
+FROM pg_namespace n
+WHERE n.nspname = 'test_owned_schema_nosuperuser';
+            nspname            | owned_by_current_user 
+-------------------------------+-----------------------
+ test_owned_schema_nosuperuser | t
+(1 row)
+
+-- Upgrades should work for superuser=false extensions
+ALTER EXTENSION test_ext_owned_schema_nosuperuser UPDATE TO '1.1';
+\dx+ test_ext_owned_schema_nosuperuser;
+Objects in extension "test_ext_owned_schema_nosuperuser"
+               Object description                
+-------------------------------------------------
+ function test_owned_schema_nosuperuser.owned1()
+ function test_owned_schema_nosuperuser.owned2()
+ schema test_owned_schema_nosuperuser
+(3 rows)
+
+DROP EXTENSION test_ext_owned_schema_nosuperuser;
+RESET SESSION AUTHORIZATION;
+-- Test owned_schema + trusted=true extension
+SET SESSION AUTHORIZATION test_ext_user;
+CREATE EXTENSION test_ext_owned_schema_trusted;
+\dx+ test_ext_owned_schema_trusted;
+Objects in extension "test_ext_owned_schema_trusted"
+             Object description              
+---------------------------------------------
+ function test_owned_schema_trusted.owned1()
+ schema test_owned_schema_trusted
+(2 rows)
+
+-- Check that schema is owned by bootstrap superuser for trusted extension,
+-- even though the extension is owned by test_ext_user
+SELECT n.nspname, n.nspowner = 10 as owned_by_bootstrap_superuser
+FROM pg_namespace n
+WHERE n.nspname = 'test_owned_schema_trusted';
+          nspname          | owned_by_bootstrap_superuser 
+---------------------------+------------------------------
+ test_owned_schema_trusted | t
+(1 row)
+
+-- Updating trusted extensions should work normally
+ALTER EXTENSION test_ext_owned_schema_trusted UPDATE TO '1.1';
+\dx+ test_ext_owned_schema_trusted;
+Objects in extension "test_ext_owned_schema_trusted"
+             Object description              
+---------------------------------------------
+ function test_owned_schema_trusted.owned1()
+ function test_owned_schema_trusted.owned2()
+ schema test_owned_schema_trusted
+(3 rows)
+
+DROP EXTENSION test_ext_owned_schema_trusted;
+RESET SESSION AUTHORIZATION;
+REVOKE CREATE ON DATABASE regression_test_extensions FROM test_ext_user;
+DROP USER test_ext_user;
diff --git a/src/test/modules/test_extensions/meson.build b/src/test/modules/test_extensions/meson.build
index be9c9ae593f..019e0d2a243 100644
--- a/src/test/modules/test_extensions/meson.build
+++ b/src/test/modules/test_extensions/meson.build
@@ -44,6 +44,17 @@ test_install_data += files(
   'test_ext_req_schema3.control',
   'test_ext_set_schema--1.0.sql',
   'test_ext_set_schema.control',
+  'test_ext_owned_schema--1.0.sql',
+  'test_ext_owned_schema--1.0--1.1.sql',
+  'test_ext_owned_schema.control',
+  'test_ext_owned_schema_nosuperuser--1.0.sql',
+  'test_ext_owned_schema_nosuperuser--1.0--1.1.sql',
+  'test_ext_owned_schema_nosuperuser.control',
+  'test_ext_owned_schema_trusted--1.0.sql',
+  'test_ext_owned_schema_trusted--1.0--1.1.sql',
+  'test_ext_owned_schema_trusted.control',
+  'test_ext_owned_schema_relocatable--1.0.sql',
+  'test_ext_owned_schema_relocatable.control',
 )
 
 tests += {
diff --git a/src/test/modules/test_extensions/sql/test_extensions.sql b/src/test/modules/test_extensions/sql/test_extensions.sql
index b5878f6f80f..700033fc13b 100644
--- a/src/test/modules/test_extensions/sql/test_extensions.sql
+++ b/src/test/modules/test_extensions/sql/test_extensions.sql
@@ -303,3 +303,78 @@ ALTER EXTENSION test_ext_req_schema1 SET SCHEMA test_s_dep2;  -- now ok
 SELECT test_s_dep2.dep_req1();
 SELECT test_s_dep.dep_req2();
 DROP EXTENSION test_ext_req_schema1 CASCADE;
+
+--
+-- Test owned schema extensions
+--
+
+CREATE SCHEMA test_ext_owned_schema;
+-- Fails for an already existing schema to be provided
+CREATE EXTENSION test_ext_owned_schema SCHEMA test_ext_owned_schema;
+-- Fails because a different schema is set in control file
+CREATE EXTENSION test_ext_owned_schema SCHEMA test_schema;
+DROP SCHEMA test_ext_owned_schema;
+CREATE EXTENSION test_ext_owned_schema;
+\dx+ test_ext_owned_schema;
+-- Test ALTER EXTENSION UPDATE with owned schema
+ALTER EXTENSION test_ext_owned_schema UPDATE TO '1.1';
+\dx+ test_ext_owned_schema;
+-- Verify that the owned schema is dropped together with the extension
+DROP EXTENSION test_ext_owned_schema;
+SELECT COUNT(*) FROM pg_namespace WHERE nspname = 'test_ext_owned_schema';
+
+-- Test drop behavior with non-extension objects in the owned schema
+CREATE EXTENSION test_ext_owned_schema;
+CREATE FUNCTION test_ext_owned_schema.non_ext_func() RETURNS int LANGUAGE SQL AS $$ SELECT 1 $$;
+-- Fails because of the non-extension object
+DROP EXTENSION test_ext_owned_schema;
+-- Succeeds and cascades to the non-extension object
+DROP EXTENSION test_ext_owned_schema CASCADE;
+
+CREATE SCHEMA already_existing;
+-- Fails for an already existing schema to be provided
+CREATE EXTENSION test_ext_owned_schema_relocatable SCHEMA already_existing;
+-- Fails because no schema is set in control file
+CREATE EXTENSION test_ext_owned_schema_relocatable;
+CREATE EXTENSION test_ext_owned_schema_relocatable SCHEMA test_schema;
+\dx+ test_ext_owned_schema_relocatable
+-- Fails because schema already exists
+ALTER EXTENSION test_ext_owned_schema_relocatable SET SCHEMA already_existing;
+ALTER EXTENSION test_ext_owned_schema_relocatable SET SCHEMA some_other_name;
+\dx+ test_ext_owned_schema_relocatable
+DROP EXTENSION test_ext_owned_schema_relocatable;
+-- Verify the schema was dropped with the extension
+DROP SCHEMA some_other_name;
+
+-- Test owned_schema + superuser=false extension
+CREATE USER test_ext_user;
+GRANT CREATE ON DATABASE regression_test_extensions TO test_ext_user;
+SET SESSION AUTHORIZATION test_ext_user;
+CREATE EXTENSION test_ext_owned_schema_nosuperuser;
+\dx+ test_ext_owned_schema_nosuperuser;
+-- Check that schema is owned by the creating user (not bootstrap superuser)
+SELECT n.nspname, n.nspowner = current_user::regrole as owned_by_current_user
+FROM pg_namespace n
+WHERE n.nspname = 'test_owned_schema_nosuperuser';
+-- Upgrades should work for superuser=false extensions
+ALTER EXTENSION test_ext_owned_schema_nosuperuser UPDATE TO '1.1';
+\dx+ test_ext_owned_schema_nosuperuser;
+DROP EXTENSION test_ext_owned_schema_nosuperuser;
+RESET SESSION AUTHORIZATION;
+
+-- Test owned_schema + trusted=true extension
+SET SESSION AUTHORIZATION test_ext_user;
+CREATE EXTENSION test_ext_owned_schema_trusted;
+\dx+ test_ext_owned_schema_trusted;
+-- Check that schema is owned by bootstrap superuser for trusted extension,
+-- even though the extension is owned by test_ext_user
+SELECT n.nspname, n.nspowner = 10 as owned_by_bootstrap_superuser
+FROM pg_namespace n
+WHERE n.nspname = 'test_owned_schema_trusted';
+-- Updating trusted extensions should work normally
+ALTER EXTENSION test_ext_owned_schema_trusted UPDATE TO '1.1';
+\dx+ test_ext_owned_schema_trusted;
+DROP EXTENSION test_ext_owned_schema_trusted;
+RESET SESSION AUTHORIZATION;
+REVOKE CREATE ON DATABASE regression_test_extensions FROM test_ext_user;
+DROP USER test_ext_user;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema--1.0--1.1.sql b/src/test/modules/test_extensions/test_ext_owned_schema--1.0--1.1.sql
new file mode 100644
index 00000000000..a87be1ac967
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema--1.0--1.1.sql
@@ -0,0 +1,3 @@
+-- Test extension upgrade script
+CREATE FUNCTION owned2() RETURNS text
+  LANGUAGE SQL AS $$ SELECT 'owned2'::text $$;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema--1.0.sql b/src/test/modules/test_extensions/test_ext_owned_schema--1.0.sql
new file mode 100644
index 00000000000..672ab8e607f
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema--1.0.sql
@@ -0,0 +1,2 @@
+CREATE FUNCTION owned1() RETURNS text
+LANGUAGE SQL AS $$ SELECT 1 $$;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema.control b/src/test/modules/test_extensions/test_ext_owned_schema.control
new file mode 100644
index 00000000000..531c38daefd
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema.control
@@ -0,0 +1,5 @@
+comment = 'Test extension with an owned schema'
+default_version = '1.0'
+relocatable = false
+schema = test_ext_owned_schema
+owned_schema = true
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0--1.1.sql b/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0--1.1.sql
new file mode 100644
index 00000000000..e9ba788d751
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0--1.1.sql
@@ -0,0 +1,5 @@
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "ALTER EXTENSION test_ext_owned_schema_nosuperuser UPDATE TO '1.1'" to load this file. \quit
+
+CREATE FUNCTION owned2() RETURNS text
+  LANGUAGE SQL AS $$ SELECT 'owned2'::text $$;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0.sql b/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0.sql
new file mode 100644
index 00000000000..6c22c777519
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0.sql
@@ -0,0 +1,3 @@
+-- Test extension with owned schema (superuser=false)
+CREATE FUNCTION owned1() RETURNS text
+  LANGUAGE SQL AS $$ SELECT 'owned1'::text $$;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser.control b/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser.control
new file mode 100644
index 00000000000..caad0960996
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser.control
@@ -0,0 +1,5 @@
+comment = 'Test extension with owned schema (superuser=false)'
+default_version = '1.0'
+superuser = false
+owned_schema = true
+schema = 'test_owned_schema_nosuperuser'
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_relocatable--1.0.sql b/src/test/modules/test_extensions/test_ext_owned_schema_relocatable--1.0.sql
new file mode 100644
index 00000000000..bfccaf4af82
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_relocatable--1.0.sql
@@ -0,0 +1,2 @@
+CREATE FUNCTION owned2() RETURNS text
+LANGUAGE SQL AS $$ SELECT 1 $$;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_relocatable.control b/src/test/modules/test_extensions/test_ext_owned_schema_relocatable.control
new file mode 100644
index 00000000000..3cda1e12341
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_relocatable.control
@@ -0,0 +1,4 @@
+comment = 'Test extension with an owned schema'
+default_version = '1.0'
+relocatable = true
+owned_schema = true
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0--1.1.sql b/src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0--1.1.sql
new file mode 100644
index 00000000000..a87be1ac967
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0--1.1.sql
@@ -0,0 +1,3 @@
+-- Test extension upgrade script
+CREATE FUNCTION owned2() RETURNS text
+  LANGUAGE SQL AS $$ SELECT 'owned2'::text $$;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0.sql b/src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0.sql
new file mode 100644
index 00000000000..4e5410c184d
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0.sql
@@ -0,0 +1,3 @@
+-- Test extension with owned schema (trusted=true)
+CREATE FUNCTION owned1() RETURNS text
+  LANGUAGE SQL AS $$ SELECT 'owned1'::text $$;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_trusted.control b/src/test/modules/test_extensions/test_ext_owned_schema_trusted.control
new file mode 100644
index 00000000000..277b3f1603b
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_trusted.control
@@ -0,0 +1,5 @@
+comment = 'Test extension with owned schema (trusted=true)'
+default_version = '1.0'
+trusted = true
+owned_schema = true
+schema = 'test_owned_schema_trusted'
diff --git a/src/test/modules/test_pg_dump/t/001_base.pl b/src/test/modules/test_pg_dump/t/001_base.pl
index 3d65ce4497a..4db2957318d 100644
--- a/src/test/modules/test_pg_dump/t/001_base.pl
+++ b/src/test/modules/test_pg_dump/t/001_base.pl
@@ -381,6 +381,38 @@ my %tests = (
 		},
 	},
 
+	'CREATE EXTENSION test_ext_owned_schema' => {
+		create_order => 1,
+		create_sql => 'CREATE EXTENSION test_ext_owned_schema;',
+		regexp => qr/^
+			\QCREATE EXTENSION IF NOT EXISTS test_ext_owned_schema WITH SCHEMA test_ext_owned_schema;\E
+			\n/xm,
+		like => {
+			%full_runs,
+			schema_only => 1,
+			section_pre_data => 1,
+		},
+		unlike => {
+			binary_upgrade => 1,
+			with_extension => 1,
+			without_extension => 1
+		}
+	},
+
+	'CREATE SCHEMA test_ext_owned_schema' => {
+		regexp => qr/^
+			\QCREATE SCHEMA test_ext_owned_schema;\E
+			\n/xm,
+		like => {},
+	},
+
+	'ALTER EXTENSION test_ext_owned_schema ADD SCHEMA test_ext_owned_schema' => {
+		regexp => qr/^
+			\QALTER EXTENSION test_ext_owned_schema ADD SCHEMA test_ext_owned_schema;\E
+			\n/xm,
+		like => {},
+	},
+
 	'CREATE ROLE regress_dump_test_role' => {
 		create_order => 1,
 		create_sql => 'CREATE ROLE regress_dump_test_role;',

base-commit: adbad833f3d9e9176e8d7005f15ea6056900227d
-- 
2.52.0

diff --git a/src/backend/commands/extension.c b/src/backend/commands/extension.c
index 77bc6a82987..03b3de357bd 100644
--- a/src/backend/commands/extension.c
+++ b/src/backend/commands/extension.c
@@ -1243,6 +1243,44 @@ extension_is_trusted(ExtensionControlFile *control)
 	return false;
 }
 
+/*
+ * Enforce superuser requirements for extension operations.
+ *
+ * Returns true if we should switch to superuser for trusted extensions.
+ * Throws an error if superuser is required but not available.
+ *
+ * This function should only be called after choosing the appropriate
+ * control file (including any secondary control files for updates).
+ */
+static bool
+check_extension_superuser_requirements(ExtensionControlFile *control,
+									   const char *from_version)
+{
+	if (control->superuser && !superuser())
+	{
+		if (extension_is_trusted(control))
+			return true;
+		else if (from_version == NULL)
+			ereport(ERROR,
+					(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+					 errmsg("permission denied to create extension \"%s\"",
+							control->name),
+					 control->trusted
+					 ? errhint("Must have CREATE privilege on current database to create this extension.")
+					 : errhint("Must be superuser to create this extension.")));
+		else
+			ereport(ERROR,
+					(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+					 errmsg("permission denied to update extension \"%s\"",
+							control->name),
+					 control->trusted
+					 ? errhint("Must have CREATE privilege on current database to update this extension.")
+					 : errhint("Must be superuser to update this extension.")));
+	}
+
+	return false;
+}
+
 /*
  * Execute the appropriate script file for installing or updating the extension
  *
@@ -1271,27 +1309,7 @@ execute_extension_script(Oid extensionOid, ExtensionControlFile *control,
 	 * here so that the control flags are correctly associated with the right
 	 * script(s) if they happen to be set in secondary control files.
 	 */
-	if (control->superuser && !superuser())
-	{
-		if (extension_is_trusted(control))
-			switch_to_superuser = true;
-		else if (from_version == NULL)
-			ereport(ERROR,
-					(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
-					 errmsg("permission denied to create extension \"%s\"",
-							control->name),
-					 control->trusted
-					 ? errhint("Must have CREATE privilege on current database to create this extension.")
-					 : errhint("Must be superuser to create this extension.")));
-		else
-			ereport(ERROR,
-					(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
-					 errmsg("permission denied to update extension \"%s\"",
-							control->name),
-					 control->trusted
-					 ? errhint("Must have CREATE privilege on current database to update this extension.")
-					 : errhint("Must be superuser to update this extension.")));
-	}
+	switch_to_superuser = check_extension_superuser_requirements(control, from_version);
 
 	filename = get_extension_script_filename(control, from_version, version);
 
@@ -1854,7 +1872,7 @@ CreateSchemaForExtension(char *schemaName)
 }
 
 /*
- * Create a owned schema with the given name, as part of CREATE EXTENSION, and
+ * Create an owned schema with the given name, as part of CREATE EXTENSION, and
  * fails if the schema already exist.
  */
 static Oid
@@ -2017,6 +2035,9 @@ CreateExtensionInternal(char *extensionName,
 	Oid			extensionOid;
 	ObjectAddress address;
 	ListCell   *lc;
+	bool		switch_to_superuser = false;
+	Oid			save_userid = 0;
+	int			save_sec_context = 0;
 
 	/*
 	 * Read the primary control file.  Note we assume that it does not contain
@@ -2084,8 +2105,27 @@ CreateExtensionInternal(char *extensionName,
 	 */
 	control = read_extension_aux_control_file(pcontrol, versionName);
 
+	/*
+	 * For trusted extensions with owned schemas, we need to create the schema
+	 * as superuser to ensure proper ownership.
+	 */
+	if (control->owned_schema)
+	{
+		switch_to_superuser = check_extension_superuser_requirements(control, NULL);
+		if (switch_to_superuser)
+		{
+			GetUserIdAndSecContext(&save_userid, &save_sec_context);
+			SetUserIdAndSecContext(BOOTSTRAP_SUPERUSERID,
+								   save_sec_context | SECURITY_LOCAL_USERID_CHANGE);
+		}
+	}
+
 	schemaOid = GetOrCreateSchemaForExtension(&schemaName, control, cascade);
 
+	/* Restore authentication state after schema creation if needed */
+	if (switch_to_superuser)
+		SetUserIdAndSecContext(save_userid, save_sec_context);
+
 	/*
 	 * Make note if a temporary namespace has been accessed in this
 	 * transaction.
diff --git a/src/test/modules/test_extensions/Makefile b/src/test/modules/test_extensions/Makefile
index a6594c19d7e..0a7cf692214 100644
--- a/src/test/modules/test_extensions/Makefile
+++ b/src/test/modules/test_extensions/Makefile
@@ -10,7 +10,8 @@ EXTENSION = test_ext1 test_ext2 test_ext3 test_ext4 test_ext5 test_ext6 \
             test_ext_evttrig \
             test_ext_set_schema \
             test_ext_req_schema1 test_ext_req_schema2 test_ext_req_schema3 \
-            test_ext_owned_schema test_ext_owned_schema_relocatable
+            test_ext_owned_schema test_ext_owned_schema_nosuperuser \
+            test_ext_owned_schema_trusted test_ext_owned_schema_relocatable
 
 DATA = test_ext1--1.0.sql test_ext2--1.0.sql test_ext3--1.0.sql \
        test_ext4--1.0.sql test_ext5--1.0.sql test_ext6--1.0.sql \
@@ -28,6 +29,11 @@ DATA = test_ext1--1.0.sql test_ext2--1.0.sql test_ext3--1.0.sql \
        test_ext_req_schema2--1.0.sql \
        test_ext_req_schema3--1.0.sql \
        test_ext_owned_schema--1.0.sql \
+       test_ext_owned_schema--1.0--1.1.sql \
+       test_ext_owned_schema_nosuperuser--1.0.sql \
+       test_ext_owned_schema_nosuperuser--1.0--1.1.sql \
+       test_ext_owned_schema_trusted--1.0.sql \
+       test_ext_owned_schema_trusted--1.0--1.1.sql \
        test_ext_owned_schema_relocatable--1.0.sql
 
 REGRESS = test_extensions test_extdepend
diff --git a/src/test/modules/test_extensions/expected/test_extensions.out b/src/test/modules/test_extensions/expected/test_extensions.out
index b82b3979a90..1820e76d746 100644
--- a/src/test/modules/test_extensions/expected/test_extensions.out
+++ b/src/test/modules/test_extensions/expected/test_extensions.out
@@ -690,7 +690,36 @@ Objects in extension "test_ext_owned_schema"
  schema test_ext_owned_schema
 (2 rows)
 
+-- Test ALTER EXTENSION UPDATE with owned schema
+ALTER EXTENSION test_ext_owned_schema UPDATE TO '1.1';
+\dx+ test_ext_owned_schema;
+Objects in extension "test_ext_owned_schema"
+           Object description            
+-----------------------------------------
+ function test_ext_owned_schema.owned1()
+ function test_ext_owned_schema.owned2()
+ schema test_ext_owned_schema
+(3 rows)
+
+-- Verify that the owned schema is dropped together with the extension
 DROP EXTENSION test_ext_owned_schema;
+SELECT COUNT(*) FROM pg_namespace WHERE nspname = 'test_ext_owned_schema';
+ count 
+-------
+     0
+(1 row)
+
+-- Test drop behavior with non-extension objects in the owned schema
+CREATE EXTENSION test_ext_owned_schema;
+CREATE FUNCTION test_ext_owned_schema.non_ext_func() RETURNS int LANGUAGE SQL AS $$ SELECT 1 $$;
+-- Fails because of the non-extension object
+DROP EXTENSION test_ext_owned_schema;
+ERROR:  cannot drop extension test_ext_owned_schema because other objects depend on it
+DETAIL:  function test_ext_owned_schema.non_ext_func() depends on schema test_ext_owned_schema
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+-- Succeeds and cascades to the non-extension object
+DROP EXTENSION test_ext_owned_schema CASCADE;
+NOTICE:  drop cascades to function test_ext_owned_schema.non_ext_func()
 CREATE SCHEMA already_existing;
 -- Fails for an already existing schema to be provided
 CREATE EXTENSION test_ext_owned_schema_relocatable SCHEMA already_existing;
@@ -721,3 +750,77 @@ Objects in extension "test_ext_owned_schema_relocatable"
 (2 rows)
 
 DROP EXTENSION test_ext_owned_schema_relocatable;
+-- Verify the schema was dropped with the extension
+DROP SCHEMA some_other_name;
+ERROR:  schema "some_other_name" does not exist
+-- Test owned_schema + superuser=false extension
+CREATE USER test_ext_user;
+GRANT CREATE ON DATABASE regression_test_extensions TO test_ext_user;
+SET SESSION AUTHORIZATION test_ext_user;
+CREATE EXTENSION test_ext_owned_schema_nosuperuser;
+\dx+ test_ext_owned_schema_nosuperuser;
+Objects in extension "test_ext_owned_schema_nosuperuser"
+               Object description                
+-------------------------------------------------
+ function test_owned_schema_nosuperuser.owned1()
+ schema test_owned_schema_nosuperuser
+(2 rows)
+
+-- Check that schema is owned by the creating user (not bootstrap superuser)
+SELECT n.nspname, n.nspowner = current_user::regrole as owned_by_current_user
+FROM pg_namespace n
+WHERE n.nspname = 'test_owned_schema_nosuperuser';
+            nspname            | owned_by_current_user 
+-------------------------------+-----------------------
+ test_owned_schema_nosuperuser | t
+(1 row)
+
+-- Upgrades should work for superuser=false extensions
+ALTER EXTENSION test_ext_owned_schema_nosuperuser UPDATE TO '1.1';
+\dx+ test_ext_owned_schema_nosuperuser;
+Objects in extension "test_ext_owned_schema_nosuperuser"
+               Object description                
+-------------------------------------------------
+ function test_owned_schema_nosuperuser.owned1()
+ function test_owned_schema_nosuperuser.owned2()
+ schema test_owned_schema_nosuperuser
+(3 rows)
+
+DROP EXTENSION test_ext_owned_schema_nosuperuser;
+RESET SESSION AUTHORIZATION;
+-- Test owned_schema + trusted=true extension
+SET SESSION AUTHORIZATION test_ext_user;
+CREATE EXTENSION test_ext_owned_schema_trusted;
+\dx+ test_ext_owned_schema_trusted;
+Objects in extension "test_ext_owned_schema_trusted"
+             Object description              
+---------------------------------------------
+ function test_owned_schema_trusted.owned1()
+ schema test_owned_schema_trusted
+(2 rows)
+
+-- Check that schema is owned by bootstrap superuser for trusted extension,
+-- even though the extension is owned by test_ext_user
+SELECT n.nspname, n.nspowner = 10 as owned_by_bootstrap_superuser
+FROM pg_namespace n
+WHERE n.nspname = 'test_owned_schema_trusted';
+          nspname          | owned_by_bootstrap_superuser 
+---------------------------+------------------------------
+ test_owned_schema_trusted | t
+(1 row)
+
+-- Updating trusted extensions should work normally
+ALTER EXTENSION test_ext_owned_schema_trusted UPDATE TO '1.1';
+\dx+ test_ext_owned_schema_trusted;
+Objects in extension "test_ext_owned_schema_trusted"
+             Object description              
+---------------------------------------------
+ function test_owned_schema_trusted.owned1()
+ function test_owned_schema_trusted.owned2()
+ schema test_owned_schema_trusted
+(3 rows)
+
+DROP EXTENSION test_ext_owned_schema_trusted;
+RESET SESSION AUTHORIZATION;
+REVOKE CREATE ON DATABASE regression_test_extensions FROM test_ext_user;
+DROP USER test_ext_user;
diff --git a/src/test/modules/test_extensions/meson.build b/src/test/modules/test_extensions/meson.build
index 3e25c4466ec..019e0d2a243 100644
--- a/src/test/modules/test_extensions/meson.build
+++ b/src/test/modules/test_extensions/meson.build
@@ -45,7 +45,14 @@ test_install_data += files(
   'test_ext_set_schema--1.0.sql',
   'test_ext_set_schema.control',
   'test_ext_owned_schema--1.0.sql',
+  'test_ext_owned_schema--1.0--1.1.sql',
   'test_ext_owned_schema.control',
+  'test_ext_owned_schema_nosuperuser--1.0.sql',
+  'test_ext_owned_schema_nosuperuser--1.0--1.1.sql',
+  'test_ext_owned_schema_nosuperuser.control',
+  'test_ext_owned_schema_trusted--1.0.sql',
+  'test_ext_owned_schema_trusted--1.0--1.1.sql',
+  'test_ext_owned_schema_trusted.control',
   'test_ext_owned_schema_relocatable--1.0.sql',
   'test_ext_owned_schema_relocatable.control',
 )
diff --git a/src/test/modules/test_extensions/sql/test_extensions.sql b/src/test/modules/test_extensions/sql/test_extensions.sql
index a97866d00ea..700033fc13b 100644
--- a/src/test/modules/test_extensions/sql/test_extensions.sql
+++ b/src/test/modules/test_extensions/sql/test_extensions.sql
@@ -316,7 +316,20 @@ CREATE EXTENSION test_ext_owned_schema SCHEMA test_schema;
 DROP SCHEMA test_ext_owned_schema;
 CREATE EXTENSION test_ext_owned_schema;
 \dx+ test_ext_owned_schema;
+-- Test ALTER EXTENSION UPDATE with owned schema
+ALTER EXTENSION test_ext_owned_schema UPDATE TO '1.1';
+\dx+ test_ext_owned_schema;
+-- Verify that the owned schema is dropped together with the extension
+DROP EXTENSION test_ext_owned_schema;
+SELECT COUNT(*) FROM pg_namespace WHERE nspname = 'test_ext_owned_schema';
+
+-- Test drop behavior with non-extension objects in the owned schema
+CREATE EXTENSION test_ext_owned_schema;
+CREATE FUNCTION test_ext_owned_schema.non_ext_func() RETURNS int LANGUAGE SQL AS $$ SELECT 1 $$;
+-- Fails because of the non-extension object
 DROP EXTENSION test_ext_owned_schema;
+-- Succeeds and cascades to the non-extension object
+DROP EXTENSION test_ext_owned_schema CASCADE;
 
 CREATE SCHEMA already_existing;
 -- Fails for an already existing schema to be provided
@@ -330,3 +343,38 @@ ALTER EXTENSION test_ext_owned_schema_relocatable SET SCHEMA already_existing;
 ALTER EXTENSION test_ext_owned_schema_relocatable SET SCHEMA some_other_name;
 \dx+ test_ext_owned_schema_relocatable
 DROP EXTENSION test_ext_owned_schema_relocatable;
+-- Verify the schema was dropped with the extension
+DROP SCHEMA some_other_name;
+
+-- Test owned_schema + superuser=false extension
+CREATE USER test_ext_user;
+GRANT CREATE ON DATABASE regression_test_extensions TO test_ext_user;
+SET SESSION AUTHORIZATION test_ext_user;
+CREATE EXTENSION test_ext_owned_schema_nosuperuser;
+\dx+ test_ext_owned_schema_nosuperuser;
+-- Check that schema is owned by the creating user (not bootstrap superuser)
+SELECT n.nspname, n.nspowner = current_user::regrole as owned_by_current_user
+FROM pg_namespace n
+WHERE n.nspname = 'test_owned_schema_nosuperuser';
+-- Upgrades should work for superuser=false extensions
+ALTER EXTENSION test_ext_owned_schema_nosuperuser UPDATE TO '1.1';
+\dx+ test_ext_owned_schema_nosuperuser;
+DROP EXTENSION test_ext_owned_schema_nosuperuser;
+RESET SESSION AUTHORIZATION;
+
+-- Test owned_schema + trusted=true extension
+SET SESSION AUTHORIZATION test_ext_user;
+CREATE EXTENSION test_ext_owned_schema_trusted;
+\dx+ test_ext_owned_schema_trusted;
+-- Check that schema is owned by bootstrap superuser for trusted extension,
+-- even though the extension is owned by test_ext_user
+SELECT n.nspname, n.nspowner = 10 as owned_by_bootstrap_superuser
+FROM pg_namespace n
+WHERE n.nspname = 'test_owned_schema_trusted';
+-- Updating trusted extensions should work normally
+ALTER EXTENSION test_ext_owned_schema_trusted UPDATE TO '1.1';
+\dx+ test_ext_owned_schema_trusted;
+DROP EXTENSION test_ext_owned_schema_trusted;
+RESET SESSION AUTHORIZATION;
+REVOKE CREATE ON DATABASE regression_test_extensions FROM test_ext_user;
+DROP USER test_ext_user;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema--1.0--1.1.sql b/src/test/modules/test_extensions/test_ext_owned_schema--1.0--1.1.sql
new file mode 100644
index 00000000000..a87be1ac967
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema--1.0--1.1.sql
@@ -0,0 +1,3 @@
+-- Test extension upgrade script
+CREATE FUNCTION owned2() RETURNS text
+  LANGUAGE SQL AS $$ SELECT 'owned2'::text $$;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0--1.1.sql b/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0--1.1.sql
new file mode 100644
index 00000000000..e9ba788d751
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0--1.1.sql
@@ -0,0 +1,5 @@
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "ALTER EXTENSION test_ext_owned_schema_nosuperuser UPDATE TO '1.1'" to load this file. \quit
+
+CREATE FUNCTION owned2() RETURNS text
+  LANGUAGE SQL AS $$ SELECT 'owned2'::text $$;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0.sql b/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0.sql
new file mode 100644
index 00000000000..6c22c777519
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser--1.0.sql
@@ -0,0 +1,3 @@
+-- Test extension with owned schema (superuser=false)
+CREATE FUNCTION owned1() RETURNS text
+  LANGUAGE SQL AS $$ SELECT 'owned1'::text $$;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser.control b/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser.control
new file mode 100644
index 00000000000..caad0960996
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_nosuperuser.control
@@ -0,0 +1,5 @@
+comment = 'Test extension with owned schema (superuser=false)'
+default_version = '1.0'
+superuser = false
+owned_schema = true
+schema = 'test_owned_schema_nosuperuser'
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0--1.1.sql b/src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0--1.1.sql
new file mode 100644
index 00000000000..a87be1ac967
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0--1.1.sql
@@ -0,0 +1,3 @@
+-- Test extension upgrade script
+CREATE FUNCTION owned2() RETURNS text
+  LANGUAGE SQL AS $$ SELECT 'owned2'::text $$;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0.sql b/src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0.sql
new file mode 100644
index 00000000000..4e5410c184d
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_trusted--1.0.sql
@@ -0,0 +1,3 @@
+-- Test extension with owned schema (trusted=true)
+CREATE FUNCTION owned1() RETURNS text
+  LANGUAGE SQL AS $$ SELECT 'owned1'::text $$;
diff --git a/src/test/modules/test_extensions/test_ext_owned_schema_trusted.control b/src/test/modules/test_extensions/test_ext_owned_schema_trusted.control
new file mode 100644
index 00000000000..277b3f1603b
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext_owned_schema_trusted.control
@@ -0,0 +1,5 @@
+comment = 'Test extension with owned schema (trusted=true)'
+default_version = '1.0'
+trusted = true
+owned_schema = true
+schema = 'test_owned_schema_trusted'

---

From: "Jelte Fennema-Nio" <[email-masked]>
Date: Sun, 15 Mar 2026 21:19:52 +0100

> Ugh, I didn't include some of the new files in the attached patch. Fixed

Fixed tests that were failing in CI.
From 22407040953a8a3fb1b6c732370cef536d960243 Mon Sep 17 00:00:00 2001
From: Jelte Fennema-Nio <[email-masked]>
Date: Fri, 4 Oct 2024 22:34:51 +0200
Subject: [PATCH v10] Add support for extensions with an owned schema

 .../expected/test_extensions.out              | 159 +++++++
 .../test_extensions/sql/test_extensions.sql   |  77 ++++
 25 files changed, 765 insertions(+), 143 deletions(-)

index 963618a64c4..5e88c1b0e12 100644
@@ -791,6 +793,14 @@ parse_extension_control_file(ExtensionControlFile *control,
@@ -1234,6 +1244,44 @@ extension_is_trusted(ExtensionControlFile *control)
 	return false;
 }
 
+/*
@@ -1262,27 +1310,7 @@ execute_extension_script(Oid extensionOid, ExtensionControlFile *control,

@@ -1821,6 +1849,164 @@ find_install_path(List *evi_list, ExtensionVersionInfo *evi_target,
 	return evi_start;
 }
 
+/*
@@ -1850,6 +2036,9 @@ CreateExtensionInternal(char *extensionName,
 
 	/*
 	 * Read the primary control file.  Note we assume that it does not contain
@@ -1918,77 +2107,25 @@ CreateExtensionInternal(char *extensionName,
 	control = read_extension_aux_control_file(pcontrol, versionName);
 
 	/*

 
 	/*
@@ -2034,6 +2171,7 @@ CreateExtensionInternal(char *extensionName,
@@ -2239,7 +2377,8 @@ CreateExtension(ParseState *pstate, CreateExtensionStmt *stmt)
@@ -2269,6 +2408,7 @@ InsertExtensionTuple(const char *extName, Oid extOwner,
 
 	if (extConfig == PointerGetDatum(NULL))
@@ -2313,6 +2453,17 @@ InsertExtensionTuple(const char *extName, Oid extOwner,
 	record_object_address_dependencies(&myself, refobjs, DEPENDENCY_NORMAL);
 	free_object_addresses(refobjs);
 
+	if (ownedSchema)
+	{
 
@@ -3247,12 +3398,16 @@ extension_config_remove(Oid extensionoid, Oid tableoid)
 
 /*
@@ -3265,11 +3420,10 @@ AlterExtensionNamespace(const char *extensionName, const char *newschema, Oid *o

@@ -3278,22 +3432,6 @@ AlterExtensionNamespace(const char *extensionName, const char *newschema, Oid *o

@@ -3317,14 +3455,43 @@ AlterExtensionNamespace(const char *extensionName, const char *newschema, Oid *o

 
 	/* Check extension is supposed to be relocatable */
@@ -3397,6 +3564,13 @@ AlterExtensionNamespace(const char *extensionName, const char *newschema, Oid *o
 			}
 		}
 
+		/*
@@ -3440,18 +3614,35 @@ AlterExtensionNamespace(const char *extensionName, const char *newschema, Oid *o

 

 

 	CHECK_IS_BINARY_UPGRADE;

 
-	if (PG_ARGISNULL(4))
+	if (PG_ARGISNULL(5))
 
-	if (PG_ARGISNULL(5))
+	if (PG_ARGISNULL(6))

+	if (ownedschema)
+	{
index 137161aa5e0..1474e9ec279 100644
@@ -1936,6 +1936,19 @@ checkExtensionMembership(DumpableObject *dobj, Archive *fout)
 	if (ext == NULL)
 		return false;
 
+	/*
 
@@ -6040,7 +6053,7 @@ binary_upgrade_extension_member(PQExpBuffer upgrade_buffer,
 
 	if (!dobj->ext_member)
@@ -6054,19 +6067,33 @@ binary_upgrade_extension_member(PQExpBuffer upgrade_buffer,
 	 */
 
+	/*
@@ -6223,6 +6250,7 @@ getExtensions(Archive *fout, int *numExtensions)
 	int			i_extname;
 	int			i_nspname;
 	int			i_extrelocatable;
+	int			i_extownedschema;
 	int			i_extversion;
 	int			i_extconfig;
 	int			i_extcondition;
@@ -6231,7 +6259,14 @@ getExtensions(Archive *fout, int *numExtensions)

@@ -6247,6 +6282,7 @@ getExtensions(Archive *fout, int *numExtensions)
@@ -6260,6 +6296,7 @@ getExtensions(Archive *fout, int *numExtensions)
@@ -11984,9 +12021,9 @@ dumpNamespace(Archive *fout, const NamespaceInfo *nspinfo)
 
@@ -12098,6 +12135,7 @@ dumpExtension(Archive *fout, const ExtensionInfo *extinfo)
 
index 1c11a79083f..6db4f1b9d5d 100644
index 19ec291ad7e..b4ebb94b42c 100644
@@ -36,6 +36,7 @@ CATALOG(pg_extension,3079,ExtensionRelationId)
 
index 361e2cfffeb..e4dd7cbf085 100644
@@ -11917,7 +11917,7 @@

 
 
index fdae52d6ab2..eb1c38ab126 100644
@@ -667,3 +668,161 @@ SELECT test_s_dep.dep_req2();
 
+CREATE USER regress_test_ext_user;
+-- The database name differs between meson and autoconf builds, so we can't
+-- hardcode it in the GRANT/REVOKE statements.
+DO $$ BEGIN EXECUTE format('GRANT CREATE ON DATABASE %I TO regress_test_ext_user', current_database()); END $$;
+SET SESSION AUTHORIZATION regress_test_ext_user;
+SET SESSION AUTHORIZATION regress_test_ext_user;
+-- even though the extension is owned by regress_test_ext_user
+DO $$ BEGIN EXECUTE format('REVOKE CREATE ON DATABASE %I FROM regress_test_ext_user', current_database()); END $$;
+DROP USER regress_test_ext_user;
 
 tests += {
index b5878f6f80f..59a64c0ddcd 100644
@@ -303,3 +303,80 @@ ALTER EXTENSION test_ext_req_schema1 SET SCHEMA test_s_dep2;  -- now ok
+CREATE USER regress_test_ext_user;
+-- The database name differs between meson and autoconf builds, so we can't
+-- hardcode it in the GRANT/REVOKE statements.
+DO $$ BEGIN EXECUTE format('GRANT CREATE ON DATABASE %I TO regress_test_ext_user', current_database()); END $$;
+SET SESSION AUTHORIZATION regress_test_ext_user;
+SET SESSION AUTHORIZATION regress_test_ext_user;
+-- even though the extension is owned by regress_test_ext_user
+DO $$ BEGIN EXECUTE format('REVOKE CREATE ON DATABASE %I FROM regress_test_ext_user', current_database()); END $$;
+DROP USER regress_test_ext_user;

base-commit: a793677e57bc27c674cb94b230164b2c28f4cbae
-- 
2.53.0