From b4dcd228402909a25a453e317520269d0685accc Mon Sep 17 00:00:00 2001
From: Dinesh Salve <cooltodinesh@gmail.com>
Date: Mon, 27 Jul 2026 21:22:24 +0530
Subject: [PATCH v7] postgres_fdw: show remote EXPLAIN plans via REMOTE_PLANS
 option

Add a REMOTE_PLANS option to EXPLAIN that, for every foreign scan or
foreign modification, runs EXPLAIN on the remote server for the
corresponding remote query and prints the result in a "Remote Plans"
section keyed by the local plan node id.

The remote query that postgres_fdw sends often contains $n parameter
placeholders (parameterized foreign scans and joins, subquery outputs,
and INSERT/UPDATE/DELETE), which a plain remote EXPLAIN cannot plan and
fails with "there is no parameter $1".  REMOTE_PLANS therefore forces
GENERIC_PLAN on the remote EXPLAIN, which plans such statements without
bound values.  GENERIC_PLAN is only available from PostgreSQL 16, so
REMOTE_PLANS errors out on older remote servers.

REMOTE_PLANS cannot be combined with ANALYZE.  All user-specified EXPLAIN
options (other than REMOTE_PLANS and GENERIC_PLAN) are forwarded to the
remote server as-is.  If the remote server does not recognize a forwarded
option, it reports an error.

The option is registered, and the EXPLAIN hooks it needs are installed,
by postgres_fdw_explain_init(), which _PG_init calls.  The work is split
across three hooks: explain_validate_options_hook rejects REMOTE_PLANS
together with ANALYZE and records the user's option list for forwarding,
explain_per_node_hook labels each node with its plan node id, and
explain_per_plan_hook prints the remote plans collected while the plan
was walked.  Each of these chains to the hook it replaced, so other
modules using the same hooks keep working regardless of the order in
which the modules are loaded.

Since the library is loaded on demand, the option is only recognized once
postgres_fdw has been loaded into the session, whether by an earlier query
against a foreign table, by LOAD, or by a preload GUC.  Note that CREATE
EXTENSION alone does not load it into the current session.

Tests for the option itself live alongside the existing tests for the
plans they report on, in postgres_fdw.sql.  The non-text output formats
are covered separately in remote_plans_formats.sql, so that the volume of
JSON/XML/YAML output does not swamp the main test file.
---
 contrib/postgres_fdw/Makefile                 |   2 +-
 .../postgres_fdw/expected/postgres_fdw.out    | 194 +++++++++
 .../expected/remote_plans_formats.out         | 114 ++++++
 contrib/postgres_fdw/meson.build              |   1 +
 contrib/postgres_fdw/option.c                 |   3 +
 contrib/postgres_fdw/postgres_fdw.c           | 379 +++++++++++++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   1 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  47 +++
 .../postgres_fdw/sql/remote_plans_formats.sql |  19 +
 doc/src/sgml/postgres-fdw.sgml                |  99 +++++
 src/tools/pgindent/typedefs.list              |   2 +
 11 files changed, 848 insertions(+), 13 deletions(-)
 create mode 100644 contrib/postgres_fdw/expected/remote_plans_formats.out
 create mode 100644 contrib/postgres_fdw/sql/remote_plans_formats.sql

diff --git a/contrib/postgres_fdw/Makefile b/contrib/postgres_fdw/Makefile
index b8c78b5..671f82c 100644
--- a/contrib/postgres_fdw/Makefile
+++ b/contrib/postgres_fdw/Makefile
@@ -16,7 +16,7 @@ SHLIB_LINK_INTERNAL = $(libpq)
 EXTENSION = postgres_fdw
 DATA = postgres_fdw--1.0.sql postgres_fdw--1.0--1.1.sql postgres_fdw--1.1--1.2.sql postgres_fdw--1.2--1.3.sql
 
-REGRESS = postgres_fdw query_cancel
+REGRESS = postgres_fdw remote_plans_formats query_cancel
 ISOLATION = eval_plan_qual
 ISOLATION_OPTS = --load-extension=postgres_fdw
 TAP_TESTS = 1
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index d19121b..1cf5a41 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -471,6 +471,36 @@ SELECT 'fixed', NULL FROM ft1 t1 WHERE c1 = 1;
  fixed    | 
 (1 row)
 
+-- with WHERE clause and REMOTE_PLANS
+EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) SELECT * FROM ft1 t1 WHERE t1.c1 = 101;
+                                          QUERY PLAN                                           
+-----------------------------------------------------------------------------------------------
+ Foreign Scan on public.ft1 t1
+   Output: c1, c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" = 101))
+   Plan Node ID: 0
+ Remote Plans:
+ -------------
+ Plan Node ID 0:
+   Index Scan using t1_pkey on "S 1"."T 1"
+     Output: "C 1", c2, c3, c4, c5, c6, c7, c8
+     Index Cond: ("T 1"."C 1" = 101)
+(10 rows)
+
+-- an error raised by the remote EXPLAIN is reported to the client; without
+-- REMOTE_PLANS this plan never contacts the remote server at all
+CREATE FOREIGN TABLE remote_plans_bad (c1 int)
+  SERVER loopback OPTIONS (schema_name 'S 1', table_name 'no_such_table');
+EXPLAIN (COSTS OFF) SELECT * FROM remote_plans_bad;
+            QUERY PLAN            
+----------------------------------
+ Foreign Scan on remote_plans_bad
+(1 row)
+
+EXPLAIN (REMOTE_PLANS, COSTS OFF) SELECT * FROM remote_plans_bad;
+ERROR:  relation "S 1.no_such_table" does not exist
+CONTEXT:  remote SQL command: EXPLAIN (GENERIC_PLAN TRUE, costs off) SELECT c1 FROM "S 1".no_such_table
+DROP FOREIGN TABLE remote_plans_bad;
 -- Test forcing the remote server to produce sorted data for a merge join.
 SET enable_hashjoin TO false;
 SET enable_nestloop TO false;
@@ -646,6 +676,57 @@ SELECT t1."C 1", t2.c1, t3.c1 FROM "S 1"."T 1" t1 full join ft1 t2 full join ft2
  110 | 110 | 110
 (10 rows)
 
