Subject: Re: [PATCH] Optionally record Plan IDs to track plan changes for a query

From: Андрей Казачков<[email-masked]>
Date: Thu, 25 Dec 2025 17:27:19 +0300

Dear PostgreSQL Hackers,

I’d like to propose a follow-up fix for a crash caused by jumbling of empty

array fields in Plan structures that was introduced in the v5-0003 patch.

For your convenience, I attached a minimal reproducible example:

```

create table foo as select i as num from generate_series(1, 1000) i;

set compute_plan_id to true;

-- server closed the connection unexpectedly

explain (costs off, verbose)

select min(num) from foo;

```

In this case the scalar aggregate operator `min(num)` is expressed as an Agg

node with an empty array of GROUP BY attributes. The jumble logic can’t handle

emptiness and terminates the backend. Similar behavior could happen during

jumbling an arbitrary plan node which contains an array.

Patchset overview:

v6-0001

-----------

Aggregates changes from v5-0001..v5-0003 and rebases onto master

commit b39013b7b1b116b5d9be51f0919b472b58b3a28d.

v6-0002

-----------

Fixes the lack of empty-array handling during jumbling (originally applies on

top of v6-0000). For anyone testing on the v5 versions, v6-0001 can be easily

applied on top of v5-0003.

--

Sincerely, Andrey Kazachkov

From 70c9720a8edd84fd31c4e9a9b1978765df5dbaea Mon Sep 17 00:00:00 2001
From: Andrey Kazachkov <[email-masked]>
Date: Wed, 24 Dec 2025 18:47:34 +0300
Subject: [PATCH v6 2/2] Fix jumbling of empty arrays in plan nodes

---
 src/backend/nodes/queryjumblefuncs.c | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/src/backend/nodes/queryjumblefuncs.c b/src/backend/nodes/queryjumblefuncs.c
index 5717480dbf5..a0d28827269 100644
--- a/src/backend/nodes/queryjumblefuncs.c
+++ b/src/backend/nodes/queryjumblefuncs.c
@@ -555,7 +555,10 @@ do { \
 		AppendJumble(jstate, (const unsigned char *) expr->item->words, sizeof(bitmapword) * expr->item->nwords); \
 } while(0)
 #define JUMBLE_ARRAY(item, len) \
-	AppendJumble(jstate, (const unsigned char *) expr->item, sizeof(*(expr->item)) * len)
+	do { \
+		if (len) \
+			AppendJumble(jstate, (const unsigned char *) expr->item, sizeof(*(expr->item)) * len); \
+	} while (0)
 #define JUMBLE_STRING(str) \
 do { \
 	if (expr->str) \
-- 
2.43.0

From 49518713775c3d24ba8e7a802c817042143e548a Mon Sep 17 00:00:00 2001
From: Andrey Kazachkov <[email-masked]>
Date: Tue, 16 Dec 2025 19:52:39 +0300
Subject: [PATCH v6 1/2] Added plan_id to the core.

1. Changed a jumbling initialization routine
2. JumbleNode() is an entry point for jumbling
3. AppendJumble() remains static
4. guc-compute-plan-id has been added
5. gen_node_support.pl changed to process plan node structures
6. pg_node_attr has been added to certain structures/fields
---
 doc/src/sgml/config.sgml                      |  34 ++++
 doc/src/sgml/monitoring.sgml                  |  16 ++
 src/backend/catalog/system_views.sql          |   1 +
 src/backend/commands/explain.c                |  17 ++
 src/backend/executor/execMain.c               |  10 +-
 src/backend/nodes/gen_node_support.pl         |  34 +++-
 src/backend/nodes/queryjumblefuncs.c          | 180 +++++++++++-------
 src/backend/optimizer/plan/planner.c          |  19 ++
 src/backend/optimizer/plan/setrefs.c          |   9 +
 src/backend/postmaster/launch_backend.c       |   3 +
 src/backend/utils/adt/pgstatfuncs.c           |   7 +-
 src/backend/utils/misc/guc_parameters.dat     |   7 +
 src/backend/utils/misc/guc_tables.c           |  18 ++
 src/backend/utils/misc/postgresql.conf.sample |   1 +
 src/include/catalog/pg_proc.dat               |   6 +-
 src/include/nodes/pathnodes.h                 |   3 +
 src/include/nodes/plannodes.h                 |  50 ++---
 src/include/nodes/primnodes.h                 |   4 +-
 src/include/nodes/queryjumble.h               |  35 +++-
 src/include/utils/backend_status.h            |   2 +-
 src/test/regress/expected/explain.out         |  11 ++
 src/test/regress/expected/rules.out           |   9 +-
 src/test/regress/sql/explain.sql              |   4 +
 23 files changed, 371 insertions(+), 109 deletions(-)

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 405c9689bd0..4cdaf9c49ba 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -8864,6 +8864,40 @@ COPY postgres_log FROM '/full/path/to/logfile.csv' WITH csv;
       </listitem>
      </varlistentry>
 
+     <varlistentry id="guc-compute-plan-id" xreflabel="compute_plan_id">
+      <term><varname>compute_plan_id</varname> (<type>enum</type>)
+      <indexterm>
+       <primary><varname>compute_plan_id</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Enables in-core computation of a plan identifier.
+        Plan identifiers can be displayed in the <link
+        linkend="monitoring-pg-stat-activity-view"><structname>pg_stat_activity</structname></link>
+        view or using <command>EXPLAIN</command>.
+        Note that an external module can alternatively be used if the
+        in-core plan identifier computation method is not acceptable.
+        In this case, in-core computation must be always disabled.
+        Valid values are <literal>off</literal> (always disabled),
+        <literal>on</literal> (always enabled), <literal>auto</literal>,
+        which lets modules that utilize plan identifiers enable
+        it automatically, and <literal>regress</literal> which
+        has the same effect as <literal>on</literal>, except that the
+        query identifier is not shown in the <literal>EXPLAIN</literal> output
+        in order to facilitate automated regression testing.
+        The default is <literal>auto</literal>.
+       </para>
+       <note>
+        <para>
+         To ensure that only one plan identifier is calculated and
+         displayed, extensions that calculate plan identifiers should
+         throw an error if a plan identifier has already been computed.
+        </para>
+       </note>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="guc-log-statement-stats">
       <term><varname>log_statement_stats</varname> (<type>boolean</type>)
       <indexterm>
diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml
index 817fd9f4ca7..9e98fd2c198 100644
--- a/doc/src/sgml/monitoring.sgml
+++ b/doc/src/sgml/monitoring.sgml
@@ -983,6 +983,22 @@ postgres   27093  0.0  0.0  30096  2752 ?        Ss   11:34   0:00 postgres: ser
      </para></entry>
     </row>
 
+     <row>
+       <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>plan_id</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Identifier of this backend's most recent query plan. If
+       <structfield>state</structfield> is <literal>active</literal> this
+       field shows the identifier of the currently executing query plan. In
+       all other states, it shows the identifier of last query plan that
+       was executed.  Plan identifiers are not computed by default so this
+       field will be null unless <xref linkend="guc-compute-plan-id"/>
+       parameter is enabled or a third-party module that computes plan
+       identifiers is configured.
+      </para></entry>
+     </row>
+
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
        <structfield>query</structfield> <type>text</type>
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 0a0f95f6bb9..35744a82f0b 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -919,6 +919,7 @@ CREATE VIEW pg_stat_activity AS
             S.backend_xid,
             S.backend_xmin,
             S.query_id,
+			S.plan_id,
             S.query,
             S.backend_type
     FROM pg_stat_get_activity(NULL) AS S
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 5a6390631eb..b21cd142111 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -818,6 +818,23 @@ ExplainPrintPlan(ExplainState *es, QueryDesc *queryDesc)
 		ExplainPropertyInteger("Query Identifier", NULL,
 							   queryDesc->plannedstmt->queryId, es);
 	}
