From d8c242865ddb8e49a209197c9e32575461fbc6bf Mon Sep 17 00:00:00 2001
From: Robert Haas <rhaas@postgresql.org>
Date: Fri, 27 Feb 2026 16:58:14 -0500
Subject: [PATCH v24 1/2] Add pg_stash_advice contrib module.

This module allows plan advice strings to be provided automatically
from an in-memory advice stash. Advice stashes are stored in dynamic
shared memory and must be recreated and repopulated after a server
restart. If pg_stash_advice.stash_name is set to the name of an advice
stash, and if query identifiers are enabled, the query identifier
for each query will be looked up in the advice stash and the
associated advice string, if any, will be used each time that query
is planned.

Reviewed-by: Lukas Fittl <lukas@fittl.com>
Reviewed-by: Alexandra Wang <alexandra.wang.oss@gmail.com>
Reviewed-by: David G. Johnston <david.g.johnston@gmail.com>
Reviewed-by: Jakub Wartak <jakub.wartak@enterprisedb.com>
---
 contrib/Makefile                              |   1 +
 contrib/meson.build                           |   1 +
 contrib/pg_stash_advice/Makefile              |  27 +
 .../expected/pg_stash_advice.out              | 334 ++++++++++
 contrib/pg_stash_advice/meson.build           |  36 ++
 .../pg_stash_advice/pg_stash_advice--1.0.sql  |  43 ++
 contrib/pg_stash_advice/pg_stash_advice.c     | 605 ++++++++++++++++++
 .../pg_stash_advice/pg_stash_advice.control   |   5 +
 contrib/pg_stash_advice/pg_stash_advice.h     |  99 +++
 .../pg_stash_advice/sql/pg_stash_advice.sql   | 150 +++++
 contrib/pg_stash_advice/stashfuncs.c          | 307 +++++++++
 doc/src/sgml/contrib.sgml                     |   1 +
 doc/src/sgml/filelist.sgml                    |   1 +
 doc/src/sgml/pgstashadvice.sgml               | 216 +++++++
 src/tools/pgindent/typedefs.list              |   6 +
 15 files changed, 1832 insertions(+)
 create mode 100644 contrib/pg_stash_advice/Makefile
 create mode 100644 contrib/pg_stash_advice/expected/pg_stash_advice.out
 create mode 100644 contrib/pg_stash_advice/meson.build
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice--1.0.sql
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.c
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.control
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.h
 create mode 100644 contrib/pg_stash_advice/sql/pg_stash_advice.sql
 create mode 100644 contrib/pg_stash_advice/stashfuncs.c
 create mode 100644 doc/src/sgml/pgstashadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index dd04c20acd2..7d91fe77db3 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -36,6 +36,7 @@ SUBDIRS = \
 		pg_overexplain \
 		pg_plan_advice \
 		pg_prewarm	\
+		pg_stash_advice	\
 		pg_stat_statements \
 		pg_surgery	\
 		pg_trgm		\
diff --git a/contrib/meson.build b/contrib/meson.build
index 5a752eac347..ebb7f83d8c5 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -51,6 +51,7 @@ subdir('pg_overexplain')
 subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
+subdir('pg_stash_advice')
 subdir('pg_stat_statements')
 subdir('pgstattuple')
 subdir('pg_surgery')