+-- Join push-down test
+-- Ensure join conditions are pushed down to the foreign server
+EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF)
+	SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) WHERE t1.c2 = 10;
+                                                               QUERY PLAN                                                                
+-----------------------------------------------------------------------------------------------------------------------------------------
+ Foreign Scan
+   Output: t1.c1, t2.c1
+   Relations: (public.ft1 t1) INNER JOIN (public.ft2 t2)
+   Remote SQL: SELECT r1."C 1", r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1")) AND ((r1.c2 = 10))))
+   Plan Node ID: 0
+ Remote Plans:
+ -------------
+ Plan Node ID 0:
+   Seq Scan on "S 1"."T 1" r2
+     Output: r2."C 1", r2."C 1"
+     Filter: (r2.c2 = 10)
+(11 rows)
+
+-- Tables on multiple foreign connections: ft5 lives on server loopback and
+-- ft6 on server loopback2, so the join cannot be pushed down as a single
+-- foreign join.  Each side is scanned over its own connection, so REMOTE_PLANS
+-- must collect and print one remote plan per connection.
+EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF)
+	SELECT t1.c1, t2.c1 FROM ft5 t1 JOIN ft6 t2 ON (t1.c1 = t2.c1) WHERE t1.c1 = 10;
+                            QUERY PLAN                            
+------------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Output: t1.c1, t2.c1
+   Plan Node ID: 0
+   ->  Foreign Scan on public.ft5 t1
+         Output: t1.c1, t1.c2, t1.c3
+         Remote SQL: SELECT c1 FROM "S 1"."T 4" WHERE ((c1 = 10))
+         Plan Node ID: 1
+   ->  Foreign Scan on public.ft6 t2
+         Output: t2.c1, t2.c2, t2.c3
+         Remote SQL: SELECT c1 FROM "S 1"."T 4" WHERE ((c1 = 10))
+         Plan Node ID: 2
+ Remote Plans:
+ -------------
+ Plan Node ID 1:
+   Seq Scan on "S 1"."T 4"
+     Output: c1
+     Filter: ("T 4".c1 = 10)
+ Plan Node ID 2:
+   Seq Scan on "S 1"."T 4"
+     Output: c1
+     Filter: ("T 4".c1 = 10)
+(22 rows)
+
 RESET enable_hashjoin;
 RESET enable_nestloop;
 -- Test executing assertion in estimate_path_cost_size() that makes sure that
@@ -790,6 +871,32 @@ SELECT * FROM "S 1"."T 1" a, ft2 b WHERE a."C 1" = 47 AND b.c1 = a.c2;
   47 |  7 | 00047 | Tue Feb 17 00:00:00 1970 PST | Tue Feb 17 00:00:00 1970 | 7  | 7          | foo |  7 |  7 | 00007 | Thu Jan 08 00:00:00 1970 PST | Thu Jan 08 00:00:00 1970 | 7  | 7          | foo
 (1 row)
 
+-- REMOTE_PLANS over a parameterized foreign scan: the deparsed remote SQL
+-- carries a $1 placeholder, which only plans on the remote side because
+-- REMOTE_PLANS forces GENERIC_PLAN.
+EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF)
+  SELECT * FROM "S 1"."T 1" a, ft2 b WHERE a."C 1" = 47 AND b.c1 = a.c2;
+                                                 QUERY PLAN                                                  
+-------------------------------------------------------------------------------------------------------------
+ Nested Loop
+   Output: a."C 1", a.c2, a.c3, a.c4, a.c5, a.c6, a.c7, a.c8, b.c1, b.c2, b.c3, b.c4, b.c5, b.c6, b.c7, b.c8
+   Plan Node ID: 0
+   ->  Index Scan using t1_pkey on "S 1"."T 1" a
+         Output: a."C 1", a.c2, a.c3, a.c4, a.c5, a.c6, a.c7, a.c8
+         Index Cond: (a."C 1" = 47)
+         Plan Node ID: 1
+   ->  Foreign Scan on public.ft2 b
+         Output: b.c1, b.c2, b.c3, b.c4, b.c5, b.c6, b.c7, b.c8
+         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" = $1::integer))
+         Plan Node ID: 2
+ Remote Plans:
+ -------------
+ Plan Node ID 2:
+   Index Scan using t1_pkey on "S 1"."T 1"
+     Output: "C 1", c2, c3, c4, c5, c6, c7, c8
+     Index Cond: ("T 1"."C 1" = $1)
+(17 rows)
+
 -- check both safe and unsafe join conditions
 EXPLAIN (VERBOSE, COSTS OFF)
   SELECT * FROM ft2 a, ft2 b
@@ -5191,6 +5298,46 @@ SELECT ft1.c1 FROM ft1 JOIN ft2 on ft1.c1 = ft2.c1 WHERE
                Remote SQL: SELECT r5."C 1", r6.c1 FROM ("S 1"."T 1" r5 INNER JOIN "S 1"."T 3" r6 ON (((r5."C 1" = r6.c1)))) ORDER BY r5."C 1" ASC NULLS LAST
 (13 rows)
 
+-- EXPLAIN with REMOTE_PLANS
+-- REMOTE_PLANS forces GENERIC_PLAN on the remote EXPLAIN, so it cannot be
+-- combined with ANALYZE
+EXPLAIN (REMOTE_PLANS, COSTS OFF, ANALYZE)
+SELECT ft1.c1 FROM ft1 JOIN ft2 on ft1.c1 = ft2.c1 WHERE
+	ft1.c1 IN (
+		SELECT ft2.c1 FROM ft2 JOIN ft4 ON ft2.c1 = ft4.c1)
+	ORDER BY ft1.c1 LIMIT 5;
+ERROR:  EXPLAIN options REMOTE_PLANS and ANALYZE cannot be used together
+EXPLAIN (REMOTE_PLANS, COSTS OFF)
+SELECT ft1.c1 FROM ft1 JOIN ft2 on ft1.c1 = ft2.c1 WHERE
+	ft1.c1 IN (
+		SELECT ft2.c1 FROM ft2 JOIN ft4 ON ft2.c1 = ft4.c1)
+	ORDER BY ft1.c1 LIMIT 5;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Limit
+   Plan Node ID: 0
+   ->  Merge Semi Join
+         Merge Cond: (ft1.c1 = ft2_1.c1)
+         Plan Node ID: 1
+         ->  Foreign Scan
+               Relations: (ft1) INNER JOIN (ft2)
+               Plan Node ID: 2
+         ->  Foreign Scan
+               Relations: (ft2 ft2_1) INNER JOIN (ft4)
+               Plan Node ID: 3
+ Remote Plans:
+ -------------
+ Plan Node ID 2:
+   Index Only Scan using t1_pkey on "T 1" r2
+ Plan Node ID 3:
+   Merge Join
+     Merge Cond: (r5."C 1" = r6.c1)
+     ->  Index Only Scan using t1_pkey on "T 1" r5
+     ->  Sort
+           Sort Key: r6.c1
+           ->  Seq Scan on "T 3" r6
+(22 rows)
+
 -- ===================================================================
 -- test writable foreign table stuff
 -- ===================================================================