+
+	/*
+	 * COMPUTE_PLAN_ID_REGRESS means COMPUTE_PLAN_ID_YES, but we don't show
+	 * the queryid in any of the EXPLAIN plans to keep stable the results
+	 * generated by regression test suites.
+	 */
+	if (es->verbose && queryDesc->plannedstmt->planId != UINT64CONST(0) &&
+		compute_plan_id != COMPUTE_PLAN_ID_REGRESS)
+	{
+		/*
+		 * Output the queryid as an int64 rather than a uint64 so we match
+		 * what would be seen in the BIGINT pg_stat_activity.plan_id column.
+		 */
+		ExplainPropertyInteger("Plan Identifier", NULL,
+							   queryDesc->plannedstmt->planId, es);
+	}
+
 }
 
 /*
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index 797d8b1ca1c..14f107d45b1 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -123,13 +123,15 @@ ExecutorStart(QueryDesc *queryDesc, int eflags)
 {
 	/*
 	 * In some cases (e.g. an EXECUTE statement or an execute message with the
-	 * extended query protocol) the query_id won't be reported, so do it now.
+	 * extended query protocol) the query_id and plan_id won't be reported, so
+	 * do it now.
 	 *
-	 * Note that it's harmless to report the query_id multiple times, as the
-	 * call will be ignored if the top level query_id has already been
-	 * reported.
+	 * Note that it's harmless to report the identifiers multiple times, as
+	 * the call will be ignored if the top level query_id or plan_id has
+	 * already been reported.
 	 */
 	pgstat_report_query_id(queryDesc->plannedstmt->queryId, false);
+	pgstat_report_plan_id(queryDesc->plannedstmt->planId, false);
 
 	if (ExecutorStart_hook)
 		(*ExecutorStart_hook) (queryDesc, eflags);
diff --git a/src/backend/nodes/gen_node_support.pl b/src/backend/nodes/gen_node_support.pl
index 9ecddb14231..eab144bd962 100644
--- a/src/backend/nodes/gen_node_support.pl
+++ b/src/backend/nodes/gen_node_support.pl
@@ -1291,6 +1291,7 @@ _jumble${n}(JumbleState *jstate, Node *node)
 	{
 		my $t = $node_type_info{$n}->{field_types}{$f};
 		my @a = @{ $node_type_info{$n}->{field_attrs}{$f} };
+		my $array_size_field;
 		my $query_jumble_ignore = $struct_no_query_jumble;
 		my $query_jumble_custom = 0;
 		my $query_jumble_location = 0;
@@ -1303,7 +1304,11 @@ _jumble${n}(JumbleState *jstate, Node *node)
 			{
 				$query_jumble_custom = 1;
 			}
-			if ($a eq 'query_jumble_ignore')
+			elsif ($a =~ /^array_size\(([\w.]+)\)$/)
+			{
+				$array_size_field = $1;
+			}
+			elsif ($a eq 'query_jumble_ignore')
 			{
 				$query_jumble_ignore = 1;
 			}
@@ -1317,12 +1322,19 @@ _jumble${n}(JumbleState *jstate, Node *node)
 			}
 		}
 
+		next if $query_jumble_ignore;
+
 		if ($query_jumble_custom)
 		{
 			# Custom function that applies to one field of a node.
 			print $jff "\tJUMBLE_CUSTOM($n, $f);\n"
 			  unless $query_jumble_ignore;
 		}
+		elsif ($t eq 'Bitmapset*')
+		{
+			print $jff "\tJUMBLE_BITMAPSET($f);\n"
+			  unless $query_jumble_ignore;
+		}
 		elsif (($t =~ /^(\w+)\*$/ or $t =~ /^struct\s+(\w+)\*$/)
 			and elem $1, @node_types)
 		{
@@ -1352,6 +1364,26 @@ _jumble${n}(JumbleState *jstate, Node *node)
 			print $jff "\tJUMBLE_STRING($f);\n"
 			  unless $query_jumble_ignore;
 		}
