Subject: Re: Extended Statistics set/restore/clear functions.

From: Michael Paquier <[email-masked]>
Date: Thu, 15 Jan 2026 17:09:08 +0900

On Wed, Jan 14, 2026 at 12:57:45PM -0500, Corey Huinker wrote:
> Some of the header cleanup work required adding utils/typcache.h to
> extended_stats.c.

Thanks for sending a rebased set.  It is a large patch, but most of
the changes are super mechanical, making it sort of "simpler" to think
about the whole.

Anyway, I have begun a review of the backend changes, and found one
independent piece that was useful, leading to 32e27bd32082 that I have
just applied after some renames and a couple of fixes.

Then I have spent a good portion of today looking at the backend
changes, with first a focus to extract the "clear" function as it is 
useful on its own, also because its relation lookup logic is the same
as the restore function.

And then, this block of code has been giving me a long pause, because
it was fishy, and clearly wrong:
+  /* no need to fetch reloid, we already have it */
+  RangeVarGetRelidExtended(makeRangeVar(nspname,
+       RelationGetRelationName(rel), -1),
+       ShareUpdateExclusiveLock, 0,
+       RangeVarCallbackForStats, &locked_table);

A shocking thing here is that we build a RangeVar for the relation of
the extended stats object based on the *extstat namespace*, not the
*relation namespace*, and that we do so *after* opening the extstat
catalog.  Anyway, I think that we need to redesign that, and for
consistency's sake do a RangeVar lookup *before* even trying to touch 
the stats data or open its catalogs.  That would be beneficial on
consistency ground, and one piece where I think that patch is designed
wrongly is that we should give in input of the new clear and restore
functions the *schema* and *relation* names of the table we expect to
find, so as we can enforce first a clean RangeVar lookup, then
manipulate the stats data.  This relates to 688dc6299a5b, as well,
except that we would be stuck with an incorrect design after release
if we are not careful because the function schema would be stuck in
time.  This has to be right from the start.

All these changes have given me the attached patch for the clear()
function.  Another piece is that we did not really check that a role
has the MAINTAIN rights of the relation where we want to manipulate
the extended stats.

A final thing is the location of the new SQL function code, let's put
that into a new file, named extended_stats_funcs.c in the patch.
There is nothing in the new restore and clear functions that require
extended_stats.c.

Finally, for the clear() part, the attached version addresses
everything I have found during my review.  We will need to make the
restore() part follow the same design model with the Rangevar lookups
of the parent relation, or we'll have trouble waiting ahead.
--
Michael
From 24a7deed008e578e7c7f055cedcefd13d449c130 Mon Sep 17 00:00:00 2001
From: Michael Paquier <[email-masked]>
Date: Thu, 15 Jan 2026 16:59:18 +0900
Subject: [PATCH v24] Add pg_clear_extended_stats()

This function is able to clear the data associated to an extended
statistics object, making things so as the object looks as
newly-created.

The caller of this function needs the following arguments for the stats
cleared:
- The name of the relation.
- The schema name of the relation.
- The name of the extended stats object.
- The schema name of the extended stats object.

The first two parameters are especially important to ensure a consistent
lookup at the relation we expect to find for the stats object, relying
on a RangeVar lookup where ACL are checks without locking a relation,
critical to prevent denial-of-service attacks when using this function.
The third and fourth parameters are important as a stats object may be
on a different schema than its relation.

This has been extracted from a larger patch by the same author, for a
piece which is again useful on its own.  I have rewritten large portions
of it.

XXX: Bump catalog version.