@@ -6465,6 +6612,25 @@ SELECT c1, c2, c3, c4 FROM fpo_part_local ORDER BY c4;
 (3 rows)
 
 DROP TABLE fpo_part_parent;
+-- test write on foreign tables with REMOTE_PLANS
+EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF)
+UPDATE ft2 SET c2 = c2 + 300 WHERE c1 % 10 = 3;
+                                      QUERY PLAN                                       
+---------------------------------------------------------------------------------------
+ Update on public.ft2
+   Plan Node ID: 0
+   ->  Foreign Update on public.ft2
+         Remote SQL: UPDATE "S 1"."T 1" SET c2 = (c2 + 300) WHERE ((("C 1" % 10) = 3))
+         Plan Node ID: 1
+ Remote Plans:
+ -------------
+ Plan Node ID 1:
+   Update on "S 1"."T 1"
+     ->  Seq Scan on "S 1"."T 1"
+           Output: (c2 + 300), ctid
+           Filter: (("T 1"."C 1" % 10) = 3)
+(12 rows)
+
 -- Test UPDATE/DELETE with RETURNING on a three-table join
 INSERT INTO ft2 (c1,c2,c3)
   SELECT id, id - 1200, to_char(id, 'FM00000') FROM generate_series(1201, 1300) id;
@@ -12497,6 +12663,34 @@ SELECT * FROM insert_tbl ORDER BY a;
  2505 | 505 | bar
 (2 rows)
 
+EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF)
+INSERT INTO insert_tbl (SELECT * FROM local_tbl UNION ALL SELECT * FROM remote_tbl);
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
+ Insert on public.insert_tbl
+   Remote SQL: INSERT INTO public.base_tbl4(a, b, c) VALUES ($1, $2, $3)
+   Batch Size: 1
+   Plan Node ID: 0
+   ->  Append
+         Plan Node ID: 1
+         ->  Seq Scan on public.local_tbl
+               Output: local_tbl.a, local_tbl.b, local_tbl.c
+               Plan Node ID: 2
+         ->  Async Foreign Scan on public.remote_tbl
+               Output: remote_tbl.a, remote_tbl.b, remote_tbl.c
+               Remote SQL: SELECT a, b, c FROM public.base_tbl3
+               Plan Node ID: 3
+ Remote Plans:
+ -------------
+ Plan Node ID 0:
+   Insert on public.base_tbl4
+     ->  Result
+           Output: $1, $2, $3
+ Plan Node ID 3:
+   Seq Scan on public.base_tbl3
+     Output: a, b, c
+(22 rows)
+
 -- Check with direct modify
 EXPLAIN (VERBOSE, COSTS OFF)
 WITH t AS (UPDATE remote_tbl SET c = c || c RETURNING *)
diff --git a/contrib/postgres_fdw/expected/remote_plans_formats.out b/contrib/postgres_fdw/expected/remote_plans_formats.out
new file mode 100644
index 0000000..37b64b4
--- /dev/null
+++ b/contrib/postgres_fdw/expected/remote_plans_formats.out
@@ -0,0 +1,114 @@
+-- ===================================================================
+-- test EXPLAIN (REMOTE_PLANS) in the non-text output formats
+-- ===================================================================
+-- The behaviour of REMOTE_PLANS itself is tested in postgres_fdw.sql, next
+-- to the tests for the plans it reports on.  This file only covers how the
+-- collected remote plans are embedded in each output format.
+-- This runs after postgres_fdw.sql in the same database and reuses the
+-- server, user mapping and foreign tables created there.
+LOAD 'postgres_fdw';
+EXPLAIN (REMOTE_PLANS, FORMAT JSON, COSTS OFF)
+SELECT c1 FROM ft1 t1 WHERE t1.c1 = 101;
+                  QUERY PLAN                   
+-----------------------------------------------
+ [                                            +
+   {                                          +
+     "Plan": {                                +
+       "Node Type": "Foreign Scan",           +
+       "Operation": "Select",                 +
+       "Parallel Aware": false,               +
+       "Async Capable": false,                +
+       "Relation Name": "ft1",                +
+       "Alias": "t1",                         +
+       "Disabled": false,                     +
+       "Plan Node ID": 0                      +
+     },                                       +
+     "Remote Plans": {                        +
+       "Plan Node ID 0": [                    +
+         [                                    +
+           {                                  +
+             "Plan": {                        +
+               "Node Type": "Index Only Scan",+
+               "Parallel Aware": false,       +
+               "Async Capable": false,        +
+               "Scan Direction": "Forward",   +
+               "Index Name": "t1_pkey",       +
+               "Relation Name": "T 1",        +
+               "Alias": "T 1",                +
+               "Disabled": false,             +
+               "Index Cond": "(\"C 1\" = 101)"+
+             }                                +
+           }                                  +
+         ]                                    +
+       ]                                      +
+     }                                        +
+   }                                          +
+ ]
+(1 row)
+
+EXPLAIN (REMOTE_PLANS, FORMAT XML, COSTS OFF)
+SELECT c1 FROM ft1 t1 WHERE t1.c1 = 101;
+                            QUERY PLAN                            
+------------------------------------------------------------------
+ <explain xmlns="http://www.postgresql.org/2009/explain">        +
+   <Query>                                                       +
+     <Plan>                                                      +
+       <Node-Type>Foreign Scan</Node-Type>                       +
+       <Operation>Select</Operation>                             +
+       <Parallel-Aware>false</Parallel-Aware>                    +
+       <Async-Capable>false</Async-Capable>                      +
+       <Relation-Name>ft1</Relation-Name>                        +
+       <Alias>t1</Alias>                                         +
+       <Disabled>false</Disabled>                                +
+       <Plan-Node-ID>0</Plan-Node-ID>                            +
+     </Plan>                                                     +
+     <Remote-Plans>                                              +
+       <Plan-Node-ID-0>                                          +
+         <explain xmlns="http://www.postgresql.org/2009/explain">+
+           <Query>                                               +
+             <Plan>                                              +
+               <Node-Type>Index Only Scan</Node-Type>            +
+               <Parallel-Aware>false</Parallel-Aware>            +
+               <Async-Capable>false</Async-Capable>              +
+               <Scan-Direction>Forward</Scan-Direction>          +
+               <Index-Name>t1_pkey</Index-Name>                  +
+               <Relation-Name>T 1</Relation-Name>                +
+               <Alias>T 1</Alias>                                +
+               <Disabled>false</Disabled>                        +
+               <Index-Cond>("C 1" = 101)</Index-Cond>            +
+             </Plan>                                             +
+           </Query>                                              +
+         </explain>                                              +
+       </Plan-Node-ID-0>                                         +
+     </Remote-Plans>                                             +
+   </Query>                                                      +
+ </explain>
+(1 row)
+
+EXPLAIN (REMOTE_PLANS, FORMAT YAML, COSTS OFF)
+SELECT c1 FROM ft1 t1 WHERE t1.c1 = 101;
+               QUERY PLAN                
+-----------------------------------------
+ - Plan:                                +
+     Node Type: "Foreign Scan"          +
+     Operation: "Select"                +
+     Parallel Aware: false              +
+     Async Capable: false               +
+     Relation Name: "ft1"               +
+     Alias: "t1"                        +
+     Disabled: false                    +
+     Plan Node ID: 0                    +
+   Remote Plans:                        +
+     Plan Node ID 0:                    +
+       - Plan:                          +
+           Node Type: "Index Only Scan" +
+           Parallel Aware: false        +
+           Async Capable: false         +
+           Scan Direction: "Forward"    +
+           Index Name: "t1_pkey"        +
+           Relation Name: "T 1"         +
+           Alias: "T 1"                 +
+           Disabled: false              +
+           Index Cond: "(\"C 1\" = 101)"
+(1 row)
+
diff --git a/contrib/postgres_fdw/meson.build b/contrib/postgres_fdw/meson.build
index 3e2ed06..6a9bb65 100644
--- a/contrib/postgres_fdw/meson.build
+++ b/contrib/postgres_fdw/meson.build
@@ -38,6 +38,7 @@ tests += {
   'regress': {
     'sql': [
       'postgres_fdw',
+      'remote_plans_formats',
       'query_cancel',
     ],
     'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index 79b16c3..5d180c9 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -592,4 +592,7 @@ _PG_init(void)
 							   NULL);
 
 	MarkGUCPrefixReserved("postgres_fdw");
+
+	/* Register EXPLAIN (REMOTE_PLANS) and install the hooks it needs. */
+	postgres_fdw_explain_init();
 }
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 8b660a6..91e22bd 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -19,6 +19,7 @@
 #include "access/table.h"
 #include "catalog/pg_opfamily.h"
 #include "commands/defrem.h"