+		elsif ($t =~ /^(\w+)(\*|\[\w+\])$/ and elem $1, @scalar_types)
+		{
+			if (!defined $array_size_field)
+			{
+				die "no array size defined for $n.$f of type $t\n";
+			}
+			if ($node_type_info{$n}->{field_types}{$array_size_field} eq
+				'List*')
+			{
+				print $jff
+				  "\tJUMBLE_ARRAY($f, list_length(expr->$array_size_field));\n"
+				  unless $query_jumble_ignore;
+			}
+			else
+			{
+				print $jff
+				  "\tJUMBLE_ARRAY($f, expr->$array_size_field);\n"
+				  unless $query_jumble_ignore;
+			}
+		}
 		else
 		{
 			print $jff "\tJUMBLE_FIELD($f);\n"
diff --git a/src/backend/nodes/queryjumblefuncs.c b/src/backend/nodes/queryjumblefuncs.c
index ffc230af427..5717480dbf5 100644
--- a/src/backend/nodes/queryjumblefuncs.c
+++ b/src/backend/nodes/queryjumblefuncs.c
@@ -3,32 +3,31 @@
  * queryjumblefuncs.c
  *	 Query normalization and fingerprinting.
  *
- * Normalization is a process whereby similar queries, typically differing only
- * in their constants (though the exact rules are somewhat more subtle than
- * that) are recognized as equivalent, and are tracked as a single entry.  This
- * is particularly useful for non-prepared queries.
+ * Fingerprinting selectively serializes key fields within a tree structure,
+ * such as a Query or Plan tree, to create a unique identifier while ignoring
+ * extraneous details. These essential fields are concatenated into a jumble,
+ * from which a 64-bit hash is computed. Unlike regular serialization, this
+ * approach excludes irrelevant information.
+ * 
+ * Use Cases:
  *
- * Normalization is implemented by fingerprinting queries, selectively
- * serializing those fields of each query tree's nodes that are judged to be
- * essential to the query.  This is referred to as a query jumble.  This is
- * distinct from a regular serialization in that various extraneous
- * information is ignored as irrelevant or not essential to the query, such
- * as the collations of Vars and, most notably, the values of constants.
- *
- * This jumble is acquired at the end of parse analysis of each query, and
- * a 64-bit hash of it is stored into the query's Query.queryId field.
- * The server then copies this value around, making it available in plan
- * tree(s) generated from the query.  The executor can then use this value
- * to blame query costs on the proper queryId.
- *
- * Arrays of two or more constants and PARAM_EXTERN parameters are "squashed"
- * and contribute only once to the jumble.  This has the effect that queries
- * that differ only on the length of such lists have the same queryId.
- *
- *
- * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
- * Portions Copyright (c) 1994, Regents of the University of California
+ * 1. In-Core Query Normalization & Identification
  *
+ * Fingerprinting is used to normalize query trees by generating a hash stored
+ * in the Query.queryId field. This ID is propagated to plan tree(s), allowing
+ * the executor to attribute query costs on the proper queryId. The process
+ * excludes information like typmod, collation, and most notably, the values
+ * of constants.
+ * 
+ * Example: The following queries produce the same queryId:
+ * 
+ * SELECT t.* FROM s1.t WHERE c1 = 1;
+ * SELECT t.* FROM s1.t WHERE c1 = 2;
+ * 
+ * 2. Modified jumbling logic for extensions
+ * 
+ * Extensions can modify the fingerprinting logic for queryId, or fingerprint
+ * other types of trees, such as a plan tree, to compute a plan identifier.
  *
  * IDENTIFICATION
  *	  src/backend/nodes/queryjumblefuncs.c
@@ -50,6 +49,7 @@
 
 /* GUC parameters */
 int			compute_query_id = COMPUTE_QUERY_ID_AUTO;
+int			compute_plan_id = COMPUTE_PLAN_ID_AUTO;
 
 /*
  * True when compute_query_id is ON or AUTO, and a module requests them.
@@ -60,15 +60,21 @@ int			compute_query_id = COMPUTE_QUERY_ID_AUTO;
  */
 bool		query_id_enabled = false;
 
-static JumbleState *InitJumble(void);
-static int64 DoJumble(JumbleState *jstate, Node *node);
+/*
+ * True when compute_plan_id is ON or AUTO, and a module requests them.
+ *
+ * Note that IsPlanIdEnabled() should be used instead of checking
+ * plan_id_enabled or compute_plan_id directly when we want to know
+ * whether query identifiers are computed in the core or not.
+ */
+bool		plan_id_enabled = false;
+
 static void AppendJumble(JumbleState *jstate,
 						 const unsigned char *value, Size size);
 static void FlushPendingNulls(JumbleState *jstate);
 static void RecordConstLocation(JumbleState *jstate,
 								bool extern_param,
 								int location, int len);
-static void _jumbleNode(JumbleState *jstate, Node *node);
 static void _jumbleList(JumbleState *jstate, Node *node);
 static void _jumbleElements(JumbleState *jstate, List *elements, Node *node);
 static void _jumbleParam(JumbleState *jstate, Node *node);
@@ -136,13 +142,13 @@ CleanQuerytext(const char *query, int *location, int *len)
 JumbleState *
 JumbleQuery(Query *query)
 {
-	JumbleState *jstate;
+	JumbleState *jstate = InitializeJumbleState(true);;
 
 	Assert(IsQueryIdEnabled());
 
-	jstate = InitJumble();
-
-	query->queryId = DoJumble(jstate, (Node *) query);
+	/* Compute query ID and mark the Query node with it */
+	JumbleNode(jstate, (Node *) query);
+	query->queryId = HashJumbleState(jstate);
 
 	/*
 	 * If we are unlucky enough to get a hash of zero, use 1 instead for
@@ -173,44 +179,44 @@ EnableQueryId(void)
 }
 
 /*
- * InitJumble
- *		Allocate a JumbleState object and make it ready to jumble.
+ * Enables plan identifier computation.
+ *
+ * Third-party plugins can use this function to inform core that they require
+ * a query identifier to be computed.
  */
-static JumbleState *
-InitJumble(void)
+void
+EnablePlanId(void)
 {
-	JumbleState *jstate;
+	if (compute_plan_id != COMPUTE_PLAN_ID_OFF)
+		plan_id_enabled = true;
+}
 
-	jstate = palloc_object(JumbleState);
+/*
+ * InitializeJumbleState
+ *		Allocate a JumbleState object and make it ready to jumble.
+ */
+JumbleState *
+InitializeJumbleState(bool record_clocations)
+{
+	JumbleState *jstate = (JumbleState *) palloc0(sizeof(JumbleState));
 
 	/* Set up workspace for query jumbling */
 	jstate->jumble = (unsigned char *) palloc(JUMBLE_SIZE);
 	jstate->jumble_len = 0;
-	jstate->clocations_buf_size = 32;
-	jstate->clocations = (LocationLen *) palloc(jstate->clocations_buf_size *
-												sizeof(LocationLen));
-	jstate->clocations_count = 0;
-	jstate->highest_extern_param_id = 0;
-	jstate->pending_nulls = 0;
-	jstate->has_squashed_lists = false;
-#ifdef USE_ASSERT_CHECKING
-	jstate->total_jumble_len = 0;
-#endif
+
+	if (record_clocations)
+	{
+		jstate->clocations_buf_size = 32;
+		jstate->clocations = (LocationLen *)
+			palloc(jstate->clocations_buf_size * sizeof(LocationLen));
+	}
 
 	return jstate;
 }
 
-/*
- * DoJumble
- *		Jumble the given Node using the given JumbleState and return the resulting
- *		jumble hash.
- */
-static int64
-DoJumble(JumbleState *jstate, Node *node)
+uint64
+HashJumbleState(JumbleState *jstate)
 {
-	/* Jumble the given node */
-	_jumbleNode(jstate, node);
-
 	/* Flush any pending NULLs before doing the final hash */
 	if (jstate->pending_nulls > 0)
 		FlushPendingNulls(jstate);
@@ -219,10 +225,9 @@ DoJumble(JumbleState *jstate, Node *node)
 	if (jstate->has_squashed_lists)
 		jstate->highest_extern_param_id = 0;
 
-	/* Process the jumble buffer and produce the hash value */
-	return DatumGetInt64(hash_any_extended(jstate->jumble,
-										   jstate->jumble_len,
-										   0));
+	return DatumGetUInt64(hash_any_extended(jstate->jumble,
+											jstate->jumble_len,
+											0));
 }
 
 /*
@@ -398,7 +403,7 @@ static void
 RecordConstLocation(JumbleState *jstate, bool extern_param, int location, int len)
 {
 	/* -1 indicates unknown or undefined location */
-	if (location >= 0)
+	if (location >= 0 && jstate->clocations_buf_size > 0)
 	{
 		/* enlarge array if needed */
 		if (jstate->clocations_count >= jstate->clocations_buf_size)
@@ -526,7 +531,7 @@ IsSquashableConstantList(List *elements)
 }
 
 #define JUMBLE_NODE(item) \
-	_jumbleNode(jstate, (Node *) expr->item)
+	JumbleNode(jstate, (Node *) expr->item)
 #define JUMBLE_ELEMENTS(list, node) \
 	_jumbleElements(jstate, (List *) expr->list, node)
 #define JUMBLE_LOCATION(location) \
@@ -544,6 +549,13 @@ do { \
 	else \
 		AppendJumble(jstate, (const unsigned char *) &(expr->item), sizeof(expr->item)); \
 } while (0)
+#define JUMBLE_BITMAPSET(item) \
+do { \
+	if (expr->item) \
+		AppendJumble(jstate, (const unsigned char *) expr->item->words, sizeof(bitmapword) * expr->item->nwords); \
+} while(0)
+#define JUMBLE_ARRAY(item, len) \
+	AppendJumble(jstate, (const unsigned char *) expr->item, sizeof(*(expr->item)) * len)
 #define JUMBLE_STRING(str) \
 do { \
 	if (expr->str) \
@@ -557,8 +569,8 @@ do { \
 
 #include "queryjumblefuncs.funcs.c"
 
-static void
-_jumbleNode(JumbleState *jstate, Node *node)
+void
+JumbleNode(JumbleState *jstate, Node *node)
 {
 	Node	   *expr = node;
 #ifdef USE_ASSERT_CHECKING
@@ -612,7 +624,7 @@ _jumbleList(JumbleState *jstate, Node *node)
 	{
 		case T_List:
 			foreach(l, expr)
-				_jumbleNode(jstate, lfirst(l));
+				JumbleNode(jstate, lfirst(l));
 			break;
 		case T_IntList:
 			foreach(l, expr)
@@ -668,7 +680,7 @@ _jumbleElements(JumbleState *jstate, List *elements, Node *node)
 
 	if (!normalize_list)
 	{
-		_jumbleNode(jstate, (Node *) elements);
+		JumbleNode(jstate, (Node *) elements);
 	}
 }
 
@@ -758,6 +770,40 @@ _jumbleVariableSetStmt(JumbleState *jstate, Node *node)
 	JUMBLE_LOCATION(location);
 }
 
+/*
+ * Jumble the entries in the rangle table to map RT indexes to relations
+ *
+ * This ensures jumbled RT indexes (e.g. in a Scan or Modify node), are
+ * distinguished by the target of the RT entry, even if the index is the same.
+ */
+void
+JumbleRangeTable(JumbleState *jstate, List *rtable)
+{
+	ListCell *lc;
+
+	foreach(lc, rtable)
+	{
+		RangeTblEntry *expr = lfirst_node(RangeTblEntry, lc);
+
+		switch (expr->rtekind)
+		{
+			case RTE_RELATION:
+				JUMBLE_FIELD(relid);
+				break;
+			case RTE_CTE:
+				JUMBLE_STRING(ctename);
+				break;
+			default:
+
+				/*
+				* Ignore other targets, the jumble includes something identifying
+				* about them already
+				*/
+				break;
+		}
+	}
+}
+
 /*
  * Custom query jumble function for RangeTblEntry.eref.
  */
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 8b22c30559b..0309566f602 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -37,6 +37,7 @@
 #ifdef OPTIMIZER_DEBUG
 #include "nodes/print.h"
 #endif
+#include "nodes/queryjumble.h"
 #include "nodes/supportnodes.h"
 #include "optimizer/appendinfo.h"
 #include "optimizer/clauses.h"
@@ -578,6 +579,16 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	Assert(glob->finalrowmarks == NIL);
 	Assert(glob->resultRelations == NIL);
 	Assert(glob->appendRelations == NIL);
+
+	/*
+	 * Initialize plan identifier jumble if needed
+	 *
+	 * Note the actual jumbling is done in the tree walk in
+	 * set_plan_references
+	 */
+	if (IsPlanIdEnabled())
+		glob->plan_jumble_state = InitializeJumbleState(false);
+
 	top_plan = set_plan_references(root, top_plan);
 	/* ... and the subplans (both regular subplans and initplans) */
 	Assert(list_length(glob->subplans) == list_length(glob->subroots));
@@ -645,6 +656,14 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 			result->jitFlags |= PGJIT_DEFORM;
 	}
 
+	if (IsPlanIdEnabled())
+	{
+		JumbleRangeTable(glob->plan_jumble_state, glob->finalrtable);
+		result->planId = HashJumbleState(glob->plan_jumble_state);
+		pfree(glob->plan_jumble_state->jumble);
+		pfree(glob->plan_jumble_state);
+	}
+
 	/* Allow plugins to take control before we discard "glob" */
 	if (planner_shutdown_hook)
 		(*planner_shutdown_hook) (glob, parse, query_string, result);
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index cd7ea1e6b58..947bd980c66 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -19,6 +19,7 @@
 #include "catalog/pg_type.h"
 #include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
+#include "nodes/queryjumble.h"
 #include "optimizer/optimizer.h"
 #include "optimizer/pathnode.h"
 #include "optimizer/planmain.h"
@@ -1338,6 +1339,14 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 	plan->lefttree = set_plan_refs(root, plan->lefttree, rtoffset);
 	plan->righttree = set_plan_refs(root, plan->righttree, rtoffset);
 
+	/*
+	 * If enabled, append significant information to the plan identifier
+	 * jumble (we do this here since we're already walking the tree in a
+	 * near-final state)
+	 */
+	if (IsPlanIdEnabled())
+		JumbleNode(root->glob->plan_jumble_state, (Node *) plan);
+
 	return plan;
 }
 
diff --git a/src/backend/postmaster/launch_backend.c b/src/backend/postmaster/launch_backend.c
index 98f7c4848c9..b64c88db200 100644
--- a/src/backend/postmaster/launch_backend.c
+++ b/src/backend/postmaster/launch_backend.c
@@ -118,6 +118,7 @@ typedef struct
 	bool		redirection_done;
 	bool		IsBinaryUpgrade;
 	bool		query_id_enabled;
+	bool		plan_id_enabled;
 	int			max_safe_fds;
 	int			MaxBackends;
 	int			num_pmchild_slots;
@@ -757,6 +758,7 @@ save_backend_variables(BackendParameters *param,
 	param->redirection_done = redirection_done;
 	param->IsBinaryUpgrade = IsBinaryUpgrade;
 	param->query_id_enabled = query_id_enabled;
+	param->plan_id_enabled = plan_id_enabled;
 	param->max_safe_fds = max_safe_fds;
 
 	param->MaxBackends = MaxBackends;
@@ -1019,6 +1021,7 @@ restore_backend_variables(BackendParameters *param)
 	redirection_done = param->redirection_done;
 	IsBinaryUpgrade = param->IsBinaryUpgrade;
 	query_id_enabled = param->query_id_enabled;
+	plan_id_enabled = param->plan_id_enabled;
 	max_safe_fds = param->max_safe_fds;
 
 	MaxBackends = param->MaxBackends;
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index ef6fffe60b9..854c9f8bffd 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -351,7 +351,7 @@ pg_stat_get_progress_info(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_activity(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_ACTIVITY_COLS	31
+#define PG_STAT_GET_ACTIVITY_COLS	32
 	int			num_backends = pgstat_fetch_stat_numbackends();
 	int			curr_backend;
 	int			pid = PG_ARGISNULL(0) ? -1 : PG_GETARG_INT32(0);
@@ -665,6 +665,10 @@ pg_stat_get_activity(PG_FUNCTION_ARGS)
 				nulls[30] = true;
 			else
 				values[30] = Int64GetDatum(beentry->st_query_id);
+			if (beentry->st_plan_id == INT64CONST(0))
+				nulls[31] = true;
+			else
+				values[31] = UInt64GetDatum(beentry->st_plan_id);
 		}
 		else
 		{
@@ -694,6 +698,7 @@ pg_stat_get_activity(PG_FUNCTION_ARGS)
 			nulls[28] = true;
 			nulls[29] = true;
 			nulls[30] = true;
+			nulls[31] = true;
 		}
 
 		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls);
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index 3b9d8349078..d9f5cf31c34 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -462,6 +462,13 @@
   check_hook => 'check_commit_ts_buffers',
 },
 
+{ name => 'compute_plan_id', type => 'enum', context => 'PGC_SUSET', group => 'STATS_MONITORING',
+  short_desc => 'Enables in-core computation of plan identifiers.',
+  variable => 'compute_plan_id',
+  boot_val => 'COMPUTE_PLAN_ID_AUTO',
+  options => 'compute_plan_id_options',
+},
+
 { name => 'compute_query_id', type => 'enum', context => 'PGC_SUSET', group => 'STATS_MONITORING',
   short_desc => 'Enables in-core computation of query identifiers.',
   variable => 'compute_query_id',
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index f87b558c2c6..3a5d7ee32ab 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -318,6 +318,24 @@ static const struct config_enum_entry compute_query_id_options[] = {
 	{NULL, 0, false}
 };
 
+/*
+ * Although only "on" and "off" are documented, we accept
+ * all the likely variants of "on" and "off".
+ */
+static const struct config_enum_entry compute_plan_id_options[] = {
+	{"auto", COMPUTE_PLAN_ID_AUTO, false},
+	{"regress", COMPUTE_PLAN_ID_REGRESS, false},
+	{"on", COMPUTE_PLAN_ID_ON, false},
+	{"off", COMPUTE_PLAN_ID_OFF, false},
+	{"true", COMPUTE_PLAN_ID_ON, true},
+	{"false", COMPUTE_PLAN_ID_OFF, true},
+	{"yes", COMPUTE_PLAN_ID_ON, true},
+	{"no", COMPUTE_PLAN_ID_OFF, true},
+	{"1", COMPUTE_PLAN_ID_ON, true},
+	{"0", COMPUTE_PLAN_ID_OFF, true},
+	{NULL, 0, false}
+};
+
 /*
  * Although only "on", "off", and "partition" are documented, we
  * accept all the likely variants of "on" and "off".
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index dc9e2255f8a..3d0e42f083b 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -674,6 +674,7 @@
 # - Monitoring -
 
 #compute_query_id = auto
+#compute_plan_id = auto
 #log_statement_stats = off
 #log_parser_stats = off
 #log_planner_stats = off
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index fd9448ec7b9..2bf284e2732 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5653,9 +5653,9 @@
   proname => 'pg_stat_get_activity', prorows => '100', proisstrict => 'f',
   proretset => 't', provolatile => 's', proparallel => 'r',
   prorettype => 'record', proargtypes => 'int4',
-  proallargtypes => '{int4,oid,int4,oid,text,text,text,text,text,timestamptz,timestamptz,timestamptz,timestamptz,inet,text,int4,xid,xid,text,bool,text,text,int4,text,numeric,text,bool,text,bool,bool,int4,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{pid,datid,pid,usesysid,application_name,state,query,wait_event_type,wait_event,xact_start,query_start,backend_start,state_change,client_addr,client_hostname,client_port,backend_xid,backend_xmin,backend_type,ssl,sslversion,sslcipher,sslbits,ssl_client_dn,ssl_client_serial,ssl_issuer_dn,gss_auth,gss_princ,gss_enc,gss_delegation,leader_pid,query_id}',
+  proallargtypes => '{int4,oid,int4,oid,text,text,text,text,text,timestamptz,timestamptz,timestamptz,timestamptz,inet,text,int4,xid,xid,text,bool,text,text,int4,text,numeric,text,bool,text,bool,bool,int4,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{pid,datid,pid,usesysid,application_name,state,query,wait_event_type,wait_event,xact_start,query_start,backend_start,state_change,client_addr,client_hostname,client_port,backend_xid,backend_xmin,backend_type,ssl,sslversion,sslcipher,sslbits,ssl_client_dn,ssl_client_serial,ssl_issuer_dn,gss_auth,gss_princ,gss_enc,gss_delegation,leader_pid,query_id,plan_id}',
   prosrc => 'pg_stat_get_activity' },
 { oid => '6318', descr => 'describe wait events',
   proname => 'pg_get_wait_events', procost => '10', prorows => '250',
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index b5ff456ef7f..c98f7b97283 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -189,6 +189,9 @@ typedef struct PlannerGlobal
 	/* extension state */
 	void	  **extension_state pg_node_attr(read_write_ignore);
 	int			extension_state_allocated;
+
+	/* optional jumble state for plan identifier calculation */
+	struct JumbleState *plan_jumble_state pg_node_attr(read_write_ignore);
 } PlannerGlobal;
 
 /* macro for fetching the Plan associated with a SubPlan node */
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index c4393a94321..a5a2ccea1f7 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -70,7 +70,7 @@ typedef struct PlannedStmt
 	/* query identifier (copied from Query) */
 	int64		queryId;
 
-	/* plan identifier (can be set by plugins) */
+	/* plan identifier (set when compute_plan_id is enabled or by plugins) */
 	int64		planId;
 
 	/* origin of plan */
@@ -184,7 +184,7 @@ typedef struct PlannedStmt
  */
 typedef struct Plan
 {
-	pg_node_attr(abstract, no_equal, no_query_jumble)
+	pg_node_attr(abstract, no_equal)
 
 	NodeTag		type;
 
@@ -192,19 +192,19 @@ typedef struct Plan
 	 * estimated execution costs for plan (see costsize.c for more info)
 	 */
 	/* count of disabled nodes */
-	int			disabled_nodes;
+	int			disabled_nodes pg_node_attr(query_jumble_ignore);
 	/* cost expended before fetching any tuples */
-	Cost		startup_cost;
+	Cost		startup_cost pg_node_attr(query_jumble_ignore);
 	/* total cost (assuming all tuples fetched) */
-	Cost		total_cost;
+	Cost		total_cost pg_node_attr(query_jumble_ignore);
 
 	/*
 	 * planner's estimate of result size of this plan step
 	 */
 	/* number of rows plan is expected to emit */
-	Cardinality plan_rows;
+	Cardinality plan_rows pg_node_attr(query_jumble_ignore);
 	/* average row width in bytes */
-	int			plan_width;
+	int			plan_width pg_node_attr(query_jumble_ignore);
 
 	/*
 	 * information needed for parallel query
@@ -230,10 +230,10 @@ typedef struct Plan
 	/* implicitly-ANDed qual conditions */
 	List	   *qual;
 	/* input plan tree(s) */
-	struct Plan *lefttree;
-	struct Plan *righttree;
+	struct Plan *lefttree pg_node_attr(query_jumble_ignore);
+	struct Plan *righttree pg_node_attr(query_jumble_ignore);
 	/* Init Plan nodes (un-correlated expr subselects) */
-	List	   *initPlan;
+	List	   *initPlan pg_node_attr(query_jumble_ignore);
 
 	/*
 	 * Information for management of parameter-change-driven rescanning
@@ -351,7 +351,7 @@ typedef struct ModifyTable
 	/* per-target-table RETURNING tlists */
 	List	   *returningLists;
 	/* per-target-table FDW private data lists */
-	List	   *fdwPrivLists;
+	List	   *fdwPrivLists pg_node_attr(query_jumble_ignore);
 	/* indices of FDW DM plans */
 	Bitmapset  *fdwDirectModifyPlans;
 	/* PlanRowMarks (non-locking only) */
@@ -390,7 +390,7 @@ typedef struct Append
 	Plan		plan;
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
-	List	   *appendplans;
+	List	   *appendplans pg_node_attr(query_jumble_ignore);
 	/* # of asynchronous plans */
 	int			nasyncplans;
 
@@ -420,7 +420,7 @@ typedef struct MergeAppend
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
 
-	List	   *mergeplans;
+	List	   *mergeplans pg_node_attr(query_jumble_ignore);
 
 	/* these fields are just like the sort-key info in struct Sort: */
 
@@ -475,7 +475,7 @@ typedef struct RecursiveUnion
 	Oid		   *dupCollations pg_node_attr(array_size(numCols));
 
 	/* estimated number of groups in input */
-	Cardinality numGroups;
+	long		numGroups pg_node_attr(query_jumble_ignore);
 } RecursiveUnion;
 
 /* ----------------
@@ -489,7 +489,7 @@ typedef struct RecursiveUnion
 typedef struct BitmapAnd
 {
 	Plan		plan;
-	List	   *bitmapplans;
+	List	   *bitmapplans pg_node_attr(query_jumble_ignore);
 } BitmapAnd;
 
 /* ----------------
@@ -504,7 +504,7 @@ typedef struct BitmapOr
 {
 	Plan		plan;
 	bool		isshared;
-	List	   *bitmapplans;
+	List	   *bitmapplans pg_node_attr(query_jumble_ignore);
 } BitmapOr;
 
 /*
@@ -752,7 +752,7 @@ typedef enum SubqueryScanStatus
 typedef struct SubqueryScan
 {
 	Scan		scan;
-	Plan	   *subplan;
+	Plan	   *subplan pg_node_attr(query_jumble_ignore);
 	SubqueryScanStatus scanstatus;
 } SubqueryScan;
 
@@ -880,7 +880,7 @@ typedef struct ForeignScan
 	/* expressions that FDW may evaluate */
 	List	   *fdw_exprs;
 	/* private data for FDW */
-	List	   *fdw_private;
+	List	   *fdw_private pg_node_attr(query_jumble_ignore);
 	/* optional tlist describing scan tuple */
 	List	   *fdw_scan_tlist;
 	/* original quals not in scan.plan.qual */
@@ -918,7 +918,7 @@ typedef struct CustomScan
 	/* expressions that custom code may evaluate */
 	List	   *custom_exprs;
 	/* private data for custom code */
-	List	   *custom_private;
+	List	   *custom_private pg_node_attr(query_jumble_ignore);
 	/* optional tlist describing scan tuple */
 	List	   *custom_scan_tlist;
 	/* RTIs generated by this scan */
@@ -929,7 +929,7 @@ typedef struct CustomScan
 	 * static table of callback functions.  So we don't copy the table itself,
 	 * just reference the original one.
 	 */
-	const struct CustomScanMethods *methods;
+	const struct CustomScanMethods *methods pg_node_attr(query_jumble_ignore);
 } CustomScan;
 
 /*
@@ -991,7 +991,7 @@ typedef struct NestLoop
 
 typedef struct NestLoopParam
 {
-	pg_node_attr(no_equal, no_query_jumble)
+	pg_node_attr(no_equal)
 
 	NodeTag		type;
 	/* number of the PARAM_EXEC Param to set */
@@ -1099,7 +1099,7 @@ typedef struct Memoize
 	 * The maximum number of entries that the planner expects will fit in the
 	 * cache, or 0 if unknown
 	 */
-	uint32		est_entries;
+	uint32		est_entries pg_node_attr(query_jumble_ignore);
 
 	/* paramids from param_exprs */
 	Bitmapset  *keyparamids;
@@ -1206,7 +1206,7 @@ typedef struct Agg
 	Oid		   *grpCollations pg_node_attr(array_size(numCols));
 
 	/* estimated number of groups in input */
-	Cardinality numGroups;
+	long		numGroups pg_node_attr(query_jumble_ignore);
 
 	/* for pass-by-ref transition data */
 	uint64		transitionSpace;
@@ -1415,7 +1415,7 @@ typedef struct Hash
 	bool		skewInherit;
 	/* all other info is in the parent HashJoin node */
 	/* estimate total rows if parallel_aware */
-	Cardinality rows_total;
+	Cardinality rows_total pg_node_attr(query_jumble_ignore);
 } Hash;
 
 /* ----------------
@@ -1446,7 +1446,7 @@ typedef struct SetOp
 	bool	   *cmpNullsFirst pg_node_attr(array_size(numCols));
 
 	/* estimated number of groups in left input */