Author: Corey Huinker <[email-masked]>
Co-authored-by: Michael Paquier <[email-masked]>
Discussion: https://postgr.es/m/CADkLM=dpz3KFnqP-dgJ-zvRvtjsa8UZv8wDAQdqho=[email-masked]
---
 src/include/catalog/pg_proc.dat               |   7 +
 src/backend/statistics/Makefile               |   1 +
 src/backend/statistics/extended_stats_funcs.c | 232 ++++++++++++++++++
 src/backend/statistics/meson.build            |   1 +
 src/test/regress/expected/stats_import.out    | 146 +++++++++++
 src/test/regress/sql/stats_import.sql         | 102 ++++++++
 doc/src/sgml/func/func-admin.sgml             |  29 +++
 7 files changed, 518 insertions(+)
 create mode 100644 src/backend/statistics/extended_stats_funcs.c

diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 2ac69bf2df55..894b6a1b6d6b 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12618,6 +12618,13 @@
   proname => 'gist_translate_cmptype_common', prorettype => 'int2',
   proargtypes => 'int4', prosrc => 'gist_translate_cmptype_common' },
 
+# Extended Statistics functions
+{ oid => '9948', descr => 'clear statistics on extended statistics object',
+  proname => 'pg_clear_extended_stats', proisstrict => 'f', provolatile => 'v',
+  proparallel => 'u', prorettype => 'void', proargtypes => 'text text text text bool',
+  proargnames => '{schemaname,relname,statistics_schemaname,statistics_name,inherited}',
+  prosrc => 'pg_clear_extended_stats' },
+
 # AIO related functions
 { oid => '6399', descr => 'information about in-progress asynchronous IOs',
   proname => 'pg_get_aios', prorows => '100', proretset => 't',
diff --git a/src/backend/statistics/Makefile b/src/backend/statistics/Makefile
index 4672bd90f225..7ff5938b0273 100644
--- a/src/backend/statistics/Makefile
+++ b/src/backend/statistics/Makefile
@@ -16,6 +16,7 @@ OBJS = \
 	attribute_stats.o \
 	dependencies.o \
 	extended_stats.o \
+	extended_stats_funcs.o \
 	mcv.o \
 	mvdistinct.o \
 	relation_stats.o \
diff --git a/src/backend/statistics/extended_stats_funcs.c b/src/backend/statistics/extended_stats_funcs.c
new file mode 100644
index 000000000000..9edc6c1a97eb
--- /dev/null
+++ b/src/backend/statistics/extended_stats_funcs.c
@@ -0,0 +1,232 @@
+/*-------------------------------------------------------------------------
+ *
+ * extended_stats_funcs.c
+ *	  Functions for manipulating extended statistics.
+ *
+ * This file includes the set of facilities required to support the direct
+ * manipulations of extended statistics objects.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/statistics/extended_stats_funcs.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/heapam.h"
+#include "catalog/indexing.h"
+#include "catalog/namespace.h"
+#include "catalog/pg_database.h"
+#include "catalog/pg_statistic_ext.h"
+#include "catalog/pg_statistic_ext_data.h"
+#include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "statistics/stat_utils.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
+#include "utils/lsyscache.h"
+#include "utils/syscache.h"
+
+
+/*
+ * Index of the arguments for the SQL functions.
+ */
+enum extended_stats_argnum
+{
+	RELSCHEMA_ARG = 0,
+	RELNAME_ARG,
+	STATSCHEMA_ARG,
+	STATNAME_ARG,
+	INHERITED_ARG,
+	NUM_EXTENDED_STATS_ARGS,
+};
+
+/*
+ * The argument names and type OIDs of the arguments for the SQL
+ * functions.
+ */
+static struct StatsArgInfo extarginfo[] =
+{
+	[RELSCHEMA_ARG] = {"schemaname", TEXTOID},
+	[RELNAME_ARG] = {"relname", TEXTOID},
+	[STATSCHEMA_ARG] = {"statistics_schemaname", TEXTOID},
+	[STATNAME_ARG] = {"statistics_name", TEXTOID},
+	[INHERITED_ARG] = {"inherited", BOOLOID},
+	[NUM_EXTENDED_STATS_ARGS] = {0},
+};
+
+static HeapTuple get_pg_statistic_ext(Relation pg_stext, Oid nspoid,
+									  const char *stxname);
+static bool delete_pg_statistic_ext_data(Oid stxoid, bool inherited);
+
+/*
+ * Fetch a pg_statistic_ext row by name and namespace OID.
+ */
+static HeapTuple
+get_pg_statistic_ext(Relation pg_stext, Oid nspoid, const char *stxname)
+{
+	ScanKeyData key[2];
+	SysScanDesc scan;
+	HeapTuple	tup;
+	Oid			stxoid = InvalidOid;
+
+	ScanKeyInit(&key[0],
+				Anum_pg_statistic_ext_stxname,
+				BTEqualStrategyNumber,
+				F_NAMEEQ,
+				CStringGetDatum(stxname));
+	ScanKeyInit(&key[1],
+				Anum_pg_statistic_ext_stxnamespace,
+				BTEqualStrategyNumber,
+				F_OIDEQ,
+				ObjectIdGetDatum(nspoid));
+
+	/*
+	 * Try to find matching pg_statistic_ext row.
+	 */
+	scan = systable_beginscan(pg_stext,
+							  StatisticExtNameIndexId,
+							  true,
+							  NULL,
+							  2,
+							  key);
+
+	/* Lookup is based on a unique index, so we get either 0 or 1 tuple. */
+	tup = systable_getnext(scan);
+
+	if (HeapTupleIsValid(tup))
+		stxoid = ((Form_pg_statistic_ext) GETSTRUCT(tup))->oid;
+
+	systable_endscan(scan);
+
+	if (!OidIsValid(stxoid))
+		return NULL;
+
+	return SearchSysCacheCopy1(STATEXTOID, ObjectIdGetDatum(stxoid));
+}
+
+/*
+ * Remove an existing pg_statistic_ext_data row for a given pg_statistic_ext
+ * row and "inherited" pair.
+ */
+static bool
+delete_pg_statistic_ext_data(Oid stxoid, bool inherited)
+{
+	Relation	sed = table_open(StatisticExtDataRelationId, RowExclusiveLock);
+	HeapTuple	oldtup;
+	bool		result = false;
+
+	/* Is there already a pg_statistic tuple for this attribute? */
+	oldtup = SearchSysCache2(STATEXTDATASTXOID,
+							 ObjectIdGetDatum(stxoid),
+							 BoolGetDatum(inherited));
+
+	if (HeapTupleIsValid(oldtup))
+	{
+		CatalogTupleDelete(sed, &oldtup->t_self);
+		ReleaseSysCache(oldtup);
+		result = true;
+	}
+
+	table_close(sed, RowExclusiveLock);
+
+	CommandCounterIncrement();
+
+	return result;
+}
+
+/*
+ * Delete statistics for the given statistics object.
+ */
+Datum
+pg_clear_extended_stats(PG_FUNCTION_ARGS)
+{
+	char	   *relnspname;
+	char	   *relname;
+	char	   *nspname;
+	Oid			nspoid;
+	Oid			relid;
+	char	   *stxname;
+	bool		inherited;
+	Relation	pg_stext;
+	HeapTuple	tup;
+	Form_pg_statistic_ext stxform;
+	Oid			locked_table = InvalidOid;
+
+	/* relation arguments */
+	stats_check_required_arg(fcinfo, extarginfo, RELSCHEMA_ARG);
+	relnspname = TextDatumGetCString(PG_GETARG_DATUM(RELSCHEMA_ARG));
+	stats_check_required_arg(fcinfo, extarginfo, RELNAME_ARG);
+	relname = TextDatumGetCString(PG_GETARG_DATUM(RELNAME_ARG));
+
+	/* extended statistics arguments */
+	stats_check_required_arg(fcinfo, extarginfo, STATSCHEMA_ARG);
+	nspname = TextDatumGetCString(PG_GETARG_DATUM(STATSCHEMA_ARG));
+	stats_check_required_arg(fcinfo, extarginfo, STATNAME_ARG);
+	stxname = TextDatumGetCString(PG_GETARG_DATUM(STATNAME_ARG));
+	stats_check_required_arg(fcinfo, extarginfo, INHERITED_ARG);
+	inherited = PG_GETARG_NAME(INHERITED_ARG);
+
+	if (RecoveryInProgress())
+	{
+		ereport(WARNING,
+				errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				errmsg("recovery is in progress"),
+				errhint("Statistics cannot be modified during recovery."));
+		PG_RETURN_VOID();
+	}
+
+	/*
+	 * First open the relation where we expect to find the statistics.  This
+	 * is similar to relation and attribute statistics, so as ACL checks are
+	 * done before any locks are taken, even before any attempts related to
+	 * the extended stats object.
+	 */
+	relid = RangeVarGetRelidExtended(makeRangeVar(relnspname, relname, -1),
+									 ShareUpdateExclusiveLock, 0,
+									 RangeVarCallbackForStats, &locked_table);
+
+	/* Now check if the namespace of the stats object exists. */
+	nspoid = get_namespace_oid(nspname, true);
+	if (nspoid == InvalidOid)
+	{
+		ereport(WARNING,
+				errcode(ERRCODE_UNDEFINED_OBJECT),
+				errmsg("could not find schema \"%s\"", nspname));
+		PG_RETURN_VOID();
+	}
+
+	pg_stext = table_open(StatisticExtRelationId, RowExclusiveLock);
+	tup = get_pg_statistic_ext(pg_stext, nspoid, stxname);
+
+	if (!HeapTupleIsValid(tup))
+	{
+		table_close(pg_stext, RowExclusiveLock);
+		ereport(WARNING,
+				errcode(ERRCODE_UNDEFINED_OBJECT),
+				errmsg("could not find extended statistics object \"%s\".\"%s\"",
+					   nspname, stxname));
+		PG_RETURN_VOID();
+	}
+
+	stxform = (Form_pg_statistic_ext) GETSTRUCT(tup);
+
+	/*
+	 * This should be consistent, based on the lock taken on the table
+	 * when we started.
+	 */
+	if (stxform->stxrelid != relid)
+		elog(ERROR, "cache lookup failed for extended stats %u: found relation %u but expected %u",
+			 stxform->oid, stxform->stxrelid, relid);
+
+	delete_pg_statistic_ext_data(stxform->oid, inherited);
+	heap_freetuple(tup);
+
+	table_close(pg_stext, RowExclusiveLock);
+
+	PG_RETURN_VOID();
+}
diff --git a/src/backend/statistics/meson.build b/src/backend/statistics/meson.build
index 5c89f869812c..9a7bf55e3014 100644
--- a/src/backend/statistics/meson.build
+++ b/src/backend/statistics/meson.build
@@ -4,6 +4,7 @@ backend_sources += files(
   'attribute_stats.c',
   'dependencies.c',
   'extended_stats.c',
+  'extended_stats_funcs.c',
   'mcv.c',
   'mvdistinct.c',
   'relation_stats.c',
diff --git a/src/test/regress/expected/stats_import.out b/src/test/regress/expected/stats_import.out
index 98ce7dc28410..f859a3a6af08 100644
--- a/src/test/regress/expected/stats_import.out
+++ b/src/test/regress/expected/stats_import.out
@@ -1084,11 +1084,17 @@ SELECT 3, 'tre', (3, 3.3, 'TRE', '2003-03-03', NULL)::stats_import.complex_type,
 UNION ALL
 SELECT 4, 'four', NULL, int4range(0,100), NULL;
 CREATE INDEX is_odd ON stats_import.test(((comp).a % 2 = 1));
+CREATE STATISTICS stats_import.test_stat
+  ON name, comp, lower(arange), array_length(tags,1)
+  FROM stats_import.test;
 -- Generate statistics on table with data
 ANALYZE stats_import.test;
 CREATE TABLE stats_import.test_clone ( LIKE stats_import.test )
     WITH (autovacuum_enabled = false);
 CREATE INDEX is_odd_clone ON stats_import.test_clone(((comp).a % 2 = 1));
+CREATE STATISTICS stats_import.test_stat_clone
+  ON name, comp, lower(arange), array_length(tags,1)
+  FROM stats_import.test_clone;
 --
 -- Copy stats from test to test_clone, and is_odd to is_odd_clone
 --
@@ -1342,6 +1348,146 @@ AND attname = 'i';
 (1 row)
 
 DROP TABLE stats_temp;
+-- Tests for pg_clear_extended_stats().
+--  Invalid argument values.
+SELECT pg_clear_extended_stats(schemaname => NULL,
+  relname => 'rel_foo',
+  statistics_schemaname => 'schema_foo',
+  statistics_name => 'stat_bar',
+  inherited => false);
+ERROR:  argument "schemaname" must not be null
+SELECT pg_clear_extended_stats(schemaname => 'schema_foo',
+  relname => NULL,
+  statistics_schemaname => 'schema_foo',
+  statistics_name => 'stat_bar',
+  inherited => false);
+ERROR:  argument "relname" must not be null
+SELECT pg_clear_extended_stats(schemaname => 'schema_foo',
+  relname => 'rel_foo',
+  statistics_schemaname => NULL,
+  statistics_name => 'stat_bar',
+  inherited => false);
+ERROR:  argument "statistics_schemaname" must not be null
+SELECT pg_clear_extended_stats(schemaname => 'schema_foo',
+  relname => 'rel_foo',
+  statistics_schemaname => 'schema_foo',
+  statistics_name => NULL,
+  inherited => false);
+ERROR:  argument "statistics_name" must not be null
+SELECT pg_clear_extended_stats(schemaname => 'schema_foo',
+  relname => 'rel_foo',
+  statistics_schemaname => 'schema_foo',
+  statistics_name => 'stat_bar',
+  inherited => NULL);
+ERROR:  argument "inherited" must not be null
+-- Missing objects
+SELECT pg_clear_extended_stats(schemaname => 'schema_not_exist',
+  relname => 'test',
+  statistics_schemaname => 'schema_not_exist',
+  statistics_name => 'test_stat',
+  inherited => false);
+ERROR:  schema "schema_not_exist" does not exist
+SELECT pg_clear_extended_stats(schemaname => 'stats_import',
+  relname => 'table_not_exist',
+  statistics_schemaname => 'stats_import',
+  statistics_name => 'test_stat',
+  inherited => false);
+ERROR:  relation "stats_import.table_not_exist" does not exist
+SELECT pg_clear_extended_stats(schemaname => 'stats_import',
+  relname => 'test',
+  statistics_schemaname => 'schema_not_exist',
+  statistics_name => 'test_stat',
+  inherited => false);
+WARNING:  could not find schema "schema_not_exist"
+ pg_clear_extended_stats 
+-------------------------
+ 
+(1 row)
+
+SELECT pg_clear_extended_stats(schemaname => 'stats_import',
+  relname => 'test',
+  statistics_schemaname => 'stats_import',
+  statistics_name => 'ext_stats_not_exist',
+  inherited => false);
+WARNING:  could not find extended statistics object "stats_import"."ext_stats_not_exist"
+ pg_clear_extended_stats 
+-------------------------
+ 
+(1 row)
+
+-- Check that records are removed after a valid clear call.
+SELECT COUNT(*) FROM pg_stats_ext AS e
+  WHERE e.statistics_schemaname = 'stats_import' AND
+  e.statistics_name = 'test_stat' AND e.inherited = false;
+ count 
+-------
+     1
+(1 row)
+
+SELECT COUNT(*) FROM pg_stats_ext_exprs AS e
+  WHERE e.statistics_schemaname = 'stats_import' AND
+  e.statistics_name = 'test_stat' AND e.inherited = false;
+ count 
+-------
+     2
+(1 row)
+
+SELECT pg_catalog.pg_clear_extended_stats(
+  schemaname => 'stats_import',
+  relname => 'test',
+  statistics_schemaname => 'stats_import',
+  statistics_name => 'test_stat',
+  inherited => false);
+ pg_clear_extended_stats 
+-------------------------
+ 
+(1 row)
+
+SELECT COUNT(*) FROM pg_stats_ext AS e
+  WHERE e.statistics_schemaname = 'stats_import' AND
+  e.statistics_name = 'test_stat' AND e.inherited = false;
+ count 
+-------
+     0
+(1 row)
+
+SELECT COUNT(*) FROM pg_stats_ext_exprs AS e
+  WHERE e.statistics_schemaname = 'stats_import' AND
+  e.statistics_name = 'test_stat' AND e.inherited = false;
+ count 
+-------
+     0
+(1 row)
+
+-- Check that MAINTAIN is required when clearing statistics.
+CREATE ROLE regress_test_extstat_clear;
+GRANT ALL ON SCHEMA stats_import TO regress_test_extstat_clear;
+SET ROLE regress_test_extstat_clear;
+SELECT pg_catalog.pg_clear_extended_stats(
+  schemaname => 'stats_import',
+  relname => 'test',
+  statistics_schemaname => 'stats_import',
+  statistics_name => 'test_stat',
+  inherited => false);
+ERROR:  permission denied for table test
+RESET ROLE;
+GRANT MAINTAIN ON stats_import.test TO regress_test_extstat_clear;
+SET ROLE regress_test_extstat_clear;
+SELECT pg_catalog.pg_clear_extended_stats(
+  schemaname => 'stats_import',
+  relname => 'test',
+  statistics_schemaname => 'stats_import',
+  statistics_name => 'test_stat',
+  inherited => false);
+ pg_clear_extended_stats 
+-------------------------
+ 
+(1 row)
+
+RESET ROLE;
+REVOKE MAINTAIN ON stats_import.test FROM regress_test_extstat_clear;
+REVOKE ALL ON SCHEMA stats_import FROM regress_test_extstat_clear;
+DROP ROLE regress_test_extstat_clear;
 DROP SCHEMA stats_import CASCADE;
 NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to type stats_import.complex_type
diff --git a/src/test/regress/sql/stats_import.sql b/src/test/regress/sql/stats_import.sql
index d140733a7502..e866cdea1b72 100644
--- a/src/test/regress/sql/stats_import.sql
+++ b/src/test/regress/sql/stats_import.sql
@@ -766,6 +766,10 @@ SELECT 4, 'four', NULL, int4range(0,100), NULL;
 
 CREATE INDEX is_odd ON stats_import.test(((comp).a % 2 = 1));
 
+CREATE STATISTICS stats_import.test_stat
+  ON name, comp, lower(arange), array_length(tags,1)
+  FROM stats_import.test;
+
 -- Generate statistics on table with data
 ANALYZE stats_import.test;
 
@@ -774,6 +778,10 @@ CREATE TABLE stats_import.test_clone ( LIKE stats_import.test )
 
 CREATE INDEX is_odd_clone ON stats_import.test_clone(((comp).a % 2 = 1));
 
+CREATE STATISTICS stats_import.test_stat_clone
+  ON name, comp, lower(arange), array_length(tags,1)
+  FROM stats_import.test_clone;
+
 --
 -- Copy stats from test to test_clone, and is_odd to is_odd_clone
 --
@@ -970,4 +978,98 @@ AND tablename = 'stats_temp'
 AND inherited = false
 AND attname = 'i';
 DROP TABLE stats_temp;
+
+-- Tests for pg_clear_extended_stats().
+--  Invalid argument values.
+SELECT pg_clear_extended_stats(schemaname => NULL,
+  relname => 'rel_foo',
+  statistics_schemaname => 'schema_foo',
+  statistics_name => 'stat_bar',
+  inherited => false);
+SELECT pg_clear_extended_stats(schemaname => 'schema_foo',
+  relname => NULL,
+  statistics_schemaname => 'schema_foo',
+  statistics_name => 'stat_bar',
+  inherited => false);
+SELECT pg_clear_extended_stats(schemaname => 'schema_foo',
+  relname => 'rel_foo',
+  statistics_schemaname => NULL,
+  statistics_name => 'stat_bar',
+  inherited => false);
+SELECT pg_clear_extended_stats(schemaname => 'schema_foo',
+  relname => 'rel_foo',
+  statistics_schemaname => 'schema_foo',
+  statistics_name => NULL,
+  inherited => false);
+SELECT pg_clear_extended_stats(schemaname => 'schema_foo',
+  relname => 'rel_foo',
+  statistics_schemaname => 'schema_foo',
+  statistics_name => 'stat_bar',
+  inherited => NULL);
+-- Missing objects
+SELECT pg_clear_extended_stats(schemaname => 'schema_not_exist',
+  relname => 'test',
+  statistics_schemaname => 'schema_not_exist',
+  statistics_name => 'test_stat',
+  inherited => false);
+SELECT pg_clear_extended_stats(schemaname => 'stats_import',
+  relname => 'table_not_exist',
+  statistics_schemaname => 'stats_import',
+  statistics_name => 'test_stat',
+  inherited => false);
+SELECT pg_clear_extended_stats(schemaname => 'stats_import',
+  relname => 'test',
+  statistics_schemaname => 'schema_not_exist',
+  statistics_name => 'test_stat',
+  inherited => false);
+SELECT pg_clear_extended_stats(schemaname => 'stats_import',
+  relname => 'test',
+  statistics_schemaname => 'stats_import',
+  statistics_name => 'ext_stats_not_exist',
+  inherited => false);
+
+-- Check that records are removed after a valid clear call.
+SELECT COUNT(*) FROM pg_stats_ext AS e
+  WHERE e.statistics_schemaname = 'stats_import' AND
+  e.statistics_name = 'test_stat' AND e.inherited = false;
+SELECT COUNT(*) FROM pg_stats_ext_exprs AS e
+  WHERE e.statistics_schemaname = 'stats_import' AND
+  e.statistics_name = 'test_stat' AND e.inherited = false;
+SELECT pg_catalog.pg_clear_extended_stats(
+  schemaname => 'stats_import',
+  relname => 'test',
+  statistics_schemaname => 'stats_import',
+  statistics_name => 'test_stat',
+  inherited => false);
+SELECT COUNT(*) FROM pg_stats_ext AS e
+  WHERE e.statistics_schemaname = 'stats_import' AND
+  e.statistics_name = 'test_stat' AND e.inherited = false;
+SELECT COUNT(*) FROM pg_stats_ext_exprs AS e
+  WHERE e.statistics_schemaname = 'stats_import' AND
+  e.statistics_name = 'test_stat' AND e.inherited = false;
+
+-- Check that MAINTAIN is required when clearing statistics.
+CREATE ROLE regress_test_extstat_clear;
+GRANT ALL ON SCHEMA stats_import TO regress_test_extstat_clear;
+SET ROLE regress_test_extstat_clear;
+SELECT pg_catalog.pg_clear_extended_stats(
+  schemaname => 'stats_import',
+  relname => 'test',
+  statistics_schemaname => 'stats_import',
+  statistics_name => 'test_stat',
+  inherited => false);
+RESET ROLE;
+GRANT MAINTAIN ON stats_import.test TO regress_test_extstat_clear;
+SET ROLE regress_test_extstat_clear;
+SELECT pg_catalog.pg_clear_extended_stats(
+  schemaname => 'stats_import',
+  relname => 'test',
+  statistics_schemaname => 'stats_import',
+  statistics_name => 'test_stat',
+  inherited => false);
+RESET ROLE;
+REVOKE MAINTAIN ON stats_import.test FROM regress_test_extstat_clear;
+REVOKE ALL ON SCHEMA stats_import FROM regress_test_extstat_clear;
+DROP ROLE regress_test_extstat_clear;
+
 DROP SCHEMA stats_import CASCADE;
diff --git a/doc/src/sgml/func/func-admin.sgml b/doc/src/sgml/func/func-admin.sgml
index 2896cd9e4290..e7ea16f73b31 100644
--- a/doc/src/sgml/func/func-admin.sgml
+++ b/doc/src/sgml/func/func-admin.sgml
@@ -2165,6 +2165,35 @@ SELECT pg_restore_attribute_stats(
         </para>
        </entry>
       </row>
+      <row>
+       <entry role="func_table_entry">
+        <para role="func_signature">
+         <indexterm>
+          <primary>pg_clear_extended_stats</primary>
+         </indexterm>
+         <function>pg_clear_extended_stats</function> (
+         <parameter>schemaname</parameter> <type>name</type>,
+         <parameter>relname</parameter> <type>name</type>,
+         <parameter>statistics_schemaname</parameter> <type>name</type>,
+         <parameter>statistics_name</parameter> <type>name</type>,
+         <parameter>inherited</parameter> <type>boolean</type> )
+         <returnvalue>void</returnvalue>
+        </para>
+        <para>
+         Clears data of an extended statistics object, as though the object
+         was newly-created. The required arguments are
+         <literal>schemaname</literal> and <literal>relname</literal> to
+         specify the schema and table name of the relation whose statistics
+         are cleared, as well as <literal>statistics_schemaname</literal>
+         and <literal>statistics_name</literal> to specify the schema and
+         extended statistics name of the extended statistics object to clear.
+        </para>
+        <para>
+         The caller must have the <literal>MAINTAIN</literal> privilege on
+         the table or be the owner of the database.
+        </para>
+       </entry>
+      </row>
      </tbody>
     </tgroup>
    </table>
-- 
2.51.0

---

From: Chao Li <[email-masked]>
Date: Thu, 15 Jan 2026 16:57:00 +0800

> On Jan 15, 2026, at 16:09, Michael Paquier <[email-masked]> wrote:
> 
> <v24-0001-Add-pg_clear_extended_stats.patch>

Hi Michael,

Here are some comments for v24:

1.
```
```

Should this use PG_GETARG_BOOL()?

2
```
  proparallel => 'u', prorettype => 'void', proargtypes => 'text text text text bool’,
```

Should we define proargtypes as “name name name name bool”? As the doc change mentions type “name” for the first 4 parameters. Maybe you want to avoid that because Name is a structure. Anyway, I don’t have a strong opinion here.

3 - extended_stats_funcs.c
```
 * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
```

This is a brand new file, so the copyright year should be just 2026. If you search for “2025-2026”, you will get a bunch of files, they should be created in 2025.

4 - Given comment 1, no test fails. I think that’s because all test cases use inherited = false. Maybe we need a test case for inherited = true. 

Best regards,
--
Chao Li (Evan)
HighGo Software Co., Ltd.
https://www.highgo.com/

---

From: Corey Huinker <[email-masked]>
Date: Thu, 15 Jan 2026 03:59:27 -0500

> just applied after some renames and a couple of fixes.

:)

> time.  This has to be right from the start.

Correct, that was done in the v7 patchset to conform to 688dc6299a5b.

All four names are available through pg_stats_ext, so the additional
requirements for pg_dump may not be a an issue. However, I do have some
vague memory that all four names was a problem in some way beyond just
giving the parameters more chances to disagree with reality. We'll find out
soon enough...

> of the parent relation, or we'll have trouble waiting ahead.

+1

---

From: Michael Paquier <[email-masked]>
Date: Thu, 15 Jan 2026 18:35:33 +0900

On Thu, Jan 15, 2026 at 04:57:00PM +0800, Chao Li wrote:
> 4 - Given comment 1, no test fails. I think that’s because all test
> cases use inherited = false. Maybe we need a test case for inherited
> = true.

Yeah, I was planning to add one but ran out of steam today.  I should
have mentioned that earlier.
--
Michael