+#include "commands/explain.h"
 #include "commands/explain_format.h"
 #include "commands/explain_state.h"
 #include "commands/vacuum.h"
@@ -139,6 +140,44 @@ enum FdwDirectModifyPrivateIndex
 	FdwDirectModifyPrivateSetProcessed,
 };
 
+/*
+ * One remote plan collected while walking the local plan, identified by the
+ * plan node id of the local node that produced the remote query.
+ */
+typedef struct PgFdwExplainRemotePlans
+{
+	int			plan_node_id;
+	StringInfoData explain_plan;
+} PgFdwExplainRemotePlans;
+
+/*
+ * Our private state within an ExplainState, created on demand while the
+ * EXPLAIN options are parsed.
+ */
+typedef struct PgFdwExplainState
+{
+	List	   *all_remote_plans;
+
+	/* EXPLAIN options */
+	bool		remote_plans;
+	List	   *options;		/* raw user DefElem list, for forwarding */
+} PgFdwExplainState;
+
+/*
+ * Our slot in an ExplainState's extension state array, assigned once by
+ * postgres_fdw_explain_init().
+ */
+static int	postgres_fdw_extension_id;
+
+/*
+ * Previously installed EXPLAIN hooks.  Every hook function we install must
+ * call the previous one, so that other modules using the same hooks keep
+ * working no matter what order the modules happen to be loaded in.
+ */
+static explain_per_node_hook_type prev_explain_per_node_hook = NULL;
+static explain_per_plan_hook_type prev_explain_per_plan_hook = NULL;
+static explain_validate_options_hook_type prev_explain_validate_options_hook = NULL;
+
 /*
  * Execution state of a foreign scan using postgres_fdw.
  */
@@ -2916,6 +2955,107 @@ postgresEndDirectModify(ForeignScanState *node)
 	/* MemoryContext will be deleted automatically. */
 }
 
+static void
+postgresExplainStatement(int plan_node_id,
+						 PgFdwExplainState *pgfdw_explain_state,
+						 PGconn *conn,
+						 char *sql)
+{
+	PGresult   *volatile res = NULL;
+	StringInfoData explain_sql;
+	int			remote_version = PQserverVersion(conn);
+	ListCell   *lc;
+
+	/*
+	 * The deparsed SQL may contain $n placeholders, which a plain remote
+	 * EXPLAIN cannot plan: it fails with "there is no parameter $1".  We
+	 * therefore force GENERIC_PLAN, which is only available from PG 16.
+	 */
+	if (remote_version < 160000)
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("EXPLAIN option REMOTE_PLANS requires a remote server version of 16 or later"),
+				errdetail("The remote server version is %d.", remote_version));
+
+	/*
+	 * Forward user-specified options as-is; force GENERIC_PLAN on.
+	 */
+	initStringInfo(&explain_sql);
+	appendStringInfoString(&explain_sql, "EXPLAIN (GENERIC_PLAN TRUE");
+
+	foreach(lc, pgfdw_explain_state->options)
+	{
+		DefElem    *opt = (DefElem *) lfirst(lc);
+
+		if (strcmp(opt->defname, "remote_plans") == 0 ||
+			strcmp(opt->defname, "generic_plan") == 0)
+			continue;
+
+		if (opt->arg == NULL)
+			appendStringInfo(&explain_sql, ", %s", opt->defname);
+		else
+			appendStringInfo(&explain_sql, ", %s %s", opt->defname,
+							 defGetString(opt));
+	}
+
+	appendStringInfo(&explain_sql, ") %s", sql);
+
+	PG_TRY();
+	{
+		int			numrows,
+					i;
+		PgFdwExplainRemotePlans *explain;
+
+		/* Run the query and collect the remote plan */
+		res = pgfdw_exec_query(conn, explain_sql.data, NULL);
+		if (PQresultStatus(res) != PGRES_TUPLES_OK)
+			pgfdw_report_error(res, conn, explain_sql.data);
+
+		explain = (PgFdwExplainRemotePlans *) palloc(sizeof(PgFdwExplainRemotePlans));
+		initStringInfo(&explain->explain_plan);
+
+		numrows = PQntuples(res);
+
+		for (i = 0; i < numrows; i++)
+			appendStringInfo(&explain->explain_plan, "%s\n", PQgetvalue(res, i, 0));
+
+		if (explain->explain_plan.len > 0 && explain->explain_plan.data[explain->explain_plan.len - 1] == '\n')
+			explain->explain_plan.data[--explain->explain_plan.len] = '\0';
+
+		explain->plan_node_id = plan_node_id;
+		pgfdw_explain_state->all_remote_plans = lappend(pgfdw_explain_state->all_remote_plans, explain);
+	}
+	PG_FINALLY();
+	{
+		if (res)
+			PQclear(res);
+	}
+	PG_END_TRY();
+
+	pfree(explain_sql.data);
+}
+
+/*
+ * explain_remote_query
+ *		Helper function to get connection and execute remote EXPLAIN
+ */
+static void
+explain_remote_query(int plan_node_id,
+					 PgFdwExplainState *pgfdw_explain_state,
+					 Oid foreign_table_oid, char *sql)
+{
+	UserMapping *user;
+	PGconn	   *conn;
+	ForeignTable *table;
+
+	table = GetForeignTable(foreign_table_oid);
+	user = GetUserMapping(GetUserId(), table->serverid);
+	conn = GetConnection(user, false, NULL);
+
+	postgresExplainStatement(plan_node_id, pgfdw_explain_state, conn, sql);
+	ReleaseConnection(conn);
+}
+
 /*
  * postgresExplainForeignScan
  *		Produce extra output for EXPLAIN of a ForeignScan on a foreign table
@@ -2925,6 +3065,12 @@ postgresExplainForeignScan(ForeignScanState *node, ExplainState *es)
 {
 	ForeignScan *plan = castNode(ForeignScan, node->ss.ps.plan);
 	List	   *fdw_private = plan->fdw_private;
+	PgFdwExplainState *pgfdw_explain_state;
+	char	   *sql;
+	Oid			foreign_table_oid = InvalidOid;
+
+	if (node->ss.ss_currentRelation)
+		foreign_table_oid = RelationGetRelid(node->ss.ss_currentRelation);
 
 	/*
 	 * Identify foreign scans that are really joins or upper relations.  The
@@ -2986,6 +3132,13 @@ postgresExplainForeignScan(ForeignScanState *node, ExplainState *es)
 				Assert(rte->rtekind == RTE_RELATION);
 				/* This logic should agree with explain.c's ExplainTargetRel */
 				relname = get_rel_name(rte->relid);