-	Cardinality numGroups;
+	long		numGroups pg_node_attr(query_jumble_ignore);
 } SetOp;
 
 /* ----------------
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index 1b4436f2ff6..4ccba6bcd03 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -1123,8 +1123,8 @@ typedef struct SubPlan
 	List	   *parParam;		/* indices of input Params from parent plan */
 	List	   *args;			/* exprs to pass as parParam values */
 	/* Estimated execution costs: */
-	Cost		startup_cost;	/* one-time setup cost */
-	Cost		per_call_cost;	/* cost for each subplan evaluation */
+	Cost		startup_cost pg_node_attr(query_jumble_ignore);	/* one-time setup cost */
+	Cost		per_call_cost pg_node_attr(query_jumble_ignore);	/* cost for each subplan evaluation */
 } SubPlan;
 
 /*
diff --git a/src/include/nodes/queryjumble.h b/src/include/nodes/queryjumble.h
index dcb36dcb44f..aeb20aa0587 100644
--- a/src/include/nodes/queryjumble.h
+++ b/src/include/nodes/queryjumble.h
@@ -15,6 +15,7 @@
 #define QUERYJUMBLE_H
 
 #include "nodes/parsenodes.h"
+#include "nodes/pathnodes.h"
 
 /*
  * Struct for tracking locations/lengths of constants during normalization
@@ -86,15 +87,26 @@ enum ComputeQueryIdType
 	COMPUTE_QUERY_ID_REGRESS,
 };
 
+/* Values for the compute_plan_id GUC */
+enum ComputePlanIdType
+{
+	COMPUTE_PLAN_ID_OFF,
+	COMPUTE_PLAN_ID_ON,
+	COMPUTE_PLAN_ID_AUTO,
+	COMPUTE_PLAN_ID_REGRESS,
+};
+
 /* GUC parameters */
 extern PGDLLIMPORT int compute_query_id;