diff --git a/contrib/pg_stash_advice/Makefile b/contrib/pg_stash_advice/Makefile
new file mode 100644
index 00000000000..f5150e14e41
--- /dev/null
+++ b/contrib/pg_stash_advice/Makefile
@@ -0,0 +1,27 @@
+# contrib/pg_stash_advice/Makefile
+
+MODULE_big = pg_stash_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_stash_advice.o \
+	stashfuncs.o
+
+EXTENSION = pg_stash_advice
+DATA = pg_stash_advice--1.0.sql
+PGFILEDESC = "pg_stash_advice - store and automatically apply plan advice"
+
+REGRESS = pg_stash_advice
+EXTRA_INSTALL = contrib/pg_plan_advice
+
+ifdef USE_PGXS
+PG_CPPFLAGS = -I$(includedir_server)/extension
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+PG_CPPFLAGS = -I$(top_srcdir)/contrib/pg_plan_advice
+subdir = contrib/pg_stash_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice.out b/contrib/pg_stash_advice/expected/pg_stash_advice.out
new file mode 100644
index 00000000000..bafe1eba523
--- /dev/null
+++ b/contrib/pg_stash_advice/expected/pg_stash_advice.out
@@ -0,0 +1,334 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(d1 aa_dim1_pkey) /* matched */
+(13 rows)
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+(13 rows)
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           2
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+ stash_name | advice_string 
+------------+---------------
+(0 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+ERROR:  advice stash "no_such_stash" does not exist
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           1
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   | advice_string 
+---------------+---------------
+ regress_stash | SEQ_SCAN(d1)
+(1 row)
+
+-- Can't create a stash that already exists, or drop one that doesn't.
+SELECT pg_create_advice_stash('regress_stash');
+ERROR:  advice stash "regress_stash" already exists
+SELECT pg_drop_advice_stash('no_such_stash');
+ERROR:  advice stash "no_such_stash" does not exist
+-- Can't add to or remove from a stash that does not exist.
+SELECT pg_set_stashed_advice('no_such_stash', 1, 'SEQ_SCAN(t)');
+ERROR:  advice stash "no_such_stash" does not exist
+SELECT pg_set_stashed_advice('no_such_stash', 1, null);
+ERROR:  advice stash "no_such_stash" does not exist
+-- Can't use query ID 0.
+SELECT pg_set_stashed_advice('regress_stash', 0, 'SEQ_SCAN(t)');
+ERROR:  cannot set advice string for query ID 0
+-- Stash names must be non-empty, ASCII, and not too long, and must look
+-- like identifiers.
+SELECT pg_create_advice_stash('');
+ERROR:  advice stash name may not be zero length
+SELECT pg_create_advice_stash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
+ERROR:  advice stash names may not be longer than 63 bytes
+SELECT pg_create_advice_stash(E'caf\u00e9');
+ERROR:  advice stash name must not contain non-ASCII characters
+SELECT pg_create_advice_stash('   ');
+ERROR:  advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores
+SET pg_stash_advice.stash_name = 'café';
+ERROR:  invalid value for parameter "pg_stash_advice.stash_name": "café"
+DETAIL:  advice stash name must not contain non-ASCII characters
+SET pg_stash_advice.stash_name = '99bottles';
+ERROR:  invalid value for parameter "pg_stash_advice.stash_name": "99bottles"
+DETAIL:  advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
+SELECT pg_drop_advice_stash('regress_empty_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_stash_advice/meson.build b/contrib/pg_stash_advice/meson.build
new file mode 100644
index 00000000000..6655f9ab4f2
--- /dev/null
+++ b/contrib/pg_stash_advice/meson.build
@@ -0,0 +1,36 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_stash_advice_sources = files(
+  'pg_stash_advice.c',
+  'stashfuncs.c'
+)
+
+if host_system == 'windows'
+  pg_stash_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_stash_advice',
+    '--FILEDESC', 'pg_stash_advice - store and automatically apply plan advice',])
+endif
+
+pg_stash_advice = shared_module('pg_stash_advice',
+  pg_stash_advice_sources,
+  include_directories: [pg_plan_advice_inc, include_directories('.')],
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_stash_advice
+
+install_data(
+  'pg_stash_advice--1.0.sql',
+  'pg_stash_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_stash_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'pg_stash_advice',
+    ],
+  },
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice--1.0.sql b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
new file mode 100644
index 00000000000..88dedd8ef1b
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
@@ -0,0 +1,43 @@
+/* contrib/pg_stash_advice/pg_stash_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_stash_advice" to load this file. \quit
+
+CREATE FUNCTION pg_create_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_create_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_drop_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_drop_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_set_stashed_advice(stash_name text, query_id bigint,
+									  advice_string text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_set_stashed_advice'
+LANGUAGE C;
+
+CREATE FUNCTION pg_get_advice_stashes(
+	OUT stash_name text,
+	OUT num_entries bigint
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stashes'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_advice_stash_contents(
+	INOUT stash_name text,
+	OUT query_id bigint,
+	OUT advice_string text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stash_contents'
+LANGUAGE C;
+
+REVOKE ALL ON FUNCTION pg_create_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_drop_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stash_contents(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stashes() FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_set_stashed_advice(text, bigint, text) FROM PUBLIC;
diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
new file mode 100644
index 00000000000..15e7adf849b
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.c
@@ -0,0 +1,605 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_stash_advice.c
+ *	  core infrastructure for pg_stash_advice contrib module
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/pg_stash_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "common/string.h"
+#include "nodes/queryjumble.h"
+#include "pg_plan_advice.h"
+#include "pg_stash_advice.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+/* Shared memory hash table parameters */
+static dshash_parameters pgsa_stash_dshash_parameters = {
+	NAMEDATALEN,
+	sizeof(pgsa_stash),
+	dshash_strcmp,
+	dshash_strhash,
+	dshash_strcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+static dshash_parameters pgsa_entry_dshash_parameters = {
+	sizeof(pgsa_entry_key),
+	sizeof(pgsa_entry),
+	dshash_memcmp,
+	dshash_memhash,
+	dshash_memcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+/* GUC variable */
+static char *pg_stash_advice_stash_name = "";
+
+/* Shared memory pointers */
+pgsa_shared_state *pgsa_state;
+dsa_area *pgsa_dsa_area;
+dshash_table *pgsa_stash_dshash;
+dshash_table *pgsa_entry_dshash;
+
+/* Other global variables */
+static MemoryContext pg_stash_advice_mcxt;
+
+/* Function prototypes */
+static char *pgsa_advisor(PlannerGlobal *glob,
+						  Query *parse,
+						  const char *query_string,
+						  int cursorOptions,
+						  ExplainState *es);
+static bool pgsa_check_stash_name_guc(char **newval, void **extra,
+									  GucSource source);
+static void pgsa_init_shared_state(void *ptr, void *arg);
+static bool pgsa_is_identifier(char *str);
+
+/* Stash name -> stash ID hash table */
+#define SH_PREFIX pgsa_stash_name_table
+#define SH_ELEMENT_TYPE pgsa_stash_name
+#define SH_KEY_TYPE uint64
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64))
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE extern
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	void		(*add_advisor_fn) (pg_plan_advice_advisor_hook hook);
+
+	/* If compute_query_id = 'auto', we would like query IDs. */
+	EnableQueryId();
+
+	/* Define our GUCs. */
+	DefineCustomStringVariable("pg_stash_advice.stash_name",
+							   "Name of the advice stash to be used in this session.",
+							   NULL,
+							   &pg_stash_advice_stash_name,
+							   "",
+							   PGC_USERSET,
+							   0,
+							   pgsa_check_stash_name_guc,
+							   NULL,
+							   NULL);
+
+	MarkGUCPrefixReserved("pg_stash_advice");
+
+	/* Tell pg_plan_advice that we want to provide advice strings. */
+	add_advisor_fn =
+		load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor",
+							   true, NULL);
+	(*add_advisor_fn) (pgsa_advisor);
+}
+
+/*
+ * Get the advice string that has been configured for this query, if any,
+ * and return it. Otherwise, return NULL.
+ */
+static char *
+pgsa_advisor(PlannerGlobal *glob, Query *parse,
+			 const char *query_string, int cursorOptions,
+			 ExplainState *es)
+{
+	pgsa_entry_key key;
+	pgsa_entry *entry;
+	char	   *advice_string;
+	uint64		stash_id;
+
+	/*
+	 * Exit quickly if the stash name is empty or there's no query ID.
+	 */
+	if (pg_stash_advice_stash_name[0] == '\0' || parse->queryId == 0)
+		return NULL;
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/*
+	 * Translate pg_stash_advice.stash_name to an integer ID.
+	 *
+	 * pgsa_check_stash_name_guc() has already validated the advice stash
+	 * name, so we don't need to call pgsa_check_stash_name() here.
+	 */
+	stash_id = pgsa_lookup_stash_id(pg_stash_advice_stash_name);
+	if (stash_id == 0)
+		return NULL;
+
+	/*
+	 * Look up the advice string for the given stash ID + query ID.
+	 *
+	 * If we find an advice string, we copy it into the current memory
+	 * context, presumably short-lived, so that we can release the lock on the
+	 * dshash entry. pg_plan_advice only needs the value to remain allocated
+	 * long enough for it to be parsed, so this should be good enough.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = parse->queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, false);
+	if (entry == NULL)
+		return NULL;
+	if (entry->advice_string == InvalidDsaPointer)
+		advice_string = NULL;
+	else
+		advice_string = pstrdup(dsa_get_address(pgsa_dsa_area,
+												entry->advice_string));
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/* If we found an advice string, emit a debug message. */
+	if (advice_string != NULL)
+		elog(DEBUG2, "supplying automatic advice for stash \"%s\", query ID %" PRId64 ": %s",
+			 pg_stash_advice_stash_name, key.queryId, advice_string);
+
+	return advice_string;
+}
+
+/*
+ * Attach to various structures in dynamic shared memory.
+ *
+ * This function is designed to be resilient against errors. That is, if it
+ * fails partway through, it should be possible to call it again, repeat no
+ * work already completed, and potentially succeed or at least get further if
+ * whatever caused the previous failure has been corrected.
+ */
+void
+pgsa_attach(void)
+{
+	bool		found;
+	MemoryContext oldcontext;
+
+	/*
+	 * Create a memory context to make sure that any control structures
+	 * allocated in local memory are sufficiently persistent.
+	 */
+	if (pg_stash_advice_mcxt == NULL)
+		pg_stash_advice_mcxt = AllocSetContextCreate(TopMemoryContext,
+													 "pg_stash_advice",
+													 ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(pg_stash_advice_mcxt);
+
+	/* Attach to the fixed-size state object if not already done. */
+	if (pgsa_state == NULL)
+		pgsa_state = GetNamedDSMSegment("pg_stash_advice",
+										sizeof(pgsa_shared_state),
+										pgsa_init_shared_state,
+										&found, NULL);
+
+	/* Attach to the DSA area if not already done. */
+	if (pgsa_dsa_area == NULL)
+	{
+		dsa_handle	area_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		area_handle = pgsa_state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgsa_dsa_area = dsa_create(pgsa_state->dsa_tranche);
+			dsa_pin(pgsa_dsa_area);
+			pgsa_state->area = dsa_get_handle(pgsa_dsa_area);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_dsa_area = dsa_attach(area_handle);
+		}
+		dsa_pin_mapping(pgsa_dsa_area);
+	}
+
+	/* Attach to the stash_name->stash_id hash table if not already done. */
+	if (pgsa_stash_dshash == NULL)
+	{
+		dshash_table_handle stash_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_stash_dshash_parameters.tranche_id = pgsa_state->stash_tranche;
+		stash_handle = pgsa_state->stash_hash;
+		if (stash_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_stash_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  NULL);
+			pgsa_state->stash_hash =
+				dshash_get_hash_table_handle(pgsa_stash_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_stash_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  stash_handle, NULL);
+		}
+	}
+
+	/* Attach to the entry hash table if not already done. */
+	if (pgsa_entry_dshash == NULL)
+	{
+		dshash_table_handle entry_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_entry_dshash_parameters.tranche_id = pgsa_state->entry_tranche;
+		entry_handle = pgsa_state->entry_hash;
+		if (entry_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_entry_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  NULL);
+			pgsa_state->entry_hash =
+				dshash_get_hash_table_handle(pgsa_entry_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_entry_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  entry_handle, NULL);
+		}
+	}
+
+	/* Restore previous memory context. */
+	MemoryContextSwitchTo(oldcontext);
+}
+
+/*
+ * Check whether an advice stash name is legal, and signal an error if not.
+ *
+ * Keep this in sync with pgsa_check_stash_name_guc, below.
+ */
+void
+pgsa_check_stash_name(char *stash_name)
+{
+	/* Reject empty advice stash name. */
+	if (stash_name[0] == '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name may not be zero length"));
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash names may not be longer than %d bytes",
+					   NAMEDATALEN - 1));
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name must not contain non-ASCII characters"));
+
+	/*
+	 * Reject things that do not look like identifiers, since the ability to
+	 * create an advice stash with non-printable characters or weird symbols
+	 * in the name is not likely to be useful to anyone.
+	 */
+	if (!pgsa_is_identifier(stash_name))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores"));
+}
+
+/*
+ * As above, but for the GUC check_hook. We allow the empty string here,
+ * though, as equivalent to disabling the feature.
+ */
+static bool
+pgsa_check_stash_name_guc(char **newval, void **extra, GucSource source)
+{
+	char	   *stash_name = *newval;
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash names may not be longer than %d bytes",
+							NAMEDATALEN - 1);
+		return false;
+	}
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash name must not contain non-ASCII characters");
+		return false;
+	}
+
+	/*
+	 * Reject things that do not look like identifiers, since the ability to
+	 * create an advice stash with non-printable characters or weird symbols
+	 * in the name is not likely to be useful to anyone.
+	 */
+	if (!pgsa_is_identifier(stash_name))
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Create an advice stash.
+ */
+void
+pgsa_create_stash(char *stash_name)
+{
+	pgsa_stash *stash;
+	bool		found;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Create a stash with this name, unless one already exists. */
+	stash = dshash_find_or_insert(pgsa_stash_dshash, stash_name, &found);
+	if (found)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" already exists", stash_name));
+	stash->pgsa_stash_id = pgsa_state->next_stash_id++;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+}
+
+/*
+ * Remove any stored advice string for the given advice stash and query ID.
+ */
+void
+pgsa_clear_advice_string(char *stash_name, int64 queryId)
+{
+	pgsa_entry *entry;
+	pgsa_entry_key key;
+	uint64		stash_id;
+	dsa_pointer old_dp;
+
+	Assert(LWLockHeldByMe(&pgsa_state->lock));
+
+	/* Translate the stash name to an integer ID. */
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/*
+	 * Look for an existing entry, and free it. But, be sure to save the
+	 * pointer to the associated advice string, if any.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+		old_dp = InvalidDsaPointer;
+	else
+	{
+		old_dp = entry->advice_string;
+		dshash_delete_entry(pgsa_entry_dshash, entry);
+	}
+
+	/* Now we free the advice string as well, if there was one. */
+	if (old_dp != InvalidDsaPointer)
+		dsa_free(pgsa_dsa_area, old_dp);
+}
+
+/*
+ * Drop an advice stash.
+ */
+void
+pgsa_drop_stash(char *stash_name)
+{
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	dshash_seq_status iterator;
+	uint64		stash_id;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Remove the entry for this advice stash. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, true);
+	if (stash == NULL)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+	stash_id = stash->pgsa_stash_id;
+	dshash_delete_entry(pgsa_stash_dshash, stash);
+
+	/*
+	 * Now remove all the entries. Since pgsa_state->lock must be held at
+	 * least in shared mode to insert entries into pgsa_entry_dshash, it
+	 * doesn't matter whether we do this before or after deleting the entry
+	 * from pgsa_stash_dshash.
+	 */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		if (stash_id == entry->key.pgsa_stash_id)
+		{
+			if (entry->advice_string != InvalidDsaPointer)
+				dsa_free(pgsa_dsa_area, entry->advice_string);
+			dshash_delete_current(&iterator);
+		}
+	}
+	dshash_seq_term(&iterator);
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgsa_init_shared_state(void *ptr, void *arg)
+{
+	pgsa_shared_state *state = (pgsa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock,
+					 LWLockNewTrancheId("pg_stash_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_stash_advice_dsa");
+	state->stash_tranche = LWLockNewTrancheId("pg_stash_advice_stash");
+	state->entry_tranche = LWLockNewTrancheId("pg_stash_advice_entry");
+	state->next_stash_id = UINT64CONST(1);
+	state->area = DSA_HANDLE_INVALID;
+	state->stash_hash = DSHASH_HANDLE_INVALID;
+	state->entry_hash = DSHASH_HANDLE_INVALID;
+}
+
+/*
+ * Check whether a string looks like a valid identifier. It must contain only
+ * ASCII identifier characters, and must not begin with a digit.
+ */
+static bool
+pgsa_is_identifier(char *str)
+{
+	if (*str >= '0' && *str <= '9')
+		return false;
+
+	while (*str != '\0')
+	{
+		char		c = *str++;
+
+		if ((c < '0' || c > '9') && (c < 'a' || c > 'z') &&
+			(c < 'A' || c > 'Z') && c != '_')
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * Look up the integer ID that corresponds to the given stash name.
+ *
+ * Returns 0 if no such stash exists.
+ */
+uint64
+pgsa_lookup_stash_id(char *stash_name)
+{
+	pgsa_stash *stash;
+	uint64		stash_id;
+
+	/* Search the shared hash table. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, false);
+	if (stash == NULL)
+		return 0;
+	stash_id = stash->pgsa_stash_id;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+
+	return stash_id;
+}
+
+/*
+ * Store a new or updated advice string for the given advice stash and query ID.
+ */
+void
+pgsa_set_advice_string(char *stash_name, int64 queryId, char *advice_string)
+{
+	pgsa_entry *entry;
+	bool		found;
+	pgsa_entry_key key;
+	uint64		stash_id;
+	dsa_pointer new_dp;
+	dsa_pointer old_dp;
+
+	/*
+	 * The caller must hold our lock, at least in shared mode.  This is
+	 * important for two reasons.
+	 *
+	 * First, it holds off interrupts, so that we can't bail out of this code
+	 * after allocating DSA memory for the advice string and before storing
+	 * the resulting pointer somewhere that others can find it.
+	 *
+	 * Second, we need to avoid a race against pgsa_drop_stash(). That
+	 * function removes a stash_name->stash_id mapping and all the entries for
+	 * that stash_id. Without the lock, there's a race condition no matter
+	 * which of those things it does first, because as soon as we've looked up
+	 * the stash ID, that whole function can execute before we do the rest of
+	 * our work, which would result in us adding an entry for a stash that no
+	 * longer exists.
+	 */
+	Assert(LWLockHeldByMe(&pgsa_state->lock));
+
+	/* Look up the stash ID. */
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/* Allocate space for the advice string. */
+	new_dp = dsa_allocate(pgsa_dsa_area, strlen(advice_string) + 1);
+	strcpy(dsa_get_address(pgsa_dsa_area, new_dp), advice_string);
+
+	/* Attempt to insert an entry into the hash table. */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find_or_insert_extended(pgsa_entry_dshash, &key, &found,
+										   DSHASH_INSERT_NO_OOM);
+
+	/*
+	 * If it didn't work, bail out, being careful to free the shared memory
+	 * we've already allocated before, since error cleanup will not do so.
+	 */
+	if (entry == NULL)
+	{
+		dsa_free(pgsa_dsa_area, new_dp);
+		ereport(ERROR,
+				errcode(ERRCODE_OUT_OF_MEMORY),
+				errmsg("out of memory"),
+				errdetail("could not insert advice string into shared hash table"));
+	}
+
+	/* Update the entry and release the lock. */
+	old_dp = found ? entry->advice_string : InvalidDsaPointer;
+	entry->advice_string = new_dp;
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/*
+	 * We're not safe from leaks yet!
+	 *
+	 * There's now a pointer to new_dp in the entry that we just updated, but
+	 * that means that there's no longer anything pointing to old_dp.
+	 */
+	if (DsaPointerIsValid(old_dp))
+		dsa_free(pgsa_dsa_area, old_dp);
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice.control b/contrib/pg_stash_advice/pg_stash_advice.control
new file mode 100644
index 00000000000..4a0fff5c866
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.control
@@ -0,0 +1,5 @@
+# pg_stash_advice extension
+comment = 'store and automatically apply plan advice'
+default_version = '1.0'
+module_pathname = '$libdir/pg_stash_advice'
+relocatable = true
diff --git a/contrib/pg_stash_advice/pg_stash_advice.h b/contrib/pg_stash_advice/pg_stash_advice.h
new file mode 100644
index 00000000000..eeaa61e0f37
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.h
@@ -0,0 +1,99 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_stash_advice.h
+ *	  main header for pg_stash_advice contrib module
+ *
+ * This module allows plan advice strings (as used and generated by
+ * pg_plan_advice) to be "stashed" in dynamic shared memory and, from
+ * there, automatically be applied to queries as they are planned.
+ * You can create any number of advice stashes, each of which is
+ * identified by a human-readable, ASCII identifier, and each of them is
+ * essentially a query ID -> advice_string mapping.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/pg_stash_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_STASH_ADVICE_H
+#define PG_STASH_ADVICE_H
+
+#include "lib/dshash.h"
+#include "storage/lwlock.h"
+
+/*
+ * The key that we use to find a particular stash entry.
+ */
+typedef struct pgsa_entry_key
+{
+	uint64		pgsa_stash_id;
+	int64		queryId;
+} pgsa_entry_key;
+
+/*
+ * A single stash entry.
+ */
+typedef struct pgsa_entry
+{
+	pgsa_entry_key key;
+	dsa_pointer advice_string;
+} pgsa_entry;
+
+/*
+ * The stash itself is just a mapping from a name to a stash ID.
+ */
+typedef struct pgsa_stash
+{
+	char		name[NAMEDATALEN];
+	uint64		pgsa_stash_id;
+} pgsa_stash;
+
+/*
+ * Top-level shared state object for pg_stash_advice.
+ */
+typedef struct pgsa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	int			stash_tranche;
+	int			entry_tranche;
+	uint64		next_stash_id;
+	dsa_handle	area;
+	dshash_table_handle stash_hash;
+	dshash_table_handle entry_hash;
+} pgsa_shared_state;
+
+/* For stash ID -> stash name hash table */
+typedef struct pgsa_stash_name
+{
+	uint32		status;
+	uint64		pgsa_stash_id;
+	char	   *name;
+} pgsa_stash_name;
+
+/* Declare stash ID -> stash name hash table */
+#define SH_PREFIX pgsa_stash_name_table
+#define SH_ELEMENT_TYPE pgsa_stash_name
+#define SH_KEY_TYPE uint64
+#define SH_SCOPE extern
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+/* Shared memory pointers */
+extern pgsa_shared_state *pgsa_state;
+extern dsa_area *pgsa_dsa_area;
+extern dshash_table *pgsa_stash_dshash;
+extern dshash_table *pgsa_entry_dshash;
+
+/* Function prototypes */
+extern void pgsa_attach(void);
+extern void pgsa_check_stash_name(char *stash_name);
+extern void pgsa_clear_advice_string(char *stash_name, int64 queryId);
+extern void pgsa_create_stash(char *stash_name);
+extern void pgsa_drop_stash(char *stash_name);
+extern uint64 pgsa_lookup_stash_id(char *stash_name);
+extern void pgsa_set_advice_string(char *stash_name, int64 queryId,
+								   char *advice_string);
+
+#endif
diff --git a/contrib/pg_stash_advice/sql/pg_stash_advice.sql b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
new file mode 100644
index 00000000000..e3fe8808937
--- /dev/null
+++ b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
@@ -0,0 +1,150 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+SET pg_stash_advice.stash_name = 'regress_stash';
+
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+
+-- Can't create a stash that already exists, or drop one that doesn't.
+SELECT pg_create_advice_stash('regress_stash');
+SELECT pg_drop_advice_stash('no_such_stash');
+
+-- Can't add to or remove from a stash that does not exist.
+SELECT pg_set_stashed_advice('no_such_stash', 1, 'SEQ_SCAN(t)');
+SELECT pg_set_stashed_advice('no_such_stash', 1, null);
+
+-- Can't use query ID 0.
+SELECT pg_set_stashed_advice('regress_stash', 0, 'SEQ_SCAN(t)');
+
+-- Stash names must be non-empty, ASCII, and not too long, and must look
+-- like identifiers.
+SELECT pg_create_advice_stash('');
+SELECT pg_create_advice_stash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
+SELECT pg_create_advice_stash(E'caf\u00e9');
+SELECT pg_create_advice_stash('   ');
+SET pg_stash_advice.stash_name = 'café';
+SET pg_stash_advice.stash_name = '99bottles';
+
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+SELECT pg_drop_advice_stash('regress_empty_stash');
diff --git a/contrib/pg_stash_advice/stashfuncs.c b/contrib/pg_stash_advice/stashfuncs.c
new file mode 100644
index 00000000000..d8c669d6ab7
--- /dev/null
+++ b/contrib/pg_stash_advice/stashfuncs.c
@@ -0,0 +1,307 @@
+/*-------------------------------------------------------------------------
+ *
+ * stashfuncs.c
+ *	  SQL interface to pg_stash_advice
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/stashfuncs.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "pg_stash_advice.h"
+#include "utils/builtins.h"
+#include "utils/tuplestore.h"
+
+PG_FUNCTION_INFO_V1(pg_create_advice_stash);
+PG_FUNCTION_INFO_V1(pg_drop_advice_stash);
+PG_FUNCTION_INFO_V1(pg_get_advice_stash_contents);
+PG_FUNCTION_INFO_V1(pg_get_advice_stashes);
+PG_FUNCTION_INFO_V1(pg_set_stashed_advice);
+
+typedef struct pgsa_stash_count
+{
+	uint32		status;
+	uint64		pgsa_stash_id;
+	int64		num_entries;
+} pgsa_stash_count;
+
+#define SH_PREFIX pgsa_stash_count_table
+#define SH_ELEMENT_TYPE pgsa_stash_count
+#define SH_KEY_TYPE uint64
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64))
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+/*
+ * SQL-callable function to create an advice stash
+ */
+Datum
+pg_create_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_create_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to drop an advice stash
+ */
+Datum
+pg_drop_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_drop_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to provide a list of advice stashes
+ */
+Datum
+pg_get_advice_stashes(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	pgsa_stash_count_table_hash *chash;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Tally up the number of entries per stash. */
+	chash = pgsa_stash_count_table_create(CurrentMemoryContext, 64, NULL);
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		pgsa_stash_count *c;
+		bool		found;
+
+		c = pgsa_stash_count_table_insert(chash,
+										  entry->key.pgsa_stash_id,
+										  &found);
+		if (!found)
+			c->num_entries = 1;
+		else
+			c->num_entries++;
+	}
+	dshash_seq_term(&iterator);
+
+	/* Emit results. */
+	dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+	while ((stash = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[2];
+		bool		nulls[2];
+		pgsa_stash_count *c;
+
+		values[0] = CStringGetTextDatum(stash->name);
+		nulls[0] = false;
+
+		c = pgsa_stash_count_table_lookup(chash, stash->pgsa_stash_id);
+		values[1] = Int64GetDatum(c == NULL ? 0 : c->num_entries);
+		nulls[1] = false;
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to provide advice stash contents
+ */
+Datum
+pg_get_advice_stash_contents(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	char	   *stash_name = NULL;
+	pgsa_stash_name_table_hash *nhash = NULL;
+	uint64		stash_id = 0;
+	pgsa_entry *entry;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* User can pass NULL for all stashes, or the name of a specific stash. */
+	if (!PG_ARGISNULL(0))
+	{
+		stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+		pgsa_check_stash_name(stash_name);
+		stash_id = pgsa_lookup_stash_id(stash_name);
+
+		/* If the user specified a stash name, it should exist. */
+		if (stash_id == 0)
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					errmsg("advice stash \"%s\" does not exist", stash_name));
+	}
+	else
+	{
+		pgsa_stash *stash;
+
+		/*
+		 * If we're dumping data about all stashes, we need an ID->name lookup
+		 * table.
+		 */
+		nhash = pgsa_stash_name_table_create(CurrentMemoryContext, 64, NULL);
+		dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+		while ((stash = dshash_seq_next(&iterator)) != NULL)
+		{
+			pgsa_stash_name *n;
+			bool		found;
+
+			n = pgsa_stash_name_table_insert(nhash,
+											 stash->pgsa_stash_id,
+											 &found);
+			Assert(!found);
+			n->name = pstrdup(stash->name);
+		}
+		dshash_seq_term(&iterator);
+	}
+
+	/* Now iterate over all the entries. */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, false);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[3];
+		bool		nulls[3];
+		char	   *this_stash_name;
+		char	   *advice_string;
+
+		/* Skip incomplete entries where the advice string was never set. */
+		if (entry->advice_string == InvalidDsaPointer)
+			continue;
+
+		if (stash_id != 0)
+		{
+			/*
+			 * We're only dumping data for one particular stash, so skip
+			 * entries for any other stash and use the stash name specified by
+			 * the user.
+			 */
+			if (stash_id != entry->key.pgsa_stash_id)
+				continue;
+			this_stash_name = stash_name;
+		}
+		else
+		{
+			pgsa_stash_name *n;
+
+			/*
+			 * We're dumping data for all stashes, so look up the correct name
+			 * to use in the hash table. If nothing is found, which is
+			 * possible due to race conditions, make up a string to use.
+			 */
+			n = pgsa_stash_name_table_lookup(nhash, entry->key.pgsa_stash_id);
+			if (n != NULL)
+				this_stash_name = n->name;
+			else
+				this_stash_name = psprintf("<stash %" PRIu64 ">",
+										   entry->key.pgsa_stash_id);
+		}
+
+		/* Work out tuple values. */
+		values[0] = CStringGetTextDatum(this_stash_name);
+		nulls[0] = false;
+		values[1] = Int64GetDatum(entry->key.queryId);
+		nulls[1] = false;
+		advice_string = dsa_get_address(pgsa_dsa_area, entry->advice_string);
+		values[2] = CStringGetTextDatum(advice_string);
+		nulls[2] = false;
+
+		/* Emit the tuple. */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to update an advice stash entry for a particular
+ * query ID
+ *
+ * If the second argument is NULL, we delete any existing advice stash
+ * entry; otherwise, we either create an entry or update it with the new
+ * advice string.
+ */
+Datum
+pg_set_stashed_advice(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name;
+	int64		queryId;
+
+	if (PG_ARGISNULL(0) || PG_ARGISNULL(1))
+		PG_RETURN_NULL();
+
+	/* Get and check advice stash name. */
+	stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	pgsa_check_stash_name(stash_name);
+
+	/*
+	 * Get and check query ID.
+	 *
+	 * queryID 0 means no query ID was computed, so reject that.
+	 */
+	queryId = PG_GETARG_INT64(1);
+	if (queryId == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("cannot set advice string for query ID 0"));
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Now call the appropriate function to do the real work. */
+	if (PG_ARGISNULL(2))
+	{
+		LWLockAcquire(&pgsa_state->lock, LW_SHARED);
+		pgsa_clear_advice_string(stash_name, queryId);
+		LWLockRelease(&pgsa_state->lock);
+	}
+	else
+	{
+		char	   *advice_string = text_to_cstring(PG_GETARG_TEXT_PP(2));
+
+		LWLockAcquire(&pgsa_state->lock, LW_SHARED);
+		pgsa_set_advice_string(stash_name, queryId, advice_string);
+		LWLockRelease(&pgsa_state->lock);
+	}
+
+	PG_RETURN_VOID();
+}
+
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index bdd4865f53f..b9b03654aad 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -159,6 +159,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pgplanadvice;
  &pgprewarm;
  &pgrowlocks;
+ &pgstashadvice;
  &pgstatstatements;
  &pgstattuple;
  &pgsurgery;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index d90b4338d2a..e8f758fc24b 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -144,6 +144,7 @@
 <!ENTITY oid2name        SYSTEM "oid2name.sgml">
 <!ENTITY pageinspect     SYSTEM "pageinspect.sgml">
 <!ENTITY passwordcheck   SYSTEM "passwordcheck.sgml">
+<!ENTITY pgstashadvice   SYSTEM "pgstashadvice.sgml">
 <!ENTITY pgbuffercache   SYSTEM "pgbuffercache.sgml">
 <!ENTITY pgcrypto        SYSTEM "pgcrypto.sgml">
 <!ENTITY pgfreespacemap  SYSTEM "pgfreespacemap.sgml">
diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
new file mode 100644
index 00000000000..ec60552a447
--- /dev/null
+++ b/doc/src/sgml/pgstashadvice.sgml
@@ -0,0 +1,216 @@
+<!-- doc/src/sgml/pgstashadvice.sgml -->
+
+<sect1 id="pgstashadvice" xreflabel="pg_stash_advice">
+ <title>pg_stash_advice &mdash; store and automatically apply plan advice</title>
+
+ <indexterm zone="pgstashadvice">
+  <primary>pg_stash_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_stash_advice</filename> extension allows you to stash
+  <link linkend="pgplanadvice">plan advice</link> strings in dynamic
+  shared memory where they can be automatically applied. An
+  <literal>advice stash</literal> is a mapping from
+  <link linkend="guc-compute-query-id">query identifiers</link> to plan advice
+  strings. Whenever a session is asked to plan a query whose query ID appears
+  in the relevant advice stash, the plan advice string is automatically applied
+  to guide planning. Note that advice stashes exist purely in memory. This
+  means both that it is important to be mindful of memory consumption when
+  deciding how much plan advice to stash, and also that advice stashes must
+  be recreated and repopulated whenever the server is restarted.
+ </para>
+
+ <para>
+  In order to use this module, you will need to execute
+  <literal>CREATE EXTENSION pg_stash_advice</literal> in at least
+  one database, so that you have access to the SQL functions to manage
+  advice stashes. You will also need the <literal>pg_stash_advice</literal>
+  module to be loaded in all sessions where you want this module to
+  automatically apply advice. It will usually be best to do this by adding
+  <literal>pg_stash_advice</literal> to
+  <xref linkend="guc-shared-preload-libraries"/> and restarting the server.
+ </para>
+
+ <para>
+  Once you have met the above criteria, you can create advice stashes
+  using the <literal>pg_create_advice_stash</literal> function described
+  below and set the plan advice for a given query ID in a given stash using
+  the <literal>pg_set_stashed_advice</literal> function. Then, you need
+  only configure <literal>pg_stash_advice.stash_name</literal> to point
+  to the chosen advice stash name. For some use cases, rather than setting
+  this on a system-wide basis, you may find it helpful to use
+  <literal>ALTER DATABASE ... SET</literal> or
+  <literal>ALTER ROLE ... SET</literal> to configure values that will apply
+  only to a database or only to a certain role. Likewise, it may sometimes
+  be better to set the stash name in a particular session using
+  <literal>SET</literal>.
+ </para>
+
+ <para>
+  Because <literal>pg_stash_advice</literal> works on the basis of query
+  identifiers, you will need to determine the query identifier for each query
+  whose plan you wish to control. You will also need to determine the advice
+  string that you wish to store for each query. One way to do this is to use
+  <literal>EXPLAIN</literal>: the <literal>VERBOSE</literal> option will
+  show the query ID, and the <literal>PLAN_ADVICE</literal> option will
+  show plan advice.  Query identifiers can also be obtained through tools
+  such as <xref linkend="pgstatstatements" /> or
+  <xref linkend="monitoring-pg-stat-activity-view" />, but these tools
+  will not provide plan advice strings. Note that
+  <xref linkend="guc-compute-query-id" /> must be enabled for query
+  identifiers to be computed; if set to <literal>auto</literal>, loading
+  <literal>pg_stash_advice</literal> will enable it automatically.
+ </para>
+
+ <para>
+  Generally, the fact that the planner is able to change query plans as
+  the underlying distribution of data changes is a feature, not a bug.
+  Moreover, applying plan advice can have a noticeable performance cost even
+  when it does not result in a change to the query plan. Therefore, it is
+  a good idea to use this feature only when and to the extent needed.
+  Plan advice strings can be trimmed down to mention only those aspects
+  of the plan that need to be controlled, and used only for queries where
+  there is believed to be a significant risk of planner error.
+ </para>
+
+ <para>
+  Note that <literal>pg_stash_advice</literal> currently lacks a sophisticated
+  security model. Only the superuser, or a user to whom the superuser has
+  granted <literal>EXECUTE</literal> permission on the relevant functions,
+  may create advice stashes or alter their contents, but any user may set
+  <literal>pg_stash_advice.stash_name</literal> for their session, and this
+  may reveal the contents of any advice stash with that name. Users should
+  assume that information embedded in stashed advice strings may become visible
+  to nonprivileged users.
+ </para>
+
+ <sect2 id="pgstashadvice-functions">
+  <title>Functions</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <function>pg_create_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_create_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Creates a new, empty advice stash with the given name.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_drop_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_drop_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Drops the named advice stash and all of its entries.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_set_stashed_advice(stash_name text, query_id bigint,
+       advice_string text) returns void</function>
+     <indexterm>
+      <primary>pg_set_stashed_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Stores an advice string in the named advice stash, associated with
+      the given query identifier. If an entry for that query identifier
+      already exists in the stash, it is replaced. If
+      <parameter>advice_string</parameter> is <literal>NULL</literal>,
+      any existing entry for that query identifier is removed.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stashes() returns setof (stash_name text,
+       num_entries bigint)</function>
+     <indexterm>
+      <primary>pg_get_advice_stashes</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each advice stash, showing the stash name and
+      the number of entries it contains.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stash_contents(stash_name text) returns setof
+       (stash_name text, query_id bigint, advice_string text)</function>
+     <indexterm>
+      <primary>pg_get_advice_stash_contents</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each entry in the named advice stash. If
+      <parameter>stash_name</parameter> is <literal>NULL</literal>, returns
+      entries from all stashes.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_stash_advice.stash_name</varname> (<type>string</type>)
+     <indexterm>
+      <primary><varname>pg_stash_advice.stash_name</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Specifies the name of the advice stash to consult during query
+      planning. The default value is the empty string, which disables
+      this module.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>rhaas@postgresql.org</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index c9da1f91cb9..840fa73698a 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4054,6 +4054,12 @@ pgpa_trove_lookup_type
 pgpa_trove_result
 pgpa_trove_slice
 pgpa_unrolled_join
+pgsa_entry
+pgsa_entry_key
+pgsa_shared_state
+pgsa_stash
+pgsa_stash_count
+pgsa_stash_name
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0