+
+				/*
+				 * Save first table OID for getting server connection
+				 */
+				if (!OidIsValid(foreign_table_oid))
+					foreign_table_oid = rte->relid;
+
 				if (es->verbose)
 				{
 					char	   *namespace;
@@ -3011,16 +3164,24 @@ postgresExplainForeignScan(ForeignScanState *node, ExplainState *es)
 		ExplainPropertyText("Relations", relations.data, es);
 	}
 
+	sql = strVal(list_nth(fdw_private, FdwScanPrivateSelectSql));
+
 	/*
 	 * Add remote query, when VERBOSE option is specified.
 	 */
 	if (es->verbose)
-	{
-		char	   *sql;
-
-		sql = strVal(list_nth(fdw_private, FdwScanPrivateSelectSql));
 		ExplainPropertyText("Remote SQL", sql, es);
-	}
+
+	pgfdw_explain_state = GetExplainExtensionState(es, postgres_fdw_extension_id);
+
+	/* If we don't have a foreign table oid by now, something went wrong */
+	Assert(foreign_table_oid);
+
+	if (pgfdw_explain_state && pgfdw_explain_state->remote_plans)
+		explain_remote_query(node->ss.ps.plan->plan_node_id,
+							 pgfdw_explain_state,
+							 foreign_table_oid,
+							 sql);
 }
 
 /*
@@ -3034,11 +3195,12 @@ postgresExplainForeignModify(ModifyTableState *mtstate,
 							 int subplan_index,
 							 ExplainState *es)
 {
+	char	   *sql = strVal(list_nth(fdw_private,
+									  FdwModifyPrivateUpdateSql));
+	PgFdwExplainState *pgfdw_explain_state;
+
 	if (es->verbose)
 	{
-		char	   *sql = strVal(list_nth(fdw_private,
-										  FdwModifyPrivateUpdateSql));
-
 		ExplainPropertyText("Remote SQL", sql, es);
 
 		/*
@@ -3048,6 +3210,13 @@ postgresExplainForeignModify(ModifyTableState *mtstate,
 		if (rinfo->ri_BatchSize > 0)
 			ExplainPropertyInteger("Batch Size", NULL, rinfo->ri_BatchSize, es);
 	}
+
+	pgfdw_explain_state = GetExplainExtensionState(es, postgres_fdw_extension_id);
+	if (pgfdw_explain_state && pgfdw_explain_state->remote_plans)
+		explain_remote_query(mtstate->ps.plan->plan_node_id,
+							 pgfdw_explain_state,
+							 rinfo->ri_RelationDesc->rd_rel->oid,
+							 sql);
 }
 
 /*
@@ -3060,13 +3229,21 @@ postgresExplainDirectModify(ForeignScanState *node, ExplainState *es)
 {
 	List	   *fdw_private;
 	char	   *sql;
+	PgFdwExplainState *pgfdw_explain_state;
+
+	fdw_private = ((ForeignScan *) node->ss.ps.plan)->fdw_private;
+	sql = strVal(list_nth(fdw_private, FdwDirectModifyPrivateUpdateSql));
 
 	if (es->verbose)
-	{
-		fdw_private = ((ForeignScan *) node->ss.ps.plan)->fdw_private;
-		sql = strVal(list_nth(fdw_private, FdwDirectModifyPrivateUpdateSql));
 		ExplainPropertyText("Remote SQL", sql, es);
-	}
+
+	pgfdw_explain_state = GetExplainExtensionState(es, postgres_fdw_extension_id);
+
+	if (pgfdw_explain_state && pgfdw_explain_state->remote_plans)
+		explain_remote_query(node->ss.ps.plan->plan_node_id,
+							 pgfdw_explain_state,
+							 RelationGetRelid(node->ss.ss_currentRelation),
+							 sql);
 }
 
 /*
@@ -8766,3 +8943,181 @@ get_batch_size_option(Relation rel)
 
 	return batch_size;
 }
+
+/*
+ * Get the PgFdwExplainState structure from an ExplainState; if there is
+ * none, create one, attach it to the ExplainState, and return it.
+ */
+static PgFdwExplainState *
+pgfdw_ensure_options(ExplainState *es)
+{
+	PgFdwExplainState *pgfdw_explain_state;
+
+	pgfdw_explain_state = GetExplainExtensionState(es, postgres_fdw_extension_id);
+
+	if (pgfdw_explain_state == NULL)
+	{
+		pgfdw_explain_state = palloc0(sizeof(PgFdwExplainState));
+		SetExplainExtensionState(es, postgres_fdw_extension_id, pgfdw_explain_state);
+		pgfdw_explain_state->all_remote_plans = NIL;
+	}
+
+	return pgfdw_explain_state;
+}
+
+/*
+ * Parse handler for EXPLAIN (REMOTE_PLANS).
+ */
+static void
+pgfdw_remote_plans_apply(ExplainState *es, DefElem *opt, ParseState *pstate)
+{
+	PgFdwExplainState *options = pgfdw_ensure_options(es);
+
+	options->remote_plans = defGetBoolean(opt);
+}
+
+static void
+postgresExplainValidateOptions(ExplainState *es, List *options, ParseState *pstate)
+{
+	ListCell   *lc;
+	PgFdwExplainState *pgfdw_explain_state = NULL;
+
+	if (prev_explain_validate_options_hook)
+		(*prev_explain_validate_options_hook) (es, options, pstate);
+
+	foreach(lc, options)
+	{
+		DefElem    *opt = (DefElem *) lfirst(lc);
+
+		if (strcmp(opt->defname, "remote_plans") == 0)
+		{
+			if (defGetBoolean(opt) && es->analyze)
+				ereport(ERROR,
+						errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						errmsg("EXPLAIN options REMOTE_PLANS and ANALYZE cannot be used together"));
+
+			pgfdw_explain_state = pgfdw_ensure_options(es);
+			pgfdw_explain_state->options = options;
+		}
+	}
+}
+
+static void
+postgresExplainPerNode(PlanState *planstate, List *ancestors,
+					   const char *relationship, const char *plan_name,
+					   ExplainState *es)
+{
+	PgFdwExplainState *pgfdw_explain_state;
+
+	if (prev_explain_per_node_hook)
+		(*prev_explain_per_node_hook) (planstate, ancestors, relationship,
+									   plan_name, es);
+
+	pgfdw_explain_state = GetExplainExtensionState(es, postgres_fdw_extension_id);
+
+	if (pgfdw_explain_state == NULL ||
+		!pgfdw_explain_state->remote_plans)
+		return;
+
+	ExplainPropertyInteger("Plan Node ID", NULL, planstate->plan->plan_node_id, es);
+}
+
+static void
+pgfdwFormatRemotePlan(PgFdwExplainRemotePlans *explain,
+					  ExplainState *es,
+					  int plan_node_id)
+{
+	char	   *token;
+	StringInfoData remote_plan_name;
+
+	initStringInfo(&remote_plan_name);
+	appendStringInfo(&remote_plan_name, "Plan Node ID %d", plan_node_id);
+
+	ExplainOpenGroup(remote_plan_name.data, remote_plan_name.data, false, es);
+
+	if (es->format == EXPLAIN_FORMAT_TEXT)
+	{
+		appendStringInfo(es->str, "Plan Node ID %d:", plan_node_id);
+		appendStringInfoString(es->str, "\n");
+	}
+
+	while ((token = strsep(&explain->explain_plan.data, "\n")) != NULL)
+	{
+		if (es->format == EXPLAIN_FORMAT_JSON ||
+			es->format == EXPLAIN_FORMAT_YAML)
+			appendStringInfoString(es->str, "\n");
+
+		appendStringInfoSpaces(es->str, (es->indent == 0) ? 2 : es->indent * 2);
+		appendStringInfoString(es->str, token);
+
+		if (es->format == EXPLAIN_FORMAT_XML ||
+			es->format == EXPLAIN_FORMAT_TEXT)
+			appendStringInfoString(es->str, "\n");
+	}
+
+	ExplainCloseGroup(remote_plan_name.data, remote_plan_name.data, false, es);
+	pfree(remote_plan_name.data);
+}
+
+static void
+postgresExplainPerPlan(PlannedStmt *plannedstmt,
+					   IntoClause *into,
+					   ExplainState *es,
+					   const char *queryString,
+					   ParamListInfo params,
+					   QueryEnvironment *queryEnv)
+{
+	ListCell   *lc;
+	PgFdwExplainState *pgfdw_explain_state;
+
+	if (prev_explain_per_plan_hook)
+		(*prev_explain_per_plan_hook) (plannedstmt, into, es, queryString,
+									   params, queryEnv);
+
+	pgfdw_explain_state = GetExplainExtensionState(es, postgres_fdw_extension_id);
+
+	if (pgfdw_explain_state == NULL ||
+		pgfdw_explain_state->all_remote_plans == NIL ||
+		!pgfdw_explain_state->remote_plans)
+		return;
+
+	ExplainOpenGroup("Remote Plans", "Remote Plans", true, es);
+	if (es->format == EXPLAIN_FORMAT_TEXT)
+	{
+		appendStringInfo(es->str, "Remote Plans:\n");
+		appendStringInfo(es->str, "-------------\n");
+	}
+
+	/* Process every remote plan captured */
+	foreach(lc, pgfdw_explain_state->all_remote_plans)
+	{
+		PgFdwExplainRemotePlans *explain = (PgFdwExplainRemotePlans *) lfirst(lc);
+
+		pgfdwFormatRemotePlan(explain,
+							  es,
+							  explain->plan_node_id);
+	}
+
+	ExplainCloseGroup("Remote Plans", "Remote Plans", true, es);
+}
+
+/*
+ * Register EXPLAIN (REMOTE_PLANS) and install the hooks that implement it.
+ * Called from _PG_init.
+ */
+void
+postgres_fdw_explain_init(void)
+{
+	postgres_fdw_extension_id = GetExplainExtensionId("postgres_fdw");
+
+	RegisterExtensionExplainOption("remote_plans", pgfdw_remote_plans_apply,
+								   GUCCheckBooleanExplainOption);
+
+	/* Install our EXPLAIN hooks, saving the previous ones for chaining. */
+	prev_explain_per_node_hook = explain_per_node_hook;
+	explain_per_node_hook = postgresExplainPerNode;
+	prev_explain_per_plan_hook = explain_per_plan_hook;
+	explain_per_plan_hook = postgresExplainPerPlan;
+	prev_explain_validate_options_hook = explain_validate_options_hook;
+	explain_validate_options_hook = postgresExplainValidateOptions;
+}
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index a2bb1ff..c043a30 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -155,6 +155,7 @@ typedef enum PgFdwSamplingMethod
 extern int	set_transmission_modes(void);
 extern void reset_transmission_modes(int nestlevel);
 extern void process_pending_request(AsyncRequest *areq);