-
+extern PGDLLIMPORT int compute_plan_id;
 
 extern const char *CleanQuerytext(const char *query, int *location, int *len);
 extern JumbleState *JumbleQuery(Query *query);
 extern void EnableQueryId(void);
+extern void EnablePlanId(void);
 
 extern PGDLLIMPORT bool query_id_enabled;
+extern PGDLLIMPORT bool plan_id_enabled;
 
 /*
  * Returns whether query identifier computation has been enabled, either
@@ -110,4 +122,25 @@ IsQueryIdEnabled(void)
 	return query_id_enabled;
 }
 
+/*
+ * Returns whether plan identifier computation has been enabled, either
+ * directly in the GUC or by a module when the setting is 'auto'.
+ */
+static inline bool
+IsPlanIdEnabled(void)
+{
+	if (compute_plan_id == COMPUTE_PLAN_ID_OFF)
+		return false;
+	if (compute_plan_id == COMPUTE_PLAN_ID_ON)
+		return true;
+	return plan_id_enabled;
+}
+
+/* Functions called for plan jumbling or extensions doing their own jumbling */
+extern JumbleState *InitializeJumbleState(bool record_clocations);
+//extern void AppendJumble(JumbleState *jstate, const unsigned char *item, Size size);
+extern void JumbleRangeTable(JumbleState *jstate, List *rtable);
+extern void JumbleNode(JumbleState *jstate, Node *node);
+extern uint64 HashJumbleState(JumbleState *jstate);
+
 #endif							/* QUERYJUMBLE_H */
diff --git a/src/include/utils/backend_status.h b/src/include/utils/backend_status.h
index 3016501ac05..45f00eb93a8 100644
--- a/src/include/utils/backend_status.h
+++ b/src/include/utils/backend_status.h
@@ -172,7 +172,7 @@ typedef struct PgBackendStatus
 	/* query identifier, optionally computed using post_parse_analyze_hook */
 	int64		st_query_id;
 
-	/* plan identifier, optionally computed using planner_hook */
+	/* plan identifier, optionally computed after planning */
 	int64		st_plan_id;
 } PgBackendStatus;
 
diff --git a/src/test/regress/expected/explain.out b/src/test/regress/expected/explain.out
index 7c1f26b182c..7ac025cca84 100644
--- a/src/test/regress/expected/explain.out
+++ b/src/test/regress/expected/explain.out
@@ -743,6 +743,17 @@ select explain_filter('explain (verbose) create table test_ctas as select 1');
  Query Identifier: N
 (3 rows)
 
+-- Test compute_plan_id
+set compute_plan_id = on;
+select explain_filter('explain (verbose) select * from int8_tbl i8');
+                         explain_filter                         
+----------------------------------------------------------------
+ Seq Scan on public.int8_tbl i8  (cost=N.N..N.N rows=N width=N)
+   Output: q1, q2
+ Query Identifier: N
+ Plan Identifier: N
+(4 rows)
+
 -- Test SERIALIZE option
 select explain_filter('explain (analyze,buffers off,serialize) select * from int8_tbl i8');
                                          explain_filter                                          
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 4286c266e17..29af9bd2de9 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -1794,9 +1794,10 @@ pg_stat_activity| SELECT s.datid,
     s.backend_xid,
     s.backend_xmin,
     s.query_id,