+extern void postgres_fdw_explain_init(void);
 
 /* in connection.c */
 extern PGconn *GetConnection(UserMapping *user, bool will_prep_stmt,
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index e701995..a82cccf 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -311,6 +311,15 @@ SELECT * FROM ft1 t1 WHERE t1.c3 = (SELECT MAX(c3) FROM ft2 t2) ORDER BY c1;
 WITH t1 AS (SELECT * FROM ft1 WHERE c1 <= 10) SELECT t2.c1, t2.c2, t2.c3, t2.c4 FROM t1, ft2 t2 WHERE t1.c1 = t2.c1 ORDER BY t1.c1;
 -- fixed values
 SELECT 'fixed', NULL FROM ft1 t1 WHERE c1 = 1;
+-- with WHERE clause and REMOTE_PLANS
+EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) SELECT * FROM ft1 t1 WHERE t1.c1 = 101;
+-- an error raised by the remote EXPLAIN is reported to the client; without
+-- REMOTE_PLANS this plan never contacts the remote server at all
+CREATE FOREIGN TABLE remote_plans_bad (c1 int)
+  SERVER loopback OPTIONS (schema_name 'S 1', table_name 'no_such_table');
+EXPLAIN (COSTS OFF) SELECT * FROM remote_plans_bad;
+EXPLAIN (REMOTE_PLANS, COSTS OFF) SELECT * FROM remote_plans_bad;
+DROP FOREIGN TABLE remote_plans_bad;
 -- Test forcing the remote server to produce sorted data for a merge join.
 SET enable_hashjoin TO false;
 SET enable_nestloop TO false;
@@ -338,6 +347,19 @@ SELECT t1."C 1", t2.c1, t3.c1 FROM "S 1"."T 1" t1 left join ft1 t2 full join ft2
 EXPLAIN (VERBOSE, COSTS OFF)
 	SELECT t1."C 1", t2.c1, t3.c1 FROM "S 1"."T 1" t1 full join ft1 t2 full join ft2 t3 on (t2.c1 = t3.c1) on (t3.c1 = t1."C 1") OFFSET 100 LIMIT 10;
 SELECT t1."C 1", t2.c1, t3.c1 FROM "S 1"."T 1" t1 full join ft1 t2 full join ft2 t3 on (t2.c1 = t3.c1) on (t3.c1 = t1."C 1") OFFSET 100 LIMIT 10;
+
+-- Join push-down test
+-- Ensure join conditions are pushed down to the foreign server
+EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF)
+	SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) WHERE t1.c2 = 10;
+
+-- Tables on multiple foreign connections: ft5 lives on server loopback and
+-- ft6 on server loopback2, so the join cannot be pushed down as a single
+-- foreign join.  Each side is scanned over its own connection, so REMOTE_PLANS
+-- must collect and print one remote plan per connection.
+EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF)
+	SELECT t1.c1, t2.c1 FROM ft5 t1 JOIN ft6 t2 ON (t1.c1 = t2.c1) WHERE t1.c1 = 10;
+
 RESET enable_hashjoin;
 RESET enable_nestloop;
 
@@ -379,6 +401,11 @@ EXPLAIN (VERBOSE, COSTS OFF) SELECT * FROM ft1 t1 WHERE c8 = 'foo';  -- can't be
 EXPLAIN (VERBOSE, COSTS OFF)
   SELECT * FROM "S 1"."T 1" a, ft2 b WHERE a."C 1" = 47 AND b.c1 = a.c2;
 SELECT * FROM "S 1"."T 1" a, ft2 b WHERE a."C 1" = 47 AND b.c1 = a.c2;
+-- REMOTE_PLANS over a parameterized foreign scan: the deparsed remote SQL
+-- carries a $1 placeholder, which only plans on the remote side because
+-- REMOTE_PLANS forces GENERIC_PLAN.
+EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF)
+  SELECT * FROM "S 1"."T 1" a, ft2 b WHERE a."C 1" = 47 AND b.c1 = a.c2;
 
 -- check both safe and unsafe join conditions
 EXPLAIN (VERBOSE, COSTS OFF)
@@ -1562,6 +1589,20 @@ SELECT ft1.c1 FROM ft1 JOIN ft2 on ft1.c1 = ft2.c1 WHERE
 		SELECT ft2.c1 FROM ft2 JOIN ft4 ON ft2.c1 = ft4.c1)
 	ORDER BY ft1.c1 LIMIT 5;
 
+-- EXPLAIN with REMOTE_PLANS
+-- REMOTE_PLANS forces GENERIC_PLAN on the remote EXPLAIN, so it cannot be
+-- combined with ANALYZE
+EXPLAIN (REMOTE_PLANS, COSTS OFF, ANALYZE)
+SELECT ft1.c1 FROM ft1 JOIN ft2 on ft1.c1 = ft2.c1 WHERE
+	ft1.c1 IN (
+		SELECT ft2.c1 FROM ft2 JOIN ft4 ON ft2.c1 = ft4.c1)
+	ORDER BY ft1.c1 LIMIT 5;
+EXPLAIN (REMOTE_PLANS, COSTS OFF)
+SELECT ft1.c1 FROM ft1 JOIN ft2 on ft1.c1 = ft2.c1 WHERE
+	ft1.c1 IN (
+		SELECT ft2.c1 FROM ft2 JOIN ft4 ON ft2.c1 = ft4.c1)
+	ORDER BY ft1.c1 LIMIT 5;
+
 -- ===================================================================
 -- test writable foreign table stuff
 -- ===================================================================
@@ -1645,6 +1686,9 @@ DELETE FROM fpo_part_parent
   FOR PORTION OF c4 FROM '2024-06-01' TO '2024-06-15' WHERE c2 = 1; -- okay
 SELECT c1, c2, c3, c4 FROM fpo_part_local ORDER BY c4;
 DROP TABLE fpo_part_parent;
+-- test write on foreign tables with REMOTE_PLANS
+EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF)
+UPDATE ft2 SET c2 = c2 + 300 WHERE c1 % 10 = 3;
 
 -- Test UPDATE/DELETE with RETURNING on a three-table join
 INSERT INTO ft2 (c1,c2,c3)
@@ -4287,6 +4331,9 @@ INSERT INTO insert_tbl (SELECT * FROM local_tbl UNION ALL SELECT * FROM remote_t
 
 SELECT * FROM insert_tbl ORDER BY a;
 
+EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF)
+INSERT INTO insert_tbl (SELECT * FROM local_tbl UNION ALL SELECT * FROM remote_tbl);
+
 -- Check with direct modify
 EXPLAIN (VERBOSE, COSTS OFF)
 WITH t AS (UPDATE remote_tbl SET c = c || c RETURNING *)