+	s.plan_id,
     s.query,
     s.backend_type
-   FROM ((pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id)
+   FROM ((pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id, plan_id)
      LEFT JOIN pg_database d ON ((s.datid = d.oid)))
      LEFT JOIN pg_authid u ON ((s.usesysid = u.oid)));
 pg_stat_all_indexes| SELECT c.oid AS relid,
@@ -1926,7 +1927,7 @@ pg_stat_gssapi| SELECT pid,
     gss_princ AS principal,
     gss_enc AS encrypted,
     gss_delegation AS credentials_delegated
-   FROM pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id)
+   FROM pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id, plan_id)
   WHERE (client_port IS NOT NULL);
 pg_stat_io| SELECT backend_type,
     object,
@@ -2156,7 +2157,7 @@ pg_stat_replication| SELECT s.pid,
     w.sync_priority,
     w.sync_state,
     w.reply_time
-   FROM ((pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id)
+   FROM ((pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id, plan_id)
      JOIN pg_stat_get_wal_senders() w(pid, state, sent_lsn, write_lsn, flush_lsn, replay_lsn, write_lag, flush_lag, replay_lag, sync_priority, sync_state, reply_time) ON ((s.pid = w.pid)))
      LEFT JOIN pg_authid u ON ((s.usesysid = u.oid)));
 pg_stat_replication_slots| SELECT s.slot_name,
@@ -2193,7 +2194,7 @@ pg_stat_ssl| SELECT pid,
     ssl_client_dn AS client_dn,
     ssl_client_serial AS client_serial,
     ssl_issuer_dn AS issuer_dn
-   FROM pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id)
+   FROM pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id, plan_id)
   WHERE (client_port IS NOT NULL);
 pg_stat_subscription| SELECT su.oid AS subid,
     su.subname,
diff --git a/src/test/regress/sql/explain.sql b/src/test/regress/sql/explain.sql
index ebdab42604b..0449f05c1e1 100644
--- a/src/test/regress/sql/explain.sql
+++ b/src/test/regress/sql/explain.sql
@@ -173,6 +173,10 @@ select explain_filter('explain (verbose) select * from int8_tbl i8');
 select explain_filter('explain (verbose) declare test_cur cursor for select * from int8_tbl');
 select explain_filter('explain (verbose) create table test_ctas as select 1');
 
+-- Test compute_plan_id
+set compute_plan_id = on;
+select explain_filter('explain (verbose) select * from int8_tbl i8');
+
 -- Test SERIALIZE option
 select explain_filter('explain (analyze,buffers off,serialize) select * from int8_tbl i8');
 select explain_filter('explain (analyze,serialize text,buffers,timing off) select * from int8_tbl i8');

base-commit: b39013b7b1b116b5d9be51f0919b472b58b3a28d
-- 
2.43.0

---

From: Андрей Казачков<[email-masked]>
Date: Thu, 25 Dec 2025 17:33:11 +0300