diff --git a/contrib/postgres_fdw/sql/remote_plans_formats.sql b/contrib/postgres_fdw/sql/remote_plans_formats.sql
new file mode 100644
index 0000000..0d7e25c
--- /dev/null
+++ b/contrib/postgres_fdw/sql/remote_plans_formats.sql
@@ -0,0 +1,19 @@
+-- ===================================================================
+-- test EXPLAIN (REMOTE_PLANS) in the non-text output formats
+-- ===================================================================
+-- The behaviour of REMOTE_PLANS itself is tested in postgres_fdw.sql, next
+-- to the tests for the plans it reports on.  This file only covers how the
+-- collected remote plans are embedded in each output format.
+-- This runs after postgres_fdw.sql in the same database and reuses the
+-- server, user mapping and foreign tables created there.
+
+LOAD 'postgres_fdw';
+
+EXPLAIN (REMOTE_PLANS, FORMAT JSON, COSTS OFF)
+SELECT c1 FROM ft1 t1 WHERE t1.c1 = 101;
+
+EXPLAIN (REMOTE_PLANS, FORMAT XML, COSTS OFF)
+SELECT c1 FROM ft1 t1 WHERE t1.c1 = 101;
+
+EXPLAIN (REMOTE_PLANS, FORMAT YAML, COSTS OFF)
+SELECT c1 FROM ft1 t1 WHERE t1.c1 = 101;
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index b9e1b04..96e5041 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -1197,6 +1197,105 @@ CREATE SUBSCRIPTION my_subscription SERVER subscription_server PUBLICATION testp
    The query that is actually sent to the remote server for execution can
    be examined using <command>EXPLAIN VERBOSE</command>.
   </para>
+
+  <para>
+   In addition to the remote query itself, the plan that the remote server
+   chooses for that query can be examined with the
+   <literal>REMOTE_PLANS</literal> option of <command>EXPLAIN</command>.
+   For every foreign scan or foreign modification in the local plan,
+   <filename>postgres_fdw</filename> runs <command>EXPLAIN</command> on the
+   remote server for the corresponding remote query and prints the result in a
+   <literal>Remote Plans</literal> section, keyed by the plan node id of the
+   local node.  For example:
+<programlisting>
+EXPLAIN (VERBOSE, REMOTE_PLANS) SELECT * FROM foreign_tbl WHERE id = 42;
+                        QUERY PLAN
+-----------------------------------------------------------
+ Foreign Scan on public.foreign_tbl
+   Output: id, val
+   Remote SQL: SELECT id, val FROM public.tbl WHERE ((id = 42))
+   Plan Node ID: 0
+ Remote Plans:
+ -------------
+ Plan Node ID 0:
+   Index Scan using tbl_pkey on public.tbl
+     Output: id, val
+     Index Cond: (tbl.id = 42)
+</programlisting>
+   When the local plan uses a parameterized foreign scan (for example, as
+   the inner side of a nested loop join), the remote SQL contains parameter
+   placeholders, which a plain <command>EXPLAIN</command> on the remote server
+   could not plan; it would fail with <literal>there is no parameter $1</literal>.
+   <literal>REMOTE_PLANS</literal> therefore asks the remote server for a
+   generic plan in this case:
+<programlisting>
+EXPLAIN (VERBOSE, REMOTE_PLANS, COSTS OFF)
+  SELECT * FROM local_tbl a, foreign_tbl b WHERE a.id = 1 AND b.id = a.val;
+                        QUERY PLAN
+-----------------------------------------------------------
+ Nested Loop
+   Output: a.id, a.val, b.id, b.val
+   -&gt;  Seq Scan on public.local_tbl a
+         Output: a.id, a.val
+         Filter: (a.id = 1)
+   -&gt;  Foreign Scan on public.foreign_tbl b
+         Output: b.id, b.val
+         Remote SQL: SELECT id, val FROM public.tbl WHERE ((id = $1::integer))
+         Plan Node ID: 1
+ Remote Plans:
+ -------------
+ Plan Node ID 1:
+   Index Scan using tbl_pkey on public.tbl
+     Output: id, val
+     Index Cond: (tbl.id = $1)
+</programlisting>
+  </para>
+
+  <para>
+   The <literal>REMOTE_PLANS</literal> option is registered when the
+   <filename>postgres_fdw</filename> library is loaded into the session.  The
+   library is loaded on demand, the first time the session plans or executes a
+   query against a foreign table, so in a session that has not yet touched a
+   foreign table the option is not recognized and
+   <command>EXPLAIN</command> reports
+   <literal>unrecognized EXPLAIN option "remote_plans"</literal>.  Note that
+   <command>CREATE EXTENSION postgres_fdw</command> alone does not load the
+   library into the current session.  To make the option available up front,
+   use <command>LOAD 'postgres_fdw'</command>, or list the library in
+   <xref linkend="guc-session-preload-libraries"/> or
+   <xref linkend="guc-shared-preload-libraries"/>.
+   The following restrictions apply:
+   <itemizedlist>
+    <listitem>
+     <para>
+      It cannot be combined with <literal>ANALYZE</literal>; doing so raises an
+      error.  The remote query is only planned, never executed, so the foreign
+      server is not affected and no remote rows are fetched.
+     </para>
+    </listitem>
+    <listitem>
+     <para>
+      The remote server must be running <productname>PostgreSQL</productname>
+      16 or later, because <literal>REMOTE_PLANS</literal> relies on the
+      <literal>GENERIC_PLAN</literal> option of <command>EXPLAIN</command>,
+      which was introduced in that release.  As shown in the parameterized
+      example above, the plan shown by <literal>REMOTE_PLANS</literal> is
+      always a generic plan, which may differ from the plan the remote
+      server actually uses at execution time when specific parameter values
+      are available.
+     </para>
+    </listitem>
+    <listitem>
+     <para>
+      All <command>EXPLAIN</command> options specified by the user (other than
+      <literal>REMOTE_PLANS</literal> itself and
+      <literal>GENERIC_PLAN</literal>) are forwarded to the remote server.
+      If the remote server is running an older version that does not recognize
+      a forwarded option, it will report an error.
+     </para>
+    </listitem>
+   </itemizedlist>
+  </para>
  </sect2>
 
  <sect2 id="postgres-fdw-remote-query-execution-environment">
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 56c1f99..be16b39 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2308,6 +2308,8 @@ PgChecksumMode
 PgFdwAnalyzeState
 PgFdwConnState
 PgFdwDirectModifyState
+PgFdwExplainRemotePlans
+PgFdwExplainState
 PgFdwModifyState
 PgFdwOption
 PgFdwPathExtraData
-- 
2.47.3