Dear PostgreSQL Hackers,

I’ve been testing the proposed v5 plan id work and found out instability of

computing the plan identifier after feeding different query texts that

produces the same physical plan trees but with different plan ids. The main

pattern regards with fields of Plan node structures that depend on positions of

RTEs in a RTE list.

For your convenience, I’ve attached minimal reproducible examples that

demonstrate this pattern. Both are based on the v6-0001 patch, which

incorporates v5-0001..v5-0003 changes and fixes jumbling empty arrays.

```

create table foo1 (num int);

create table foo2 (num int);

insert into foo1 (num)

select *

from generate_series(1, 1000);

insert into foo2 (num)

select *

from generate_series(1, 10);

analyze foo1;

analyze foo2;

```

The first example changes join order between two tables:

```

select 1 from foo1 join foo2 on foo1.num = foo2.num;

QUERY PLAN

-------------------------------------

Hash Join

Output: 1

Hash Cond: (foo1.num = foo2.num)

->  Seq Scan on public.foo1

Output: foo1.num

->  Hash

Output: foo2.num

->  Seq Scan on public.foo2

Output: foo2.num

Plan Identifier: 538643160186222168

select 1 from foo2 join foo1 on foo1.num = foo2.num;

QUERY PLAN

--------------------------------------

Hash Join

Output: 1

Hash Cond: (foo1.num = foo2.num)

->  Seq Scan on public.foo1

Output: foo1.num

->  Hash

Output: foo2.num

->  Seq Scan on public.foo2

Output: foo2.num

Plan Identifier: -953143034841089498

```

Here the reordering of relations in the JOIN operator changes their order in

the RTE list, which in turn changes RTE position-sensitive fields like `varno`

in Vars, and those differences leak into jumbling.

The second example assumes transformation of some table expressions to a

new form like CTE-inlining or completely its eliminating. Let's see example:

```

WITH foo_cte as (

SELECT num FROM foo1)

select * from foo_cte;

QUERY PLAN

--------------------------------------

Seq Scan on public.foo1

Output: foo1.num

Plan Identifier: 3494394630757173099

select * from foo1;

QUERY PLAN

--------------------------------------

Seq Scan on public.foo1

Output: num

Plan Identifier: 8116143677260771228

```

In the example with a CTE, RTE list contains the outdated subquery item and the

relation one, whereas without a CTE we have just the relation item. Although

the CTE is inlined, its RTE is not removed from the rtable; as a result,

position-sensitive fields (such as `varno` for Vars) differ from the no-CTE

case.

A possible way to address it during jumbling process:

1. Remove/skip unused RTE list elements.

2. Sort active RTE list elements by some stable criteria.

3. Adjust fields referring to active RTEs.

But the main challenge is identifying all “position-sensitive” fields across

node types efficiently and maintainably. I’d happy to see your feedback on

this issue.

Additionally, I noticed that the `location` field is being jumbled for several

structures (for example, `Var`). Since it’s only a token location, I believe we

should not include it in the final plan id value.

--

Sincerely,

Andrey Kazachkov

From 3d40912f2cba7ba2c2edae06a4a3d016607a1513 Mon Sep 17 00:00:00 2001
From: Andrey Kazachkov <[email-masked]>
Date: Tue, 16 Dec 2025 19:52:39 +0300
Subject: [PATCH v6] Add plan_id support to the core.

---
 src/backend/nodes/queryjumblefuncs.c          | 183 +++++++++++-------
 23 files changed, 374 insertions(+), 109 deletions(-)

 
+     <row>
 
 /*
 
 	if (ExecutorStart_hook)
 
index ffc230af427..a0d28827269 100644
 
 /* GUC parameters */
 
 /*

 
-	jstate = InitJumble();
-
 
 	/*
 
 /*
- * InitJumble

 
 	return jstate;
 }
 
-/*
- * DoJumble

 /*
 
@@ -544,6 +549,16 @@ do { \
@@ -557,8 +572,8 @@ do { \

-static void
-_jumbleNode(JumbleState *jstate, Node *node)
+void
+JumbleNode(JumbleState *jstate, Node *node)
 {
 	Node	   *expr = node;
 #ifdef USE_ASSERT_CHECKING
@@ -612,7 +627,7 @@ _jumbleList(JumbleState *jstate, Node *node)
 	{
 		case T_List:
 			foreach(l, expr)
-				_jumbleNode(jstate, lfirst(l));
+				JumbleNode(jstate, lfirst(l));
 			break;
 		case T_IntList:
 			foreach(l, expr)
@@ -668,7 +683,7 @@ _jumbleElements(JumbleState *jstate, List *elements, Node *node)
 
 	if (!normalize_list)
 	{
 
@@ -758,6 +773,40 @@ _jumbleVariableSetStmt(JumbleState *jstate, Node *node)
 	JUMBLE_LOCATION(location);
 }
 
+/*
 
+	if (IsPlanIdEnabled())
+	{
 
+	/*

 

 
+/*
 
 #compute_query_id = auto
+#compute_plan_id = auto

 
 	/* origin of plan */
 
 	NodeTag		type;

 	/*
 
 	/*
 
 	/*

-	List	   *mergeplans;

 
 
 /* ----------------
 
 /* ----------------
 
 /*

 /*

 	NodeTag		type;

 
 
 /* ----------------

 /* ----------------
 
 /*

 /*

 
 
 /*
 
+/*

 
+-- Test compute_plan_id
 
+-- Test compute_plan_id

---

From: Michael Paquier <[email-masked]>
Date: Fri, 26 Dec 2025 08:01:41 +0900

> pattern regards with fields of Plan node structures that depend on positions of

FWIW, I don't think that we have a clear agreement about what would be
a good enough ID for plan trees, as it may be also a per-vendor
computation that fills specific user requirements.

+    /*

Now, looking at this block of code, I am wondering if you don't have a
point here even without compute_plan_id..  Could there be merit in
showing this information for an EXPLAIN if this field is not zero?
With EXPLAIN being pluggable in a hook, I doubt that it matters much,
but I am wondering if providing this information could make the work
of some extensions easier.
--
Michael

---

From: Lukas Fittl <[email-masked]>
Date: Thu, 19 Mar 2026 00:15:07 -0700

> of some extensions easier.

I missed this at the time, but happened to run across this by coincidence.

Consider this a late +1 on the idea, i.e. I do think that emitting the
plan ID as "plan identifier" in EXPLAIN seems reasonable when a plugin
sets it - the cost is negligible, and it'd make it easier to work with
extensions like pg_stat_plans.

Thanks,
Lukas

--
Lukas Fittl

---

From: Sami Imseih <[email-masked]>
Date: Thu, 19 Mar 2026 10:13:50 -0500

> extensions like pg_stat_plans.

FWIW, the per plan hooks introduced in 4fd02bf7cf94c can allow an extension
to emit the plan identifier in EXPLAIN as well.

--
Sami Imseih
Amazon Web Services (AWS)