agora inbox for pgsql-hackers@postgresql.org  
help / color / mirror / Atom feed
[(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
30+ messages / 9 participants
[nested] [flat]

* [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
@ 2025-07-18 15:53 Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Jehan-Guillaume de Rorthais @ 2025-07-18 15:53 UTC (permalink / raw)
  To: pgsql-hackers@lists.postgresql.org

Hi all,

We have been bitten by this old bug recently:

https://www.postgresql.org/message-id/flat/CAFjFpRfcgwsHRmpvoOK-GUQi-n8MgAS%2BOxcQo%3DaBDn1COywmcg%4...

I suspect this bug lost attention when the only fixed submitted to the
commitfest has been flagged as "returned with feedback":

https://commitfest.postgresql.org/patch/1819/

Please, find in attachment the old patch forbidding more than one row to be
deleted/updated from postgresExecForeignDelete and postgresExecForeignUpdate. I
just rebased them without modification. I suppose this is much safer than
leaving the FDW destroy arbitrary rows on the remote side based on their sole
ctid.

The original discussion talked about using "WHERE CURRENT OF" with cursors to
update/delete rows but discard it because of performance penalty. As adding
tableoid as a junk clause as been rejected, should we investigate the former?
At least for existing major release?

Or maybe we should just not support foreign table to reference a
remote partitioned table?

I'm afraid other fix suggestions from 2018-2019 couldn't be backported as they
seem to require additional feature in FDW altogether. This might be another
reason this bug has been forgotten.

Regards,

Attachments:

  [text/x-patch] v3-0001-Test-exposing-bug-when-foreign-table-points-to-a-.patch (10.2K, ../../20250718175314.4513c00a@karst/2-v3-0001-Test-exposing-bug-when-foreign-table-points-to-a-.patch)
  download | inline diff:
From d93f026e74659d3387a0ca1bbd8ae94fb0c240e1 Mon Sep 17 00:00:00 2001
From: Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
Date: Tue, 15 Jul 2025 12:45:21 +0200
Subject: [PATCH v3 1/2] Test exposing bug when foreign table points to a
 partitioned table

When a foreign table points to a partitioned table or an inheritance
parent on the foreign server, a non-direct DML can affect multiple
rows when only one row is intended to be affected. This
happens because postgres_fdw uses only ctid to identify a row to work
on. Though ctid uniquely identifies a row in a single table, in a
partitioned table or in an inheritance hierarchy, there can be be
multiple rows, in different partitions, with the same ctid. So
DML statement sent to the foreign server by postgres_fdw ends up
affecting more than one rows, only one of which is intended to be
affected.

This commit adds testcases to show the problem. A subsequent commit
would have a fix to the problem.

Author: Ashutosh Bapat <ashutosh.bapat.oss@gmail.com>
Reviewed-by: Kyotaro Horiguchi <horikyota.ntt@gmail.com>
Discussion: https://www.postgresql.org/message-id/flat/CAFjFpRfcgwsHRmpvoOK-GUQi-n8MgAS%2BOxcQo%3DaBDn1COywmcg%40mail.gmail.com

Rebased by Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
---
 .../postgres_fdw/expected/postgres_fdw.out    | 125 ++++++++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  58 ++++++++
 2 files changed, 183 insertions(+)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 2185b42bb4f..62019eaa881 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -8954,6 +8954,131 @@ drop foreign table remt2;
 drop table loct1;
 drop table loct2;
 drop table parent;
+-- test DML statement on a foreign table pointing to an inheritance hierarchy
+-- on the remote server
+CREATE TABLE a(aa TEXT);
+ALTER TABLE a SET (autovacuum_enabled = 'false');
+CREATE TABLE b() INHERITS(a);
+ALTER TABLE b SET (autovacuum_enabled = 'false');
+INSERT INTO a(aa) VALUES('aaa');
+INSERT INTO b(aa) VALUES('bbb');
+CREATE FOREIGN TABLE fa (aa TEXT) SERVER loopback OPTIONS (table_name 'a');
+SELECT tableoid::regclass, ctid, * FROM fa;
+ tableoid | ctid  | aa  
+----------+-------+-----
+ fa       | (0,1) | aaa
+ fa       | (0,1) | bbb
+(2 rows)
+
+-- use random() so that DML statement is not pushed down to the foreign
+-- server
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa';
+                                                   QUERY PLAN                                                    
+-----------------------------------------------------------------------------------------------------------------
+ Update on public.fa
+   Remote SQL: UPDATE public.a SET aa = $2 WHERE ctid = $1
+   ->  Foreign Scan on public.fa
+         Output: CASE WHEN (random() <= '1'::double precision) THEN 'zzzz'::text ELSE NULL::text END, ctid, fa.*
+         Remote SQL: SELECT aa, ctid FROM public.a WHERE ((aa = 'aaa')) FOR UPDATE
+(5 rows)
+
+UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa';
+SELECT tableoid::regclass, ctid, * FROM fa;
+ tableoid | ctid  |  aa  
+----------+-------+------
+ fa       | (0,2) | zzzz
+ fa       | (0,1) | bbb
+(2 rows)
+
+-- repopulate tables so that we have rows with same ctid
+TRUNCATE a, b;
+INSERT INTO a(aa) VALUES('aaa');
+INSERT INTO b(aa) VALUES('bbb');
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END);
+                                                  QUERY PLAN                                                   
+---------------------------------------------------------------------------------------------------------------
+ Delete on public.fa
+   Remote SQL: DELETE FROM public.a WHERE ctid = $1
+   ->  Foreign Scan on public.fa
+         Output: ctid
+         Filter: (fa.aa = CASE WHEN (random() <= '1'::double precision) THEN 'aaa'::text ELSE 'bbb'::text END)
+         Remote SQL: SELECT aa, ctid FROM public.a FOR UPDATE
+(6 rows)
+
+DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END);
+SELECT tableoid::regclass, ctid, * FROM fa;
+ tableoid | ctid  | aa 
+----------+-------+-----
+ fa       | (0,1) | bbb
+(1 row)
+
+-- cleanup
+DROP FOREIGN TABLE fa;
+DROP TABLE a CASCADE;
+NOTICE:  drop cascades to table b
+-- ===================================================================
+-- test foreign table pointing to a remote partitioned table
+-- ===================================================================
+-- test DML statement on foreign table pointing to a foreign partitioned table
+CREATE TABLE plt (a int, b int) PARTITION BY LIST(a);
+CREATE TABLE plt_p1 PARTITION OF plt FOR VALUES IN (1);
+CREATE TABLE plt_p2 PARTITION OF plt FOR VALUES IN (2);
+INSERT INTO plt VALUES (1, 1), (2, 2);
+CREATE FOREIGN TABLE fplt (a int, b int) SERVER loopback OPTIONS (table_name 'plt');
+SELECT tableoid::regclass, ctid, * FROM fplt;
+ tableoid | ctid  | a | b 
+----------+-------+---+---
+ fplt     | (0,1) | 1 | 1
+ fplt     | (0,1) | 2 | 2
+(2 rows)
+
+-- use random() so that DML statement is not pushed down to the foreign
+-- server
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1;
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
+ Update on public.fplt
+   Remote SQL: UPDATE public.plt SET b = $2 WHERE ctid = $1
+   ->  Foreign Scan on public.fplt
+         Output: CASE WHEN (random() <= '1'::double precision) THEN 10 ELSE 20 END, ctid, fplt.*
+         Remote SQL: SELECT a, b, ctid FROM public.plt WHERE ((a = 1)) FOR UPDATE
+(5 rows)
+
+UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1;
+SELECT tableoid::regclass, ctid, * FROM fplt;
+ tableoid | ctid  | a | b  
+----------+-------+---+----
+ fplt     | (0,2) | 1 | 10
+ fplt     | (0,1) | 2 | 2
+(2 rows)
+
+-- repopulate partitioned table so that we have rows with same ctid
+TRUNCATE plt;
+INSERT INTO plt VALUES (1, 1), (2, 2);
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM fplt WHERE a = (CASE WHEN random() <=  1 THEN 1 ELSE 10 END);
+                                         QUERY PLAN                                          
+---------------------------------------------------------------------------------------------
+ Delete on public.fplt
+   Remote SQL: DELETE FROM public.plt WHERE ctid = $1
+   ->  Foreign Scan on public.fplt
+         Output: ctid
+         Filter: (fplt.a = CASE WHEN (random() <= '1'::double precision) THEN 1 ELSE 10 END)
+         Remote SQL: SELECT a, ctid FROM public.plt FOR UPDATE
+(6 rows)
+
+DELETE FROM fplt WHERE a = (CASE WHEN random() <=  1 THEN 1 ELSE 10 END);
+SELECT tableoid::regclass, ctid, * FROM fplt;
+ tableoid | ctid  | a | b 
+----------+-------+---+---
+ fplt     | (0,1) | 2 | 2 
+(1 row)
+
+DROP TABLE plt;
+DROP FOREIGN TABLE fplt;
 -- ===================================================================
 -- test tuple routing for foreign-table partitions
 -- ===================================================================
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index e534b40de3c..ae3777b7edb 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -2494,6 +2494,64 @@ drop table loct1;
 drop table loct2;
 drop table parent;
 
+-- test DML statement on a foreign table pointing to an inheritance hierarchy
+-- on the remote server
+CREATE TABLE a(aa TEXT);
+ALTER TABLE a SET (autovacuum_enabled = 'false');
+CREATE TABLE b() INHERITS(a);
+ALTER TABLE b SET (autovacuum_enabled = 'false');
+INSERT INTO a(aa) VALUES('aaa');
+INSERT INTO b(aa) VALUES('bbb');
+CREATE FOREIGN TABLE fa (aa TEXT) SERVER loopback OPTIONS (table_name 'a');
+
+SELECT tableoid::regclass, ctid, * FROM fa;
+-- use random() so that DML statement is not pushed down to the foreign
+-- server
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa';
+UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa';
+SELECT tableoid::regclass, ctid, * FROM fa;
+-- repopulate tables so that we have rows with same ctid
+TRUNCATE a, b;
+INSERT INTO a(aa) VALUES('aaa');
+INSERT INTO b(aa) VALUES('bbb');
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END);
+DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END);
+SELECT tableoid::regclass, ctid, * FROM fa;
+
+-- cleanup
+DROP FOREIGN TABLE fa;
+DROP TABLE a CASCADE;
+
+-- ===================================================================
+-- test foreign table pointing to a remote partitioned table
+-- ===================================================================
+
+-- test DML statement on foreign table pointing to a foreign partitioned table
+CREATE TABLE plt (a int, b int) PARTITION BY LIST(a);
+CREATE TABLE plt_p1 PARTITION OF plt FOR VALUES IN (1);
+CREATE TABLE plt_p2 PARTITION OF plt FOR VALUES IN (2);
+INSERT INTO plt VALUES (1, 1), (2, 2);
+CREATE FOREIGN TABLE fplt (a int, b int) SERVER loopback OPTIONS (table_name 'plt');
+SELECT tableoid::regclass, ctid, * FROM fplt;
+-- use random() so that DML statement is not pushed down to the foreign
+-- server
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1;
+UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1;
+SELECT tableoid::regclass, ctid, * FROM fplt;
+-- repopulate partitioned table so that we have rows with same ctid
+TRUNCATE plt;
+INSERT INTO plt VALUES (1, 1), (2, 2);
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM fplt WHERE a = (CASE WHEN random() <=  1 THEN 1 ELSE 10 END);
+DELETE FROM fplt WHERE a = (CASE WHEN random() <=  1 THEN 1 ELSE 10 END);
+SELECT tableoid::regclass, ctid, * FROM fplt;
+
+DROP TABLE plt;
+DROP FOREIGN TABLE fplt;
+
 -- ===================================================================
 -- test tuple routing for foreign-table partitions
 -- ===================================================================
-- 
2.50.0

  [text/x-patch] v3-0002-Error-out-if-one-iteration-of-non-direct-DML-affe.patch (5.7K, ../../20250718175314.4513c00a@karst/3-v3-0002-Error-out-if-one-iteration-of-non-direct-DML-affe.patch)
  download | inline diff:
From 20d78db73ecc0ccea34d6cab03e9d882564493f6 Mon Sep 17 00:00:00 2001
From: Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
Date: Fri, 18 Jul 2025 16:52:38 +0200
Subject: [PATCH v3 2/2] Error out if one iteration of non-direct DML affects
 more than one row on the foreign server

When a foreign table points to a partitioned table or an inheritance
parent on the foreign server, a non-direct DML can affect multiple
rows when only one row is intended to be affected. This happens
because postgres_fdw uses only ctid to identify a row to work on.
Though ctid uniquely identifies a row in a single table, in a
partitioned table or in an inheritance hierarchy, there can be be
multiple rows, in different partitions, with the same ctid. So a DML
statement sent to the foreign server by postgres_fdw ends up affecting
more than one rows, only one of which is intended to be affected.

In such a case it's good to throw an error instead of corrupting
remote database with unwanted UPDATE/DELETEs. Subsequent commits will
try to fix this situation.

Author: Ashutosh Bapat <ashutosh.bapat.oss@gmail.com>
Author: Kyotaro Horiguchi <horikyota.ntt@gmail.com>

Rebased by Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
---
 .../postgres_fdw/expected/postgres_fdw.out    | 26 ++++++++------
 contrib/postgres_fdw/postgres_fdw.c           | 36 +++++++++++++++----
 2 files changed, 46 insertions(+), 16 deletions(-)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 62019eaa881..b0ef54a2889 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa
 (5 rows)
 
 UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa';
+ERROR:  foreign server affected 2 rows when only one was expected
 SELECT tableoid::regclass, ctid, * FROM fa;
- tableoid | ctid  |  aa  
-----------+-------+------
- fa       | (0,2) | zzzz
+ tableoid | ctid  | aa  
+----------+-------+-----
+ fa       | (0,1) | aaa
  fa       | (0,1) | bbb
 (2 rows)
 
@@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END);
 (6 rows)
 
 DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END);
+ERROR:  foreign server affected 2 rows when only one was expected
 SELECT tableoid::regclass, ctid, * FROM fa;
- tableoid | ctid  | aa 
+ tableoid | ctid  | aa  
 ----------+-------+-----
+ fa       | (0,1) | aaa
  fa       | (0,1) | bbb
-(1 row)
+(2 rows)
 
 -- cleanup
 DROP FOREIGN TABLE fa;
@@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1;
 (5 rows)
 
 UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1;
+ERROR:  foreign server affected 2 rows when only one was expected
 SELECT tableoid::regclass, ctid, * FROM fplt;
- tableoid | ctid  | a | b  
-----------+-------+---+----
- fplt     | (0,2) | 1 | 10
+ tableoid | ctid  | a | b 
+----------+-------+---+---
+ fplt     | (0,1) | 1 | 1
  fplt     | (0,1) | 2 | 2
 (2 rows)
 
@@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <=  1 THEN 1 ELSE 10 END);
 (6 rows)
 
 DELETE FROM fplt WHERE a = (CASE WHEN random() <=  1 THEN 1 ELSE 10 END);
+ERROR:  foreign server affected 2 rows when only one was expected
 SELECT tableoid::regclass, ctid, * FROM fplt;
  tableoid | ctid  | a | b 
 ----------+-------+---+---
- fplt     | (0,1) | 2 | 2 
-(1 row)
+ fplt     | (0,1) | 1 | 1
+ fplt     | (0,1) | 2 | 2
+(2 rows)
 
 DROP TABLE plt;
 DROP FOREIGN TABLE fplt;
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index e0a34b27c7c..09c87d0e5d8 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate,
 	ItemPointer ctid = NULL;
 	const char **p_values;
 	PGresult   *res;
-	int			n_rows;
+	int			n_rows_returned;
+	int			n_rows_affected;
 	StringInfoData sql;
 
 	/* The operation should be INSERT, UPDATE, or DELETE */
@@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate,
 		pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query);
 
 	/* Check number of rows affected, and fetch RETURNING tuple if any */
+	n_rows_affected = atoi(PQcmdTuples(res));
 	if (fmstate->has_returning)
 	{
 		Assert(*numSlots == 1);
-		n_rows = PQntuples(res);
-		if (n_rows > 0)
+		n_rows_returned = PQntuples(res);
+		if (n_rows_returned > 0)
 			store_returning_result(fmstate, slots[0], res);
+
+		// FIXME: shouldn't we check the max number of rows returned is one?
 	}
 	else
-		n_rows = atoi(PQcmdTuples(res));
+		n_rows_returned = 0;
 
 	/* And clean up */
 	PQclear(res);
 
 	MemoryContextReset(fmstate->temp_cxt);
 
-	*numSlots = n_rows;
+	/*
+	 * UPDATE & DELETE command can only affect one row, make sure this contract
+	 * is respected.
+	 * CMD_INSERT can insert multiple row when called from ForeignBatchInsert.
+	 */
+	if (operation != CMD_INSERT)
+	{
+		/* No rows should be returned if no rows were affected */
+		if (n_rows_affected == 0 && n_rows_returned != 0)
+			elog(ERROR, "foreign server returned %d rows when no row was affected",
+				 n_rows_returned);
+
+		/* ERROR if more than one row was updated on the remote end */
+		if (n_rows_affected > 1)
+			ereport(ERROR,
+					(errcode (ERRCODE_FDW_ERROR), /* XXX */
+					 errmsg ("foreign server affected %d rows when only one was expected",
+							 n_rows_affected)));
+	}
+
+	*numSlots = n_rows_returned;
 
 	/*
 	 * Return NULL if nothing was inserted/updated/deleted on the remote end
 	 */
-	return (n_rows > 0) ? slots : NULL;
+	return (n_rows_affected > 0) ? slots : NULL;
 }
 
 /*
-- 
2.50.0

^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
@ 2025-07-23 10:38 ` Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-27 10:35   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  0 siblings, 2 replies; 30+ messages in thread

From: Etsuro Fujita @ 2025-07-23 10:38 UTC (permalink / raw)
  To: Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; +Cc: pgsql-hackers@lists.postgresql.org

Hi,

On Sat, Jul 19, 2025 at 12:53 AM Jehan-Guillaume de Rorthais
<jgdr@dalibo.com> wrote:
> We have been bitten by this old bug recently:
>
> https://www.postgresql.org/message-id/flat/CAFjFpRfcgwsHRmpvoOK-GUQi-n8MgAS%2BOxcQo%3DaBDn1COywmcg%4...
>
> I suspect this bug lost attention when the only fixed submitted to the
> commitfest has been flagged as "returned with feedback":
>
> https://commitfest.postgresql.org/patch/1819/

This is on my TODO list, but I didn't have time to work on it, unfortunately.

> Please, find in attachment the old patch forbidding more than one row to be
> deleted/updated from postgresExecForeignDelete and postgresExecForeignUpdate. I
> just rebased them without modification. I suppose this is much safer than
> leaving the FDW destroy arbitrary rows on the remote side based on their sole
> ctid.

Thanks for rebasing the patch set, but I don't think the idea
implemented in the second patch is safe; even with the patch we have:

create table plt (a int, b int) partition by list (a);
create table plt_p1 partition of plt for values in (1);
create table plt_p2 partition of plt for values in (2);
create function trig_null() returns trigger language plpgsql as
  $$ begin return null; end; $$;
create trigger trig_null before update or delete on plt_p1
  for each row execute function trig_null();
create foreign table fplt (a int, b int)
  server loopback options (table_name 'plt');

insert into fplt values (1, 1), (2, 2);
INSERT 0 0
select tableoid::regclass, ctid, * from plt;
 tableoid | ctid  | a | b
----------+-------+---+---
 plt_p1   | (0,1) | 1 | 1
 plt_p2   | (0,1) | 2 | 2
(2 rows)

explain verbose update fplt set b = (case when random() <= 1 then 10
else 20 end) where a = 1;
                                           QUERY PLAN
-------------------------------------------------------------------------------------------------
 Update on public.fplt  (cost=100.00..128.02 rows=0 width=0)
   Remote SQL: UPDATE public.plt SET b = $2 WHERE ctid = $1
   ->  Foreign Scan on public.fplt  (cost=100.00..128.02 rows=7 width=42)
         Output: CASE WHEN (random() <= '1'::double precision) THEN 10
ELSE 20 END, ctid, fplt.*
         Remote SQL: SELECT a, b, ctid FROM public.plt WHERE ((a = 1))
FOR UPDATE
(5 rows)

update fplt set b = (case when random() <= 1 then 10 else 20 end) where a = 1;
UPDATE 1
select tableoid::regclass, ctid, * from plt;
 tableoid | ctid  | a | b
----------+-------+---+----
 plt_p1   | (0,1) | 1 |  1
 plt_p2   | (0,2) | 2 | 10
(2 rows)

The row in the partition plt_p2 is updated, which is wrong as the row
doesn't satisfy the query's WHERE condition.

> Or maybe we should just not support foreign table to reference a
> remote partitioned table?

I don't think so because we can execute SELECT, INSERT, and direct
UPDATE/DELETE safely on such a foreign table.

I think a simple fix for this is to teach the system that the foreign
table is a partitioned table; in more detail, I would like to propose
to 1) add to postgres_fdw a table option, inherited, to indicate
whether the foreign table is a partitioned/inherited table or not, and
2) modify postgresPlanForeignModify() to throw an error if the given
operation is an update/delete on such a foreign table.  Attached is a
WIP patch for that.  I think it is the user's responsibility to set
the option properly, but we could modify postgresImportForeignSchema()
to support that.  Also, I think this would be back-patchable.

What do you think about that?

Best regards,
Etsuro Fujita

Attachments:

  [application/octet-stream] postgres_fdw-disallow-upddel-in-problematic-cases-wip.patch (8.0K, ../../CAPmGK15CQK-oYFMAyq+rR0rQapUHtvAGuGgY5ahERHzZ4tmC8g@mail.gmail.com/2-postgres_fdw-disallow-upddel-in-problematic-cases-wip.patch)
  download | inline diff:
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index fa9ee716067..fdadfb06ef9 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -7121,6 +7121,33 @@ RESET enable_material;
 DROP FOREIGN TABLE remt2;
 DROP TABLE loct1;
 DROP TABLE loct2;
+-- Test non-pushed-down UPDATE/DELETE on partitioned/inherited foreign tables
+CREATE TABLE plt (a int, b int) PARTITION BY LIST(a);
+CREATE TABLE plt_p1 PARTITION OF plt FOR VALUES IN (1);
+CREATE TABLE plt_p2 PARTITION OF plt FOR VALUES IN (2);
+CREATE FOREIGN TABLE inh_ft (a int, b int) SERVER loopback OPTIONS (table_name 'plt', inherited 'true');
+INSERT INTO inh_ft VALUES (1, 1), (2, 2);
+SELECT tableoid::regclass, ctid, * FROM inh_ft;
+ tableoid | ctid  | a | b 
+----------+-------+---+---
+ inh_ft   | (0,1) | 1 | 1
+ inh_ft   | (0,1) | 2 | 2
+(2 rows)
+
+-- Use random() so that UPDATE/DELETE is not pushed down to the remote
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE inh_ft SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 100 END) WHERE a = 1; -- should fail
+ERROR:  cannot update foreign table "inh_ft"
+UPDATE inh_ft SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 100 END) WHERE a = 1; -- should fail
+ERROR:  cannot update foreign table "inh_ft"
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM inh_ft WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); -- should fail
+ERROR:  cannot delete from foreign table "inh_ft"
+DELETE FROM inh_ft WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); -- should fail
+ERROR:  cannot delete from foreign table "inh_ft"
+-- Cleanup
+DROP FOREIGN TABLE inh_ft;
+DROP TABLE plt;
 -- ===================================================================
 -- test check constraints
 -- ===================================================================
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index c2f936640bc..85b40b948c3 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -119,7 +119,8 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 		/*
 		 * Validate option value, when we can do so without any context.
 		 */
-		if (strcmp(def->defname, "use_remote_estimate") == 0 ||
+		if (strcmp(def->defname, "inherited") == 0 ||
+			strcmp(def->defname, "use_remote_estimate") == 0 ||
 			strcmp(def->defname, "updatable") == 0 ||
 			strcmp(def->defname, "truncatable") == 0 ||
 			strcmp(def->defname, "async_capable") == 0 ||
@@ -247,6 +248,7 @@ InitPgFdwOptions(void)
 		{"schema_name", ForeignTableRelationId, false},
 		{"table_name", ForeignTableRelationId, false},
 		{"column_name", AttributeRelationId, false},
+		{"inherited", ForeignTableRelationId, false},
 		/* use_remote_estimate is available on both server and table */
 		{"use_remote_estimate", ForeignServerRelationId, false},
 		{"use_remote_estimate", ForeignTableRelationId, false},
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index e0a34b27c7c..e5d186dc62c 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -649,6 +649,7 @@ postgresGetForeignRelSize(PlannerInfo *root,
 	 * use_remote_estimate, fetch_size and async_capable override per-server
 	 * settings of them, respectively.
 	 */
+	fpinfo->inherited = false;
 	fpinfo->use_remote_estimate = false;
 	fpinfo->fdw_startup_cost = DEFAULT_FDW_STARTUP_COST;
 	fpinfo->fdw_tuple_cost = DEFAULT_FDW_TUPLE_COST;
@@ -1794,6 +1795,28 @@ postgresPlanForeignModify(PlannerInfo *root,
 	bool		doNothing = false;
 	int			values_end_len = -1;
 
+	/*
+	 * We don't currently support update/delete on partitioned/inherited
+	 * foreign tables via ExecForeignUpdate()/ExecForeignDelete(); if the
+	 * operation is update/delete, check whether the foreign table is
+	 * partitioned/inherited or not, and if so, throw an error.  Note that
+	 * we would have already determined that the operation on the foreign
+	 * table cannot be executed via DirectModify before we get here.
+	 */
+	if (operation == CMD_UPDATE || operation == CMD_DELETE)
+	{
+		RelOptInfo *rel = find_base_rel(root, resultRelation);
+		PgFdwRelationInfo *fpinfo = (PgFdwRelationInfo *) rel->fdw_private;
+
+		if (fpinfo && fpinfo->inherited)
+			ereport(ERROR,
+					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					 errmsg((operation == CMD_UPDATE) ?
+							"cannot update foreign table \"%s\"" :
+							"cannot delete from foreign table \"%s\"",
+							get_rel_name(rte->relid))));
+	}
+
 	initStringInfo(&sql);
 
 	/*
@@ -6292,7 +6315,9 @@ apply_table_options(PgFdwRelationInfo *fpinfo)
 	{
 		DefElem    *def = (DefElem *) lfirst(lc);
 
-		if (strcmp(def->defname, "use_remote_estimate") == 0)
+		if (strcmp(def->defname, "inherited") == 0)
+			fpinfo->inherited = defGetBoolean(def);
+		else if (strcmp(def->defname, "use_remote_estimate") == 0)
 			fpinfo->use_remote_estimate = defGetBoolean(def);
 		else if (strcmp(def->defname, "fetch_size") == 0)
 			(void) parse_int(defGetString(def), &fpinfo->fetch_size, 0, NULL);
@@ -6414,6 +6439,8 @@ postgresGetForeignJoinPaths(PlannerInfo *root,
 	joinrel->fdw_private = fpinfo;
 	/* attrs_used is only for base relations. */
 	fpinfo->attrs_used = NULL;
+	/* inherited is only for base relations. */
+	fpinfo->inherited = false;
 
 	/*
 	 * If there is a possibility that EvalPlanQual will be executed, we need
@@ -6780,6 +6807,8 @@ postgresGetForeignUpperPaths(PlannerInfo *root, UpperRelationKind stage,
 
 	fpinfo = (PgFdwRelationInfo *) palloc0(sizeof(PgFdwRelationInfo));
 	fpinfo->pushdown_safe = false;
+	/* inherited is only for base relations. */
+	fpinfo->inherited = false;
 	fpinfo->stage = stage;
 	output_rel->fdw_private = fpinfo;
 
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index 81358f3bde7..7163b2966dd 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -76,10 +76,12 @@ typedef struct PgFdwRelationInfo
 	Cost		rel_total_cost;
 
 	/* Options extracted from catalogs. */
+	bool		inherited;
 	bool		use_remote_estimate;
 	Cost		fdw_startup_cost;
 	Cost		fdw_tuple_cost;
 	List	   *shippable_extensions;	/* OIDs of shippable extensions */
+	int			fetch_size;		/* fetch size for this remote table */
 	bool		async_capable;
 
 	/* Cached catalog information. */
@@ -87,8 +89,6 @@ typedef struct PgFdwRelationInfo
 	ForeignServer *server;
 	UserMapping *user;			/* only set in use_remote_estimate mode */
 
-	int			fetch_size;		/* fetch size for this remote table */
-
 	/*
 	 * Name of the relation, for use while EXPLAINing ForeignScan.  It is used
 	 * for join and upper relations but is set for all relations.  For a base
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index b449e122abe..f447eba29dd 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -1725,6 +1725,28 @@ DROP FOREIGN TABLE remt2;
 DROP TABLE loct1;
 DROP TABLE loct2;
 
+-- Test non-pushed-down UPDATE/DELETE on partitioned/inherited foreign tables
+CREATE TABLE plt (a int, b int) PARTITION BY LIST(a);
+CREATE TABLE plt_p1 PARTITION OF plt FOR VALUES IN (1);
+CREATE TABLE plt_p2 PARTITION OF plt FOR VALUES IN (2);
+CREATE FOREIGN TABLE inh_ft (a int, b int) SERVER loopback OPTIONS (table_name 'plt', inherited 'true');
+
+INSERT INTO inh_ft VALUES (1, 1), (2, 2);
+SELECT tableoid::regclass, ctid, * FROM inh_ft;
+
+-- Use random() so that UPDATE/DELETE is not pushed down to the remote
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE inh_ft SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 100 END) WHERE a = 1; -- should fail
+UPDATE inh_ft SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 100 END) WHERE a = 1; -- should fail
+
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM inh_ft WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); -- should fail
+DELETE FROM inh_ft WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); -- should fail
+
+-- Cleanup
+DROP FOREIGN TABLE inh_ft;
+DROP TABLE plt;
+
 -- ===================================================================
 -- test check constraints
 -- ===================================================================


^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
@ 2025-07-27 10:35   ` Etsuro Fujita <etsuro.fujita@gmail.com>
  1 sibling, 0 replies; 30+ messages in thread

From: Etsuro Fujita @ 2025-07-27 10:35 UTC (permalink / raw)
  To: Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; +Cc: pgsql-hackers@lists.postgresql.org

On Wed, Jul 23, 2025 at 7:38 PM Etsuro Fujita <etsuro.fujita@gmail.com> wrote:
> I think a simple fix for this is to teach the system that the foreign
> table is a partitioned table; in more detail, I would like to propose
> to 1) add to postgres_fdw a table option, inherited, to indicate
> whether the foreign table is a partitioned/inherited table or not, and
> 2) modify postgresPlanForeignModify() to throw an error if the given
> operation is an update/delete on such a foreign table.  Attached is a
> WIP patch for that.  I think it is the user's responsibility to set
> the option properly, but we could modify postgresImportForeignSchema()
> to support that.  Also, I think this would be back-patchable.

I noticed that this issue occurs in other cases: eg, if a foreign
table is an updatable view on a partitioned/inherited table,
non-pushed-down updates/deletes on the foreign table have the same
issue.  So adding the support in postgresImportForeignSchema() is not
that simple.  I feel like leaving it to the user, at least for
back-branches.

Best regards,
Etsuro Fujita





^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
@ 2025-07-29 15:48   ` Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  1 sibling, 1 reply; 30+ messages in thread

From: Jehan-Guillaume de Rorthais @ 2025-07-29 15:48 UTC (permalink / raw)
  To: Etsuro Fujita <etsuro.fujita@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org

Hi,

On Wed, 23 Jul 2025 19:38:19 +0900
Etsuro Fujita <etsuro.fujita@gmail.com> wrote:

> Hi,
> 
> On Sat, Jul 19, 2025 at 12:53 AM Jehan-Guillaume de Rorthais
> <jgdr@dalibo.com> wrote:
> > […]
> > Please, find in attachment the old patch forbidding more than one row to be
> > deleted/updated from postgresExecForeignDelete and
> > postgresExecForeignUpdate. I just rebased them without modification. I
> > suppose this is much safer than leaving the FDW destroy arbitrary rows on
> > the remote side based on their sole ctid.
> 
> Thanks for rebasing the patch set, but I don't think the idea
> implemented in the second patch is safe; even with the patch we have:
> 
> […]
> 
> The row in the partition plt_p2 is updated, which is wrong as the row
> doesn't satisfy the query's WHERE condition.

That's a clever test to expose the weakness of this patch…

> > Or maybe we should just not support foreign table to reference a
> > remote partitioned table?
> 
> I don't think so because we can execute SELECT, INSERT, and direct
> UPDATE/DELETE safely on such a foreign table.

Sure, but it's still possible to create one local foreign partition pointing to
remote foreign equivalent. And it seems safer considering how hard it seems to
keep corruptions away from the current situation. 

> I think a simple fix for this is to teach the system that the foreign
> table is a partitioned table; in more detail, I would like to propose
> to 1) add to postgres_fdw a table option, inherited, to indicate
> whether the foreign table is a partitioned/inherited table or not, and
> 2) modify postgresPlanForeignModify() to throw an error if the given
> operation is an update/delete on such a foreign table.  Attached is a
> WIP patch for that.  I think it is the user's responsibility to set
> the option properly, but we could modify postgresImportForeignSchema()
> to support that.  Also, I think this would be back-patchable.

So it's just a flag the user must set to allow/disallow UPDATE/DELETE on a
foreign table. I'm not convinced by this solution as users can still
easily corrupt their data just because they overlooked the documentation.

What about the first solution Ashutosh Bapat was suggesting: «Use WHERE CURRENT
OF with cursors to update rows.» ?
https://www.postgresql.org/message-id/CAFjFpRfcgwsHRmpvoOK-GUQi-n8MgAS%2BOxcQo%3DaBDn1COywmcg%40mail...

It seems to me it never has been explored, is it?

Regards,





^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
@ 2025-08-06 11:25     ` Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Etsuro Fujita @ 2025-08-06 11:25 UTC (permalink / raw)
  To: Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; +Cc: pgsql-hackers@lists.postgresql.org

On Wed, Jul 30, 2025 at 12:48 AM Jehan-Guillaume de Rorthais
<jgdr@dalibo.com> wrote:
> On Wed, 23 Jul 2025 19:38:19 +0900
> Etsuro Fujita <etsuro.fujita@gmail.com> wrote:
> > On Sat, Jul 19, 2025 at 12:53 AM Jehan-Guillaume de Rorthais
> > <jgdr@dalibo.com> wrote:
> > > Or maybe we should just not support foreign table to reference a
> > > remote partitioned table?
> >
> > I don't think so because we can execute SELECT, INSERT, and direct
> > UPDATE/DELETE safely on such a foreign table.
>
> Sure, but it's still possible to create one local foreign partition pointing to
> remote foreign equivalent. And it seems safer considering how hard it seems to
> keep corruptions away from the current situation.

Yeah, that would be a simple workaround for this issue.

> > I think a simple fix for this is to teach the system that the foreign
> > table is a partitioned table; in more detail, I would like to propose
> > to 1) add to postgres_fdw a table option, inherited, to indicate
> > whether the foreign table is a partitioned/inherited table or not, and
> > 2) modify postgresPlanForeignModify() to throw an error if the given
> > operation is an update/delete on such a foreign table.  Attached is a
> > WIP patch for that.  I think it is the user's responsibility to set
> > the option properly, but we could modify postgresImportForeignSchema()
> > to support that.  Also, I think this would be back-patchable.
>
> So it's just a flag the user must set to allow/disallow UPDATE/DELETE on a
> foreign table. I'm not convinced by this solution as users can still
> easily corrupt their data just because they overlooked the documentation.
>
> What about the first solution Ashutosh Bapat was suggesting: «Use WHERE CURRENT
> OF with cursors to update rows.» ?
> https://www.postgresql.org/message-id/CAFjFpRfcgwsHRmpvoOK-GUQi-n8MgAS%2BOxcQo%3DaBDn1COywmcg%40mail...
>
> It seems to me it never has been explored, is it?

My concern about that solution is: as mentioned by him, it requires
fetching only one row from the remote at a time, which would lead to
large performance degradation when updating many rows.

Best regards,
Etsuro Fujita





^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
@ 2026-04-21 11:34       ` Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Nikita Malakhov @ 2026-04-21 11:34 UTC (permalink / raw)
  To: Etsuro Fujita <etsuro.fujita@gmail.com>; +Cc: Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Hi hackers!

We've been bitten by this bug recently too. So I've taken off from the
previous approach,
have fixed some issues and developed it a but further, please check this
POC patch.

While working on it I've stumbled upon a bunch of checks inside copy
slot/tuple machinery
which considered only case when source and destination match, with an old
commentary
that this is not very good and has to be improved, so I've slightly
modified this functionality
for my purposes.

On Wed, Aug 6, 2025 at 2:25 PM Etsuro Fujita <etsuro.fujita@gmail.com>
wrote:

> On Wed, Jul 30, 2025 at 12:48 AM Jehan-Guillaume de Rorthais
> <jgdr@dalibo.com> wrote:
> > On Wed, 23 Jul 2025 19:38:19 +0900
> > Etsuro Fujita <etsuro.fujita@gmail.com> wrote:
> > > On Sat, Jul 19, 2025 at 12:53 AM Jehan-Guillaume de Rorthais
> > > <jgdr@dalibo.com> wrote:
> > > > Or maybe we should just not support foreign table to reference a
> > > > remote partitioned table?
> > >
> > > I don't think so because we can execute SELECT, INSERT, and direct
> > > UPDATE/DELETE safely on such a foreign table.
> >
> > Sure, but it's still possible to create one local foreign partition
> pointing to
> > remote foreign equivalent. And it seems safer considering how hard it
> seems to
> > keep corruptions away from the current situation.
>
> Yeah, that would be a simple workaround for this issue.
>
> > > I think a simple fix for this is to teach the system that the foreign
> > > table is a partitioned table; in more detail, I would like to propose
> > > to 1) add to postgres_fdw a table option, inherited, to indicate
> > > whether the foreign table is a partitioned/inherited table or not, and
> > > 2) modify postgresPlanForeignModify() to throw an error if the given
> > > operation is an update/delete on such a foreign table.  Attached is a
> > > WIP patch for that.  I think it is the user's responsibility to set
> > > the option properly, but we could modify postgresImportForeignSchema()
> > > to support that.  Also, I think this would be back-patchable.
> >
> > So it's just a flag the user must set to allow/disallow UPDATE/DELETE on
> a
> > foreign table. I'm not convinced by this solution as users can still
> > easily corrupt their data just because they overlooked the documentation.
> >
> > What about the first solution Ashutosh Bapat was suggesting: «Use WHERE
> CURRENT
> > OF with cursors to update rows.» ?
> >
> https://www.postgresql.org/message-id/CAFjFpRfcgwsHRmpvoOK-GUQi-n8MgAS%2BOxcQo%3DaBDn1COywmcg%40mail...
> >
> > It seems to me it never has been explored, is it?
>
> My concern about that solution is: as mentioned by him, it requires
> fetching only one row from the remote at a time, which would lead to
> large performance degradation when updating many rows.
>
> Best regards,
> Etsuro Fujita
>
>
>

-- 
Regards,
Nikita Malakhov
Postgres Professional
The Russian Postgres Company
https://postgrespro.ru/

Attachments:

  [application/octet-stream] v1-0003-fdw-del-upd-using-tableoid.patch (92.3K, ../../CAN-LCVMz58ukZ7ubGXiLuTeFE7wWmSwDw4URpF0q1ejzRvqbzg@mail.gmail.com/3-v1-0003-fdw-del-upd-using-tableoid.patch)
  download | inline diff:
From 8e8d186585c1716f6573e9e0593bdec6badbc7e4 Mon Sep 17 00:00:00 2001
From: Nikita Malakhov <n.malakhov@postgrespro.ru>
Date: Tue, 21 Apr 2026 14:21:13 +0300
Subject: [PATCH] [POC] This patch modifies FDW engine to use remote table OID
 for DELETE and UPDATE of partitioned tables.

[1] https://www.postgresql.org/message-id/flat/CAPmGK15CQK-oYFMAyq%2BrR0rQapUHtvAGuGgY5ahERHzZ4tmC8g%40mail.gmail.com#4321975bbd1af71c78d45d9a441e8458
---
 contrib/postgres_fdw/deparse.c                |  85 ++-
 .../postgres_fdw/expected/postgres_fdw.out    | 508 ++++++++++--------
 contrib/postgres_fdw/postgres_fdw.c           | 308 ++++++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   3 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  44 ++
 5 files changed, 700 insertions(+), 248 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index 2dcc6c8af1b..8d566bce4bf 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -48,6 +48,7 @@
 #include "catalog/pg_ts_dict.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
+#include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
 #include "nodes/plannodes.h"
 #include "optimizer/optimizer.h"
@@ -132,6 +133,7 @@ static void deparseTargetList(StringInfo buf,
 							  Relation rel,
 							  bool is_returning,
 							  Bitmapset *attrs_used,
+							  bool tableoid_needed,
 							  bool qualify_col,
 							  List **retrieved_attrs);
 static void deparseExplicitTargetList(List *tlist,
@@ -350,10 +352,11 @@ foreign_expr_walker(Node *node,
 					/* Var belongs to foreign table */
 
 					/*
-					 * System columns other than ctid should not be sent to
-					 * the remote, since we don't make any effort to ensure
-					 * that local and remote values match (tableoid, in
-					 * particular, almost certainly doesn't match).
+					 * System columns other than ctid and remote table oid
+					 * should not be sent to the remote, since we don't make
+					 * any effort to ensure that local and remote values
+					 * match (tableoid, in particular, almost certainly
+					 * doesn't match).
 					 */
 					if (var->varattno < 0 &&
 						var->varattno != SelfItemPointerAttributeNumber)
@@ -1235,6 +1238,23 @@ build_tlist_to_deparse(RelOptInfo *foreignrel)
 												  PVC_RECURSE_PLACEHOLDERS));
 	}
 
+	/* Also, add the Param representing the remote table OID, if it exists. */
+	if (fpinfo->tableoid_param)
+	{
+		TargetEntry *tle;
+		/*
+		 * Core code should have contained the Param in the given relation's
+		 * reltarget.
+		 */
+		Assert(list_member(foreignrel->reltarget->exprs,
+						   fpinfo->tableoid_param));
+		tle = makeTargetEntry((Expr *) copyObject(fpinfo->tableoid_param),
+							  list_length(tlist) + 1,
+							  NULL,
+							  false);
+		tlist = lappend(tlist, tle);
+	}
+
 	return tlist;
 }
 
@@ -1390,7 +1410,9 @@ deparseSelectSql(List *tlist, bool is_subquery, List **retrieved_attrs,
 		Relation	rel = table_open(rte->relid, NoLock);
 
 		deparseTargetList(buf, rte, foreignrel->relid, rel, false,
-						  fpinfo->attrs_used, false, retrieved_attrs);
+						  fpinfo->attrs_used,
+						  fpinfo->tableoid_param != NULL,
+						  false, retrieved_attrs);
 		table_close(rel, NoLock);
 	}
 }
@@ -1441,6 +1463,7 @@ deparseTargetList(StringInfo buf,
 				  Relation rel,
 				  bool is_returning,
 				  Bitmapset *attrs_used,
+				  bool tableoid_needed,
 				  bool qualify_col,
 				  List **retrieved_attrs)
 {
@@ -1499,6 +1522,22 @@ deparseTargetList(StringInfo buf,
 									   SelfItemPointerAttributeNumber);
 	}
 
+	if (tableoid_needed &&
+	(bms_is_member(TableOidAttributeNumber - FirstLowInvalidHeapAttributeNumber,
+					  attrs_used)))
+	{
+			Assert(!first);
+			Assert(!is_returning);
+
+			appendStringInfoString(buf, ", ");
+			if (qualify_col)
+				ADD_REL_QUALIFIER(buf, rtindex);
+			appendStringInfoString(buf, "tableoid");
+
+			*retrieved_attrs = lappend_int(*retrieved_attrs,
+											TableOidAttributeNumber);
+	}
+
 	/* Don't generate bad syntax if no undropped columns */
 	if (first && !is_returning)
 		appendStringInfoString(buf, "NULL");
@@ -2259,7 +2298,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 	deparseRelation(buf, rel);
 	appendStringInfoString(buf, " SET ");
 
-	pindex = 2;					/* ctid is always the first param */
+	pindex = 3;					/* ctid is always the first param */
 	first = true;
 	foreach(lc, targetAttrs)
 	{
@@ -2279,7 +2318,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 			pindex++;
 		}
 	}
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_update_after_row,
@@ -2397,7 +2436,7 @@ deparseDeleteSql(StringInfo buf, RangeTblEntry *rte,
 {
 	appendStringInfoString(buf, "DELETE FROM ");
 	deparseRelation(buf, rel);
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_delete_after_row,
@@ -2512,7 +2551,7 @@ deparseReturningList(StringInfo buf, RangeTblEntry *rte,
 	}
 
 	if (attrs_used != NULL)
-		deparseTargetList(buf, rte, rtindex, rel, true, attrs_used, false,
+		deparseTargetList(buf, rte, rtindex, rel, true, attrs_used, false, false,
 						  retrieved_attrs);
 	else
 		*retrieved_attrs = NIL;
@@ -2719,6 +2758,12 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 			ADD_REL_QUALIFIER(buf, varno);
 		appendStringInfoString(buf, "ctid");
 	}
+	else if (varattno == TableOidAttributeNumber)
+	{
+		if (qualify_col)
+			ADD_REL_QUALIFIER(buf, varno);
+		appendStringInfoString(buf, "tableoid");
+	}
 	else if (varattno < 0)
 	{
 		/*
@@ -2730,8 +2775,9 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 		Oid			fetchval = 0;
 
 		if (varattno == TableOidAttributeNumber)
+		{
 			fetchval = rte->relid;
-
+		}
 		if (qualify_col)
 		{
 			appendStringInfoString(buf, "CASE WHEN (");
@@ -2782,7 +2828,7 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 
 		appendStringInfoString(buf, "ROW(");
 		deparseTargetList(buf, rte, varno, rel, false, attrs_used, qualify_col,
-						  &retrieved_attrs);
+						  qualify_col, &retrieved_attrs);
 		appendStringInfoChar(buf, ')');
 
 		/* Complete the CASE WHEN statement started above. */
@@ -3167,6 +3213,23 @@ deparseConst(Const *node, deparse_expr_cxt *context, int showtype)
 static void
 deparseParam(Param *node, deparse_expr_cxt *context)
 {
+	PgFdwRelationInfo *fpinfo = (PgFdwRelationInfo *) context->foreignrel->fdw_private;
+
+	/*
+	 * If the Param is the one representing the remote table OID, the value
+	 * needs to be produced; fetch the remote table OID, instead.
+	 */
+	if (equal(node, (Node *) fpinfo->tableoid_param))
+	{
+		Assert(bms_is_member(context->root->parse->resultRelation,
+							 context->foreignrel->relids));
+		Assert(bms_membership(context->foreignrel->relids) == BMS_MULTIPLE);
+		ADD_REL_QUALIFIER(context->buf, context->root->parse->resultRelation);
+
+		appendStringInfoString(context->buf, "tableoid");
+		return;
+	}
+
 	if (context->params_list)
 	{
 		int			pindex = 0;
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 10e87acabef..8b93f6785fc 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -5275,14 +5275,14 @@ BEGIN;
   EXPLAIN (verbose, costs off)
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
     RETURNING old.*, new.*;                                                         -- can't be pushed down
-                                                                  QUERY PLAN                                                                  
-----------------------------------------------------------------------------------------------------------------------------------------------
+                                                                       QUERY PLAN                                                                       
+--------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.c1, old.c2, old.c3, old.c4, old.c5, old.c6, old.c7, old.c8, new.c1, new.c2, new.c3, new.c4, new.c5, new.c6, new.c7, new.c8
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c3 = $3 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c3 = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan on public.ft2
-         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, ft2.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
+         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, tableoid, $0, ft2.*
+         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
 (6 rows)
 
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
@@ -5429,14 +5429,14 @@ DELETE FROM ft2 WHERE c1 % 10 = 5 RETURNING c1, c4;
 BEGIN;
   EXPLAIN (verbose, costs off)
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;               -- can't be pushed down
-                                                QUERY PLAN                                                 
------------------------------------------------------------------------------------------------------------
+                                                     QUERY PLAN                                                      
+---------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: old.c1, c4
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c4
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c4
    ->  Foreign Scan on public.ft2
-         Output: ctid
-         Remote SQL: SELECT ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
+         Output: ctid, tableoid, $0
+         Remote SQL: SELECT ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
 (6 rows)
 
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;
@@ -6400,27 +6400,27 @@ BEGIN;
     FROM ft4 INNER JOIN ft5 ON (ft4.c1 = ft5.c1)
     WHERE ft2.c1 > 1200 AND ft2.c2 = ft4.c1
     RETURNING old, new, ft2, ft2.*, ft4, ft4.*;  -- can't be pushed down
-                                                                                                                                                                                                                                     QUERY PLAN                                                                                                                                                                                                                                     
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                                                                                  QUERY PLAN                                                                                                                                                                                                                                                  
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.*, new.*, ft2.*, ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.*, ft4.c1, ft4.c2, ft4.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan
-         Output: 'bar'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+         Output: 'bar'::text, ft2.ctid, ft2.tableoid, ($0), ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3, r1.tableoid FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+               Output: ft2.ctid, ft2.tableoid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ($0)
                Join Filter: (ft4.c1 = ft5.c1)
                ->  Sort
-                     Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                     Output: ft2.ctid, ft2.tableoid, ft2.*, ($0), ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                      Sort Key: ft2.c2
                      ->  Hash Join
-                           Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                           Output: ft2.ctid, ft2.tableoid, ft2.*, ($0), ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                            Hash Cond: (ft2.c2 = ft4.c1)
                            ->  Foreign Scan on public.ft2
-                                 Output: ft2.ctid, ft2.*, ft2.c2
-                                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
+                                 Output: ft2.ctid, ft2.tableoid, ft2.*, $0, ft2.c2
+                                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
                            ->  Hash
                                  Output: ft4.*, ft4.c1, ft4.c2, ft4.c3
                                  ->  Foreign Scan on public.ft4
@@ -6498,13 +6498,13 @@ UPDATE ft2 AS target SET (c2, c7) = (
         FROM ft2 AS src
         WHERE target.c1 = src.c1
 ) WHERE c1 > 1100;
-                                                         QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
+                                                                    QUERY PLAN                                                                    
+--------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 target
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c7 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c7 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.ft2 target
-         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, target.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
+         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, target.tableoid, $3, target.*
+         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
          SubPlan multiexpr_1
            ->  Foreign Scan on public.ft2 src
                  Output: (src.c2 * 10), src.c7
@@ -6526,20 +6526,20 @@ UPDATE ft2 AS target SET (c2) = (
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE ft2 d SET c2 = CASE WHEN random() >= 0 THEN d.c2 ELSE 0 END
   FROM ft2 AS t WHERE d.c1 = t.c1 AND d.c1 > 1000;
-                                                                                                                                                                                       QUERY PLAN                                                                                                                                                                                        
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                                    QUERY PLAN                                                                                                                                                                                                     
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 d
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan
-         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, d.*, t.*
+         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, d.tableoid, ($0), d.*, t.*
          Relations: (public.ft2 d) INNER JOIN (public.ft2 t)
-         Remote SQL: SELECT r1.c2, r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.c2, r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END, r1.tableoid FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
          ->  Hash Join
-               Output: d.c2, d.ctid, d.*, t.*
+               Output: d.c2, d.ctid, d.tableoid, d.*, t.*, ($0)
                Hash Cond: (d.c1 = t.c1)
                ->  Foreign Scan on public.ft2 d
-                     Output: d.c2, d.ctid, d.*, d.c1
-                     Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
+                     Output: d.c2, d.ctid, d.tableoid, d.*, $0, d.c1
+                     Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
                ->  Hash
                      Output: t.*, t.c1
                      ->  Foreign Scan on public.ft2 t
@@ -6559,19 +6559,19 @@ EXPLAIN (verbose, costs off)
 WITH cte AS (
   UPDATE ft2 SET c3 = 'bar' WHERE postgres_fdw_abs(c1) > 2000 RETURNING *
 ) SELECT * FROM cte ORDER BY c1;          -- can't be pushed down
-                                                    QUERY PLAN                                                    
-------------------------------------------------------------------------------------------------------------------
+                                                             QUERY PLAN                                                             
+------------------------------------------------------------------------------------------------------------------------------------
  Sort
    Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
    Sort Key: cte.c1
    CTE cte
      ->  Update on public.ft2
            Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8
-           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
            ->  Foreign Scan on public.ft2
-                 Output: 'bar'::text, ft2.ctid, ft2.*
+                 Output: 'bar'::text, ft2.ctid, ft2.tableoid, $0, ft2.*
                  Filter: (postgres_fdw_abs(ft2.c1) > 2000)
-                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" FOR UPDATE
+                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" FOR UPDATE
    ->  CTE Scan on cte
          Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
 (13 rows)
@@ -6602,13 +6602,13 @@ UPDATE ft2 SET c3 = 'baz'
 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Nested Loop
-         Output: 'baz'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
+         Output: 'baz'::text, ft2.ctid, ft2.tableoid, ($0), ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
          Join Filter: (ft2.c2 === ft4.c1)
          ->  Foreign Scan on public.ft2
-               Output: ft2.ctid, ft2.*, ft2.c2
-               Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+               Output: ft2.ctid, ft2.tableoid, ft2.*, $0, ft2.c2
+               Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
          ->  Foreign Scan
                Output: ft4.*, ft4.c1, ft4.c2, ft4.c3, ft5.*, ft5.c1, ft5.c2, ft5.c3
                Relations: (public.ft4) INNER JOIN (public.ft5)
@@ -6640,24 +6640,24 @@ DELETE FROM ft2
   USING ft4 INNER JOIN ft5 ON (ft4.c1 === ft5.c1)
   WHERE ft2.c1 > 2000 AND ft2.c2 = ft4.c1
   RETURNING ft2.c1, ft2.c2, ft2.c3;       -- can't be pushed down
-                                                                                                                                                                     QUERY PLAN                                                                                                                                                                     
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                  QUERY PLAN                                                                                                                                                                                  
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c2, c3
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3
    ->  Foreign Scan
-         Output: ft2.ctid, ft4.*, ft5.*
+         Output: ft2.ctid, ft2.tableoid, ($0), ft4.*, ft5.*
          Filter: (ft4.c1 === ft5.c1)
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1, r1.tableoid FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft4.*, ft5.*, ft4.c1, ft5.c1
+               Output: ft2.ctid, ft2.tableoid, ft4.*, ft5.*, ft4.c1, ft5.c1, ($0)
                ->  Nested Loop
-                     Output: ft2.ctid, ft4.*, ft4.c1
+                     Output: ft2.ctid, ft2.tableoid, ($0), ft4.*, ft4.c1
                      Join Filter: (ft2.c2 = ft4.c1)
                      ->  Foreign Scan on public.ft2
-                           Output: ft2.ctid, ft2.c2
-                           Remote SQL: SELECT c2, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+                           Output: ft2.ctid, ft2.tableoid, $0, ft2.c2
+                           Remote SQL: SELECT c2, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
                      ->  Foreign Scan on public.ft4
                            Output: ft4.*, ft4.c1
                            Remote SQL: SELECT c1, c2, c3 FROM "S 1"."T 3"
@@ -7169,19 +7169,19 @@ SET enable_hashjoin TO false;
 SET enable_material TO false;
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
-                                   QUERY PLAN                                   
---------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.remt2
    Output: remt2.c1, remt2.c2
-   Remote SQL: UPDATE public.loct2 SET c2 = $2 WHERE ctid = $1 RETURNING c1, c2
+   Remote SQL: UPDATE public.loct2 SET c2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING c1, c2
    ->  Nested Loop
-         Output: (remt2.c2 || remt2.c2), remt2.ctid, remt2.*, loct1.ctid
+         Output: (remt2.c2 || remt2.c2), remt2.ctid, remt2.tableoid, ($0), remt2.*, loct1.ctid
          Join Filter: (remt2.c1 = loct1.c1)
          ->  Seq Scan on public.loct1
                Output: loct1.ctid, loct1.c1
          ->  Foreign Scan on public.remt2
-               Output: remt2.c2, remt2.ctid, remt2.*, remt2.c1
-               Remote SQL: SELECT c1, c2, ctid FROM public.loct2 FOR UPDATE
+               Output: remt2.c2, remt2.ctid, remt2.tableoid, remt2.*, $0, remt2.c1
+               Remote SQL: SELECT c1, c2, ctid, tableoid FROM public.loct2 FOR UPDATE
 (11 rows)
 
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
@@ -7342,13 +7342,13 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, foreign_tbl.tableoid, $0, foreign_tbl.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -7356,13 +7356,13 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, foreign_tbl.tableoid, $0, foreign_tbl.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -7455,14 +7455,14 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                         
+---------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -7470,14 +7470,14 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -7526,14 +7526,14 @@ CREATE VIEW rw_view AS SELECT * FROM parent_tbl WHERE a < 5 WITH CHECK OPTION;
 INSERT INTO parent_tbl (a) VALUES(1),(5);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = 'text', c = 123.456;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.child_foreign parent_tbl_1
-     Remote SQL: UPDATE public.child_local SET b = $2, c = $3 WHERE ctid = $1 RETURNING a
+     Remote SQL: UPDATE public.child_local SET b = $3, c = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING a
    ->  Foreign Scan on public.child_foreign parent_tbl_1
-         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT b, c, a, ctid FROM public.child_local WHERE ((a < 5)) FOR UPDATE
+         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT b, c, a, ctid, tableoid FROM public.child_local WHERE ((a < 5)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = 'text', c = 123.456;
@@ -7612,13 +7612,13 @@ insert into grem1 (a) values (1), (2);
 insert into grem1 (a) values (1), (2);
 explain (verbose, costs off)
 update grem1 set a = 22 where a = 2;
-                                       QUERY PLAN                                       
-----------------------------------------------------------------------------------------
+                                                QUERY PLAN                                                
+----------------------------------------------------------------------------------------------------------
  Update on public.grem1
-   Remote SQL: UPDATE public.gloc1 SET a = $2, b = DEFAULT, c = DEFAULT WHERE ctid = $1
+   Remote SQL: UPDATE public.gloc1 SET a = $3, b = DEFAULT, c = DEFAULT WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.grem1
-         Output: 22, ctid, grem1.*
-         Remote SQL: SELECT a, b, c, ctid FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
+         Output: 22, ctid, tableoid, $0, grem1.*
+         Remote SQL: SELECT a, b, c, ctid, tableoid FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
 (5 rows)
 
 update grem1 set a = 22 where a = 2;
@@ -7945,13 +7945,13 @@ SELECT * from loc1;
 
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f1 = 10;          -- all columns should be transmitted
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: 10, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: 10, ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 UPDATE rem1 set f1 = 10;
@@ -8093,12 +8093,12 @@ DELETE FROM rem1;                 -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1 WHERE false;     -- currently can't be pushed down
-                      QUERY PLAN                       
--------------------------------------------------------
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: ctid
+         Output: ctid, tableoid, $0
          Replaces: Scan on rem1
          One-Time Filter: false
 (6 rows)
@@ -8199,13 +8199,13 @@ BEFORE UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -8223,13 +8223,13 @@ AFTER UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                                  QUERY PLAN                                   
--------------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f2 = $2 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: UPDATE public.loc1 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -8257,13 +8257,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                             QUERY PLAN                              
----------------------------------------------------------------------
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_before_delete ON rem1;
@@ -8281,13 +8281,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_after_delete ON rem1;
@@ -8324,28 +8324,28 @@ CONTEXT:  COPY parent_tbl, line 1: "AAA	42"
 ALTER SERVER loopback OPTIONS (DROP batch_size);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                         
+---------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                            QUERY PLAN                            
-------------------------------------------------------------------
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Foreign Delete on public.foreign_tbl parent_tbl_1
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
-         Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
+         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0
+         Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 DELETE FROM parent_tbl;
@@ -8363,39 +8363,41 @@ CREATE TRIGGER parent_tbl_delete_trig
   FOR EACH STATEMENT EXECUTE PROCEDURE trigger_func();
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                              QUERY PLAN                                              
-------------------------------------------------------------------------------------------------------
+                                                           QUERY PLAN                                                            
+---------------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Update on public.parent_tbl parent_tbl_1
    Foreign Update on public.foreign_tbl parent_tbl_2
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::record)
+         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::oid), $0, (NULL::record)
          ->  Append
                ->  Seq Scan on public.parent_tbl parent_tbl_1
-                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::record
+                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.tableoid, parent_tbl_2.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.local_tbl FOR UPDATE
 (12 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Delete on public.parent_tbl parent_tbl_1
    Foreign Delete on public.foreign_tbl parent_tbl_2
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
-   ->  Append
-         ->  Seq Scan on public.parent_tbl parent_tbl_1
-               Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
-         ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-               Output: parent_tbl_2.tableoid, parent_tbl_2.ctid
-               Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
-(10 rows)
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
+   ->  Result
+         Output: parent_tbl.tableoid, parent_tbl.ctid, (NULL::oid), $0
+         ->  Append
+               ->  Seq Scan on public.parent_tbl parent_tbl_1
+                     Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid
+               ->  Foreign Scan on public.foreign_tbl parent_tbl_2
+                     Output: parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.tableoid, $0
+                     Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
+(12 rows)
 
 DELETE FROM parent_tbl;
 ERROR:  cannot collect transition tuples from child foreign tables
@@ -8737,22 +8739,22 @@ drop table foo2child;
 -- Check UPDATE with inherited target and an inherited source table
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 where f1 in (select f1 from foo);
-                                              QUERY PLAN                                               
--------------------------------------------------------------------------------------------------------
+                                                       QUERY PLAN                                                       
+------------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Hash Join
-         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::record), foo.*, foo.tableoid
+         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record), foo.*, foo.tableoid
          Inner Unique: true
          Hash Cond: (bar.f1 = foo.f1)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                     Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 FOR UPDATE
          ->  Hash
                Output: foo.ctid, foo.f1, foo.*, foo.tableoid
                ->  HashAggregate
@@ -8784,24 +8786,24 @@ update bar set f2 = f2 + 100
 from
   ( select f1 from foo union all select f1+3 from foo ) ss
 where bar.f1 = ss.f1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                  QUERY PLAN                                                   
+---------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Merge Join
-         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
          Merge Cond: (bar.f1 = foo.f1)
          ->  Sort
-               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::record)
+               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
                Sort Key: bar.f1
                ->  Append
                      ->  Seq Scan on public.bar bar_1
-                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                      ->  Foreign Scan on public.bar2 bar_2
-                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                           Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                           Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 FOR UPDATE
          ->  Sort
                Output: (ROW(foo.f1)), foo.f1
                Sort Key: foo.f1
@@ -8942,19 +8944,21 @@ ERROR:  WHERE CURRENT OF is not supported for this table type
 rollback;
 explain (verbose, costs off)
 delete from foo where f1 < 5 returning *;
-                                      QUERY PLAN                                      
---------------------------------------------------------------------------------------
+                                         QUERY PLAN                                         
+--------------------------------------------------------------------------------------------
  Delete on public.foo
    Output: foo_1.f1, foo_1.f2
    Delete on public.foo foo_1
    Foreign Delete on public.foo2 foo_2
-   ->  Append
-         ->  Index Scan using i_foo_f1 on public.foo foo_1
-               Output: foo_1.tableoid, foo_1.ctid
-               Index Cond: (foo_1.f1 < 5)
-         ->  Foreign Delete on public.foo2 foo_2
-               Remote SQL: DELETE FROM public.loct1 WHERE ((f1 < 5)) RETURNING f1, f2
-(10 rows)
+   ->  Result
+         Output: foo.tableoid, foo.ctid, (NULL::oid), $0
+         ->  Append
+               ->  Index Scan using i_foo_f1 on public.foo foo_1
+                     Output: foo_1.tableoid, foo_1.ctid, NULL::oid
+                     Index Cond: (foo_1.f1 < 5)
+               ->  Foreign Delete on public.foo2 foo_2
+                     Remote SQL: DELETE FROM public.loct1 WHERE ((f1 < 5)) RETURNING f1, f2
+(12 rows)
 
 delete from foo where f1 < 5 returning *;
  f1 | f2 
@@ -8968,17 +8972,17 @@ delete from foo where f1 < 5 returning *;
 
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 returning *;
-                                        QUERY PLAN                                        
-------------------------------------------------------------------------------------------
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
  Update on public.bar
    Output: bar_1.f1, bar_1.f2
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Update on public.bar2 bar_2
                      Remote SQL: UPDATE public.loct2 SET f2 = (f2 + 100) RETURNING f1, f2
 (11 rows)
@@ -9003,20 +9007,20 @@ AFTER UPDATE OR DELETE ON bar2
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 explain (verbose, costs off)
 update bar set f2 = f2 + 100;
-                                               QUERY PLAN                                               
---------------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                        
+--------------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f1 = $2, f2 = $3, f3 = $4 WHERE ctid = $1 RETURNING f1, f2, f3
+     Remote SQL: UPDATE public.loct2 SET f1 = $3, f2 = $4, f3 = $5 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                     Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 FOR UPDATE
 (12 rows)
 
 update bar set f2 = f2 + 100;
@@ -9034,20 +9038,22 @@ NOTICE:  trig_row_after(23, skidoo) AFTER ROW UPDATE ON bar2
 NOTICE:  OLD: (7,277,77),NEW: (7,377,77)
 explain (verbose, costs off)
 delete from bar where f2 < 400;
-                                            QUERY PLAN                                             
----------------------------------------------------------------------------------------------------
+                                                    QUERY PLAN                                                     
+-------------------------------------------------------------------------------------------------------------------
  Delete on public.bar
    Delete on public.bar bar_1
    Foreign Delete on public.bar2 bar_2
-     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 RETURNING f1, f2, f3
-   ->  Append
-         ->  Seq Scan on public.bar bar_1
-               Output: bar_1.tableoid, bar_1.ctid, NULL::record
-               Filter: (bar_1.f2 < 400)
-         ->  Foreign Scan on public.bar2 bar_2
-               Output: bar_2.tableoid, bar_2.ctid, bar_2.*
-               Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
-(11 rows)
+     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
+   ->  Result
+         Output: bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
+         ->  Append
+               ->  Seq Scan on public.bar bar_1
+                     Output: bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
+                     Filter: (bar_1.f2 < 400)
+               ->  Foreign Scan on public.bar2 bar_2
+                     Output: bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                     Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
+(13 rows)
 
 delete from bar where f2 < 400;
 NOTICE:  trig_row_before(23, skidoo) BEFORE ROW DELETE ON bar2
@@ -9078,22 +9084,22 @@ analyze remt1;
 analyze remt2;
 explain (verbose, costs off)
 update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a returning *;
-                                                   QUERY PLAN                                                   
-----------------------------------------------------------------------------------------------------------------
+                                                           QUERY PLAN                                                            
+---------------------------------------------------------------------------------------------------------------------------------
  Update on public.parent
    Output: parent_1.a, parent_1.b, remt2.a, remt2.b
    Update on public.parent parent_1
    Foreign Update on public.remt1 parent_2
-     Remote SQL: UPDATE public.loct1 SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct1 SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::record)
+         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::oid), $0, (NULL::record)
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::record
+                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.tableoid, parent_2.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.b, remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -9110,22 +9116,22 @@ update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a re
 
 explain (verbose, costs off)
 delete from parent using remt2 where parent.a = remt2.a returning parent;
-                                 QUERY PLAN                                  
------------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Delete on public.parent
    Output: parent_1.*
    Delete on public.parent parent_1
    Foreign Delete on public.remt1 parent_2
-     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: remt2.*, parent.tableoid, parent.ctid
+         Output: remt2.*, parent.tableoid, parent.ctid, (NULL::oid), $0
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid
+                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid
-                     Remote SQL: SELECT a, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.tableoid, $0
+                     Remote SQL: SELECT a, ctid, tableoid FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -9355,7 +9361,7 @@ update utrtest set a = 1 where a = 1 or a = 2 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 WHERE (((a = 1) OR (a = 2))) RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, $0, NULL::record
                Filter: ((utrtest_2.a = 1) OR (utrtest_2.a = 2))
 (10 rows)
 
@@ -9394,8 +9400,8 @@ insert into utrtest values (2, 'qux');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Foreign Update on public.remp utrtest_1
@@ -9404,7 +9410,7 @@ update utrtest set a = 1 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, $0, NULL::record
 (9 rows)
 
 update utrtest set a = 1 returning *;
@@ -9415,22 +9421,22 @@ insert into utrtest values (2, 'qux');
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 from (values (1), (2)) s(x) where a = s.x returning *;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Foreign Update on public.remp utrtest_1
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    Update on public.locp utrtest_2
    ->  Hash Join
-         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, utrtest.*
+         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, utrtest.tableoid, $0, utrtest.*
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Foreign Scan on public.remp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, utrtest_1.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, utrtest_1.tableoid, utrtest_1.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.loct FOR UPDATE
                ->  Seq Scan on public.locp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, NULL::record
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -9454,15 +9460,15 @@ insert into utrtest values (3, 'xyzzy');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
    ->  Append
          ->  Seq Scan on public.locp utrtest_1
-               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, $0, NULL::record
          ->  Foreign Update on public.remp utrtest_2
                Remote SQL: UPDATE public.loct SET a = 3 RETURNING a, b
 (9 rows)
@@ -9472,22 +9478,22 @@ ERROR:  cannot route tuples into foreign table to be updated "remp"
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 from (values (2), (3)) s(x) where a = s.x returning *;
-                                             QUERY PLAN                                              
------------------------------------------------------------------------------------------------------
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Hash Join
-         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::record)
+         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::oid), $0, (NULL::record)
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Seq Scan on public.locp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, utrtest_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, utrtest_2.tableoid, utrtest_2.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.loct FOR UPDATE
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -12374,8 +12380,8 @@ RESET enable_hashjoin;
 -- Test that UPDATE/DELETE with inherited target works with async_capable enabled
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
-                                                QUERY PLAN                                                
-----------------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                          
+-----------------------------------------------------------------------------------------------------------------------------
  Update on public.async_pt
    Output: async_pt_1.a, async_pt_1.b, async_pt_1.c
    Foreign Update on public.async_p1 async_pt_1
@@ -12387,7 +12393,7 @@ UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
          ->  Foreign Update on public.async_p2 async_pt_2
                Remote SQL: UPDATE public.base_tbl2 SET c = (c || c) WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::record
+               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::oid, $0, NULL::record, $1
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -12414,7 +12420,7 @@ DELETE FROM async_pt WHERE b = 0 RETURNING *;
          ->  Foreign Delete on public.async_p2 async_pt_2
                Remote SQL: DELETE FROM public.base_tbl2 WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: async_pt_3.tableoid, async_pt_3.ctid
+               Output: async_pt_3.tableoid, async_pt_3.ctid, NULL::oid, $0, $1
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -12977,3 +12983,49 @@ SELECT server_name,
 -- Clean up
 \set VERBOSITY default
 RESET debug_discard_caches;
+-- ===================================================================
+-- check whether fdw created for partitioned table will delete tuples only from
+-- desired partition
+-- ===================================================================
+CREATE TABLE measurement (
+	city_id		int not null,
+	logdate		date not null,
+	peaktemp	int,
+	unitsales	int
+) PARTITION BY RANGE (logdate);
+CREATE TABLE measurement_y2006m02 PARTITION OF measurement
+	FOR VALUES FROM ('2006-02-01') TO ('2006-03-01');
+CREATE TABLE measurement_y2006m03 PARTITION OF measurement
+	FOR VALUES FROM ('2006-03-01') TO ('2006-04-01');
+CREATE TABLE measurement_y2006m04 PARTITION OF measurement
+	FOR VALUES FROM ('2006-04-01') TO ('2006-05-01');
+INSERT INTO measurement VALUES (1,'2006-02-01',1,1);
+INSERT INTO measurement VALUES (2,'2006-03-01',1,1);
+INSERT INTO measurement VALUES (3,'2006-04-01',1,1);
+create foreign table measurement_fdw (
+	city_id int options (column_name 'city_id') not null,
+	logdate date options (column_name 'logdate') not null,
+	peaktemp text options (column_name 'peaktemp'),
+	unitsales integer options (column_name 'unitsales')
+) SERVER loopback OPTIONS (table_name 'measurement');
+DELETE FROM measurement_fdw
+USING (
+	SELECT t1.city_id sub_city_id
+	FROM measurement_fdw t1
+	WHERE t1.city_id=1
+	LIMIT 1000
+) sub
+WHERE measurement_fdw.city_id = sub.sub_city_id
+RETURNING city_id, logdate, peaktemp, unitsales;
+ city_id |  logdate   | peaktemp | unitsales 
+---------+------------+----------+-----------
+       1 | 02-01-2006 | 1        |         1
+(1 row)
+
+SELECT * FROM measurement_fdw;
+ city_id |  logdate   | peaktemp | unitsales 
+---------+------------+----------+-----------
+       2 | 03-01-2006 | 1        |         1
+       3 | 04-01-2006 | 1        |         1
+(2 rows)
+
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 0f20f38c83e..45079999ecf 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -80,6 +80,8 @@ enum FdwScanPrivateIndex
 	FdwScanPrivateSelectSql,
 	/* Integer list of attribute numbers retrieved by the SELECT */
 	FdwScanPrivateRetrievedAttrs,
+	/* Param ID for remote table OID for target rel (-1 if none) */
+	FdwScanPrivateTableOidParamId,
 	/* Integer representing the desired fetch_size */
 	FdwScanPrivateFetchSize,
 
@@ -178,6 +180,10 @@ typedef struct PgFdwScanState
 	MemoryContext temp_cxt;		/* context for per-tuple temporary data */
 
 	int			fetch_size;		/* number of tuples per fetch */
+
+	int			tableoid_param_id;	/* Param ID for remote table OID */
+	bool		set_tableoid_param; /* Do we need to set the Param? */
+	Oid			remote_tableoid;
 } PgFdwScanState;
 
 /*
@@ -204,6 +210,7 @@ typedef struct PgFdwModifyState
 
 	/* info about parameters for prepared statement */
 	AttrNumber	ctidAttno;		/* attnum of input resjunk ctid column */
+	AttrNumber	tableoidAttno;	/* attnum of input resjunk tableoid column */
 	int			p_nums;			/* number of parameters to transmit */
 	FmgrInfo   *p_flinfo;		/* output conversion functions for them */
 
@@ -304,6 +311,20 @@ typedef struct
 	int64		offset_est;
 } PgFdwPathExtraData;
 
+typedef struct OidMappingEntry
+{
+	Oid		key;
+	Oid		remote_oid;
+} OidMappingEntry;
+
+typedef struct PgFdwRemoteMap
+{
+	HTAB	*oid_mapping;
+	uint32	 refcount;
+} PgFdwRemoteMap;
+
+static PgFdwRemoteMap *remoteMap;
+
 /*
  * Identify the attribute where data conversion fails.
  */
@@ -656,6 +677,7 @@ static TupleTableSlot **execute_foreign_modify(EState *estate,
 static void prepare_foreign_modify(PgFdwModifyState *fmstate);
 static const char **convert_prep_stmt_params(PgFdwModifyState *fmstate,
 											 ItemPointer tupleid,
+											 Oid tableoid,
 											 TupleTableSlot **slots,
 											 int numSlots);
 static void store_returning_result(PgFdwModifyState *fmstate,
@@ -760,6 +782,88 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
 
+/* Private cache */
+static void
+init_remote_map(void)
+{
+	MemoryContext	 hash_cxt;
+	HASHCTL			 info;
+
+	Assert(remoteMap == NULL);
+
+	remoteMap = (PgFdwRemoteMap *)
+		MemoryContextAllocZero(CacheMemoryContext, sizeof(PgFdwRemoteMap));
+
+	hash_cxt = AllocSetContextCreate(CacheMemoryContext,
+									 "PgFdwRemoteMap",
+									 ALLOCSET_DEFAULT_SIZES);
+	info.keysize = sizeof(Oid);
+	info.entrysize = sizeof(OidMappingEntry);
+	info.hcxt = hash_cxt;
+
+	remoteMap->oid_mapping =
+		hash_create("PgFdwRemoteMap hashtable",
+					256, /* arbitrary initial size */
+					&info,
+					HASH_ELEM | HASH_BLOBS | HASH_CONTEXT);
+}
+
+static void
+insert_oid_pair(Oid local_oid, Oid remote_oid)
+{
+	bool			 found;
+	OidMappingEntry	*entry;
+
+	Assert(OidIsValid(local_oid) && OidIsValid(remote_oid));
+
+	if (remoteMap == NULL)
+		init_remote_map();
+
+	/* This mapping may already present in the hashtable. */
+	entry = (OidMappingEntry *)
+		hash_search(remoteMap->oid_mapping, &local_oid, HASH_ENTER, &found);
+
+	if (!found)
+	{
+		remoteMap->refcount++;
+		entry->remote_oid = remote_oid;
+	}
+}
+
+static Oid
+find_remote_oid(Oid local_oid)
+{
+	OidMappingEntry *entry;
+	bool			 found;
+
+	Assert(OidIsValid(local_oid));
+
+	if (remoteMap == NULL)
+		return InvalidOid;
+
+	entry = (OidMappingEntry *)
+		hash_search(remoteMap->oid_mapping, &local_oid, HASH_FIND, &found);
+
+	return (found ? entry->remote_oid : InvalidOid);
+}
+
+static void
+destroy_remote_map(void)
+{
+	if (remoteMap == NULL)
+		return;
+
+	Assert(remoteMap->refcount > 0);
+	remoteMap->refcount--;
+
+	if (remoteMap->refcount == 0)
+	{
+		hash_destroy(remoteMap->oid_mapping);
+
+		pfree(remoteMap);
+		remoteMap = NULL;
+	}
+}
 
 /*
  * Foreign-data wrapper handler function: return a struct with pointers
@@ -1002,6 +1106,23 @@ postgresGetForeignRelSize(PlannerInfo *root,
 	fpinfo->hidden_subquery_rels = NULL;
 	/* Set the relation index. */
 	fpinfo->relation_index = baserel->relid;
+	fpinfo->tableoid_param = NULL;
+
+	/*
+	 * If the table is an UPDATE/DELETE target, the table's reltarget would
+	 * have contained a Param representing the remote table OID of the target;
+	 * get the Param and save a copy of it in fpinfo for use later.
+	 */
+	foreach(lc, baserel->reltarget->exprs)
+	{
+		Param	   *param = (Param *) lfirst(lc);
+		if (IsA(param, Param))
+		{
+			Assert(IS_FOREIGN_PARAM(root, param));
+			fpinfo->tableoid_param = (Param *) copyObject(param);
+			break;
+		}
+	}
 }
 
 /*
@@ -1463,6 +1584,7 @@ postgresGetForeignPlan(PlannerInfo *root,
 	bool		has_final_sort = false;
 	bool		has_limit = false;
 	ListCell   *lc;
+	int			tableoid_param_id = -1;
 
 	/*
 	 * Get FDW private data created by postgresGetForeignUpperPaths(), if any.
@@ -1627,12 +1749,16 @@ postgresGetForeignPlan(PlannerInfo *root,
 	/* Remember remote_exprs for possible use by postgresPlanDirectModify */
 	fpinfo->final_remote_exprs = remote_exprs;
 
+	if (fpinfo->tableoid_param)
+		tableoid_param_id = fpinfo->tableoid_param->paramid;
+
 	/*
 	 * Build the fdw_private list that will be available to the executor.
 	 * Items in the list must match order in enum FdwScanPrivateIndex.
 	 */
-	fdw_private = list_make3(makeString(sql.data),
+	fdw_private = list_make4(makeString(sql.data),
 							 retrieved_attrs,
+							 makeInteger(tableoid_param_id),
 							 makeInteger(fpinfo->fetch_size));
 	if (IS_JOIN_REL(foreignrel) || IS_UPPER_REL(foreignrel))
 		fdw_private = lappend(fdw_private,
@@ -1765,6 +1891,8 @@ postgresBeginForeignScan(ForeignScanState *node, int eflags)
 									 FdwScanPrivateSelectSql));
 	fsstate->retrieved_attrs = (List *) list_nth(fsplan->fdw_private,
 												 FdwScanPrivateRetrievedAttrs);
+	fsstate->tableoid_param_id = intVal(list_nth(fsplan->fdw_private,
+										FdwScanPrivateTableOidParamId));
 	fsstate->fetch_size = intVal(list_nth(fsplan->fdw_private,
 										  FdwScanPrivateFetchSize));
 
@@ -1784,11 +1912,13 @@ postgresBeginForeignScan(ForeignScanState *node, int eflags)
 	{
 		fsstate->rel = node->ss.ss_currentRelation;
 		fsstate->tupdesc = RelationGetDescr(fsstate->rel);
+		fsstate->set_tableoid_param = (fsstate->tableoid_param_id >= 0);
 	}
 	else
 	{
 		fsstate->rel = NULL;
 		fsstate->tupdesc = get_tupdesc_for_join_scan_tuples(node);
+		fsstate->set_tableoid_param = false;
 	}
 
 	fsstate->attinmeta = TupleDescGetAttInMetadata(fsstate->tupdesc);
@@ -1820,6 +1950,7 @@ postgresIterateForeignScan(ForeignScanState *node)
 {
 	PgFdwScanState *fsstate = (PgFdwScanState *) node->fdw_state;
 	TupleTableSlot *slot = node->ss.ss_ScanTupleSlot;
+	HeapTuple		tuple;
 
 	/*
 	 * In sync mode, if this is the first call after Begin or ReScan, we need
@@ -1846,12 +1977,23 @@ postgresIterateForeignScan(ForeignScanState *node)
 			return ExecClearTuple(slot);
 	}
 
+	tuple = fsstate->tuples[fsstate->next_tuple++];
+
+	if (fsstate->set_tableoid_param)
+	{
+		ExprContext *econtext = node->ss.ps.ps_ExprContext;
+		ParamExecData *prm = &(econtext->ecxt_param_exec_vals[fsstate->tableoid_param_id]);
+
+		prm->execPlan = NULL;
+		prm->value = ObjectIdGetDatum(tuple->t_tableOid);
+		prm->isnull = false;
+
+		insert_oid_pair(RelationGetRelid(fsstate->rel), tuple->t_tableOid);
+	}
 	/*
 	 * Return the next tuple.
 	 */
-	ExecStoreHeapTuple(fsstate->tuples[fsstate->next_tuple++],
-					   slot,
-					   false);
+	ExecStoreHeapTuple(tuple, slot, false);
 
 	return slot;
 }
@@ -1966,6 +2108,9 @@ postgresAddForeignUpdateTargets(PlannerInfo *root,
 								Relation target_relation)
 {
 	Var		   *var;
+	Param	   *param;
+	const char *attrname;
+	TargetEntry *tle;
 
 	/*
 	 * In postgres_fdw, what we need is the ctid, same as for a regular table.
@@ -1981,6 +2126,39 @@ postgresAddForeignUpdateTargets(PlannerInfo *root,
 
 	/* Register it as a row-identity column needed by this target rel */
 	add_row_identity_var(root, var, rtindex, "ctid");
+
+	/* Make a Var representing the desired value */
+	var = makeVar(rtindex,
+		TableOidAttributeNumber,
+		OIDOID,
+		-1,
+		InvalidOid,
+		0);
+	/* Register it as a row-identity column needed by this target rel */
+	add_row_identity_var(root, var, rtindex, "remote_tableoid");
+
+	/* Make a Param representing the tableoid value */
+	param = makeNode(Param);
+	param->paramkind = PARAM_EXEC;
+	param->paramtype = OIDOID;
+	param->paramtypmod = -1;
+	param->paramcollid = InvalidOid;
+	param->location = -1;
+	/* paramid will be filled in by fix_foreign_params */
+	param->paramid = -1;
+	param->target_rte = rtindex;
+
+	/* Wrap it in a resjunk TLE with the right name ... */
+
+	attrname = "remote_tableoid";
+
+	tle = makeTargetEntry((Expr *) param,
+						  list_length(root->processed_tlist) + 1,
+						  pstrdup(attrname),
+						  true);
+
+	/* ... and add it to the query's targetlist */
+	root->processed_tlist = lappend(root->processed_tlist, tle);
 }
 
 /*
@@ -2343,6 +2521,7 @@ postgresExecForeignDelete(EState *estate,
 	rslot = execute_foreign_modify(estate, resultRelInfo, CMD_DELETE,
 								   &slot, &planSlot, &numSlots);
 
+	destroy_remote_map();
 	return rslot ? rslot[0] : NULL;
 }
 
@@ -4228,7 +4407,7 @@ create_foreign_modify(EState *estate,
 		fmstate->attinmeta = TupleDescGetAttInMetadata(tupdesc);
 
 	/* Prepare for output conversion of parameters used in prepared stmt. */
-	n_params = list_length(fmstate->target_attrs) + 1;
+	n_params = list_length(fmstate->target_attrs) + 2;
 	fmstate->p_flinfo = palloc0_array(FmgrInfo, n_params);
 	fmstate->p_nums = 0;
 
@@ -4246,6 +4425,20 @@ create_foreign_modify(EState *estate,
 		getTypeOutputInfo(TIDOID, &typefnoid, &isvarlena);
 		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
 		fmstate->p_nums++;
+
+		/* Find the tableoid resjunk column in the subplan's result */
+		fmstate->tableoidAttno = ExecFindJunkAttributeInTlist(subplan->targetlist,
+															  "remote_tableoid");
+
+		if (!AttributeNumberIsValid(fmstate->tableoidAttno))
+			ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("could not find junk tableoid column")));
+
+		/* Second transmittable parameter will be tableoid */
+		getTypeOutputInfo(OIDOID, &typefnoid, &isvarlena);
+		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
+		fmstate->p_nums++;
 	}
 
 	if (operation == CMD_INSERT || operation == CMD_UPDATE)
@@ -4298,6 +4491,7 @@ execute_foreign_modify(EState *estate,
 {
 	PgFdwModifyState *fmstate = (PgFdwModifyState *) resultRelInfo->ri_FdwState;
 	ItemPointer ctid = NULL;
+	Oid			tableoid = InvalidOid;
 	const char **p_values;
 	PGresult   *res;
 	int			n_rows;
@@ -4343,7 +4537,9 @@ execute_foreign_modify(EState *estate,
 	if (operation == CMD_UPDATE || operation == CMD_DELETE)
 	{
 		Datum		datum;
+		Datum		datum2;
 		bool		isNull;
+		Oid remote_tableoid = InvalidOid;
 
 		datum = ExecGetJunkAttribute(planSlots[0],
 									 fmstate->ctidAttno,
@@ -4352,10 +4548,28 @@ execute_foreign_modify(EState *estate,
 		if (isNull)
 			elog(ERROR, "ctid is NULL");
 		ctid = (ItemPointer) DatumGetPointer(datum);
+
+		/* Get the tableoid that was passed up as a resjunk column */
+		datum2 = ExecGetJunkAttribute(planSlots[0],
+									  fmstate->tableoidAttno,
+									  &isNull);
+		/* shouldn't ever get a null result... */
+		if (isNull)
+			ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("tableoid is NULL")));
+
+		tableoid = DatumGetObjectId(datum2);
+
+		/* Remote OID */
+		remote_tableoid = find_remote_oid(tableoid);
+
+		if(OidIsValid(remote_tableoid))
+			tableoid = remote_tableoid;
 	}
 
 	/* Convert parameters needed by prepared statement to text form */
-	p_values = convert_prep_stmt_params(fmstate, ctid, slots, *numSlots);
+	p_values = convert_prep_stmt_params(fmstate, ctid, tableoid, slots, *numSlots);
 
 	/*
 	 * Execute the prepared statement.
@@ -4460,6 +4674,7 @@ prepare_foreign_modify(PgFdwModifyState *fmstate)
 static const char **
 convert_prep_stmt_params(PgFdwModifyState *fmstate,
 						 ItemPointer tupleid,
+						 Oid tableoid,
 						 TupleTableSlot **slots,
 						 int numSlots)
 {
@@ -4486,6 +4701,16 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 		pindex++;
 	}
 
+	/* 2nd parameter should be tableoid, if it's in use */
+	if (OidIsValid(tableoid))
+	{
+		Assert(tupleid != NULL);
+		/* don't need set_transmission_modes for OID output */
+		p_values[pindex] = OutputFunctionCall(&fmstate->p_flinfo[pindex],
+											  ObjectIdGetDatum(tableoid));
+		pindex++;
+	}
+
 	/* get following parameters from slots */
 	if (slots != NULL && fmstate->target_attrs != NIL)
 	{
@@ -4497,7 +4722,7 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 
 		for (i = 0; i < numSlots; i++)
 		{
-			j = (tupleid != NULL) ? 1 : 0;
+			j = (tupleid != NULL) ? 2 : 0;
 			foreach(lc, fmstate->target_attrs)
 			{
 				int			attnum = lfirst_int(lc);
@@ -4562,9 +4787,10 @@ finish_foreign_modify(PgFdwModifyState *fmstate)
 {
 	Assert(fmstate != NULL);
 
+	destroy_remote_map();
+
 	/* If we created a prepared statement, destroy it */
 	deallocate_query(fmstate);
-
 	/* Release remote connection */
 	ReleaseConnection(fmstate->conn);
 	fmstate->conn = NULL;
@@ -4670,7 +4896,8 @@ build_remote_returning(Index rtindex, Relation rel, List *returningList)
 		if (IsA(var, Var) &&
 			var->varno == rtindex &&
 			var->varattno <= InvalidAttrNumber &&
-			var->varattno != SelfItemPointerAttributeNumber)
+			var->varattno != SelfItemPointerAttributeNumber &&
+			var->varattno != TableOidAttributeNumber)
 			continue;			/* don't need it */
 
 		if (tlist_member((Expr *) var, tlist))
@@ -4876,6 +5103,17 @@ init_returning_filter(PgFdwDirectModifyState *dmstate,
 		TargetEntry *tle = (TargetEntry *) lfirst(lc);
 		Var		   *var = (Var *) tle->expr;
 
+		/*
+		 * No need to set the Param for the remote table OID; ignore it.
+		 */
+		if (IsA(var, Param))
+		{
+			/* We would not retrieve the remote table OID anymore. */
+			Assert(!list_member_int(dmstate->retrieved_attrs, i));
+			i++;
+			continue;
+		}
+
 		Assert(IsA(var, Var));
 
 		/*
@@ -4894,6 +5132,8 @@ init_returning_filter(PgFdwDirectModifyState *dmstate,
 				 */
 				if (attrno == SelfItemPointerAttributeNumber)
 					dmstate->ctidAttno = i;
+				else if (attrno == TableOidAttributeNumber)
+					dmstate->oidAttno = i;
 				else
 					Assert(false);
 				dmstate->hasSystemCols = true;
@@ -4985,10 +5225,13 @@ apply_returning_filter(PgFdwDirectModifyState *dmstate,
 		/* ctid */
 		if (dmstate->ctidAttno)
 		{
+			Oid tableoid = InvalidOid;
 			ItemPointer ctid = NULL;
 
 			ctid = (ItemPointer) DatumGetPointer(old_values[dmstate->ctidAttno - 1]);
+			tableoid = DatumGetObjectId(old_values[dmstate->oidAttno - 1]);
 			resultTup->t_self = *ctid;
+			resultTup->t_tableOid = tableoid;
 		}
 
 		/*
@@ -6901,6 +7144,38 @@ foreign_join_ok(PlannerInfo *root, RelOptInfo *joinrel, JoinType jointype,
 	/* Mark that this join can be pushed down safely */
 	fpinfo->pushdown_safe = true;
 
+	/*
+	 * If the join relation contains an UPDATE/DELETE target, either of the
+	 * input relations would have saved the Param representing the remote
+	 * table OID of the target; get the Param and remember it in fpinfo for
+	 * use later.
+	 */
+	if ((root->parse->commandType == CMD_UPDATE ||
+		 root->parse->commandType == CMD_DELETE) &&
+		bms_is_member(root->parse->resultRelation, joinrel->relids))
+	{
+		if (bms_is_member(root->parse->resultRelation,
+						  outerrel->relids))
+		{
+			Assert(fpinfo_o->tableoid_param);
+			fpinfo->tableoid_param = fpinfo_o->tableoid_param;
+		}
+		else
+		{
+			Assert(bms_is_member(root->parse->resultRelation,
+								 innerrel->relids));
+			Assert(fpinfo_i->tableoid_param);
+			fpinfo->tableoid_param = fpinfo_i->tableoid_param;
+		}
+		/*
+		 * Core code should have contained the Param in the join relation's
+		 * reltarget.
+		 */
+		Assert(list_member(joinrel->reltarget->exprs, fpinfo->tableoid_param));
+	}
+	else
+		fpinfo->tableoid_param = NULL;
+
 	/* Get user mapping */
 	if (fpinfo->use_remote_estimate)
 	{
@@ -8434,6 +8709,7 @@ make_tuple_from_result_row(PGresult *res,
 	ErrorContextCallback errcallback;
 	MemoryContext oldcontext;
 	ListCell   *lc;
+	Oid tableoid = InvalidOid;
 	int			j;
 
 	Assert(row < PQntuples(res));
@@ -8516,6 +8792,17 @@ make_tuple_from_result_row(PGresult *res,
 				ctid = (ItemPointer) DatumGetPointer(datum);
 			}
 		}
+		else if (i == TableOidAttributeNumber)
+		{
+			/* tableoid */
+			if (valstr != NULL)
+			{
+				Datum		datum;
+
+				datum = DirectFunctionCall1(oidin, CStringGetDatum(valstr));
+				tableoid = DatumGetObjectId(datum);
+			}
+		}
 		errpos.cur_attno = 0;
 
 		j++;
@@ -8547,6 +8834,9 @@ make_tuple_from_result_row(PGresult *res,
 	if (ctid)
 		tuple->t_self = tuple->t_data->t_ctid = *ctid;
 
+//	if (OidIsValid(tableoid))
+		tuple->t_tableOid = tableoid;
+
 	/*
 	 * Stomp on the xmin, xmax, and cmin fields from the tuple created by
 	 * heap_form_tuple.  heap_form_tuple actually creates the tuple with
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index a2bb1ff352c..376c55d4dd0 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -129,6 +129,9 @@ typedef struct PgFdwRelationInfo
 	 * representing the relation.
 	 */
 	int			relation_index;
+
+	/* PARAM_EXEC Param representing the remote table OID of a target rel */
+	Param	   *tableoid_param;
 } PgFdwRelationInfo;
 
 /*
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 79ad5be8bf9..81d7cf03c1a 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -4620,3 +4620,47 @@ SELECT server_name,
 -- Clean up
 \set VERBOSITY default
 RESET debug_discard_caches;
+
+-- ===================================================================
+-- check whether fdw created for partitioned table will delete tuples only from
+-- desired partition
+-- ===================================================================
+
+CREATE TABLE measurement (
+	city_id		int not null,
+	logdate		date not null,
+	peaktemp	int,
+	unitsales	int
+) PARTITION BY RANGE (logdate);
+
+CREATE TABLE measurement_y2006m02 PARTITION OF measurement
+	FOR VALUES FROM ('2006-02-01') TO ('2006-03-01');
+
+CREATE TABLE measurement_y2006m03 PARTITION OF measurement
+	FOR VALUES FROM ('2006-03-01') TO ('2006-04-01');
+
+CREATE TABLE measurement_y2006m04 PARTITION OF measurement
+	FOR VALUES FROM ('2006-04-01') TO ('2006-05-01');
+
+INSERT INTO measurement VALUES (1,'2006-02-01',1,1);
+INSERT INTO measurement VALUES (2,'2006-03-01',1,1);
+INSERT INTO measurement VALUES (3,'2006-04-01',1,1);
+
+create foreign table measurement_fdw (
+	city_id int options (column_name 'city_id') not null,
+	logdate date options (column_name 'logdate') not null,
+	peaktemp text options (column_name 'peaktemp'),
+	unitsales integer options (column_name 'unitsales')
+) SERVER loopback OPTIONS (table_name 'measurement');
+
+DELETE FROM measurement_fdw
+USING (
+	SELECT t1.city_id sub_city_id
+	FROM measurement_fdw t1
+	WHERE t1.city_id=1
+	LIMIT 1000
+) sub
+WHERE measurement_fdw.city_id = sub.sub_city_id
+RETURNING city_id, logdate, peaktemp, unitsales;
+
+SELECT * FROM measurement_fdw;
-- 
2.43.0



  [application/octet-stream] v1-0001-copy-slot-tuple-natts.patch (29.9K, ../../CAN-LCVMz58ukZ7ubGXiLuTeFE7wWmSwDw4URpF0q1ejzRvqbzg@mail.gmail.com/4-v1-0001-copy-slot-tuple-natts.patch)
  download | inline diff:
From 864d2e675dd531c7cd65cc16adef9fc830994957 Mon Sep 17 00:00:00 2001
From: Nikita Malakhov <n.malakhov@postgrespro.ru>
Date: Tue, 21 Apr 2026 14:01:19 +0300
Subject: [PATCH] [POC] Current copyTuple and copySlot machinery supposes that
 source and destination slots and tuples always match. This patch modifies
 copy internals to consider more complex case and copy only actual attributes,
 and if destination slot/tuple has more they are set to null. This is a proof
 of concept patch which shows such mechanics does not break anything and
 allows to use it in cases when we have to pass or receive not matching number
 of attributes.

---
 src/backend/access/common/heaptuple.c | 195 ++++++++++++++++++++-----
 src/backend/executor/execTuples.c     | 196 +++++++++++++++++++++++---
 src/backend/optimizer/util/tlist.c    |  39 +++--
 src/include/access/htup_details.h     |  14 ++
 src/include/executor/tuptable.h       |  47 ++++++
 5 files changed, 424 insertions(+), 67 deletions(-)

diff --git a/src/backend/access/common/heaptuple.c b/src/backend/access/common/heaptuple.c
index f30346469ed..ff4bdda0327 100644
--- a/src/backend/access/common/heaptuple.c
+++ b/src/backend/access/common/heaptuple.c
@@ -58,9 +58,12 @@
 #include "postgres.h"
 
 #include "access/heaptoast.h"
+#include "access/htup.h"
+#include "access/htup_details.h"
 #include "access/sysattr.h"
 #include "access/tupdesc_details.h"
 #include "common/hashfn.h"
+#include "executor/tuptable.h"
 #include "utils/datum.h"
 #include "utils/expandeddatum.h"
 #include "utils/hsearch.h"
@@ -212,17 +215,18 @@ getmissingattr(TupleDesc tupleDesc,
 }
 
 /*
- * heap_compute_data_size
+ * heap_compute_data_size_ext
  *		Determine size of the data area of a tuple to be constructed
  */
 Size
-heap_compute_data_size(TupleDesc tupleDesc,
+heap_compute_data_size_ext(TupleDesc tupleDesc,
 					   const Datum *values,
-					   const bool *isnull)
+					   const bool *isnull,
+					   int attnum)
 {
 	Size		data_length = 0;
 	int			i;
-	int			numberOfAttributes = tupleDesc->natts;
+	int			numberOfAttributes = (attnum >= 0 ? attnum : tupleDesc->natts);
 
 	for (i = 0; i < numberOfAttributes; i++)
 	{
@@ -266,6 +270,18 @@ heap_compute_data_size(TupleDesc tupleDesc,
 	return data_length;
 }
 
+/*
+ * heap_compute_data_size
+ *		Determine size of the data area of a tuple to be constructed
+ */
+Size
+heap_compute_data_size(TupleDesc tupleDesc,
+					   const Datum *values,
+					   const bool *isnull)
+{
+	return heap_compute_data_size_ext(tupleDesc, values, isnull, -1);
+}
+
 /*
  * Per-attribute helper for heap_fill_tuple and other routines building tuples.
  *
@@ -398,10 +414,10 @@ fill_val(CompactAttribute *att,
  * NOTE: it is now REQUIRED that the caller have pre-zeroed the data area.
  */
 void
-heap_fill_tuple(TupleDesc tupleDesc,
+heap_fill_tuple_ext(TupleDesc tupleDesc,
 				const Datum *values, const bool *isnull,
 				char *data, Size data_size,
-				uint16 *infomask, uint8 *bit)
+				uint16 *infomask, uint8 *bit, int natts)
 {
 	uint8	   *bitP;
 	int			bitmask;
@@ -412,6 +428,9 @@ heap_fill_tuple(TupleDesc tupleDesc,
 	char	   *start = data;
 #endif
 
+	if(natts > 0)
+		numberOfAttributes = natts;
+
 	if (bit != NULL)
 	{
 		bitP = &bit[-1];
@@ -442,6 +461,25 @@ heap_fill_tuple(TupleDesc tupleDesc,
 	Assert((data - start) == data_size);
 }
 
+/*
+ * heap_fill_tuple
+ *		Load data portion of a tuple from values/isnull arrays
+ *
+ * We also fill the null bitmap (if any) and set the infomask bits
+ * that reflect the tuple's data contents.
+ *
+ * NOTE: it is now REQUIRED that the caller have pre-zeroed the data area.
+ */
+void
+heap_fill_tuple(TupleDesc tupleDesc,
+				const Datum *values, const bool *isnull,
+				char *data, Size data_size,
+				uint16 *infomask, uint8 *bit)
+{
+	heap_fill_tuple_ext(tupleDesc, values, isnull,
+				data, data_size, infomask, bit, -1);
+}
+
 
 /* ----------------------------------------------------------------
  *						heap tuple interface
@@ -685,6 +723,9 @@ heap_getsysattr(HeapTuple tup, int attnum, TupleDesc tupleDesc, bool *isnull)
 HeapTuple
 heap_copytuple(HeapTuple tuple)
 {
+	return heap_copytuple_ext(tuple, NULL, -1);
+
+	/*
 	HeapTuple	newTuple;
 
 	if (!HeapTupleIsValid(tuple) || tuple->t_data == NULL)
@@ -697,13 +738,53 @@ heap_copytuple(HeapTuple tuple)
 	newTuple->t_data = (HeapTupleHeader) ((char *) newTuple + HEAPTUPLESIZE);
 	memcpy(newTuple->t_data, tuple->t_data, tuple->t_len);
 	return newTuple;
+	*/
+}
+
+/* ----------------
+ *		heap_copytuple_ext
+ *
+ *		returns a copy of a tuple consists of attnum atts of original
+ *
+ * The HeapTuple struct, tuple header, and tuple data are all allocated
+ * as a single palloc() block.
+ * ----------------
+ */
+HeapTuple
+heap_copytuple_ext(HeapTuple tuple, void *slotptr, int dstnatts)
+{
+	Size tsz = 0;
+	HeapTuple	newTuple;
+	TupleTableSlot *slot = NULL;
+
+	if (!HeapTupleIsValid(tuple) || tuple->t_data == NULL)
+		return NULL;
+
+	tsz = tuple->t_len;
+	if(slotptr != NULL)
+	{
+		slot = (TupleTableSlot *) slotptr;
+		if(dstnatts <= 0)
+			dstnatts = slot->tts_tupleDescriptor->natts;
+
+		if(dstnatts < slot->tts_tupleDescriptor->natts)
+			tsz = heap_compute_data_size_ext(slot->tts_tupleDescriptor, slot->tts_values, slot->tts_isnull, dstnatts);
+	}
+	newTuple = (HeapTuple) palloc(HEAPTUPLESIZE + tsz);
+	newTuple->t_len = tsz;
+	newTuple->t_self = tuple->t_self;
+	newTuple->t_tableOid = tuple->t_tableOid;
+
+	newTuple->t_data = (HeapTupleHeader) ((char *) newTuple + HEAPTUPLESIZE);
+	memcpy((char *) newTuple->t_data, (char *) tuple->t_data, tsz);
+	return newTuple;
 }
 
 /* ----------------
  *		heap_copytuple_with_tuple
  *
  *		copy a tuple into a caller-supplied HeapTuple management struct
- *
+ * 
  * Note that after calling this function, the "dest" HeapTuple will not be
  * allocated as a single palloc() block (unlike with heap_copytuple()).
  * ----------------
@@ -1014,17 +1095,11 @@ heap_copy_tuple_as_datum(HeapTuple tuple, TupleDesc tupleDesc)
 	return PointerGetDatum(td);
 }
 
-/*
- * heap_form_tuple
- *		construct a tuple from the given values[] and isnull[] arrays,
- *		which are of the length indicated by tupleDescriptor->natts
- *
- * The result is allocated in the current memory context.
- */
 HeapTuple
-heap_form_tuple(TupleDesc tupleDescriptor,
+heap_form_tuple_ext(TupleDesc tupleDescriptor,
 				const Datum *values,
-				const bool *isnull)
+				const bool *isnull,
+				int natts)
 {
 	HeapTuple	tuple;			/* return tuple */
 	HeapTupleHeader td;			/* tuple data */
@@ -1035,6 +1110,9 @@ heap_form_tuple(TupleDesc tupleDescriptor,
 	int			numberOfAttributes = tupleDescriptor->natts;
 	int			i;
 
+	if(natts > 0)
+		numberOfAttributes = natts;
+
 	if (numberOfAttributes > MaxTupleAttributeNumber)
 		ereport(ERROR,
 				(errcode(ERRCODE_TOO_MANY_COLUMNS),
@@ -1063,7 +1141,10 @@ heap_form_tuple(TupleDesc tupleDescriptor,
 
 	hoff = len = MAXALIGN(len); /* align user data safely */
 
-	data_len = heap_compute_data_size(tupleDescriptor, values, isnull);
+	if(natts > 0)
+		data_len = heap_compute_data_size_ext(tupleDescriptor, values, isnull, natts);
+	else
+		data_len = heap_compute_data_size(tupleDescriptor, values, isnull);
 
 	len += data_len;
 
@@ -1092,7 +1173,17 @@ heap_form_tuple(TupleDesc tupleDescriptor,
 	HeapTupleHeaderSetNatts(td, numberOfAttributes);
 	td->t_hoff = hoff;
 
-	heap_fill_tuple(tupleDescriptor,
+	if(natts > 0)
+		heap_fill_tuple_ext(tupleDescriptor,
+					values,
+					isnull,
+					(char *) td + hoff,
+					data_len,
+					&td->t_infomask,
+					(hasnull ? td->t_bits : NULL),
+					natts);
+	else
+		heap_fill_tuple(tupleDescriptor,
 					values,
 					isnull,
 					(char *) td + hoff,
@@ -1103,6 +1194,24 @@ heap_form_tuple(TupleDesc tupleDescriptor,
 	return tuple;
 }
 
+/*
+ * heap_form_tuple
+ *		construct a tuple from the given values[] and isnull[] arrays,
+ *		which are of the length indicated by tupleDescriptor->natts
+ *
+ * The result is allocated in the current memory context.
+ */
+HeapTuple
+heap_form_tuple(TupleDesc tupleDescriptor,
+				const Datum *values,
+				const bool *isnull)
+{
+	return heap_form_tuple_ext(tupleDescriptor,
+				values,
+				isnull,
+				-1);
+}
+
 /*
  * heap_modify_tuple
  *		form a new tuple from an old tuple and a set of replacement values.
@@ -1375,22 +1484,12 @@ heap_freetuple(HeapTuple htup)
 }
 
 
-/*
- * heap_form_minimal_tuple
- *		construct a MinimalTuple from the given values[] and isnull[] arrays,
- *		which are of the length indicated by tupleDescriptor->natts
- *
- * This is exactly like heap_form_tuple() except that the result is a
- * "minimal" tuple lacking a HeapTupleData header as well as room for system
- * columns.
- *
- * The result is allocated in the current memory context.
- */
 MinimalTuple
-heap_form_minimal_tuple(TupleDesc tupleDescriptor,
+heap_form_minimal_tuple_ext(TupleDesc tupleDescriptor,
 						const Datum *values,
 						const bool *isnull,
-						Size extra)
+						Size extra,
+						int natts)
 {
 	MinimalTuple tuple;			/* return tuple */
 	char	   *mem;
@@ -1403,6 +1502,9 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 
 	Assert(extra == MAXALIGN(extra));
 
+	if(natts > 0)
+		numberOfAttributes = natts;
+
 	if (numberOfAttributes > MaxTupleAttributeNumber)
 		ereport(ERROR,
 				(errcode(ERRCODE_TOO_MANY_COLUMNS),
@@ -1431,7 +1533,7 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 
 	hoff = len = MAXALIGN(len); /* align user data safely */
 
-	data_len = heap_compute_data_size(tupleDescriptor, values, isnull);
+	data_len = heap_compute_data_size_ext(tupleDescriptor, values, isnull, natts);
 
 	len += data_len;
 
@@ -1448,17 +1550,42 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 	HeapTupleHeaderSetNatts(tuple, numberOfAttributes);
 	tuple->t_hoff = hoff + MINIMAL_TUPLE_OFFSET;
 
-	heap_fill_tuple(tupleDescriptor,
+	heap_fill_tuple_ext(tupleDescriptor,
 					values,
 					isnull,
 					(char *) tuple + hoff,
 					data_len,
 					&tuple->t_infomask,
-					(hasnull ? tuple->t_bits : NULL));
+					(hasnull ? tuple->t_bits : NULL),
+					natts);
 
 	return tuple;
 }
 
+/*
+ * heap_form_minimal_tuple
+ *		construct a MinimalTuple from the given values[] and isnull[] arrays,
+ *		which are of the length indicated by tupleDescriptor->natts
+ *
+ * This is exactly like heap_form_tuple() except that the result is a
+ * "minimal" tuple lacking a HeapTupleData header as well as room for system
+ * columns.
+ *
+ * The result is allocated in the current memory context.
+ */
+MinimalTuple
+heap_form_minimal_tuple(TupleDesc tupleDescriptor,
+						const Datum *values,
+						const bool *isnull,
+						Size extra)
+{
+	return heap_form_minimal_tuple_ext(tupleDescriptor,
+						values,
+						isnull,
+						extra,
+						-1);
+}
+
 /*
  * heap_free_minimal_tuple
  */
diff --git a/src/backend/executor/execTuples.c b/src/backend/executor/execTuples.c
index f08982a43cc..c98b49486b0 100644
--- a/src/backend/executor/execTuples.c
+++ b/src/backend/executor/execTuples.c
@@ -80,6 +80,7 @@ static inline void tts_buffer_heap_store_tuple(TupleTableSlot *slot,
 											   bool transfer_pin);
 static void tts_heap_store_tuple(TupleTableSlot *slot, HeapTuple tuple, bool shouldFree);
 
+static void tts_heap_materialize_ext(TupleTableSlot *slot, int natts);
 
 const TupleTableSlotOps TTSOpsVirtual;
 const TupleTableSlotOps TTSOpsHeapTuple;
@@ -269,24 +270,61 @@ static void
 tts_virtual_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 {
 	TupleDesc	srcdesc = srcslot->tts_tupleDescriptor;
+	TupleDesc	dstdesc = dstslot->tts_tupleDescriptor;
+	int attnum = srcdesc->natts;
 
 	tts_virtual_clear(dstslot);
 
 	slot_getallattrs(srcslot);
 
-	for (int natt = 0; natt < srcdesc->natts; natt++)
+	if(dstdesc->natts < srcdesc->natts)
+		attnum = dstdesc->natts;
+
+	for (int natt = 0; natt < attnum; natt++)
 	{
 		dstslot->tts_values[natt] = srcslot->tts_values[natt];
 		dstslot->tts_isnull[natt] = srcslot->tts_isnull[natt];
 	}
 
-	dstslot->tts_nvalid = srcdesc->natts;
+	if(dstdesc->natts > srcdesc->natts)
+	{
+		for (int natt = srcdesc->natts; natt < dstdesc->natts; natt++)
+		{
+			dstslot->tts_values[natt] = (Datum) 0;
+			dstslot->tts_isnull[natt] = true;
+		}
+	}
+
+	dstslot->tts_nvalid = dstdesc->natts; //srcdesc->natts;
 	dstslot->tts_flags &= ~TTS_FLAG_EMPTY;
 
 	/* make sure storage doesn't depend on external memory */
 	tts_virtual_materialize(dstslot);
 }
 
+static HeapTuple
+tts_virtual_copy_heap_tuple_natts(TupleTableSlot *slot, int natts)
+{
+	Assert(!TTS_EMPTY(slot));
+
+	return heap_form_tuple_ext(slot->tts_tupleDescriptor,
+						   slot->tts_values,
+						   slot->tts_isnull,
+						   natts);
+}
+
+static MinimalTuple
+tts_virtual_copy_minimal_tuple_natts(TupleTableSlot *slot, int natts)
+{
+	Assert(!TTS_EMPTY(slot));
+
+	return heap_form_minimal_tuple_ext(slot->tts_tupleDescriptor,
+								   slot->tts_values,
+								   slot->tts_isnull,
+								   0,
+								   natts);
+}
+
 static HeapTuple
 tts_virtual_copy_heap_tuple(TupleTableSlot *slot)
 {
@@ -397,6 +435,12 @@ tts_heap_is_current_xact_tuple(TupleTableSlot *slot)
 
 static void
 tts_heap_materialize(TupleTableSlot *slot)
+{
+	tts_heap_materialize_ext(slot, -1);
+}
+
+static void
+tts_heap_materialize_ext(TupleTableSlot *slot, int natts)
 {
 	HeapTupleTableSlot *hslot = (HeapTupleTableSlot *) slot;
 	MemoryContext oldContext;
@@ -417,9 +461,10 @@ tts_heap_materialize(TupleTableSlot *slot)
 	hslot->off = 0;
 
 	if (!hslot->tuple)
-		hslot->tuple = heap_form_tuple(slot->tts_tupleDescriptor,
+		hslot->tuple = heap_form_tuple_ext(slot->tts_tupleDescriptor,
 									   slot->tts_values,
-									   slot->tts_isnull);
+									   slot->tts_isnull,
+									   natts);
 	else
 	{
 		/*
@@ -427,7 +472,7 @@ tts_heap_materialize(TupleTableSlot *slot)
 		 * context of the given slot (else it would have TTS_FLAG_SHOULDFREE
 		 * set).  Copy the tuple into the given slot's memory context.
 		 */
-		hslot->tuple = heap_copytuple(hslot->tuple);
+		hslot->tuple = heap_copytuple_ext(hslot->tuple, slot, natts);
 	}
 
 	slot->tts_flags |= TTS_FLAG_SHOULDFREE;
@@ -483,6 +528,29 @@ tts_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
 	return minimal_tuple_from_heap_tuple(hslot->tuple, extra);
 }
 
+static HeapTuple
+tts_heap_copy_heap_tuple_natts(TupleTableSlot *slot, int attnum)
+{
+	HeapTupleTableSlot *hslot = (HeapTupleTableSlot *) slot;
+
+	Assert(!TTS_EMPTY(slot));
+	if (!hslot->tuple)
+		tts_heap_materialize_ext(slot, attnum);
+
+	return heap_copytuple_ext(hslot->tuple, slot, attnum);
+}
+
+static MinimalTuple
+tts_heap_copy_minimal_tuple_natts(TupleTableSlot *slot, int attnum)
+{
+	HeapTupleTableSlot *hslot = (HeapTupleTableSlot *) slot;
+
+	if (!hslot->tuple)
+		tts_heap_materialize_ext(slot, attnum);
+
+	return minimal_tuple_from_heap_tuple(hslot->tuple, 0);
+}
+
 static void
 tts_heap_store_tuple(TupleTableSlot *slot, HeapTuple tuple, bool shouldFree)
 {
@@ -584,7 +652,7 @@ tts_minimal_is_current_xact_tuple(TupleTableSlot *slot)
 }
 
 static void
-tts_minimal_materialize(TupleTableSlot *slot)
+tts_minimal_materialize_ext(TupleTableSlot *slot, int natts)
 {
 	MinimalTupleTableSlot *mslot = (MinimalTupleTableSlot *) slot;
 	MemoryContext oldContext;
@@ -606,10 +674,11 @@ tts_minimal_materialize(TupleTableSlot *slot)
 
 	if (!mslot->mintuple)
 	{
-		mslot->mintuple = heap_form_minimal_tuple(slot->tts_tupleDescriptor,
+		mslot->mintuple = heap_form_minimal_tuple_ext(slot->tts_tupleDescriptor,
 												  slot->tts_values,
 												  slot->tts_isnull,
-												  0);
+												  0,
+												  natts);
 	}
 	else
 	{
@@ -632,16 +701,32 @@ tts_minimal_materialize(TupleTableSlot *slot)
 	MemoryContextSwitchTo(oldContext);
 }
 
+static void
+tts_minimal_materialize(TupleTableSlot *slot)
+{
+	tts_minimal_materialize_ext(slot, -1);
+}
+
 static void
 tts_minimal_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 {
 	MemoryContext oldcontext;
 	MinimalTuple mintuple;
 
-	oldcontext = MemoryContextSwitchTo(dstslot->tts_mcxt);
-	mintuple = ExecCopySlotMinimalTuple(srcslot);
-	MemoryContextSwitchTo(oldcontext);
-
+	if(dstslot->tts_tupleDescriptor->natts != srcslot->tts_tupleDescriptor->natts)
+	{
+		int natts = (dstslot->tts_tupleDescriptor->natts < srcslot->tts_tupleDescriptor->natts ? dstslot->tts_tupleDescriptor->natts : srcslot->tts_tupleDescriptor->natts);
+		oldcontext = MemoryContextSwitchTo(dstslot->tts_mcxt);
+		mintuple = ExecCopySlotMinimalTupleNatts(srcslot, natts);
+		MemoryContextSwitchTo(oldcontext);
+	}
+	else
+	{
+		oldcontext = MemoryContextSwitchTo(dstslot->tts_mcxt);
+		mintuple = ExecCopySlotMinimalTuple(srcslot);
+		MemoryContextSwitchTo(oldcontext);
+	}
+	dstslot->tts_remoteOid = srcslot->tts_remoteOid;
 	ExecStoreMinimalTuple(mintuple, dstslot, true);
 }
 
@@ -678,6 +763,28 @@ tts_minimal_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
 	return heap_copy_minimal_tuple(mslot->mintuple, extra);
 }
 
+static HeapTuple
+tts_minimal_copy_heap_tuple_natts(TupleTableSlot *slot, int natts)
+{
+	MinimalTupleTableSlot *mslot = (MinimalTupleTableSlot *) slot;
+
+	if (!mslot->mintuple)
+		tts_minimal_materialize_ext(slot, natts);
+
+	return heap_tuple_from_minimal_tuple(mslot->mintuple);
+}
+
+static MinimalTuple
+tts_minimal_copy_minimal_tuple_natts(TupleTableSlot *slot, int natts)
+{
+	MinimalTupleTableSlot *mslot = (MinimalTupleTableSlot *) slot;
+
+	if (!mslot->mintuple)
+		tts_minimal_materialize_ext(slot, natts);
+
+	return heap_copy_minimal_tuple(mslot->mintuple, 0);
+}
+
 static void
 tts_minimal_store_tuple(TupleTableSlot *slot, MinimalTuple mtup, bool shouldFree)
 {
@@ -801,7 +908,7 @@ tts_buffer_is_current_xact_tuple(TupleTableSlot *slot)
 }
 
 static void
-tts_buffer_heap_materialize(TupleTableSlot *slot)
+tts_buffer_heap_materialize_ext(TupleTableSlot *slot, int natts)
 {
 	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
 	MemoryContext oldContext;
@@ -830,13 +937,14 @@ tts_buffer_heap_materialize(TupleTableSlot *slot)
 		 * tuples in a buffer slot, which then also needs to be
 		 * materializable.
 		 */
-		bslot->base.tuple = heap_form_tuple(slot->tts_tupleDescriptor,
+		bslot->base.tuple = heap_form_tuple_ext(slot->tts_tupleDescriptor,
 											slot->tts_values,
-											slot->tts_isnull);
+											slot->tts_isnull,
+											natts);
 	}
 	else
 	{
-		bslot->base.tuple = heap_copytuple(bslot->base.tuple);
+		bslot->base.tuple = heap_copytuple_ext(bslot->base.tuple, slot, natts);
 
 		/*
 		 * A heap tuple stored in a BufferHeapTupleTableSlot should have a
@@ -859,6 +967,12 @@ tts_buffer_heap_materialize(TupleTableSlot *slot)
 	MemoryContextSwitchTo(oldContext);
 }
 
+static void
+tts_buffer_heap_materialize(TupleTableSlot *slot)
+{
+	tts_buffer_heap_materialize_ext(slot, -1);
+}
+
 static void
 tts_buffer_heap_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 {
@@ -874,12 +988,14 @@ tts_buffer_heap_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 		TTS_SHOULDFREE(srcslot) ||
 		!bsrcslot->base.tuple)
 	{
+		int natts;
 		MemoryContext oldContext;
 
+		natts = (dstslot->tts_tupleDescriptor->natts < srcslot->tts_tupleDescriptor->natts ? dstslot->tts_tupleDescriptor->natts : srcslot->tts_tupleDescriptor->natts);
 		ExecClearTuple(dstslot);
 		dstslot->tts_flags &= ~TTS_FLAG_EMPTY;
 		oldContext = MemoryContextSwitchTo(dstslot->tts_mcxt);
-		bdstslot->base.tuple = ExecCopySlotHeapTuple(srcslot);
+		bdstslot->base.tuple = ExecCopySlotHeapTupleNatts(srcslot, natts);
 		dstslot->tts_flags |= TTS_FLAG_SHOULDFREE;
 		MemoryContextSwitchTo(oldContext);
 	}
@@ -914,6 +1030,19 @@ tts_buffer_heap_get_heap_tuple(TupleTableSlot *slot)
 	return bslot->base.tuple;
 }
 
+static HeapTuple
+tts_buffer_heap_copy_heap_tuple_natts(TupleTableSlot *slot, int attnum)
+{
+	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
+
+	Assert(!TTS_EMPTY(slot));
+
+	if (!bslot->base.tuple)
+		tts_buffer_heap_materialize_ext(slot, attnum);
+
+	return heap_copytuple_ext(bslot->base.tuple, slot, attnum);
+}
+
 static HeapTuple
 tts_buffer_heap_copy_heap_tuple(TupleTableSlot *slot)
 {
@@ -927,6 +1056,19 @@ tts_buffer_heap_copy_heap_tuple(TupleTableSlot *slot)
 	return heap_copytuple(bslot->base.tuple);
 }
 
+static MinimalTuple
+tts_buffer_copy_minimal_tuple_natts(TupleTableSlot *slot, int natts)
+{
+	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
+
+	Assert(!TTS_EMPTY(slot));
+
+	if (!bslot->base.tuple)
+		tts_buffer_heap_materialize_ext(slot, natts);
+
+	return minimal_tuple_from_heap_tuple(bslot->base.tuple, 0);
+}
+
 static MinimalTuple
 tts_buffer_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
 {
@@ -1274,7 +1416,9 @@ const TupleTableSlotOps TTSOpsVirtual = {
 	.get_heap_tuple = NULL,
 	.get_minimal_tuple = NULL,
 	.copy_heap_tuple = tts_virtual_copy_heap_tuple,
-	.copy_minimal_tuple = tts_virtual_copy_minimal_tuple
+	.copy_minimal_tuple = tts_virtual_copy_minimal_tuple,
+	.copy_heap_tuple_ext = tts_virtual_copy_heap_tuple_natts,
+	.copy_minimal_tuple_ext = tts_virtual_copy_minimal_tuple_natts
 };
 
 const TupleTableSlotOps TTSOpsHeapTuple = {
@@ -1292,7 +1436,9 @@ const TupleTableSlotOps TTSOpsHeapTuple = {
 	/* A heap tuple table slot can not "own" a minimal tuple. */
 	.get_minimal_tuple = NULL,
 	.copy_heap_tuple = tts_heap_copy_heap_tuple,
-	.copy_minimal_tuple = tts_heap_copy_minimal_tuple
+	.copy_minimal_tuple = tts_heap_copy_minimal_tuple,
+	.copy_heap_tuple_ext = tts_heap_copy_heap_tuple_natts,
+	.copy_minimal_tuple_ext = tts_heap_copy_minimal_tuple_natts
 };
 
 const TupleTableSlotOps TTSOpsMinimalTuple = {
@@ -1310,7 +1456,9 @@ const TupleTableSlotOps TTSOpsMinimalTuple = {
 	.get_heap_tuple = NULL,
 	.get_minimal_tuple = tts_minimal_get_minimal_tuple,
 	.copy_heap_tuple = tts_minimal_copy_heap_tuple,
-	.copy_minimal_tuple = tts_minimal_copy_minimal_tuple
+	.copy_minimal_tuple = tts_minimal_copy_minimal_tuple,
+	.copy_heap_tuple_ext = tts_minimal_copy_heap_tuple_natts,
+	.copy_minimal_tuple_ext = tts_minimal_copy_minimal_tuple_natts
 };
 
 const TupleTableSlotOps TTSOpsBufferHeapTuple = {
@@ -1328,7 +1476,9 @@ const TupleTableSlotOps TTSOpsBufferHeapTuple = {
 	/* A buffer heap tuple table slot can not "own" a minimal tuple. */
 	.get_minimal_tuple = NULL,
 	.copy_heap_tuple = tts_buffer_heap_copy_heap_tuple,
-	.copy_minimal_tuple = tts_buffer_heap_copy_minimal_tuple
+	.copy_minimal_tuple = tts_buffer_heap_copy_minimal_tuple,
+	.copy_heap_tuple_ext = tts_buffer_heap_copy_heap_tuple_natts,
+	.copy_minimal_tuple_ext = tts_buffer_copy_minimal_tuple_natts
 };
 
 
@@ -1867,8 +2017,7 @@ ExecStoreAllNullTuple(TupleTableSlot *slot)
  * Until the slot is materialized, the contents of the slot depend on the
  * datum.
  */
-void
-ExecStoreHeapTupleDatum(Datum data, TupleTableSlot *slot)
+void ExecStoreHeapTupleDatum(Datum data, TupleTableSlot *slot)
 {
 	HeapTupleData tuple = {0};
 	HeapTupleHeader td;
@@ -1878,6 +2027,7 @@ ExecStoreHeapTupleDatum(Datum data, TupleTableSlot *slot)
 	tuple.t_len = HeapTupleHeaderGetDatumLength(td);
 	tuple.t_self = td->t_ctid;
 	tuple.t_data = td;
+	tuple.t_tableOid = InvalidOid;
 
 	ExecClearTuple(slot);
 
diff --git a/src/backend/optimizer/util/tlist.c b/src/backend/optimizer/util/tlist.c
index 752ea9222f6..3c99c72d38c 100644
--- a/src/backend/optimizer/util/tlist.c
+++ b/src/backend/optimizer/util/tlist.c
@@ -329,18 +329,37 @@ apply_tlist_labeling(List *dest_tlist, List *src_tlist)
 	ListCell   *ld,
 			   *ls;
 
-	Assert(list_length(dest_tlist) == list_length(src_tlist));
-	forboth(ld, dest_tlist, ls, src_tlist)
+	foreach(ld, dest_tlist)
 	{
+		bool has_src = false;
 		TargetEntry *dest_tle = (TargetEntry *) lfirst(ld);
-		TargetEntry *src_tle = (TargetEntry *) lfirst(ls);
-
-		Assert(dest_tle->resno == src_tle->resno);
-		dest_tle->resname = src_tle->resname;
-		dest_tle->ressortgroupref = src_tle->ressortgroupref;
-		dest_tle->resorigtbl = src_tle->resorigtbl;
-		dest_tle->resorigcol = src_tle->resorigcol;
-		dest_tle->resjunk = src_tle->resjunk;
+
+		foreach(ls, src_tlist)
+		{
+			TargetEntry *src_tle = (TargetEntry *) lfirst(ls);
+
+			if(dest_tle->resno == src_tle->resno)
+			{
+				has_src = true;
+				dest_tle->resname = src_tle->resname;
+				dest_tle->ressortgroupref = src_tle->ressortgroupref;
+				dest_tle->resorigtbl = src_tle->resorigtbl;
+				dest_tle->resorigcol = src_tle->resorigcol;
+				dest_tle->resjunk = src_tle->resjunk;
+				break;
+			}
+		}
+
+		if(!has_src)
+		{
+			TargetEntry *new_entry = palloc0(sizeof(TargetEntry));
+			new_entry->resname = dest_tle->resname;
+			new_entry->ressortgroupref = dest_tle->ressortgroupref;
+			new_entry->resorigtbl = dest_tle->resorigtbl;
+			new_entry->resorigcol = dest_tle->resorigcol;
+			new_entry->resjunk = dest_tle->resjunk;
+			src_tlist = lappend(src_tlist, new_entry);
+		}
 	}
 }
 
diff --git a/src/include/access/htup_details.h b/src/include/access/htup_details.h
index 77a6c48fd71..338c123402f 100644
--- a/src/include/access/htup_details.h
+++ b/src/include/access/htup_details.h
@@ -794,10 +794,19 @@ HeapTupleClearHeapOnly(const HeapTupleData *tuple)
 /* prototypes for functions in common/heaptuple.c */
 extern Size heap_compute_data_size(TupleDesc tupleDesc,
 								   const Datum *values, const bool *isnull);
+extern Size heap_compute_data_size_ext(TupleDesc tupleDesc,
+									   const Datum *values,
+									   const bool *isnull,
+									   int attnum);
+
 extern void heap_fill_tuple(TupleDesc tupleDesc,
 							const Datum *values, const bool *isnull,
 							char *data, Size data_size,
 							uint16 *infomask, uint8 *bit);
+extern void heap_fill_tuple_ext(TupleDesc tupleDesc,
+								const Datum *values, const bool *isnull,
+								char *data, Size data_size,
+								uint16 *infomask, uint8 *bit, int natts);
 extern bool heap_attisnull(HeapTuple tup, int attnum, TupleDesc tupleDesc);
 extern Datum nocachegetattr(HeapTuple tup, int attnum,
 							TupleDesc tupleDesc);
@@ -806,10 +815,13 @@ extern Datum heap_getsysattr(HeapTuple tup, int attnum, TupleDesc tupleDesc,
 extern Datum getmissingattr(TupleDesc tupleDesc,
 							int attnum, bool *isnull);
 extern HeapTuple heap_copytuple(HeapTuple tuple);
+extern HeapTuple heap_copytuple_ext(HeapTuple tuple, void *slot, int dstnatts);
 extern void heap_copytuple_with_tuple(HeapTuple src, HeapTuple dest);
 extern Datum heap_copy_tuple_as_datum(HeapTuple tuple, TupleDesc tupleDesc);
 extern HeapTuple heap_form_tuple(TupleDesc tupleDescriptor,
 								 const Datum *values, const bool *isnull);
+extern HeapTuple heap_form_tuple_ext(TupleDesc tupleDescriptor,
+								 const Datum *values, const bool *isnull, int natts);
 extern HeapTuple heap_modify_tuple(HeapTuple tuple,
 								   TupleDesc tupleDesc,
 								   const Datum *replValues,
@@ -827,6 +839,8 @@ extern void heap_freetuple(HeapTuple htup);
 extern MinimalTuple heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 											const Datum *values, const bool *isnull,
 											Size extra);
+extern MinimalTuple heap_form_minimal_tuple_ext(TupleDesc tupleDescriptor,
+												const Datum *values, const bool *isnull, Size extra, int natts);
 extern void heap_free_minimal_tuple(MinimalTuple mtup);
 extern MinimalTuple heap_copy_minimal_tuple(MinimalTuple mtup, Size extra);
 extern HeapTuple heap_tuple_from_minimal_tuple(MinimalTuple mtup);
diff --git a/src/include/executor/tuptable.h b/src/include/executor/tuptable.h
index d7e8e72aeae..75e0d69d46d 100644
--- a/src/include/executor/tuptable.h
+++ b/src/include/executor/tuptable.h
@@ -141,6 +141,7 @@ typedef struct TupleTableSlot
 	MemoryContext tts_mcxt;		/* slot itself is in this context */
 	ItemPointerData tts_tid;	/* stored tuple's tid */
 	Oid			tts_tableOid;	/* table oid of tuple */
+	Oid			tts_remoteOid;
 } TupleTableSlot;
 
 /* routines for a TupleTableSlot implementation */
@@ -239,6 +240,26 @@ struct TupleTableSlotOps
 	 * with the minimal tuple without the need for an additional allocation.
 	 */
 	MinimalTuple (*copy_minimal_tuple) (TupleTableSlot *slot, Size extra);
+
+	/*
+	 * Return a copy of heap tuple's N atts representing the contents of the slot.
+	 * The copy needs to be palloc'd in the current memory context. The slot
+	 * itself is expected to remain unaffected. It is *not* expected to have
+	 * meaningful "system columns" in the copy. The copy is not be "owned" by
+	 * the slot i.e. the caller has to take responsibility to free memory
+	 * consumed by the slot.
+	 */
+	HeapTuple (*copy_heap_tuple_ext) (TupleTableSlot *slot, int natts);
+
+	/*
+	 * Return a copy of minimal tuple's N atts representing the contents of the slot.
+	 * The copy needs to be palloc'd in the current memory context. The slot
+	 * itself is expected to remain unaffected. It is *not* expected to have
+	 * meaningful "system columns" in the copy. The copy is not be "owned" by
+	 * the slot i.e. the caller has to take responsibility to free memory
+	 * consumed by the slot.
+	 */
+	MinimalTuple (*copy_minimal_tuple_ext) (TupleTableSlot *slot, int natts);
 };
 
 /*
@@ -500,6 +521,14 @@ ExecMaterializeSlot(TupleTableSlot *slot)
  * ExecCopySlotHeapTuple - return HeapTuple allocated in caller's context
  */
 static inline HeapTuple
+ExecCopySlotHeapTupleNatts(TupleTableSlot *slot, int natts)
+{
+	Assert(!TTS_EMPTY(slot));
+
+	return slot->tts_ops->copy_heap_tuple_ext(slot, natts);
+}
+
+ static inline HeapTuple
 ExecCopySlotHeapTuple(TupleTableSlot *slot)
 {
 	Assert(!TTS_EMPTY(slot));
@@ -507,6 +536,15 @@ ExecCopySlotHeapTuple(TupleTableSlot *slot)
 	return slot->tts_ops->copy_heap_tuple(slot);
 }
 
+/*
+ * ExecCopySlotMinimalTuple - return MinimalTuple allocated in caller's context
+ */
+static inline MinimalTuple
+ExecCopySlotMinimalTupleNatts(TupleTableSlot *slot, int natts)
+{
+	return slot->tts_ops->copy_minimal_tuple_ext(slot, natts);
+}
+
 /*
  * ExecCopySlotMinimalTuple - return MinimalTuple allocated in caller's context
  */
@@ -544,9 +582,18 @@ ExecCopySlot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 {
 	Assert(!TTS_EMPTY(srcslot));
 	Assert(srcslot != dstslot);
+
+	/* This assert supposes that we always have same number
+	 * of attributes and is not relevant in new case
+	 */
+/*
 	Assert(dstslot->tts_tupleDescriptor->natts ==
 		   srcslot->tts_tupleDescriptor->natts);
+*/
 
+	/* If source slot has less attributes then target - copy source and
+	 * set target to nulls. Otherwise copy only leading attributes and set
+	 * target natts to source counter */
 	dstslot->tts_ops->copyslot(dstslot, srcslot);
 
 	return dstslot;
-- 
2.43.0



  [application/octet-stream] v1-0002-add-remote-tableoid-param.patch (16.1K, ../../CAN-LCVMz58ukZ7ubGXiLuTeFE7wWmSwDw4URpF0q1ejzRvqbzg@mail.gmail.com/5-v1-0002-add-remote-tableoid-param.patch)
  download | inline diff:
From 32deb2248ea65f83ca5cdc81459648486d80eac4 Mon Sep 17 00:00:00 2001
From: Nikita Malakhov <n.malakhov@postgrespro.ru>
Date: Tue, 21 Apr 2026 14:10:39 +0300
Subject: [PATCH] [POC] There is very old and well-known problem in FDW wrapper
 - while deleteing or updating rows of partitioned tables via FDW the FDW
 engine does not consider partitions which rows belong to, and when user tries
 to delete or update just 1 row - actually all rows with the same TID are
 deleted/updated across all partitions. There was previous approach to this
 problem [1] but it had some unsolved problems resulted in segfaults. Current
 solution appears to be more complex and requires core modification allowing
 copying tuples with different amount of attributes.

This core patch adds new field in exec params to keep remote table
OID and use it along with the TID for delete and update operations.

[1] https://www.postgresql.org/message-id/flat/CAPmGK15CQK-oYFMAyq%2BrR0rQapUHtvAGuGgY5ahERHzZ4tmC8g%40mail.gmail.com#4321975bbd1af71c78d45d9a441e8458
---
 src/backend/executor/execMain.c         |  4 ++
 src/backend/executor/nodeForeignscan.c  |  2 +
 src/backend/optimizer/path/allpaths.c   | 19 ++++++++
 src/backend/optimizer/plan/createplan.c | 19 ++++++++
 src/backend/optimizer/plan/initsplan.c  | 43 ++++++++++++++++++
 src/backend/optimizer/plan/planner.c    | 14 +++---
 src/backend/optimizer/plan/setrefs.c    | 59 ++++++++++++++++++++++++-
 src/backend/optimizer/plan/subselect.c  |  7 ++-
 src/backend/optimizer/util/appendinfo.c | 27 +++++++++++
 src/backend/optimizer/util/relnode.c    | 21 +++++++++
 src/backend/utils/adt/ruleutils.c       |  2 +-
 src/include/nodes/pathnodes.h           |  7 +++
 src/include/nodes/primnodes.h           |  1 +
 13 files changed, 217 insertions(+), 8 deletions(-)

diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index 4b30f768680..b7b3b73941e 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -2621,6 +2621,10 @@ ExecBuildAuxRowMark(ExecRowMark *erm, List *targetlist)
 													   resname);
 		if (!AttributeNumberIsValid(aerm->ctidAttNo))
 			elog(ERROR, "could not find junk %s column", resname);
+
+		snprintf(resname, sizeof(resname), "tableoid%u", erm->rowmarkId);
+		aerm->toidAttNo = ExecFindJunkAttributeInTlist(targetlist,
+													   resname);
 	}
 	else
 	{
diff --git a/src/backend/executor/nodeForeignscan.c b/src/backend/executor/nodeForeignscan.c
index 6f0daddce07..21b3e9a961b 100644
--- a/src/backend/executor/nodeForeignscan.c
+++ b/src/backend/executor/nodeForeignscan.c
@@ -65,6 +65,8 @@ ForeignNext(ForeignScanState *node)
 	 * Insert valid value into tableoid, the only actually-useful system
 	 * column.
 	 */
+
+	slot->tts_remoteOid = slot->tts_tableOid;
 	if (plan->fsSystemCol && !TupIsNull(slot))
 		slot->tts_tableOid = RelationGetRelid(node->ss.ss_currentRelation);
 
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 61093f222a1..006ab5f89d6 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -1161,6 +1161,25 @@ set_append_rel_size(PlannerInfo *root, RelOptInfo *rel,
 								   (Node *) rel->reltarget->exprs,
 								   1, &appinfo);
 
+		/* Do it if fdw is partition */
+		if (planner_rt_fetch(childRTindex, root)->relkind == RELKIND_FOREIGN_TABLE &&
+			!bms_is_empty(root->glob->foreignParamIDs))
+		{
+			foreach(lc, root->processed_tlist)
+			{
+				TargetEntry *tle = (TargetEntry *) lfirst(lc);
+				Param	   *param = (Param *) tle->expr;
+
+				if (tle->resjunk && IsA(param, Param) &&
+					IS_FOREIGN_PARAM(root, param) &&
+					param->target_rte == childRTindex) // TODO same for another case
+				{
+					childrel->reltarget->exprs =
+						lappend(childrel->reltarget->exprs, param);
+				}
+			}
+		}
+
 		/*
 		 * We have to make child entries in the EquivalenceClass data
 		 * structures as well.  This is needed either if the parent
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index de6a183da79..00ad2dcb07e 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -980,6 +980,25 @@ use_physical_tlist(PlannerInfo *root, Path *path, int flags)
 		}
 	}
 
+	/*
+	 * Also, can't do it to a ForeignPath if the path is requested to emit
+	 * Params generated by the FDW.
+	 */
+	if (IsA(path, ForeignPath) &&
+		path->parent->relid == root->parse->resultRelation &&
+		!bms_is_empty(root->glob->foreignParamIDs))
+	{
+		foreach(lc, path->pathtarget->exprs)
+		{
+			Param	   *param = (Param *) lfirst(lc);
+			if (param && IsA(param, Param))
+			{
+				Assert(IS_FOREIGN_PARAM(root, param));
+				return false;
+			}
+		}
+	}
+
 	return true;
 }
 
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index 96ee312ebdf..64be8a8b9d1 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -32,6 +32,7 @@
 #include "optimizer/planner.h"
 #include "optimizer/restrictinfo.h"
 #include "parser/analyze.h"
+#include "parser/parsetree.h"
 #include "rewrite/rewriteManip.h"
 #include "utils/lsyscache.h"
 #include "utils/rel.h"
@@ -231,6 +232,38 @@ add_other_rels_to_query(PlannerInfo *root)
  *
  *****************************************************************************/
 
+/*
+ * add_params_to_result_rel
+ *	  If the query's final tlist contains Params the FDW generated, add
+ *	  targetlist entries for each such Param to the result relation.
+ */
+static void
+add_params_to_result_rel(PlannerInfo *root, List *final_tlist)
+{
+	RelOptInfo *target_rel = find_base_rel(root, root->parse->resultRelation);
+	ListCell   *lc;
+
+	/*
+	 * If no parameters have been generated by any FDWs, we certainly don't
+	 * need to do anything here.
+	 */
+	if (bms_is_empty(root->glob->foreignParamIDs))
+		return;
+
+	foreach(lc, final_tlist)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+		Param	   *param = (Param *) tle->expr;
+
+		if (tle->resjunk && IsA(param, Param) &&
+			IS_FOREIGN_PARAM(root, param) &&
+			param->target_rte == target_rel->relid)
+		{
+			target_rel->reltarget->exprs = lappend(target_rel->reltarget->exprs, param);
+		}
+	}
+}
+
 /*
  * build_base_rel_tlists
  *	  Add targetlist entries for each var needed in the query's final tlist
@@ -270,6 +303,16 @@ build_base_rel_tlists(PlannerInfo *root, List *final_tlist)
 			list_free(having_vars);
 		}
 	}
+
+	if (root->parse->commandType == CMD_UPDATE ||
+		root->parse->commandType == CMD_DELETE)
+	{
+		int			result_relation = root->parse->resultRelation;
+
+		if (planner_rt_fetch(result_relation, root)->relkind == RELKIND_FOREIGN_TABLE)
+			add_params_to_result_rel(root, final_tlist);
+
+	}
 }
 
 /*
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 4ec76ce31a9..d168368c2fe 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -375,6 +375,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	glob->dependsOnRole = false;
 	glob->partition_directory = NULL;
 	glob->rel_notnullatts_hash = NULL;
+	glob->foreignParamIDs = NULL;
 
 	/*
 	 * Assess whether it's feasible to use parallel mode for this query. We
@@ -603,12 +604,15 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	}
 
 	/*
-	 * If any Params were generated, run through the plan tree and compute
-	 * each plan node's extParam/allParam sets.  Ideally we'd merge this into
-	 * set_plan_references' tree traversal, but for now it has to be separate
-	 * because we need to visit subplans before not after main plan.
+	 * If any Params were generated by the planner not by FDWs, run through
+	 * the plan tree and compute each plan node's extParam/allParam sets.
+	 * (Params added by FDWs are irrelevant for parameter change signaling.)
+	 * Ideally we'd merge this into set_plan_references' tree traversal, but
+	 * for now it has to be separate because we need to visit subplans before
+	 * not after main plan.
 	 */
-	if (glob->paramExecTypes != NIL)
+	if (glob->paramExecTypes != NIL &&
+		bms_num_members(glob->foreignParamIDs) < list_length(glob->paramExecTypes))
 	{
 		Assert(list_length(glob->subplans) == list_length(glob->subroots));
 		forboth(lp, glob->subplans, lr, glob->subroots)
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index ff0e875f2a2..87e97360eae 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -1901,7 +1901,8 @@ set_append_references(PlannerInfo *root,
 	{
 		Plan	   *p = (Plan *) linitial(aplan->appendplans);
 
-		if (p->parallel_aware == aplan->plan.parallel_aware)
+		if ((p->parallel_aware == aplan->plan.parallel_aware) &&
+			(list_length(((Plan *) aplan)->targetlist) == list_length(p->targetlist)))
 		{
 			Plan	   *result;
 
@@ -3310,7 +3311,42 @@ fix_join_expr_mutator(Node *node, fix_join_expr_context *context)
 	}
 	/* Special cases (apply only AFTER failing to match to lower tlist) */
 	if (IsA(node, Param))
+	{
+		Param	   *param = (Param *) node;
+
+		/*
+		 * If the Param is a PARAM_EXEC Param generated by an FDW, it should
+		 * have bubbled up from a lower plan node; convert it into a simple
+		 * Var referencing the output of the subplan.
+		 *
+		 * Note: set_join_references() would have kept has_non_vars=true for
+		 * the subplan emitting the Param since it effectively belong to the
+		 * result relation and that relation can never be the nullable side of
+		 * an outer join.
+		 */
+		if (IS_FOREIGN_PARAM(context->root, param))
+		{
+			if (context->outer_itlist && context->outer_itlist->has_non_vars)
+			{
+				newvar = search_indexed_tlist_for_non_var((Expr *) node,
+														  context->outer_itlist,
+														  OUTER_VAR);
+				if (newvar)
+					return (Node *) newvar;
+			}
+			if (context->inner_itlist && context->inner_itlist->has_non_vars)
+			{
+				newvar = search_indexed_tlist_for_non_var((Expr *) node,
+														  context->inner_itlist,
+														  INNER_VAR);
+				if (newvar)
+					return (Node *) newvar;
+			}
+			// XXX Is it an error to be here?
+		}
+		/* If not, do fix_param_node() */
 		return fix_param_node(context->root, (Param *) node);
+	}
 	if (IsA(node, AlternativeSubPlan))
 		return fix_join_expr_mutator(fix_alternative_subplan(context->root,
 															 (AlternativeSubPlan *) node,
@@ -3421,7 +3457,28 @@ fix_upper_expr_mutator(Node *node, fix_upper_expr_context *context)
 	}
 	/* Special cases (apply only AFTER failing to match to lower tlist) */
 	if (IsA(node, Param))
+	{
+		Param	   *param = (Param *) node;
+		/*
+		 * If the Param is a PARAM_EXEC Param generated by an FDW, it should
+		 * have bubbled up from a lower plan node; convert it into a simple
+		 * Var referencing the output of the subplan.
+		 */
+		if (IS_FOREIGN_PARAM(context->root, param))
+		{
+			if (context->subplan_itlist->has_non_vars)
+			{
+				newvar = search_indexed_tlist_for_non_var((Expr *) node,
+														  context->subplan_itlist,
+														  context->newvarno);
+				if (newvar)
+					return (Node *) newvar;
+			}
+			// XXX Is it an error to be here?
+		}
+		/* If not, do fix_param_node() */
 		return fix_param_node(context->root, (Param *) node);
+	}
 	if (IsA(node, Aggref))
 	{
 		Aggref	   *aggref = (Aggref *) node;
diff --git a/src/backend/optimizer/plan/subselect.c b/src/backend/optimizer/plan/subselect.c
index ccec1eaa7fe..375fdf27e53 100644
--- a/src/backend/optimizer/plan/subselect.c
+++ b/src/backend/optimizer/plan/subselect.c
@@ -3192,7 +3192,12 @@ finalize_primnode(Node *node, finalize_primnode_context *context)
 		{
 			int			paramid = ((Param *) node)->paramid;
 
-			context->paramids = bms_add_member(context->paramids, paramid);
+			/*
+			 * Params added by FDWs are irrelevant for parameter change
+			 * signaling.
+			 */
+			if (!bms_is_member(paramid, context->root->glob->foreignParamIDs))
+				context->paramids = bms_add_member(context->paramids, paramid);
 		}
 		return false;			/* no more to do here */
 	}
diff --git a/src/backend/optimizer/util/appendinfo.c b/src/backend/optimizer/util/appendinfo.c
index 778e4662f6e..7089a267125 100644
--- a/src/backend/optimizer/util/appendinfo.c
+++ b/src/backend/optimizer/util/appendinfo.c
@@ -948,6 +948,29 @@ add_row_identity_var(PlannerInfo *root, Var *orig_var,
 	root->processed_tlist = lappend(root->processed_tlist, tle);
 }
 
+static void
+fix_foreign_params(PlannerInfo *root, List *tlist)
+{
+	ListCell   *lc;
+
+	foreach(lc, tlist)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+		Param	   *param = (Param *) tle->expr;
+
+		if (tle->resjunk && IsA(param, Param) &&
+			param->paramkind == PARAM_EXEC &&
+			param->paramid == -1)
+		{
+			param->paramid = list_length(root->glob->paramExecTypes);
+			root->glob->paramExecTypes =
+				lappend_oid(root->glob->paramExecTypes, param->paramtype);
+			root->glob->foreignParamIDs =
+				bms_add_member(root->glob->foreignParamIDs, param->paramid);
+		}
+	}
+}
+
 /*
  * add_row_identity_columns
  *
@@ -992,8 +1015,12 @@ add_row_identity_columns(PlannerInfo *root, Index rtindex,
 		fdwroutine = GetFdwRoutineForRelation(target_relation, false);
 
 		if (fdwroutine->AddForeignUpdateTargets != NULL)
+		{
+
 			fdwroutine->AddForeignUpdateTargets(root, rtindex,
 												target_rte, target_relation);
+			fix_foreign_params(root, root->processed_tlist);
+		}
 
 		/*
 		 * For UPDATE, we need to make the FDW fetch unchanged columns by
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 3fc2c2f71d0..655b2b00bd7 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -1320,6 +1320,27 @@ build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 			}
 			continue;
 		}
+		/*
+		 * We allow FDWs to have PARAM_EXEC Params here.
+		 */
+		else if (IsA(var, Param))
+		{
+			Param	   *param = (Param *) var;
+
+			Assert(IS_FOREIGN_PARAM(root, param));
+
+			joinrel->reltarget->exprs =
+				lappend(joinrel->reltarget->exprs, param);
+
+			/*
+			 * Estimate using the type info  (Note: keep this in sync with
+			 * set_rel_width())
+			 */
+			joinrel->reltarget->width +=
+				get_typavgwidth(param->paramtype, param->paramtypmod);
+
+			continue;
+		}
 
 		/*
 		 * Otherwise, anything in a baserel or joinrel targetlist ought to be
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 090e8cc28c1..d2357018d0a 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -9374,7 +9374,7 @@ get_parameter(Param *param, deparse_context *context)
 	 * It's a bug if we get here for anything except PARAM_EXTERN Params, but
 	 * in production builds printing $N seems more useful than failing.
 	 */
-	Assert(param->paramkind == PARAM_EXTERN);
+	Assert(param->paramkind == PARAM_EXTERN || param->paramkind == PARAM_EXEC);
 
 	appendStringInfo(context->buf, "$%d", param->paramid);
 }
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 693b879f76d..3ad051d0af4 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -265,6 +265,9 @@ typedef struct PlannerGlobal
 	/* partition descriptors */
 	PartitionDirectory partition_directory pg_node_attr(read_write_ignore);
 
+	/* PARAM_EXEC Params generated by FDWs */
+	Bitmapset  *foreignParamIDs;
+
 	/* hash table for NOT NULL attnums of relations */
 	struct HTAB *rel_notnullatts_hash pg_node_attr(read_write_ignore);
 
@@ -277,6 +280,10 @@ typedef struct PlannerGlobal
 #define planner_subplan_get_plan(root, subplan) \
 	((Plan *) list_nth((root)->glob->subplans, (subplan)->plan_id - 1))
 
+/* macro for checking if a Param is a PARAM_EXEC Param generated by an FDW */
+#define IS_FOREIGN_PARAM(root, param) \
+	((param)->paramkind == PARAM_EXEC && \
+	 bms_is_member((param)->paramid, (root)->glob->foreignParamIDs))
 
 /*----------
  * PlannerInfo
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index 6dfc946c20b..b85c54e8ec2 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -402,6 +402,7 @@ typedef struct Param
 	Oid			paramcollid;
 	/* token location, or -1 if unknown */
 	ParseLoc	location;
+	Index		target_rte;
 } Param;
 
 /*
-- 
2.43.0



^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
@ 2026-05-14 15:05         ` Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Nikita Malakhov @ 2026-05-14 15:05 UTC (permalink / raw)
  To: Etsuro Fujita <etsuro.fujita@gmail.com>; +Cc: Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Hi hackers!

I've rebased the patch set and created patch in current commitfest.
Reviewers are welcome!
Current Postgres copy slot/tuple mechanics supports only the case where
source and destination
slot/tuple have an equal number of attributes. To support additional remote
OID attribute
I've extended these functions to be able to work when source and
destination differ.
The second patch - core-remote-tableoid-param - introduces remote table OID
and
passes it through the executor to be used by the FDW engine.
The third patch teaches FDW engine to use this remote table OID in UPDATE
and
DELETE queries.

CF entry: https://commitfest.postgresql.org/patch/6770/

--
Regards,
Nikita Malakhov
Postgres Professional
The Russian Postgres Company
https://postgrespro.ru/

Attachments:

  [application/octet-stream] v2-0002-core-remote-tableoid-param.patch (17.3K, ../../CAN-LCVM2iOWkfFt22yVEGOrp-76YP3-BVKQg+A20TENkVh8o1w@mail.gmail.com/3-v2-0002-core-remote-tableoid-param.patch)
  download | inline diff:
From 04447372a3e63035cc2501c338f0f89cce866957 Mon Sep 17 00:00:00 2001
From: Nikita Malakhov <n.malakhov@postgrespro.ru>
Date: Thu, 14 May 2026 17:41:02 +0300
Subject: [PATCH] DELETE/UPDATE more than one row in partitioned foreign table
 (2/3)

There is known bug in delete and update rows in partitioned foreign
tables via FDW [1]

Core patch - introduction of remote table OID returned from remote
query and passing it through planner and executor to use in foreign
queries.

[1] Discussion: https://www.postgresql.org/message-id/flat/20250718175314.4513c00a%40karst
---
 src/backend/executor/execMain.c         |  4 ++
 src/backend/executor/execTuples.c       |  4 +-
 src/backend/executor/nodeForeignscan.c  |  2 +
 src/backend/optimizer/path/allpaths.c   | 20 +++++++++
 src/backend/optimizer/plan/createplan.c | 19 +++++++++
 src/backend/optimizer/plan/initsplan.c  | 45 ++++++++++++++++++++
 src/backend/optimizer/plan/planner.c    | 14 ++++---
 src/backend/optimizer/plan/setrefs.c    | 56 +++++++++++++++++++++++++
 src/backend/optimizer/plan/subselect.c  |  7 +++-
 src/backend/optimizer/util/appendinfo.c | 27 ++++++++++++
 src/backend/optimizer/util/inherit.c    |  1 +
 src/backend/optimizer/util/relnode.c    | 21 ++++++++++
 src/backend/utils/adt/ruleutils.c       |  2 +-
 src/include/executor/tuptable.h         |  1 +
 src/include/nodes/pathnodes.h           |  7 ++++
 src/include/nodes/primnodes.h           |  1 +
 16 files changed, 222 insertions(+), 9 deletions(-)

diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index 4b30f768680..d18ee581c6f 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -2621,6 +2621,10 @@ ExecBuildAuxRowMark(ExecRowMark *erm, List *targetlist)
 													   resname);
 		if (!AttributeNumberIsValid(aerm->ctidAttNo))
 			elog(ERROR, "could not find junk %s column", resname);
+//fdwproblem
+			snprintf(resname, sizeof(resname), "tableoid%u", erm->rowmarkId);
+		aerm->toidAttNo = ExecFindJunkAttributeInTlist(targetlist,
+													   resname);
 	}
 	else
 	{
diff --git a/src/backend/executor/execTuples.c b/src/backend/executor/execTuples.c
index 6322a8a64b3..f8c66bdaef6 100644
--- a/src/backend/executor/execTuples.c
+++ b/src/backend/executor/execTuples.c
@@ -1911,8 +1911,7 @@ ExecStoreAllNullTuple(TupleTableSlot *slot)
  * Until the slot is materialized, the contents of the slot depend on the
  * datum.
  */
-void
-ExecStoreHeapTupleDatum(Datum data, TupleTableSlot *slot)
+void ExecStoreHeapTupleDatum(Datum data, TupleTableSlot *slot)
 {
 	HeapTupleData tuple = {0};
 	HeapTupleHeader td;
@@ -1922,6 +1921,7 @@ ExecStoreHeapTupleDatum(Datum data, TupleTableSlot *slot)
 	tuple.t_len = HeapTupleHeaderGetDatumLength(td);
 	tuple.t_self = td->t_ctid;
 	tuple.t_data = td;
+	tuple.t_tableOid = InvalidOid;
 
 	ExecClearTuple(slot);
 
diff --git a/src/backend/executor/nodeForeignscan.c b/src/backend/executor/nodeForeignscan.c
index 6f0daddce07..21b3e9a961b 100644
--- a/src/backend/executor/nodeForeignscan.c
+++ b/src/backend/executor/nodeForeignscan.c
@@ -65,6 +65,8 @@ ForeignNext(ForeignScanState *node)
 	 * Insert valid value into tableoid, the only actually-useful system
 	 * column.
 	 */
+
+	slot->tts_remoteOid = slot->tts_tableOid;
 	if (plan->fsSystemCol && !TupIsNull(slot))
 		slot->tts_tableOid = RelationGetRelid(node->ss.ss_currentRelation);
 
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 61093f222a1..040763caf35 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -1161,6 +1161,26 @@ set_append_rel_size(PlannerInfo *root, RelOptInfo *rel,
 								   (Node *) rel->reltarget->exprs,
 								   1, &appinfo);
 
+		/* Do it if fdw is partition */
+		if (planner_rt_fetch(childRTindex, root)->relkind == RELKIND_FOREIGN_TABLE &&
+			!bms_is_empty(root->glob->foreignParamIDs))
+		{
+			foreach(lc, root->processed_tlist)
+			{
+				TargetEntry *tle = (TargetEntry *) lfirst(lc);
+				Param	   *param = (Param *) tle->expr;
+
+				if (tle->resjunk && IsA(param, Param) &&
+					IS_FOREIGN_PARAM(root, param) &&
+					param->target_rte == childRTindex) // TODO same for another case
+				{
+					/* XXX is copyObject necessary here? */
+					childrel->reltarget->exprs =
+						lappend(childrel->reltarget->exprs, copyObject(param));
+				}
+			}
+		}
+
 		/*
 		 * We have to make child entries in the EquivalenceClass data
 		 * structures as well.  This is needed either if the parent
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index de6a183da79..00ad2dcb07e 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -980,6 +980,25 @@ use_physical_tlist(PlannerInfo *root, Path *path, int flags)
 		}
 	}
 
+	/*
+	 * Also, can't do it to a ForeignPath if the path is requested to emit
+	 * Params generated by the FDW.
+	 */
+	if (IsA(path, ForeignPath) &&
+		path->parent->relid == root->parse->resultRelation &&
+		!bms_is_empty(root->glob->foreignParamIDs))
+	{
+		foreach(lc, path->pathtarget->exprs)
+		{
+			Param	   *param = (Param *) lfirst(lc);
+			if (param && IsA(param, Param))
+			{
+				Assert(IS_FOREIGN_PARAM(root, param));
+				return false;
+			}
+		}
+	}
+
 	return true;
 }
 
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index b38422c47a4..cc09065e561 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -32,6 +32,7 @@
 #include "optimizer/planner.h"
 #include "optimizer/restrictinfo.h"
 #include "parser/analyze.h"
+#include "parser/parsetree.h"
 #include "rewrite/rewriteManip.h"
 #include "utils/lsyscache.h"
 #include "utils/rel.h"
@@ -243,6 +244,40 @@ add_other_rels_to_query(PlannerInfo *root)
  *
  *****************************************************************************/
 
+/*
+ * add_params_to_result_rel
+ *	  If the query's final tlist contains Params the FDW generated, add
+ *	  targetlist entries for each such Param to the result relation.
+ */
+static void
+add_params_to_result_rel(PlannerInfo *root, List *final_tlist)
+{
+	RelOptInfo *target_rel = find_base_rel(root, root->parse->resultRelation);
+	ListCell   *lc;
+
+	/*
+	 * If no parameters have been generated by any FDWs, we certainly don't
+	 * need to do anything here.
+	 */
+	if (bms_is_empty(root->glob->foreignParamIDs))
+		return;
+
+	foreach(lc, final_tlist)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+		Param	   *param = (Param *) tle->expr;
+
+		if (tle->resjunk && IsA(param, Param) &&
+			IS_FOREIGN_PARAM(root, param) &&
+			param->target_rte == target_rel->relid)
+		{
+			/* XXX is copyObject necessary here? */
+			target_rel->reltarget->exprs = lappend(target_rel->reltarget->exprs,
+												   copyObject(param));
+		}
+	}
+}
+
 /*
  * build_base_rel_tlists
  *	  Add targetlist entries for each var needed in the query's final tlist
@@ -282,6 +317,16 @@ build_base_rel_tlists(PlannerInfo *root, List *final_tlist)
 			list_free(having_vars);
 		}
 	}
+
+	if (root->parse->commandType == CMD_UPDATE ||
+		root->parse->commandType == CMD_DELETE)
+	{
+		int			result_relation = root->parse->resultRelation;
+
+		if (planner_rt_fetch(result_relation, root)->relkind == RELKIND_FOREIGN_TABLE)
+			add_params_to_result_rel(root, final_tlist);
+
+	}
 }
 
 /*
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index f4689e7c9f8..a560ecfbd8a 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -393,6 +393,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	glob->dependsOnRole = false;
 	glob->partition_directory = NULL;
 	glob->rel_notnullatts_hash = NULL;
+	glob->foreignParamIDs = NULL;
 
 	/*
 	 * Assess whether it's feasible to use parallel mode for this query. We
@@ -621,12 +622,15 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	}
 
 	/*
-	 * If any Params were generated, run through the plan tree and compute
-	 * each plan node's extParam/allParam sets.  Ideally we'd merge this into
-	 * set_plan_references' tree traversal, but for now it has to be separate
-	 * because we need to visit subplans before not after main plan.
+	 * If any Params were generated by the planner not by FDWs, run through
+	 * the plan tree and compute each plan node's extParam/allParam sets.
+	 * (Params added by FDWs are irrelevant for parameter change signaling.)
+	 * Ideally we'd merge this into set_plan_references' tree traversal, but
+	 * for now it has to be separate because we need to visit subplans before
+	 * not after main plan.
 	 */
-	if (glob->paramExecTypes != NIL)
+	if (glob->paramExecTypes != NIL &&
+		bms_num_members(glob->foreignParamIDs) < list_length(glob->paramExecTypes))
 	{
 		Assert(list_length(glob->subplans) == list_length(glob->subroots));
 		forboth(lp, glob->subplans, lr, glob->subroots)
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index ff0e875f2a2..4fd162920db 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -3310,7 +3310,42 @@ fix_join_expr_mutator(Node *node, fix_join_expr_context *context)
 	}
 	/* Special cases (apply only AFTER failing to match to lower tlist) */
 	if (IsA(node, Param))
+	{
+		Param	   *param = (Param *) node;
+
+		/*
+		 * If the Param is a PARAM_EXEC Param generated by an FDW, it should
+		 * have bubbled up from a lower plan node; convert it into a simple
+		 * Var referencing the output of the subplan.
+		 *
+		 * Note: set_join_references() would have kept has_non_vars=true for
+		 * the subplan emitting the Param since it effectively belong to the
+		 * result relation and that relation can never be the nullable side of
+		 * an outer join.
+		 */
+		if (IS_FOREIGN_PARAM(context->root, param))
+		{
+			if (context->outer_itlist && context->outer_itlist->has_non_vars)
+			{
+				newvar = search_indexed_tlist_for_non_var((Expr *) node,
+														  context->outer_itlist,
+														  OUTER_VAR);
+				if (newvar)
+					return (Node *) newvar;
+			}
+			if (context->inner_itlist && context->inner_itlist->has_non_vars)
+			{
+				newvar = search_indexed_tlist_for_non_var((Expr *) node,
+														  context->inner_itlist,
+														  INNER_VAR);
+				if (newvar)
+					return (Node *) newvar;
+			}
+			// XXX Is it an error to be here?
+		}
+		/* If not, do fix_param_node() */
 		return fix_param_node(context->root, (Param *) node);
+	}
 	if (IsA(node, AlternativeSubPlan))
 		return fix_join_expr_mutator(fix_alternative_subplan(context->root,
 															 (AlternativeSubPlan *) node,
@@ -3421,7 +3456,28 @@ fix_upper_expr_mutator(Node *node, fix_upper_expr_context *context)
 	}
 	/* Special cases (apply only AFTER failing to match to lower tlist) */
 	if (IsA(node, Param))
+	{
+		Param	   *param = (Param *) node;
+		/*
+		 * If the Param is a PARAM_EXEC Param generated by an FDW, it should
+		 * have bubbled up from a lower plan node; convert it into a simple
+		 * Var referencing the output of the subplan.
+		 */
+		if (IS_FOREIGN_PARAM(context->root, param))
+		{
+			if (context->subplan_itlist->has_non_vars)
+			{
+				newvar = search_indexed_tlist_for_non_var((Expr *) node,
+														  context->subplan_itlist,
+														  context->newvarno);
+				if (newvar)
+					return (Node *) newvar;
+			}
+			// XXX Is it an error to be here?
+		}
+		/* If not, do fix_param_node() */
 		return fix_param_node(context->root, (Param *) node);
+	}
 	if (IsA(node, Aggref))
 	{
 		Aggref	   *aggref = (Aggref *) node;
diff --git a/src/backend/optimizer/plan/subselect.c b/src/backend/optimizer/plan/subselect.c
index ccec1eaa7fe..375fdf27e53 100644
--- a/src/backend/optimizer/plan/subselect.c
+++ b/src/backend/optimizer/plan/subselect.c
@@ -3192,7 +3192,12 @@ finalize_primnode(Node *node, finalize_primnode_context *context)
 		{
 			int			paramid = ((Param *) node)->paramid;
 
-			context->paramids = bms_add_member(context->paramids, paramid);
+			/*
+			 * Params added by FDWs are irrelevant for parameter change
+			 * signaling.
+			 */
+			if (!bms_is_member(paramid, context->root->glob->foreignParamIDs))
+				context->paramids = bms_add_member(context->paramids, paramid);
 		}
 		return false;			/* no more to do here */
 	}
diff --git a/src/backend/optimizer/util/appendinfo.c b/src/backend/optimizer/util/appendinfo.c
index 778e4662f6e..7089a267125 100644
--- a/src/backend/optimizer/util/appendinfo.c
+++ b/src/backend/optimizer/util/appendinfo.c
@@ -948,6 +948,29 @@ add_row_identity_var(PlannerInfo *root, Var *orig_var,
 	root->processed_tlist = lappend(root->processed_tlist, tle);
 }
 
+static void
+fix_foreign_params(PlannerInfo *root, List *tlist)
+{
+	ListCell   *lc;
+
+	foreach(lc, tlist)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+		Param	   *param = (Param *) tle->expr;
+
+		if (tle->resjunk && IsA(param, Param) &&
+			param->paramkind == PARAM_EXEC &&
+			param->paramid == -1)
+		{
+			param->paramid = list_length(root->glob->paramExecTypes);
+			root->glob->paramExecTypes =
+				lappend_oid(root->glob->paramExecTypes, param->paramtype);
+			root->glob->foreignParamIDs =
+				bms_add_member(root->glob->foreignParamIDs, param->paramid);
+		}
+	}
+}
+
 /*
  * add_row_identity_columns
  *
@@ -992,8 +1015,12 @@ add_row_identity_columns(PlannerInfo *root, Index rtindex,
 		fdwroutine = GetFdwRoutineForRelation(target_relation, false);
 
 		if (fdwroutine->AddForeignUpdateTargets != NULL)
+		{
+
 			fdwroutine->AddForeignUpdateTargets(root, rtindex,
 												target_rte, target_relation);
+			fix_foreign_params(root, root->processed_tlist);
+		}
 
 		/*
 		 * For UPDATE, we need to make the FDW fetch unchanged columns by
diff --git a/src/backend/optimizer/util/inherit.c b/src/backend/optimizer/util/inherit.c
index 6a7b9edff3f..d8f08485e39 100644
--- a/src/backend/optimizer/util/inherit.c
+++ b/src/backend/optimizer/util/inherit.c
@@ -294,6 +294,7 @@ expand_inherited_rtentry(PlannerInfo *root, RelOptInfo *rel,
 						  InvalidOid,
 						  0);
 			snprintf(resname, sizeof(resname), "tableoid%u", oldrc->rowmarkId);
+
 			tle = makeTargetEntry((Expr *) var,
 								  list_length(root->processed_tlist) + 1,
 								  pstrdup(resname),
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 3fc2c2f71d0..655b2b00bd7 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -1320,6 +1320,27 @@ build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 			}
 			continue;
 		}
+		/*
+		 * We allow FDWs to have PARAM_EXEC Params here.
+		 */
+		else if (IsA(var, Param))
+		{
+			Param	   *param = (Param *) var;
+
+			Assert(IS_FOREIGN_PARAM(root, param));
+
+			joinrel->reltarget->exprs =
+				lappend(joinrel->reltarget->exprs, param);
+
+			/*
+			 * Estimate using the type info  (Note: keep this in sync with
+			 * set_rel_width())
+			 */
+			joinrel->reltarget->width +=
+				get_typavgwidth(param->paramtype, param->paramtypmod);
+
+			continue;
+		}
 
 		/*
 		 * Otherwise, anything in a baserel or joinrel targetlist ought to be
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 88de5c0481c..004f22084f8 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -9374,7 +9374,7 @@ get_parameter(Param *param, deparse_context *context)
 	 * It's a bug if we get here for anything except PARAM_EXTERN Params, but
 	 * in production builds printing $N seems more useful than failing.
 	 */
-	Assert(param->paramkind == PARAM_EXTERN);
+	Assert(param->paramkind == PARAM_EXTERN || param->paramkind == PARAM_EXEC);
 
 	appendStringInfo(context->buf, "$%d", param->paramid);
 }
diff --git a/src/include/executor/tuptable.h b/src/include/executor/tuptable.h
index 43018ed4dfa..02ddc4fb79d 100644
--- a/src/include/executor/tuptable.h
+++ b/src/include/executor/tuptable.h
@@ -141,6 +141,7 @@ typedef struct TupleTableSlot
 	MemoryContext tts_mcxt;		/* slot itself is in this context */
 	ItemPointerData tts_tid;	/* stored tuple's tid */
 	Oid			tts_tableOid;	/* table oid of tuple */
+	Oid			tts_remoteOid;
 } TupleTableSlot;
 
 /* routines for a TupleTableSlot implementation */
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 27a2c6815b7..abe4904cc24 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -271,12 +271,19 @@ typedef struct PlannerGlobal
 	/* extension state */
 	void	  **extension_state pg_node_attr(read_write_ignore);
 	int			extension_state_allocated;
+
+	/* PARAM_EXEC Params generated by FDWs */
+	Bitmapset  *foreignParamIDs;
 } PlannerGlobal;
 
 /* macro for fetching the Plan associated with a SubPlan node */
 #define planner_subplan_get_plan(root, subplan) \
 	((Plan *) list_nth((root)->glob->subplans, (subplan)->plan_id - 1))
 
+/* macro for checking if a Param is a PARAM_EXEC Param generated by an FDW */
+#define IS_FOREIGN_PARAM(root, param) \
+       ((param)->paramkind == PARAM_EXEC && \
+        bms_is_member((param)->paramid, (root)->glob->foreignParamIDs))
 
 /*----------
  * PlannerInfo
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index 7977ee24783..cd70f673236 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -402,6 +402,7 @@ typedef struct Param
 	Oid			paramcollid;
 	/* token location, or -1 if unknown */
 	ParseLoc	location;
+	Index		target_rte;
 } Param;
 
 /*
-- 
2.43.0



  [application/octet-stream] v2-0001-copy-slot-tuple-natts.patch (25.0K, ../../CAN-LCVM2iOWkfFt22yVEGOrp-76YP3-BVKQg+A20TENkVh8o1w@mail.gmail.com/4-v2-0001-copy-slot-tuple-natts.patch)
  download | inline diff:
From 20c3d44ea3a8e1070be314aa0fbed152e7ed93c5 Mon Sep 17 00:00:00 2001
From: Nikita Malakhov <n.malakhov@postgrespro.ru>
Date: Thu, 14 May 2026 15:36:39 +0300
Subject: [PATCH] DELETE/UPDATE more than one row in partitioned foreign table
 (1/3)

There is known bug in delete and update rows in partitioned foreign
tables via FDW [1]

Current copyTuple and copySlot machinery supposes that
source and destination slots and tuples always match. This patch modifies
copy internals to consider more complex case and copy only actual attributes,
and if destination slot/tuple has more they are set to null. This is a proof
of concept patch which shows such mechanics does not break anything and
allows to use it in cases when we have to pass or receive not matching number
of attributes.

[1] Discussion: https://www.postgresql.org/message-id/flat/20250718175314.4513c00a%40karst
---
 src/backend/access/common/heaptuple.c | 127 ++++++++++++++++++++++++--
 src/backend/executor/execGrouping.c   |   3 +-
 src/backend/executor/execTuples.c     | 103 +++++++++++++++------
 src/backend/optimizer/util/tlist.c    |  39 ++++++--
 src/include/access/htup_details.h     |  22 +++++
 src/include/executor/tuptable.h       |  41 +++++++--
 6 files changed, 278 insertions(+), 57 deletions(-)

diff --git a/src/backend/access/common/heaptuple.c b/src/backend/access/common/heaptuple.c
index f30346469ed..d4fbd962ec1 100644
--- a/src/backend/access/common/heaptuple.c
+++ b/src/backend/access/common/heaptuple.c
@@ -61,6 +61,7 @@
 #include "access/sysattr.h"
 #include "access/tupdesc_details.h"
 #include "common/hashfn.h"
+#include "executor/tuptable.h"
 #include "utils/datum.h"
 #include "utils/expandeddatum.h"
 #include "utils/hsearch.h"
@@ -219,11 +220,30 @@ Size
 heap_compute_data_size(TupleDesc tupleDesc,
 					   const Datum *values,
 					   const bool *isnull)
+{
+	return heap_compute_data_size_ext(tupleDesc, values, isnull,
+									  - 1 /* take natts from tupleDesc */);
+}
+
+/*
+ * heap_compute_data_size_ext
+ *		Same as above, but allows to specify number of attributes explicitly.
+ *
+ * If "natts" <= 0, then number of attributes is taken from the TupleDesc.
+ */
+Size
+heap_compute_data_size_ext(TupleDesc tupleDesc,
+ 					   const Datum *values,
+					   const bool *isnull,
+					   int natts)
 {
 	Size		data_length = 0;
 	int			i;
 	int			numberOfAttributes = tupleDesc->natts;
 
+	if (natts > 0)
+		numberOfAttributes = natts;
+
 	for (i = 0; i < numberOfAttributes; i++)
 	{
 		Datum		val;
@@ -402,6 +422,22 @@ heap_fill_tuple(TupleDesc tupleDesc,
 				const Datum *values, const bool *isnull,
 				char *data, Size data_size,
 				uint16 *infomask, uint8 *bit)
+{
+	heap_fill_tuple_ext(tupleDesc, values, isnull,  data, data_size, infomask,
+						bit, -1 /* take natts from tupleDesc */);
+}
+
+/*
+ * heap_fill_tuple_ext
+ *             Same as above, but allows to specify number of attributes explicitly.
+ *
+ * If "natts" <= 0, then number of attributes is taken from the TupleDesc.
+ */
+void
+heap_fill_tuple_ext(TupleDesc tupleDesc,
+					const Datum *values, const bool *isnull,
+					char *data, Size data_size,
+					uint16 *infomask, uint8 *bit, int natts)
 {
 	uint8	   *bitP;
 	int			bitmask;
@@ -412,6 +448,9 @@ heap_fill_tuple(TupleDesc tupleDesc,
 	char	   *start = data;
 #endif
 
+	if (natts > 0)
+		numberOfAttributes = natts;
+
 	if (bit != NULL)
 	{
 		bitP = &bit[-1];
@@ -684,18 +723,48 @@ heap_getsysattr(HeapTuple tup, int attnum, TupleDesc tupleDesc, bool *isnull)
  */
 HeapTuple
 heap_copytuple(HeapTuple tuple)
+{
+	return heap_copytuple_ext(tuple, NULL, -1);
+}
+
+/* ----------------
+ *		heap_copytuple_ext
+ *
+ *		returns a copy of a tuple consists of attnum atts of original
+ *
+ * The HeapTuple struct, tuple header, and tuple data are all allocated
+ * as a single palloc() block.
+ * ----------------
+ */
+HeapTuple
+heap_copytuple_ext(HeapTuple tuple, TupleTableSlot *slot, int dstnatts)
 {
 	HeapTuple	newTuple;
+	Size		tuplen;
 
 	if (!HeapTupleIsValid(tuple) || tuple->t_data == NULL)
 		return NULL;
 
-	newTuple = (HeapTuple) palloc(HEAPTUPLESIZE + tuple->t_len);
-	newTuple->t_len = tuple->t_len;
+	tuplen = tuple->t_len;
+
+	if (slot != NULL)
+	{
+		if (dstnatts < 0)
+			dstnatts = slot->tts_tupleDescriptor->natts;
+
+		if (dstnatts < slot->tts_tupleDescriptor->natts)
+			tuplen = heap_compute_data_size_ext(slot->tts_tupleDescriptor,
+												slot->tts_values,
+												slot->tts_isnull,
+												dstnatts);
+	}
+
+	newTuple = (HeapTuple) palloc(HEAPTUPLESIZE + tuplen);
+	newTuple->t_len = tuplen;
 	newTuple->t_self = tuple->t_self;
 	newTuple->t_tableOid = tuple->t_tableOid;
 	newTuple->t_data = (HeapTupleHeader) ((char *) newTuple + HEAPTUPLESIZE);
-	memcpy(newTuple->t_data, tuple->t_data, tuple->t_len);
+	memcpy(newTuple->t_data, tuple->t_data, tuplen);
 	return newTuple;
 }
 
@@ -1025,6 +1094,21 @@ HeapTuple
 heap_form_tuple(TupleDesc tupleDescriptor,
 				const Datum *values,
 				const bool *isnull)
+{
+	return heap_form_tuple_ext(tupleDescriptor, values, isnull, -1);
+}
+
+/*
+ * heap_form_tuple_ext
+ *		Same as above, but allows to specify number of attributes explicitly.
+ *
+ * If "natts" <= 0, then number of attributes is taken from the TupleDesc.
+ */
+HeapTuple
+heap_form_tuple_ext(TupleDesc tupleDescriptor,
+					const Datum *values,
+					const bool *isnull,
+					int natts)
 {
 	HeapTuple	tuple;			/* return tuple */
 	HeapTupleHeader td;			/* tuple data */
@@ -1035,6 +1119,9 @@ heap_form_tuple(TupleDesc tupleDescriptor,
 	int			numberOfAttributes = tupleDescriptor->natts;
 	int			i;
 
+	if(natts > 0)
+		numberOfAttributes = natts;
+
 	if (numberOfAttributes > MaxTupleAttributeNumber)
 		ereport(ERROR,
 				(errcode(ERRCODE_TOO_MANY_COLUMNS),
@@ -1063,7 +1150,7 @@ heap_form_tuple(TupleDesc tupleDescriptor,
 
 	hoff = len = MAXALIGN(len); /* align user data safely */
 
-	data_len = heap_compute_data_size(tupleDescriptor, values, isnull);
+	data_len = heap_compute_data_size_ext(tupleDescriptor, values, isnull, natts);
 
 	len += data_len;
 
@@ -1092,13 +1179,14 @@ heap_form_tuple(TupleDesc tupleDescriptor,
 	HeapTupleHeaderSetNatts(td, numberOfAttributes);
 	td->t_hoff = hoff;
 
-	heap_fill_tuple(tupleDescriptor,
+	heap_fill_tuple_ext(tupleDescriptor,
 					values,
 					isnull,
 					(char *) td + hoff,
 					data_len,
 					&td->t_infomask,
-					(hasnull ? td->t_bits : NULL));
+					(hasnull ? td->t_bits : NULL),
+					natts);
 
 	return tuple;
 }
@@ -1391,6 +1479,23 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 						const Datum *values,
 						const bool *isnull,
 						Size extra)
+{
+	return heap_form_minimal_tuple_ext(tupleDescriptor, values, isnull, extra,
+									   -1 /* take natts from tupleDesc */);
+}
+
+/*
+ * heap_form_minimal_tuple_ext
+ *		Same as above, but allows to specify number of attributes explicitly.
+ *
+ * If "natts" <= 0, then number of attributes is taken from the TupleDesc.
+ */
+MinimalTuple
+heap_form_minimal_tuple_ext(TupleDesc tupleDescriptor,
+ 							const Datum *values,
+							const bool *isnull,
+							Size extra,
+							int natts)
 {
 	MinimalTuple tuple;			/* return tuple */
 	char	   *mem;
@@ -1401,6 +1506,9 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 	int			numberOfAttributes = tupleDescriptor->natts;
 	int			i;
 
+	if (natts > 0)
+		numberOfAttributes = natts;
+
 	Assert(extra == MAXALIGN(extra));
 
 	if (numberOfAttributes > MaxTupleAttributeNumber)
@@ -1431,7 +1539,7 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 
 	hoff = len = MAXALIGN(len); /* align user data safely */
 
-	data_len = heap_compute_data_size(tupleDescriptor, values, isnull);
+	data_len = heap_compute_data_size_ext(tupleDescriptor, values, isnull, natts);
 
 	len += data_len;
 
@@ -1448,13 +1556,14 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 	HeapTupleHeaderSetNatts(tuple, numberOfAttributes);
 	tuple->t_hoff = hoff + MINIMAL_TUPLE_OFFSET;
 
-	heap_fill_tuple(tupleDescriptor,
+	heap_fill_tuple_ext(tupleDescriptor,
 					values,
 					isnull,
 					(char *) tuple + hoff,
 					data_len,
 					&tuple->t_infomask,
-					(hasnull ? tuple->t_bits : NULL));
+					(hasnull ? tuple->t_bits : NULL),
+					natts);
 
 	return tuple;
 }
diff --git a/src/backend/executor/execGrouping.c b/src/backend/executor/execGrouping.c
index c107514a85d..7ef7c8c6d9d 100644
--- a/src/backend/executor/execGrouping.c
+++ b/src/backend/executor/execGrouping.c
@@ -583,7 +583,8 @@ LookupTupleHashEntry_internal(TupleHashTable hashtable, TupleTableSlot *slot,
 			 * TupleHashEntryData or allocate an additional chunk.
 			 */
 			entry->firstTuple = ExecCopySlotMinimalTupleExtra(slot,
-															  hashtable->additionalsize);
+															  hashtable->additionalsize,
+															  -1 /* natts */);
 		}
 	}
 	else
diff --git a/src/backend/executor/execTuples.c b/src/backend/executor/execTuples.c
index b0a0028b165..6322a8a64b3 100644
--- a/src/backend/executor/execTuples.c
+++ b/src/backend/executor/execTuples.c
@@ -79,6 +79,9 @@ static inline void tts_buffer_heap_store_tuple(TupleTableSlot *slot,
 											   Buffer buffer,
 											   bool transfer_pin);
 static void tts_heap_store_tuple(TupleTableSlot *slot, HeapTuple tuple, bool shouldFree);
+static void tts_heap_materialize_ext(TupleTableSlot *slot, int natts);
+static void tts_minimal_materialize_ext(TupleTableSlot *slot, int natts);
+static void tts_buffer_heap_materialize_ext(TupleTableSlot *slot, int natts);
 
 
 const TupleTableSlotOps TTSOpsVirtual;
@@ -269,18 +272,29 @@ static void
 tts_virtual_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 {
 	TupleDesc	srcdesc = srcslot->tts_tupleDescriptor;
+	TupleDesc	dstdesc = dstslot->tts_tupleDescriptor;
+	int			min_natts = Min(dstdesc->natts, srcdesc->natts);
 
 	tts_virtual_clear(dstslot);
 
 	slot_getallattrs(srcslot);
 
-	for (int natt = 0; natt < srcdesc->natts; natt++)
+	for (int natt = 0; natt < min_natts; natt++)
 	{
 		dstslot->tts_values[natt] = srcslot->tts_values[natt];
 		dstslot->tts_isnull[natt] = srcslot->tts_isnull[natt];
 	}
 
-	dstslot->tts_nvalid = srcdesc->natts;
+	if (dstdesc->natts > srcdesc->natts)
+	{
+		for (int natt = min_natts; natt < dstdesc->natts; natt++)
+		{
+			dstslot->tts_values[natt] = (Datum) 0;
+			dstslot->tts_isnull[natt] = true;
+		}
+	}
+
+	dstslot->tts_nvalid = dstdesc->natts;
 	dstslot->tts_flags &= ~TTS_FLAG_EMPTY;
 
 	/* make sure storage doesn't depend on external memory */
@@ -288,24 +302,26 @@ tts_virtual_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 }
 
 static HeapTuple
-tts_virtual_copy_heap_tuple(TupleTableSlot *slot)
+tts_virtual_copy_heap_tuple(TupleTableSlot *slot, int natts)
 {
 	Assert(!TTS_EMPTY(slot));
 
-	return heap_form_tuple(slot->tts_tupleDescriptor,
+	return heap_form_tuple_ext(slot->tts_tupleDescriptor,
 						   slot->tts_values,
-						   slot->tts_isnull);
+						   slot->tts_isnull,
+						   natts);
 }
 
 static MinimalTuple
-tts_virtual_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
+tts_virtual_copy_minimal_tuple(TupleTableSlot *slot, Size extra, int natts)
 {
 	Assert(!TTS_EMPTY(slot));
 
-	return heap_form_minimal_tuple(slot->tts_tupleDescriptor,
+	return heap_form_minimal_tuple_ext(slot->tts_tupleDescriptor,
 								   slot->tts_values,
 								   slot->tts_isnull,
-								   extra);
+								   extra,
+								   natts);
 }
 
 
@@ -397,6 +413,12 @@ tts_heap_is_current_xact_tuple(TupleTableSlot *slot)
 
 static void
 tts_heap_materialize(TupleTableSlot *slot)
+{
+	tts_heap_materialize_ext(slot, -1);
+}
+
+static void
+tts_heap_materialize_ext(TupleTableSlot *slot, int natts)
 {
 	HeapTupleTableSlot *hslot = (HeapTupleTableSlot *) slot;
 	MemoryContext oldContext;
@@ -417,9 +439,10 @@ tts_heap_materialize(TupleTableSlot *slot)
 	hslot->off = 0;
 
 	if (!hslot->tuple)
-		hslot->tuple = heap_form_tuple(slot->tts_tupleDescriptor,
+		hslot->tuple = heap_form_tuple_ext(slot->tts_tupleDescriptor,
 									   slot->tts_values,
-									   slot->tts_isnull);
+									   slot->tts_isnull,
+									   natts);
 	else
 	{
 		/*
@@ -427,7 +450,7 @@ tts_heap_materialize(TupleTableSlot *slot)
 		 * context of the given slot (else it would have TTS_FLAG_SHOULDFREE
 		 * set).  Copy the tuple into the given slot's memory context.
 		 */
-		hslot->tuple = heap_copytuple(hslot->tuple);
+		hslot->tuple = heap_copytuple_ext(hslot->tuple, slot, natts);
 	}
 
 	slot->tts_flags |= TTS_FLAG_SHOULDFREE;
@@ -461,24 +484,24 @@ tts_heap_get_heap_tuple(TupleTableSlot *slot)
 }
 
 static HeapTuple
-tts_heap_copy_heap_tuple(TupleTableSlot *slot)
+tts_heap_copy_heap_tuple(TupleTableSlot *slot, int natts)
 {
 	HeapTupleTableSlot *hslot = (HeapTupleTableSlot *) slot;
 
 	Assert(!TTS_EMPTY(slot));
 	if (!hslot->tuple)
-		tts_heap_materialize(slot);
+		tts_heap_materialize_ext(slot, natts);
 
-	return heap_copytuple(hslot->tuple);
+	return heap_copytuple_ext(hslot->tuple, slot, natts);
 }
 
 static MinimalTuple
-tts_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
+tts_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra, int natts)
 {
 	HeapTupleTableSlot *hslot = (HeapTupleTableSlot *) slot;
 
 	if (!hslot->tuple)
-		tts_heap_materialize(slot);
+		tts_heap_materialize_ext(slot, natts);
 
 	return minimal_tuple_from_heap_tuple(hslot->tuple, extra);
 }
@@ -585,6 +608,12 @@ tts_minimal_is_current_xact_tuple(TupleTableSlot *slot)
 
 static void
 tts_minimal_materialize(TupleTableSlot *slot)
+{
+	tts_minimal_materialize_ext(slot, -1);
+}
+
+static void
+tts_minimal_materialize_ext(TupleTableSlot *slot, int natts)
 {
 	MinimalTupleTableSlot *mslot = (MinimalTupleTableSlot *) slot;
 	MemoryContext oldContext;
@@ -606,10 +635,11 @@ tts_minimal_materialize(TupleTableSlot *slot)
 
 	if (!mslot->mintuple)
 	{
-		mslot->mintuple = heap_form_minimal_tuple(slot->tts_tupleDescriptor,
+		mslot->mintuple = heap_form_minimal_tuple_ext(slot->tts_tupleDescriptor,
 												  slot->tts_values,
 												  slot->tts_isnull,
-												  0);
+												  0 /* no extra */,
+												  natts);
 	}
 	else
 	{
@@ -637,9 +667,13 @@ tts_minimal_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 {
 	MemoryContext oldcontext;
 	MinimalTuple mintuple;
+	int			min_natts;
+
+	min_natts = Min(dstslot->tts_tupleDescriptor->natts,
+					srcslot->tts_tupleDescriptor->natts);
 
 	oldcontext = MemoryContextSwitchTo(dstslot->tts_mcxt);
-	mintuple = ExecCopySlotMinimalTuple(srcslot);
+	mintuple = ExecCopySlotMinimalTupleExteded(srcslot, min_natts);
 	MemoryContextSwitchTo(oldcontext);
 
 	ExecStoreMinimalTuple(mintuple, dstslot, true);
@@ -657,7 +691,7 @@ tts_minimal_get_minimal_tuple(TupleTableSlot *slot)
 }
 
 static HeapTuple
-tts_minimal_copy_heap_tuple(TupleTableSlot *slot)
+tts_minimal_copy_heap_tuple(TupleTableSlot *slot, int natts)
 {
 	MinimalTupleTableSlot *mslot = (MinimalTupleTableSlot *) slot;
 
@@ -668,7 +702,7 @@ tts_minimal_copy_heap_tuple(TupleTableSlot *slot)
 }
 
 static MinimalTuple
-tts_minimal_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
+tts_minimal_copy_minimal_tuple(TupleTableSlot *slot, Size extra, int natts)
 {
 	MinimalTupleTableSlot *mslot = (MinimalTupleTableSlot *) slot;
 
@@ -802,6 +836,12 @@ tts_buffer_is_current_xact_tuple(TupleTableSlot *slot)
 
 static void
 tts_buffer_heap_materialize(TupleTableSlot *slot)
+{
+	tts_buffer_heap_materialize_ext(slot, -1 /* natts */);
+}
+
+static void
+tts_buffer_heap_materialize_ext(TupleTableSlot *slot, int natts)
 {
 	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
 	MemoryContext oldContext;
@@ -874,12 +914,16 @@ tts_buffer_heap_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 		TTS_SHOULDFREE(srcslot) ||
 		!bsrcslot->base.tuple)
 	{
+		int		min_natts;
 		MemoryContext oldContext;
 
+		min_natts = Min(dstslot->tts_tupleDescriptor->natts,
+						srcslot->tts_tupleDescriptor->natts);
+
 		ExecClearTuple(dstslot);
 		dstslot->tts_flags &= ~TTS_FLAG_EMPTY;
 		oldContext = MemoryContextSwitchTo(dstslot->tts_mcxt);
-		bdstslot->base.tuple = ExecCopySlotHeapTuple(srcslot);
+		bdstslot->base.tuple = ExecCopySlotHeapTupleExteded(srcslot, min_natts);
 		dstslot->tts_flags |= TTS_FLAG_SHOULDFREE;
 		MemoryContextSwitchTo(oldContext);
 	}
@@ -915,27 +959,27 @@ tts_buffer_heap_get_heap_tuple(TupleTableSlot *slot)
 }
 
 static HeapTuple
-tts_buffer_heap_copy_heap_tuple(TupleTableSlot *slot)
+tts_buffer_heap_copy_heap_tuple(TupleTableSlot *slot, int natts)
 {
 	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
 
 	Assert(!TTS_EMPTY(slot));
 
 	if (!bslot->base.tuple)
-		tts_buffer_heap_materialize(slot);
+		tts_buffer_heap_materialize_ext(slot, natts);
 
-	return heap_copytuple(bslot->base.tuple);
+	return heap_copytuple_ext(bslot->base.tuple, slot, natts);
 }
 
 static MinimalTuple
-tts_buffer_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
+tts_buffer_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra, int natts)
 {
 	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
 
 	Assert(!TTS_EMPTY(slot));
 
 	if (!bslot->base.tuple)
-		tts_buffer_heap_materialize(slot);
+		tts_buffer_heap_materialize_ext(slot, natts);
 
 	return minimal_tuple_from_heap_tuple(bslot->base.tuple, extra);
 }
@@ -1922,7 +1966,7 @@ ExecFetchSlotHeapTuple(TupleTableSlot *slot, bool materialize, bool *shouldFree)
 	{
 		if (shouldFree)
 			*shouldFree = true;
-		return slot->tts_ops->copy_heap_tuple(slot);
+		return slot->tts_ops->copy_heap_tuple(slot, -1 /* natts */);
 	}
 	else
 	{
@@ -1973,7 +2017,8 @@ ExecFetchSlotMinimalTuple(TupleTableSlot *slot,
 	{
 		if (shouldFree)
 			*shouldFree = true;
-		return slot->tts_ops->copy_minimal_tuple(slot, 0);
+		return slot->tts_ops->copy_minimal_tuple(slot, 0 /* no extra */,
+												 -1 /* natts */);
 	}
 }
 
diff --git a/src/backend/optimizer/util/tlist.c b/src/backend/optimizer/util/tlist.c
index 752ea9222f6..e382810b19f 100644
--- a/src/backend/optimizer/util/tlist.c
+++ b/src/backend/optimizer/util/tlist.c
@@ -329,18 +329,37 @@ apply_tlist_labeling(List *dest_tlist, List *src_tlist)
 	ListCell   *ld,
 			   *ls;
 
-	Assert(list_length(dest_tlist) == list_length(src_tlist));
-	forboth(ld, dest_tlist, ls, src_tlist)
+	foreach(ld, dest_tlist)
 	{
+		bool has_src = false;
 		TargetEntry *dest_tle = (TargetEntry *) lfirst(ld);
-		TargetEntry *src_tle = (TargetEntry *) lfirst(ls);
-
-		Assert(dest_tle->resno == src_tle->resno);
-		dest_tle->resname = src_tle->resname;
-		dest_tle->ressortgroupref = src_tle->ressortgroupref;
-		dest_tle->resorigtbl = src_tle->resorigtbl;
-		dest_tle->resorigcol = src_tle->resorigcol;
-		dest_tle->resjunk = src_tle->resjunk;
+
+		foreach(ls, src_tlist)
+		{
+			TargetEntry *src_tle = (TargetEntry *) lfirst(ls);
+
+			if (dest_tle->resno == src_tle->resno)
+			{
+				has_src = true;
+				dest_tle->resname = src_tle->resname;
+				dest_tle->ressortgroupref = src_tle->ressortgroupref;
+				dest_tle->resorigtbl = src_tle->resorigtbl;
+				dest_tle->resorigcol = src_tle->resorigcol;
+				dest_tle->resjunk = src_tle->resjunk;
+				break;
+			}
+		}
+
+		if (!has_src)
+		{
+			TargetEntry *new_entry = palloc0(sizeof(TargetEntry));
+			new_entry->resname = dest_tle->resname;
+			new_entry->ressortgroupref = dest_tle->ressortgroupref;
+			new_entry->resorigtbl = dest_tle->resorigtbl;
+			new_entry->resorigcol = dest_tle->resorigcol;
+			new_entry->resjunk = dest_tle->resjunk;
+			src_tlist = lappend(src_tlist, new_entry);
+		}
 	}
 }
 
diff --git a/src/include/access/htup_details.h b/src/include/access/htup_details.h
index 77a6c48fd71..1f59f363a68 100644
--- a/src/include/access/htup_details.h
+++ b/src/include/access/htup_details.h
@@ -794,10 +794,17 @@ HeapTupleClearHeapOnly(const HeapTupleData *tuple)
 /* prototypes for functions in common/heaptuple.c */
 extern Size heap_compute_data_size(TupleDesc tupleDesc,
 								   const Datum *values, const bool *isnull);
+extern Size heap_compute_data_size_ext(TupleDesc tupleDesc,
+									   const Datum *values, const bool *isnull,
+									   int natts);
 extern void heap_fill_tuple(TupleDesc tupleDesc,
 							const Datum *values, const bool *isnull,
 							char *data, Size data_size,
 							uint16 *infomask, uint8 *bit);
+extern void heap_fill_tuple_ext(TupleDesc tupleDesc,
+								const Datum *values, const bool *isnull,
+								char *data, Size data_size,
+								uint16 *infomask, uint8 *bit, int natts);
 extern bool heap_attisnull(HeapTuple tup, int attnum, TupleDesc tupleDesc);
 extern Datum nocachegetattr(HeapTuple tup, int attnum,
 							TupleDesc tupleDesc);
@@ -806,10 +813,20 @@ extern Datum heap_getsysattr(HeapTuple tup, int attnum, TupleDesc tupleDesc,
 extern Datum getmissingattr(TupleDesc tupleDesc,
 							int attnum, bool *isnull);
 extern HeapTuple heap_copytuple(HeapTuple tuple);
+
+struct TupleTableSlot;
+extern HeapTuple heap_copytuple_ext(HeapTuple tuple,
+									struct TupleTableSlot *slot,
+									int dstnatts);
+
 extern void heap_copytuple_with_tuple(HeapTuple src, HeapTuple dest);
 extern Datum heap_copy_tuple_as_datum(HeapTuple tuple, TupleDesc tupleDesc);
 extern HeapTuple heap_form_tuple(TupleDesc tupleDescriptor,
 								 const Datum *values, const bool *isnull);
+extern HeapTuple heap_form_tuple_ext(TupleDesc tupleDescriptor,
+									 const Datum *values,
+									 const bool *isnull,
+									 int natts);
 extern HeapTuple heap_modify_tuple(HeapTuple tuple,
 								   TupleDesc tupleDesc,
 								   const Datum *replValues,
@@ -827,6 +844,11 @@ extern void heap_freetuple(HeapTuple htup);
 extern MinimalTuple heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 											const Datum *values, const bool *isnull,
 											Size extra);
+extern MinimalTuple heap_form_minimal_tuple_ext(TupleDesc tupleDescriptor,
+												const Datum *values,
+												const bool *isnull,
+												Size extra,
+												int natts);
 extern void heap_free_minimal_tuple(MinimalTuple mtup);
 extern MinimalTuple heap_copy_minimal_tuple(MinimalTuple mtup, Size extra);
 extern HeapTuple heap_tuple_from_minimal_tuple(MinimalTuple mtup);
diff --git a/src/include/executor/tuptable.h b/src/include/executor/tuptable.h
index 3db6c9c9bd0..43018ed4dfa 100644
--- a/src/include/executor/tuptable.h
+++ b/src/include/executor/tuptable.h
@@ -223,8 +223,10 @@ struct TupleTableSlotOps
 	 * meaningful "system columns" in the copy. The copy is not be "owned" by
 	 * the slot i.e. the caller has to take responsibility to free memory
 	 * consumed by the slot.
+	 *
+	 * TODO comment for "natts"
 	 */
-	HeapTuple	(*copy_heap_tuple) (TupleTableSlot *slot);
+	HeapTuple	(*copy_heap_tuple) (TupleTableSlot *slot, int natts);
 
 	/*
 	 * Return a copy of minimal tuple representing the contents of the slot.
@@ -237,8 +239,10 @@ struct TupleTableSlotOps
 	 * The copy has "extra" bytes (maxaligned and zeroed) available before the
 	 * tuple, which is useful so that some callers may store extra data along
 	 * with the minimal tuple without the need for an additional allocation.
+	 *
+	 * TODO comment for "natts"
 	 */
-	MinimalTuple (*copy_minimal_tuple) (TupleTableSlot *slot, Size extra);
+	MinimalTuple (*copy_minimal_tuple) (TupleTableSlot *slot, Size extra, int natts);
 };
 
 /*
@@ -368,6 +372,12 @@ extern void slot_getsomeattrs_int(TupleTableSlot *slot, int attnum);
 
 #ifndef FRONTEND
 
+/* Forward declarations */
+static inline HeapTuple ExecCopySlotHeapTupleExteded(TupleTableSlot *slot,
+													 int natts);
+static inline MinimalTuple ExecCopySlotMinimalTupleExteded(TupleTableSlot *slot,
+														   int natts);
+
 /*
  * This function forces the entries of the slot's Datum/isnull arrays to be
  * valid at least up through the attnum'th entry.
@@ -502,10 +512,16 @@ ExecMaterializeSlot(TupleTableSlot *slot)
  */
 static inline HeapTuple
 ExecCopySlotHeapTuple(TupleTableSlot *slot)
+{
+	return ExecCopySlotHeapTupleExteded(slot, -1 /* natts */);
+}
+
+static inline HeapTuple
+ExecCopySlotHeapTupleExteded(TupleTableSlot *slot, int natts)
 {
 	Assert(!TTS_EMPTY(slot));
 
-	return slot->tts_ops->copy_heap_tuple(slot);
+	return slot->tts_ops->copy_heap_tuple(slot, natts);
 }
 
 /*
@@ -514,7 +530,13 @@ ExecCopySlotHeapTuple(TupleTableSlot *slot)
 static inline MinimalTuple
 ExecCopySlotMinimalTuple(TupleTableSlot *slot)
 {
-	return slot->tts_ops->copy_minimal_tuple(slot, 0);
+	return ExecCopySlotMinimalTupleExteded(slot, -1 /* natts */);
+}
+
+static inline MinimalTuple
+ExecCopySlotMinimalTupleExteded(TupleTableSlot *slot, int natts)
+{
+	return slot->tts_ops->copy_minimal_tuple(slot, 0 /* no extra */, natts);
 }
 
 /*
@@ -524,9 +546,9 @@ ExecCopySlotMinimalTuple(TupleTableSlot *slot)
  * caller to make an additional allocation).
  */
 static inline MinimalTuple
-ExecCopySlotMinimalTupleExtra(TupleTableSlot *slot, Size extra)
+ExecCopySlotMinimalTupleExtra(TupleTableSlot *slot, Size extra, int natts)
 {
-	return slot->tts_ops->copy_minimal_tuple(slot, extra);
+	return slot->tts_ops->copy_minimal_tuple(slot, extra, natts);
 }
 
 /*
@@ -545,9 +567,12 @@ ExecCopySlot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 {
 	Assert(!TTS_EMPTY(srcslot));
 	Assert(srcslot != dstslot);
-	Assert(dstslot->tts_tupleDescriptor->natts ==
-		   srcslot->tts_tupleDescriptor->natts);
 
+	/*
+	 * If source slot has less attributes then target - copy source and
+	 * set target to nulls. Otherwise copy only leading attributes and set
+	 * target natts to source counter.
+	 */
 	dstslot->tts_ops->copyslot(dstslot, srcslot);
 
 	return dstslot;
-- 
2.43.0



  [application/octet-stream] v2-0003-teach-fdw-use-remote-tableoid.patch (93.5K, ../../CAN-LCVM2iOWkfFt22yVEGOrp-76YP3-BVKQg+A20TENkVh8o1w@mail.gmail.com/5-v2-0003-teach-fdw-use-remote-tableoid.patch)
  download | inline diff:
From 47999936a1b9a670307d4528ec6c4b560cec3961 Mon Sep 17 00:00:00 2001
From: Nikita Malakhov <n.malakhov@postgrespro.ru>
Date: Thu, 14 May 2026 17:45:21 +0300
Subject: [PATCH] DELETE/UPDATE more than one row in partitioned foreign table
 (3/3)

This patch modifies FDW engine to use remote table OID introduced
in previous patch for DELETE and UPDATE of partitioned tables.

[1] Discussion: https://www.postgresql.org/message-id/flat/20250718175314.4513c00a%40karst
---
 contrib/postgres_fdw/deparse.c                |  85 ++-
 .../postgres_fdw/expected/postgres_fdw.out    | 508 ++++++++++--------
 contrib/postgres_fdw/postgres_fdw.c           | 320 ++++++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   3 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  44 ++
 5 files changed, 712 insertions(+), 248 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index 2dcc6c8af1b..8d566bce4bf 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -48,6 +48,7 @@
 #include "catalog/pg_ts_dict.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
+#include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
 #include "nodes/plannodes.h"
 #include "optimizer/optimizer.h"
@@ -132,6 +133,7 @@ static void deparseTargetList(StringInfo buf,
 							  Relation rel,
 							  bool is_returning,
 							  Bitmapset *attrs_used,
+							  bool tableoid_needed,
 							  bool qualify_col,
 							  List **retrieved_attrs);
 static void deparseExplicitTargetList(List *tlist,
@@ -350,10 +352,11 @@ foreign_expr_walker(Node *node,
 					/* Var belongs to foreign table */
 
 					/*
-					 * System columns other than ctid should not be sent to
-					 * the remote, since we don't make any effort to ensure
-					 * that local and remote values match (tableoid, in
-					 * particular, almost certainly doesn't match).
+					 * System columns other than ctid and remote table oid
+					 * should not be sent to the remote, since we don't make
+					 * any effort to ensure that local and remote values
+					 * match (tableoid, in particular, almost certainly
+					 * doesn't match).
 					 */
 					if (var->varattno < 0 &&
 						var->varattno != SelfItemPointerAttributeNumber)
@@ -1235,6 +1238,23 @@ build_tlist_to_deparse(RelOptInfo *foreignrel)
 												  PVC_RECURSE_PLACEHOLDERS));
 	}
 
+	/* Also, add the Param representing the remote table OID, if it exists. */
+	if (fpinfo->tableoid_param)
+	{
+		TargetEntry *tle;
+		/*
+		 * Core code should have contained the Param in the given relation's
+		 * reltarget.
+		 */
+		Assert(list_member(foreignrel->reltarget->exprs,
+						   fpinfo->tableoid_param));
+		tle = makeTargetEntry((Expr *) copyObject(fpinfo->tableoid_param),
+							  list_length(tlist) + 1,
+							  NULL,
+							  false);
+		tlist = lappend(tlist, tle);
+	}
+
 	return tlist;
 }
 
@@ -1390,7 +1410,9 @@ deparseSelectSql(List *tlist, bool is_subquery, List **retrieved_attrs,
 		Relation	rel = table_open(rte->relid, NoLock);
 
 		deparseTargetList(buf, rte, foreignrel->relid, rel, false,
-						  fpinfo->attrs_used, false, retrieved_attrs);
+						  fpinfo->attrs_used,
+						  fpinfo->tableoid_param != NULL,
+						  false, retrieved_attrs);
 		table_close(rel, NoLock);
 	}
 }
@@ -1441,6 +1463,7 @@ deparseTargetList(StringInfo buf,
 				  Relation rel,
 				  bool is_returning,
 				  Bitmapset *attrs_used,
+				  bool tableoid_needed,
 				  bool qualify_col,
 				  List **retrieved_attrs)
 {
@@ -1499,6 +1522,22 @@ deparseTargetList(StringInfo buf,
 									   SelfItemPointerAttributeNumber);
 	}
 
+	if (tableoid_needed &&
+	(bms_is_member(TableOidAttributeNumber - FirstLowInvalidHeapAttributeNumber,
+					  attrs_used)))
+	{
+			Assert(!first);
+			Assert(!is_returning);
+
+			appendStringInfoString(buf, ", ");
+			if (qualify_col)
+				ADD_REL_QUALIFIER(buf, rtindex);
+			appendStringInfoString(buf, "tableoid");
+
+			*retrieved_attrs = lappend_int(*retrieved_attrs,
+											TableOidAttributeNumber);
+	}
+
 	/* Don't generate bad syntax if no undropped columns */
 	if (first && !is_returning)
 		appendStringInfoString(buf, "NULL");
@@ -2259,7 +2298,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 	deparseRelation(buf, rel);
 	appendStringInfoString(buf, " SET ");
 
-	pindex = 2;					/* ctid is always the first param */
+	pindex = 3;					/* ctid is always the first param */
 	first = true;
 	foreach(lc, targetAttrs)
 	{
@@ -2279,7 +2318,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 			pindex++;
 		}
 	}
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_update_after_row,
@@ -2397,7 +2436,7 @@ deparseDeleteSql(StringInfo buf, RangeTblEntry *rte,
 {
 	appendStringInfoString(buf, "DELETE FROM ");
 	deparseRelation(buf, rel);
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_delete_after_row,
@@ -2512,7 +2551,7 @@ deparseReturningList(StringInfo buf, RangeTblEntry *rte,
 	}
 
 	if (attrs_used != NULL)
-		deparseTargetList(buf, rte, rtindex, rel, true, attrs_used, false,
+		deparseTargetList(buf, rte, rtindex, rel, true, attrs_used, false, false,
 						  retrieved_attrs);
 	else
 		*retrieved_attrs = NIL;
@@ -2719,6 +2758,12 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 			ADD_REL_QUALIFIER(buf, varno);
 		appendStringInfoString(buf, "ctid");
 	}
+	else if (varattno == TableOidAttributeNumber)
+	{
+		if (qualify_col)
+			ADD_REL_QUALIFIER(buf, varno);
+		appendStringInfoString(buf, "tableoid");
+	}
 	else if (varattno < 0)
 	{
 		/*
@@ -2730,8 +2775,9 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 		Oid			fetchval = 0;
 
 		if (varattno == TableOidAttributeNumber)
+		{
 			fetchval = rte->relid;
-
+		}
 		if (qualify_col)
 		{
 			appendStringInfoString(buf, "CASE WHEN (");
@@ -2782,7 +2828,7 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 
 		appendStringInfoString(buf, "ROW(");
 		deparseTargetList(buf, rte, varno, rel, false, attrs_used, qualify_col,
-						  &retrieved_attrs);
+						  qualify_col, &retrieved_attrs);
 		appendStringInfoChar(buf, ')');
 
 		/* Complete the CASE WHEN statement started above. */
@@ -3167,6 +3213,23 @@ deparseConst(Const *node, deparse_expr_cxt *context, int showtype)
 static void
 deparseParam(Param *node, deparse_expr_cxt *context)
 {
+	PgFdwRelationInfo *fpinfo = (PgFdwRelationInfo *) context->foreignrel->fdw_private;
+
+	/*
+	 * If the Param is the one representing the remote table OID, the value
+	 * needs to be produced; fetch the remote table OID, instead.
+	 */
+	if (equal(node, (Node *) fpinfo->tableoid_param))
+	{
+		Assert(bms_is_member(context->root->parse->resultRelation,
+							 context->foreignrel->relids));
+		Assert(bms_membership(context->foreignrel->relids) == BMS_MULTIPLE);
+		ADD_REL_QUALIFIER(context->buf, context->root->parse->resultRelation);
+
+		appendStringInfoString(context->buf, "tableoid");
+		return;
+	}
+
 	if (context->params_list)
 	{
 		int			pindex = 0;
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index e90289e4ab1..d8641209acb 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -5275,14 +5275,14 @@ BEGIN;
   EXPLAIN (verbose, costs off)
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
     RETURNING old.*, new.*;                                                         -- can't be pushed down
-                                                                  QUERY PLAN                                                                  
-----------------------------------------------------------------------------------------------------------------------------------------------
+                                                                       QUERY PLAN                                                                       
+--------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.c1, old.c2, old.c3, old.c4, old.c5, old.c6, old.c7, old.c8, new.c1, new.c2, new.c3, new.c4, new.c5, new.c6, new.c7, new.c8
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c3 = $3 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c3 = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan on public.ft2
-         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, ft2.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
+         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, tableoid, $0, ft2.*
+         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
 (6 rows)
 
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
@@ -5429,14 +5429,14 @@ DELETE FROM ft2 WHERE c1 % 10 = 5 RETURNING c1, c4;
 BEGIN;
   EXPLAIN (verbose, costs off)
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;               -- can't be pushed down
-                                                QUERY PLAN                                                 
------------------------------------------------------------------------------------------------------------
+                                                     QUERY PLAN                                                      
+---------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: old.c1, c4
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c4
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c4
    ->  Foreign Scan on public.ft2
-         Output: ctid
-         Remote SQL: SELECT ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
+         Output: ctid, tableoid, $0
+         Remote SQL: SELECT ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
 (6 rows)
 
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;
@@ -6400,27 +6400,27 @@ BEGIN;
     FROM ft4 INNER JOIN ft5 ON (ft4.c1 = ft5.c1)
     WHERE ft2.c1 > 1200 AND ft2.c2 = ft4.c1
     RETURNING old, new, ft2, ft2.*, ft4, ft4.*;  -- can't be pushed down
-                                                                                                                                                                                                                                     QUERY PLAN                                                                                                                                                                                                                                     
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                                                                                  QUERY PLAN                                                                                                                                                                                                                                                  
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.*, new.*, ft2.*, ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.*, ft4.c1, ft4.c2, ft4.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan
-         Output: 'bar'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+         Output: 'bar'::text, ft2.ctid, ft2.tableoid, ($0), ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3, r1.tableoid FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+               Output: ft2.ctid, ft2.tableoid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ($0)
                Join Filter: (ft4.c1 = ft5.c1)
                ->  Sort
-                     Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                     Output: ft2.ctid, ft2.tableoid, ft2.*, ($0), ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                      Sort Key: ft2.c2
                      ->  Hash Join
-                           Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                           Output: ft2.ctid, ft2.tableoid, ft2.*, ($0), ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                            Hash Cond: (ft2.c2 = ft4.c1)
                            ->  Foreign Scan on public.ft2
-                                 Output: ft2.ctid, ft2.*, ft2.c2
-                                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
+                                 Output: ft2.ctid, ft2.tableoid, ft2.*, $0, ft2.c2
+                                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
                            ->  Hash
                                  Output: ft4.*, ft4.c1, ft4.c2, ft4.c3
                                  ->  Foreign Scan on public.ft4
@@ -6498,13 +6498,13 @@ UPDATE ft2 AS target SET (c2, c7) = (
         FROM ft2 AS src
         WHERE target.c1 = src.c1
 ) WHERE c1 > 1100;
-                                                         QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
+                                                                    QUERY PLAN                                                                    
+--------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 target
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c7 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c7 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.ft2 target
-         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, target.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
+         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, target.tableoid, $3, target.*
+         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
          SubPlan multiexpr_1
            ->  Foreign Scan on public.ft2 src
                  Output: (src.c2 * 10), src.c7
@@ -6526,20 +6526,20 @@ UPDATE ft2 AS target SET (c2) = (
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE ft2 d SET c2 = CASE WHEN random() >= 0 THEN d.c2 ELSE 0 END
   FROM ft2 AS t WHERE d.c1 = t.c1 AND d.c1 > 1000;
-                                                                                                                                                                                       QUERY PLAN                                                                                                                                                                                        
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                                    QUERY PLAN                                                                                                                                                                                                     
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 d
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan
-         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, d.*, t.*
+         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, d.tableoid, ($0), d.*, t.*
          Relations: (public.ft2 d) INNER JOIN (public.ft2 t)
-         Remote SQL: SELECT r1.c2, r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.c2, r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END, r1.tableoid FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
          ->  Hash Join
-               Output: d.c2, d.ctid, d.*, t.*
+               Output: d.c2, d.ctid, d.tableoid, d.*, t.*, ($0)
                Hash Cond: (d.c1 = t.c1)
                ->  Foreign Scan on public.ft2 d
-                     Output: d.c2, d.ctid, d.*, d.c1
-                     Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
+                     Output: d.c2, d.ctid, d.tableoid, d.*, $0, d.c1
+                     Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
                ->  Hash
                      Output: t.*, t.c1
                      ->  Foreign Scan on public.ft2 t
@@ -6559,19 +6559,19 @@ EXPLAIN (verbose, costs off)
 WITH cte AS (
   UPDATE ft2 SET c3 = 'bar' WHERE postgres_fdw_abs(c1) > 2000 RETURNING *
 ) SELECT * FROM cte ORDER BY c1;          -- can't be pushed down
-                                                    QUERY PLAN                                                    
-------------------------------------------------------------------------------------------------------------------
+                                                             QUERY PLAN                                                             
+------------------------------------------------------------------------------------------------------------------------------------
  Sort
    Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
    Sort Key: cte.c1
    CTE cte
      ->  Update on public.ft2
            Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8
-           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
            ->  Foreign Scan on public.ft2
-                 Output: 'bar'::text, ft2.ctid, ft2.*
+                 Output: 'bar'::text, ft2.ctid, ft2.tableoid, $0, ft2.*
                  Filter: (postgres_fdw_abs(ft2.c1) > 2000)
-                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" FOR UPDATE
+                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" FOR UPDATE
    ->  CTE Scan on cte
          Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
 (13 rows)
@@ -6602,13 +6602,13 @@ UPDATE ft2 SET c3 = 'baz'
 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Nested Loop
-         Output: 'baz'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
+         Output: 'baz'::text, ft2.ctid, ft2.tableoid, ($0), ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
          Join Filter: (ft2.c2 === ft4.c1)
          ->  Foreign Scan on public.ft2
-               Output: ft2.ctid, ft2.*, ft2.c2
-               Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+               Output: ft2.ctid, ft2.tableoid, ft2.*, $0, ft2.c2
+               Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
          ->  Foreign Scan
                Output: ft4.*, ft4.c1, ft4.c2, ft4.c3, ft5.*, ft5.c1, ft5.c2, ft5.c3
                Relations: (public.ft4) INNER JOIN (public.ft5)
@@ -6640,24 +6640,24 @@ DELETE FROM ft2
   USING ft4 INNER JOIN ft5 ON (ft4.c1 === ft5.c1)
   WHERE ft2.c1 > 2000 AND ft2.c2 = ft4.c1
   RETURNING ft2.c1, ft2.c2, ft2.c3;       -- can't be pushed down
-                                                                                                                                                                     QUERY PLAN                                                                                                                                                                     
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                  QUERY PLAN                                                                                                                                                                                  
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c2, c3
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3
    ->  Foreign Scan
-         Output: ft2.ctid, ft4.*, ft5.*
+         Output: ft2.ctid, ft2.tableoid, ($0), ft4.*, ft5.*
          Filter: (ft4.c1 === ft5.c1)
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1, r1.tableoid FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft4.*, ft5.*, ft4.c1, ft5.c1
+               Output: ft2.ctid, ft2.tableoid, ft4.*, ft5.*, ft4.c1, ft5.c1, ($0)
                ->  Nested Loop
-                     Output: ft2.ctid, ft4.*, ft4.c1
+                     Output: ft2.ctid, ft2.tableoid, ($0), ft4.*, ft4.c1
                      Join Filter: (ft2.c2 = ft4.c1)
                      ->  Foreign Scan on public.ft2
-                           Output: ft2.ctid, ft2.c2
-                           Remote SQL: SELECT c2, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+                           Output: ft2.ctid, ft2.tableoid, $0, ft2.c2
+                           Remote SQL: SELECT c2, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
                      ->  Foreign Scan on public.ft4
                            Output: ft4.*, ft4.c1
                            Remote SQL: SELECT c1, c2, c3 FROM "S 1"."T 3"
@@ -7169,19 +7169,19 @@ SET enable_hashjoin TO false;
 SET enable_material TO false;
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
-                                   QUERY PLAN                                   
---------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.remt2
    Output: remt2.c1, remt2.c2
-   Remote SQL: UPDATE public.loct2 SET c2 = $2 WHERE ctid = $1 RETURNING c1, c2
+   Remote SQL: UPDATE public.loct2 SET c2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING c1, c2
    ->  Nested Loop
-         Output: (remt2.c2 || remt2.c2), remt2.ctid, remt2.*, loct1.ctid
+         Output: (remt2.c2 || remt2.c2), remt2.ctid, remt2.tableoid, ($0), remt2.*, loct1.ctid
          Join Filter: (remt2.c1 = loct1.c1)
          ->  Seq Scan on public.loct1
                Output: loct1.ctid, loct1.c1
          ->  Foreign Scan on public.remt2
-               Output: remt2.c2, remt2.ctid, remt2.*, remt2.c1
-               Remote SQL: SELECT c1, c2, ctid FROM public.loct2 FOR UPDATE
+               Output: remt2.c2, remt2.ctid, remt2.tableoid, remt2.*, $0, remt2.c1
+               Remote SQL: SELECT c1, c2, ctid, tableoid FROM public.loct2 FOR UPDATE
 (11 rows)
 
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
@@ -7342,13 +7342,13 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, foreign_tbl.tableoid, $0, foreign_tbl.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -7356,13 +7356,13 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, foreign_tbl.tableoid, $0, foreign_tbl.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -7455,14 +7455,14 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                         
+---------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -7470,14 +7470,14 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -7526,14 +7526,14 @@ CREATE VIEW rw_view AS SELECT * FROM parent_tbl WHERE a < 5 WITH CHECK OPTION;
 INSERT INTO parent_tbl (a) VALUES(1),(5);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = 'text', c = 123.456;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.child_foreign parent_tbl_1
-     Remote SQL: UPDATE public.child_local SET b = $2, c = $3 WHERE ctid = $1 RETURNING a
+     Remote SQL: UPDATE public.child_local SET b = $3, c = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING a
    ->  Foreign Scan on public.child_foreign parent_tbl_1
-         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT b, c, a, ctid FROM public.child_local WHERE ((a < 5)) FOR UPDATE
+         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT b, c, a, ctid, tableoid FROM public.child_local WHERE ((a < 5)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = 'text', c = 123.456;
@@ -7612,13 +7612,13 @@ insert into grem1 (a) values (1), (2);
 insert into grem1 (a) values (1), (2);
 explain (verbose, costs off)
 update grem1 set a = 22 where a = 2;
-                                       QUERY PLAN                                       
-----------------------------------------------------------------------------------------
+                                                QUERY PLAN                                                
+----------------------------------------------------------------------------------------------------------
  Update on public.grem1
-   Remote SQL: UPDATE public.gloc1 SET a = $2, b = DEFAULT, c = DEFAULT WHERE ctid = $1
+   Remote SQL: UPDATE public.gloc1 SET a = $3, b = DEFAULT, c = DEFAULT WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.grem1
-         Output: 22, ctid, grem1.*
-         Remote SQL: SELECT a, b, c, ctid FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
+         Output: 22, ctid, tableoid, $0, grem1.*
+         Remote SQL: SELECT a, b, c, ctid, tableoid FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
 (5 rows)
 
 update grem1 set a = 22 where a = 2;
@@ -7945,13 +7945,13 @@ SELECT * from loc1;
 
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f1 = 10;          -- all columns should be transmitted
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: 10, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: 10, ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 UPDATE rem1 set f1 = 10;
@@ -8093,12 +8093,12 @@ DELETE FROM rem1;                 -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1 WHERE false;     -- currently can't be pushed down
-                      QUERY PLAN                       
--------------------------------------------------------
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: ctid
+         Output: ctid, tableoid, $0
          Replaces: Scan on rem1
          One-Time Filter: false
 (6 rows)
@@ -8199,13 +8199,13 @@ BEFORE UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -8223,13 +8223,13 @@ AFTER UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                                  QUERY PLAN                                   
--------------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f2 = $2 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: UPDATE public.loc1 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -8257,13 +8257,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                             QUERY PLAN                              
----------------------------------------------------------------------
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_before_delete ON rem1;
@@ -8281,13 +8281,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_after_delete ON rem1;
@@ -8324,28 +8324,28 @@ CONTEXT:  COPY parent_tbl, line 1: "AAA	42"
 ALTER SERVER loopback OPTIONS (DROP batch_size);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                         
+---------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                            QUERY PLAN                            
-------------------------------------------------------------------
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Foreign Delete on public.foreign_tbl parent_tbl_1
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
-         Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
+         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0
+         Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 DELETE FROM parent_tbl;
@@ -8363,39 +8363,41 @@ CREATE TRIGGER parent_tbl_delete_trig
   FOR EACH STATEMENT EXECUTE PROCEDURE trigger_func();
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                              QUERY PLAN                                              
-------------------------------------------------------------------------------------------------------
+                                                           QUERY PLAN                                                            
+---------------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Update on public.parent_tbl parent_tbl_1
    Foreign Update on public.foreign_tbl parent_tbl_2
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::record)
+         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::oid), $0, (NULL::record)
          ->  Append
                ->  Seq Scan on public.parent_tbl parent_tbl_1
-                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::record
+                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.tableoid, parent_tbl_2.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.local_tbl FOR UPDATE
 (12 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Delete on public.parent_tbl parent_tbl_1
    Foreign Delete on public.foreign_tbl parent_tbl_2
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
-   ->  Append
-         ->  Seq Scan on public.parent_tbl parent_tbl_1
-               Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
-         ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-               Output: parent_tbl_2.tableoid, parent_tbl_2.ctid
-               Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
-(10 rows)
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
+   ->  Result
+         Output: parent_tbl.tableoid, parent_tbl.ctid, (NULL::oid), $0
+         ->  Append
+               ->  Seq Scan on public.parent_tbl parent_tbl_1
+                     Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid
+               ->  Foreign Scan on public.foreign_tbl parent_tbl_2
+                     Output: parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.tableoid, $0
+                     Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
+(12 rows)
 
 DELETE FROM parent_tbl;
 ERROR:  cannot collect transition tuples from child foreign tables
@@ -8737,22 +8739,22 @@ drop table foo2child;
 -- Check UPDATE with inherited target and an inherited source table
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 where f1 in (select f1 from foo);
-                                              QUERY PLAN                                               
--------------------------------------------------------------------------------------------------------
+                                                       QUERY PLAN                                                       
+------------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Hash Join
-         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::record), foo.*, foo.tableoid
+         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record), foo.*, foo.tableoid
          Inner Unique: true
          Hash Cond: (bar.f1 = foo.f1)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                     Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 FOR UPDATE
          ->  Hash
                Output: foo.ctid, foo.f1, foo.*, foo.tableoid
                ->  HashAggregate
@@ -8784,24 +8786,24 @@ update bar set f2 = f2 + 100
 from
   ( select f1 from foo union all select f1+3 from foo ) ss
 where bar.f1 = ss.f1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                  QUERY PLAN                                                   
+---------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Merge Join
-         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
          Merge Cond: (bar.f1 = foo.f1)
          ->  Sort
-               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::record)
+               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
                Sort Key: bar.f1
                ->  Append
                      ->  Seq Scan on public.bar bar_1
-                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                      ->  Foreign Scan on public.bar2 bar_2
-                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                           Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                           Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 FOR UPDATE
          ->  Sort
                Output: (ROW(foo.f1)), foo.f1
                Sort Key: foo.f1
@@ -8942,19 +8944,21 @@ ERROR:  WHERE CURRENT OF is not supported for this table type
 rollback;
 explain (verbose, costs off)
 delete from foo where f1 < 5 returning *;
-                                      QUERY PLAN                                      
---------------------------------------------------------------------------------------
+                                         QUERY PLAN                                         
+--------------------------------------------------------------------------------------------
  Delete on public.foo
    Output: foo_1.f1, foo_1.f2
    Delete on public.foo foo_1
    Foreign Delete on public.foo2 foo_2
-   ->  Append
-         ->  Index Scan using i_foo_f1 on public.foo foo_1
-               Output: foo_1.tableoid, foo_1.ctid
-               Index Cond: (foo_1.f1 < 5)
-         ->  Foreign Delete on public.foo2 foo_2
-               Remote SQL: DELETE FROM public.loct1 WHERE ((f1 < 5)) RETURNING f1, f2
-(10 rows)
+   ->  Result
+         Output: foo.tableoid, foo.ctid, (NULL::oid), $0
+         ->  Append
+               ->  Index Scan using i_foo_f1 on public.foo foo_1
+                     Output: foo_1.tableoid, foo_1.ctid, NULL::oid
+                     Index Cond: (foo_1.f1 < 5)
+               ->  Foreign Delete on public.foo2 foo_2
+                     Remote SQL: DELETE FROM public.loct1 WHERE ((f1 < 5)) RETURNING f1, f2
+(12 rows)
 
 delete from foo where f1 < 5 returning *;
  f1 | f2 
@@ -8968,17 +8972,17 @@ delete from foo where f1 < 5 returning *;
 
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 returning *;
-                                        QUERY PLAN                                        
-------------------------------------------------------------------------------------------
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
  Update on public.bar
    Output: bar_1.f1, bar_1.f2
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Update on public.bar2 bar_2
                      Remote SQL: UPDATE public.loct2 SET f2 = (f2 + 100) RETURNING f1, f2
 (11 rows)
@@ -9003,20 +9007,20 @@ AFTER UPDATE OR DELETE ON bar2
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 explain (verbose, costs off)
 update bar set f2 = f2 + 100;
-                                               QUERY PLAN                                               
---------------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                        
+--------------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f1 = $2, f2 = $3, f3 = $4 WHERE ctid = $1 RETURNING f1, f2, f3
+     Remote SQL: UPDATE public.loct2 SET f1 = $3, f2 = $4, f3 = $5 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                     Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 FOR UPDATE
 (12 rows)
 
 update bar set f2 = f2 + 100;
@@ -9034,20 +9038,22 @@ NOTICE:  trig_row_after(23, skidoo) AFTER ROW UPDATE ON bar2
 NOTICE:  OLD: (7,277,77),NEW: (7,377,77)
 explain (verbose, costs off)
 delete from bar where f2 < 400;
-                                            QUERY PLAN                                             
----------------------------------------------------------------------------------------------------
+                                                    QUERY PLAN                                                     
+-------------------------------------------------------------------------------------------------------------------
  Delete on public.bar
    Delete on public.bar bar_1
    Foreign Delete on public.bar2 bar_2
-     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 RETURNING f1, f2, f3
-   ->  Append
-         ->  Seq Scan on public.bar bar_1
-               Output: bar_1.tableoid, bar_1.ctid, NULL::record
-               Filter: (bar_1.f2 < 400)
-         ->  Foreign Scan on public.bar2 bar_2
-               Output: bar_2.tableoid, bar_2.ctid, bar_2.*
-               Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
-(11 rows)
+     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
+   ->  Result
+         Output: bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
+         ->  Append
+               ->  Seq Scan on public.bar bar_1
+                     Output: bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
+                     Filter: (bar_1.f2 < 400)
+               ->  Foreign Scan on public.bar2 bar_2
+                     Output: bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                     Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
+(13 rows)
 
 delete from bar where f2 < 400;
 NOTICE:  trig_row_before(23, skidoo) BEFORE ROW DELETE ON bar2
@@ -9078,22 +9084,22 @@ analyze remt1;
 analyze remt2;
 explain (verbose, costs off)
 update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a returning *;
-                                                   QUERY PLAN                                                   
-----------------------------------------------------------------------------------------------------------------
+                                                           QUERY PLAN                                                            
+---------------------------------------------------------------------------------------------------------------------------------
  Update on public.parent
    Output: parent_1.a, parent_1.b, remt2.a, remt2.b
    Update on public.parent parent_1
    Foreign Update on public.remt1 parent_2
-     Remote SQL: UPDATE public.loct1 SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct1 SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::record)
+         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::oid), $0, (NULL::record)
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::record
+                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.tableoid, parent_2.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.b, remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -9110,22 +9116,22 @@ update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a re
 
 explain (verbose, costs off)
 delete from parent using remt2 where parent.a = remt2.a returning parent;
-                                 QUERY PLAN                                  
------------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Delete on public.parent
    Output: parent_1.*
    Delete on public.parent parent_1
    Foreign Delete on public.remt1 parent_2
-     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: remt2.*, parent.tableoid, parent.ctid
+         Output: remt2.*, parent.tableoid, parent.ctid, (NULL::oid), $0
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid
+                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid
-                     Remote SQL: SELECT a, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.tableoid, $0
+                     Remote SQL: SELECT a, ctid, tableoid FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -9355,7 +9361,7 @@ update utrtest set a = 1 where a = 1 or a = 2 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 WHERE (((a = 1) OR (a = 2))) RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, $0, NULL::record
                Filter: ((utrtest_2.a = 1) OR (utrtest_2.a = 2))
 (10 rows)
 
@@ -9394,8 +9400,8 @@ insert into utrtest values (2, 'qux');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Foreign Update on public.remp utrtest_1
@@ -9404,7 +9410,7 @@ update utrtest set a = 1 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, $0, NULL::record
 (9 rows)
 
 update utrtest set a = 1 returning *;
@@ -9415,22 +9421,22 @@ insert into utrtest values (2, 'qux');
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 from (values (1), (2)) s(x) where a = s.x returning *;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Foreign Update on public.remp utrtest_1
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    Update on public.locp utrtest_2
    ->  Hash Join
-         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, utrtest.*
+         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, utrtest.tableoid, $0, utrtest.*
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Foreign Scan on public.remp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, utrtest_1.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, utrtest_1.tableoid, utrtest_1.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.loct FOR UPDATE
                ->  Seq Scan on public.locp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, NULL::record
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -9454,15 +9460,15 @@ insert into utrtest values (3, 'xyzzy');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
    ->  Append
          ->  Seq Scan on public.locp utrtest_1
-               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, $0, NULL::record
          ->  Foreign Update on public.remp utrtest_2
                Remote SQL: UPDATE public.loct SET a = 3 RETURNING a, b
 (9 rows)
@@ -9472,22 +9478,22 @@ ERROR:  cannot route tuples into foreign table to be updated "remp"
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 from (values (2), (3)) s(x) where a = s.x returning *;
-                                             QUERY PLAN                                              
------------------------------------------------------------------------------------------------------
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Hash Join
-         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::record)
+         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::oid), $0, (NULL::record)
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Seq Scan on public.locp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, utrtest_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, utrtest_2.tableoid, utrtest_2.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.loct FOR UPDATE
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -12374,8 +12380,8 @@ RESET enable_hashjoin;
 -- Test that UPDATE/DELETE with inherited target works with async_capable enabled
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
-                                                QUERY PLAN                                                
-----------------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                          
+-----------------------------------------------------------------------------------------------------------------------------
  Update on public.async_pt
    Output: async_pt_1.a, async_pt_1.b, async_pt_1.c
    Foreign Update on public.async_p1 async_pt_1
@@ -12387,7 +12393,7 @@ UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
          ->  Foreign Update on public.async_p2 async_pt_2
                Remote SQL: UPDATE public.base_tbl2 SET c = (c || c) WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::record
+               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::oid, $0, NULL::record, $1
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -12414,7 +12420,7 @@ DELETE FROM async_pt WHERE b = 0 RETURNING *;
          ->  Foreign Delete on public.async_p2 async_pt_2
                Remote SQL: DELETE FROM public.base_tbl2 WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: async_pt_3.tableoid, async_pt_3.ctid
+               Output: async_pt_3.tableoid, async_pt_3.ctid, NULL::oid, $0, $1
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -12989,6 +12995,52 @@ SELECT server_name,
 -- Clean up
 \set VERBOSITY default
 RESET debug_discard_caches;
+-- ===================================================================
+-- check whether fdw created for partitioned table will delete tuples only from
+-- desired partition
+-- ===================================================================
+CREATE TABLE measurement (
+       city_id         int not null,
+       logdate         date not null,
+       peaktemp        int,
+       unitsales       int
+) PARTITION BY RANGE (logdate);
+CREATE TABLE measurement_y2006m02 PARTITION OF measurement
+       FOR VALUES FROM ('2006-02-01') TO ('2006-03-01');
+CREATE TABLE measurement_y2006m03 PARTITION OF measurement
+       FOR VALUES FROM ('2006-03-01') TO ('2006-04-01');
+CREATE TABLE measurement_y2006m04 PARTITION OF measurement
+       FOR VALUES FROM ('2006-04-01') TO ('2006-05-01');
+INSERT INTO measurement VALUES (1,'2006-02-01',1,1);
+INSERT INTO measurement VALUES (2,'2006-03-01',1,1);
+INSERT INTO measurement VALUES (3,'2006-04-01',1,1);
+create foreign table measurement_fdw (
+       city_id int options (column_name 'city_id') not null,
+       logdate date options (column_name 'logdate') not null,
+       peaktemp text options (column_name 'peaktemp'),
+       unitsales integer options (column_name 'unitsales')
+) SERVER loopback OPTIONS (table_name 'measurement');
+DELETE FROM measurement_fdw
+USING (
+       SELECT t1.city_id sub_city_id
+       FROM measurement_fdw t1
+       WHERE t1.city_id=1
+       LIMIT 1000
+) sub
+WHERE measurement_fdw.city_id = sub.sub_city_id
+RETURNING city_id, logdate, peaktemp, unitsales;
+ city_id |  logdate   | peaktemp | unitsales 
+---------+------------+----------+-----------
+       1 | 02-01-2006 | 1        |         1
+(1 row)
+
+SELECT * FROM measurement_fdw;
+ city_id |  logdate   | peaktemp | unitsales 
+---------+------------+----------+-----------
+       2 | 03-01-2006 | 1        |         1
+       3 | 04-01-2006 | 1        |         1
+(2 rows)
+
 -- ===================================================================
 -- test cleanup of failed connections on abort
 -- ===================================================================
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 85be46eb2a2..2b4889ba3e6 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -22,6 +22,7 @@
 #include "commands/explain_format.h"
 #include "commands/explain_state.h"
 #include "commands/vacuum.h"
+#include "common/hashfn.h"
 #include "executor/execAsync.h"
 #include "executor/instrument.h"
 #include "executor/spi.h"
@@ -80,6 +81,8 @@ enum FdwScanPrivateIndex
 	FdwScanPrivateSelectSql,
 	/* Integer list of attribute numbers retrieved by the SELECT */
 	FdwScanPrivateRetrievedAttrs,
+	/* Param ID for remote table OID for target rel (-1 if none) */
+	FdwScanPrivateTableOidParamId,
 	/* Integer representing the desired fetch_size */
 	FdwScanPrivateFetchSize,
 
@@ -178,6 +181,10 @@ typedef struct PgFdwScanState
 	MemoryContext temp_cxt;		/* context for per-tuple temporary data */
 
 	int			fetch_size;		/* number of tuples per fetch */
+
+	int			tableoid_param_id;	/* Param ID for remote table OID */
+	bool		set_tableoid_param; /* Do we need to set the Param? */
+	Oid			remote_tableoid;
 } PgFdwScanState;
 
 /*
@@ -204,6 +211,7 @@ typedef struct PgFdwModifyState
 
 	/* info about parameters for prepared statement */
 	AttrNumber	ctidAttno;		/* attnum of input resjunk ctid column */
+	AttrNumber	tableoidAttno;	/* attnum of input resjunk tableoid column */
 	int			p_nums;			/* number of parameters to transmit */
 	FmgrInfo   *p_flinfo;		/* output conversion functions for them */
 
@@ -304,6 +312,35 @@ typedef struct
 	int64		offset_est;
 } PgFdwPathExtraData;
 
+typedef struct OidMappingEntry
+{
+	Oid		local_oid;	/* key */
+	Oid		remote_oid; /* value */
+	char	status;		/* hash entry status */
+} OidMappingEntry;
+
+#define SH_PREFIX oidmapping
+#define SH_ELEMENT_TYPE OidMappingEntry
+#define SH_KEY_TYPE Oid
+#define SH_KEY local_oid
+#define SH_HASH_KEY(tb, key) murmurhash32(key)
+#define SH_EQUAL(tb, a, b) a == b
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+typedef struct PgFdwRemoteMap
+{
+	oidmapping_hash	*oid_mapping;
+	MemoryContext	 hash_cxt;
+	uint32			 refcount;
+} PgFdwRemoteMap;
+
+static PgFdwRemoteMap *remoteMap;
+
+#define		N_INITIAL_OIDMAPPING_ELEMS	50
+
 /*
  * Identify the attribute where data conversion fails.
  */
@@ -502,6 +539,85 @@ static const Oid attclear_argtypes[ATTCLEAR_SQL_NUM_FIELDS] =
  */
 PG_FUNCTION_INFO_V1(postgres_fdw_handler);
 
+/* Private cache */
+static void
+init_remote_map(void)
+{
+	MemoryContext	remoteMapCxt;
+
+	Assert(remoteMap == NULL);
+
+	remoteMapCxt = AllocSetContextCreate(CacheMemoryContext,
+										 "PgFdwRemoteMap",
+										 ALLOCSET_SMALL_SIZES);
+
+	remoteMap = (PgFdwRemoteMap *)
+		MemoryContextAllocZero(remoteMapCxt, sizeof(PgFdwRemoteMap));
+
+	remoteMap->hash_cxt = remoteMapCxt;
+	remoteMap->oid_mapping = oidmapping_create(remoteMap->hash_cxt,
+											   N_INITIAL_OIDMAPPING_ELEMS,
+											   NULL);
+}
+
+static void
+insert_oid_pair(Oid local_oid, Oid remote_oid)
+{
+	bool			 found;
+	OidMappingEntry	*entry;
+	MemoryContext	 tmp;
+
+	Assert(OidIsValid(local_oid) && OidIsValid(remote_oid));
+
+	if (remoteMap == NULL)
+		init_remote_map();
+
+	tmp = MemoryContextSwitchTo(remoteMap->hash_cxt);
+
+	/* This mapping may already present in the hashtable. */
+	entry = oidmapping_insert(remoteMap->oid_mapping, local_oid, &found);
+
+	if (!found)
+	{
+		remoteMap->refcount++;
+		entry->remote_oid = remote_oid;
+	}
+
+	MemoryContextSwitchTo(tmp);
+}
+
+static Oid
+find_remote_oid(Oid local_oid)
+{
+	OidMappingEntry *entry;
+
+	Assert(OidIsValid(local_oid));
+
+	if (remoteMap == NULL)
+		return InvalidOid;
+
+	entry = oidmapping_lookup(remoteMap->oid_mapping, local_oid);
+
+	return ((entry != NULL) ? entry->remote_oid : InvalidOid);
+}
+
+static void
+destroy_remote_map(void)
+{
+	if (remoteMap == NULL)
+		return;
+
+	Assert(remoteMap->refcount > 0);
+	remoteMap->refcount--;
+
+	if (remoteMap->refcount == 0)
+	{
+		oidmapping_destroy(remoteMap->oid_mapping);
+		MemoryContextDelete(remoteMap->hash_cxt);
+		remoteMap = NULL;
+	}
+}
+
 /*
  * FDW callback routines
  */
@@ -656,6 +772,7 @@ static TupleTableSlot **execute_foreign_modify(EState *estate,
 static void prepare_foreign_modify(PgFdwModifyState *fmstate);
 static const char **convert_prep_stmt_params(PgFdwModifyState *fmstate,
 											 ItemPointer tupleid,
+											 Oid tableoid,
 											 TupleTableSlot **slots,
 											 int numSlots);
 static void store_returning_result(PgFdwModifyState *fmstate,
@@ -1002,6 +1119,23 @@ postgresGetForeignRelSize(PlannerInfo *root,
 	fpinfo->hidden_subquery_rels = NULL;
 	/* Set the relation index. */
 	fpinfo->relation_index = baserel->relid;
+	fpinfo->tableoid_param = NULL;
+
+	/*
+	 * If the table is an UPDATE/DELETE target, the table's reltarget would
+	 * have contained a Param representing the remote table OID of the target;
+	 * get the Param and save a copy of it in fpinfo for use later.
+	 */
+	foreach(lc, baserel->reltarget->exprs)
+	{
+		Param	   *param = (Param *) lfirst(lc);
+		if (IsA(param, Param))
+		{
+			Assert(IS_FOREIGN_PARAM(root, param));
+			fpinfo->tableoid_param = (Param *) copyObject(param);
+			break;
+		}
+	}
 }
 
 /*
@@ -1463,6 +1597,7 @@ postgresGetForeignPlan(PlannerInfo *root,
 	bool		has_final_sort = false;
 	bool		has_limit = false;
 	ListCell   *lc;
+	int			tableoid_param_id = -1;
 
 	/*
 	 * Get FDW private data created by postgresGetForeignUpperPaths(), if any.
@@ -1627,12 +1762,16 @@ postgresGetForeignPlan(PlannerInfo *root,
 	/* Remember remote_exprs for possible use by postgresPlanDirectModify */
 	fpinfo->final_remote_exprs = remote_exprs;
 
+	if (fpinfo->tableoid_param)
+		tableoid_param_id = fpinfo->tableoid_param->paramid;
+
 	/*
 	 * Build the fdw_private list that will be available to the executor.
 	 * Items in the list must match order in enum FdwScanPrivateIndex.
 	 */
-	fdw_private = list_make3(makeString(sql.data),
+	fdw_private = list_make4(makeString(sql.data),
 							 retrieved_attrs,
+							 makeInteger(tableoid_param_id),
 							 makeInteger(fpinfo->fetch_size));
 	if (IS_JOIN_REL(foreignrel) || IS_UPPER_REL(foreignrel))
 		fdw_private = lappend(fdw_private,
@@ -1765,6 +1904,8 @@ postgresBeginForeignScan(ForeignScanState *node, int eflags)
 									 FdwScanPrivateSelectSql));
 	fsstate->retrieved_attrs = (List *) list_nth(fsplan->fdw_private,
 												 FdwScanPrivateRetrievedAttrs);
+	fsstate->tableoid_param_id = intVal(list_nth(fsplan->fdw_private,
+										FdwScanPrivateTableOidParamId));
 	fsstate->fetch_size = intVal(list_nth(fsplan->fdw_private,
 										  FdwScanPrivateFetchSize));
 
@@ -1784,11 +1925,13 @@ postgresBeginForeignScan(ForeignScanState *node, int eflags)
 	{
 		fsstate->rel = node->ss.ss_currentRelation;
 		fsstate->tupdesc = RelationGetDescr(fsstate->rel);
+		fsstate->set_tableoid_param = (fsstate->tableoid_param_id >= 0);
 	}
 	else
 	{
 		fsstate->rel = NULL;
 		fsstate->tupdesc = get_tupdesc_for_join_scan_tuples(node);
+		fsstate->set_tableoid_param = false;
 	}
 
 	fsstate->attinmeta = TupleDescGetAttInMetadata(fsstate->tupdesc);
@@ -1820,6 +1963,7 @@ postgresIterateForeignScan(ForeignScanState *node)
 {
 	PgFdwScanState *fsstate = (PgFdwScanState *) node->fdw_state;
 	TupleTableSlot *slot = node->ss.ss_ScanTupleSlot;
+	HeapTuple		tuple;
 
 	/*
 	 * In sync mode, if this is the first call after Begin or ReScan, we need
@@ -1846,12 +1990,23 @@ postgresIterateForeignScan(ForeignScanState *node)
 			return ExecClearTuple(slot);
 	}
 
+	tuple = fsstate->tuples[fsstate->next_tuple++];
+
+	if (fsstate->set_tableoid_param)
+	{
+		ExprContext *econtext = node->ss.ps.ps_ExprContext;
+		ParamExecData *prm = &(econtext->ecxt_param_exec_vals[fsstate->tableoid_param_id]);
+
+		prm->execPlan = NULL;
+		prm->value = ObjectIdGetDatum(tuple->t_tableOid);
+		prm->isnull = false;
+
+		insert_oid_pair(RelationGetRelid(fsstate->rel), tuple->t_tableOid);
+	}
 	/*
 	 * Return the next tuple.
 	 */
-	ExecStoreHeapTuple(fsstate->tuples[fsstate->next_tuple++],
-					   slot,
-					   false);
+	ExecStoreHeapTuple(tuple, slot, false);
 
 	return slot;
 }
@@ -1966,6 +2121,9 @@ postgresAddForeignUpdateTargets(PlannerInfo *root,
 								Relation target_relation)
 {
 	Var		   *var;
+	Param	   *param;
+	const char *attrname;
+	TargetEntry *tle;
 
 	/*
 	 * In postgres_fdw, what we need is the ctid, same as for a regular table.
@@ -1981,6 +2139,38 @@ postgresAddForeignUpdateTargets(PlannerInfo *root,
 
 	/* Register it as a row-identity column needed by this target rel */
 	add_row_identity_var(root, var, rtindex, "ctid");
+
+	/* Make a Var representing the desired value */
+	var = makeVar(rtindex,
+		TableOidAttributeNumber,
+		OIDOID,
+		-1,
+		InvalidOid,
+		0);
+	/* Register it as a row-identity column needed by this target rel */
+	add_row_identity_var(root, var, rtindex, "remote_tableoid");
+
+	/* Make a Param representing the tableoid value */
+	param = makeNode(Param);
+	param->paramkind = PARAM_EXEC;
+	param->paramtype = OIDOID;
+	param->paramtypmod = -1;
+	param->paramcollid = InvalidOid;
+	param->location = -1;
+	/* paramid will be filled in by fix_foreign_params */
+	param->paramid = -1;
+	param->target_rte = rtindex;
+
+	/* Wrap it in a resjunk TLE with the right name ... */
+	attrname = "remote_tableoid";
+
+	tle = makeTargetEntry((Expr *) param,
+						  list_length(root->processed_tlist) + 1,
+						  pstrdup(attrname),
+						  true);
+
+	/* ... and add it to the query's targetlist */
+	root->processed_tlist = lappend(root->processed_tlist, tle);
 }
 
 /*
@@ -2343,6 +2533,7 @@ postgresExecForeignDelete(EState *estate,
 	rslot = execute_foreign_modify(estate, resultRelInfo, CMD_DELETE,
 								   &slot, &planSlot, &numSlots);
 
+	destroy_remote_map();
 	return rslot ? rslot[0] : NULL;
 }
 
@@ -4228,7 +4419,7 @@ create_foreign_modify(EState *estate,
 		fmstate->attinmeta = TupleDescGetAttInMetadata(tupdesc);
 
 	/* Prepare for output conversion of parameters used in prepared stmt. */
-	n_params = list_length(fmstate->target_attrs) + 1;
+	n_params = list_length(fmstate->target_attrs) + 2;
 	fmstate->p_flinfo = palloc0_array(FmgrInfo, n_params);
 	fmstate->p_nums = 0;
 
@@ -4246,6 +4437,20 @@ create_foreign_modify(EState *estate,
 		getTypeOutputInfo(TIDOID, &typefnoid, &isvarlena);
 		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
 		fmstate->p_nums++;
+
+		/* Find the tableoid resjunk column in the subplan's result */
+		fmstate->tableoidAttno = ExecFindJunkAttributeInTlist(subplan->targetlist,
+															  "remote_tableoid");
+
+		if (!AttributeNumberIsValid(fmstate->tableoidAttno))
+			ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("could not find junk tableoid column")));
+
+		/* Second transmittable parameter will be tableoid */
+		getTypeOutputInfo(OIDOID, &typefnoid, &isvarlena);
+		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
+		fmstate->p_nums++;
 	}
 
 	if (operation == CMD_INSERT || operation == CMD_UPDATE)
@@ -4298,6 +4503,7 @@ execute_foreign_modify(EState *estate,
 {
 	PgFdwModifyState *fmstate = (PgFdwModifyState *) resultRelInfo->ri_FdwState;
 	ItemPointer ctid = NULL;
+	Oid			tableoid = InvalidOid;
 	const char **p_values;
 	PGresult   *res;
 	int			n_rows;
@@ -4343,7 +4549,9 @@ execute_foreign_modify(EState *estate,
 	if (operation == CMD_UPDATE || operation == CMD_DELETE)
 	{
 		Datum		datum;
+		Datum		datum2;
 		bool		isNull;
+		Oid remote_tableoid = InvalidOid;
 
 		datum = ExecGetJunkAttribute(planSlots[0],
 									 fmstate->ctidAttno,
@@ -4352,10 +4560,28 @@ execute_foreign_modify(EState *estate,
 		if (isNull)
 			elog(ERROR, "ctid is NULL");
 		ctid = (ItemPointer) DatumGetPointer(datum);
+
+		/* Get the tableoid that was passed up as a resjunk column */
+		datum2 = ExecGetJunkAttribute(planSlots[0],
+									  fmstate->tableoidAttno,
+									  &isNull);
+		/* shouldn't ever get a null result... */
+		if (isNull)
+			ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("tableoid is NULL")));
+
+		tableoid = DatumGetObjectId(datum2);
+
+		/* Remote OID */
+		remote_tableoid = find_remote_oid(tableoid);
+
+		if(OidIsValid(remote_tableoid))
+			tableoid = remote_tableoid;
 	}
 
 	/* Convert parameters needed by prepared statement to text form */
-	p_values = convert_prep_stmt_params(fmstate, ctid, slots, *numSlots);
+	p_values = convert_prep_stmt_params(fmstate, ctid, tableoid, slots, *numSlots);
 
 	/*
 	 * Execute the prepared statement.
@@ -4460,6 +4686,7 @@ prepare_foreign_modify(PgFdwModifyState *fmstate)
 static const char **
 convert_prep_stmt_params(PgFdwModifyState *fmstate,
 						 ItemPointer tupleid,
+						 Oid tableoid,
 						 TupleTableSlot **slots,
 						 int numSlots)
 {
@@ -4486,6 +4713,16 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 		pindex++;
 	}
 
+	/* 2nd parameter should be tableoid, if it's in use */
+	if (OidIsValid(tableoid))
+	{
+		Assert(tupleid != NULL);
+		/* don't need set_transmission_modes for OID output */
+		p_values[pindex] = OutputFunctionCall(&fmstate->p_flinfo[pindex],
+											  ObjectIdGetDatum(tableoid));
+		pindex++;
+	}
+
 	/* get following parameters from slots */
 	if (slots != NULL && fmstate->target_attrs != NIL)
 	{
@@ -4497,7 +4734,7 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 
 		for (i = 0; i < numSlots; i++)
 		{
-			j = (tupleid != NULL) ? 1 : 0;
+			j = (tupleid != NULL) ? 2 : 0;
 			foreach(lc, fmstate->target_attrs)
 			{
 				int			attnum = lfirst_int(lc);
@@ -4562,9 +4799,10 @@ finish_foreign_modify(PgFdwModifyState *fmstate)
 {
 	Assert(fmstate != NULL);
 
+	destroy_remote_map();
+
 	/* If we created a prepared statement, destroy it */
 	deallocate_query(fmstate);
-
 	/* Release remote connection */
 	ReleaseConnection(fmstate->conn);
 	fmstate->conn = NULL;
@@ -4670,7 +4908,8 @@ build_remote_returning(Index rtindex, Relation rel, List *returningList)
 		if (IsA(var, Var) &&
 			var->varno == rtindex &&
 			var->varattno <= InvalidAttrNumber &&
-			var->varattno != SelfItemPointerAttributeNumber)
+			var->varattno != SelfItemPointerAttributeNumber &&
+			var->varattno != TableOidAttributeNumber)
 			continue;			/* don't need it */
 
 		if (tlist_member((Expr *) var, tlist))
@@ -4876,6 +5115,17 @@ init_returning_filter(PgFdwDirectModifyState *dmstate,
 		TargetEntry *tle = (TargetEntry *) lfirst(lc);
 		Var		   *var = (Var *) tle->expr;
 
+		/*
+		 * No need to set the Param for the remote table OID; ignore it.
+		 */
+		if (IsA(var, Param))
+		{
+			/* We would not retrieve the remote table OID anymore. */
+			Assert(!list_member_int(dmstate->retrieved_attrs, i));
+			i++;
+			continue;
+		}
+
 		Assert(IsA(var, Var));
 
 		/*
@@ -4894,6 +5144,8 @@ init_returning_filter(PgFdwDirectModifyState *dmstate,
 				 */
 				if (attrno == SelfItemPointerAttributeNumber)
 					dmstate->ctidAttno = i;
+				else if (attrno == TableOidAttributeNumber)
+					dmstate->oidAttno = i;
 				else
 					Assert(false);
 				dmstate->hasSystemCols = true;
@@ -4985,10 +5237,13 @@ apply_returning_filter(PgFdwDirectModifyState *dmstate,
 		/* ctid */
 		if (dmstate->ctidAttno)
 		{
+			Oid tableoid = InvalidOid;
 			ItemPointer ctid = NULL;
 
 			ctid = (ItemPointer) DatumGetPointer(old_values[dmstate->ctidAttno - 1]);
+			tableoid = DatumGetObjectId(old_values[dmstate->oidAttno - 1]);
 			resultTup->t_self = *ctid;
+			resultTup->t_tableOid = tableoid;
 		}
 
 		/*
@@ -6901,6 +7156,38 @@ foreign_join_ok(PlannerInfo *root, RelOptInfo *joinrel, JoinType jointype,
 	/* Mark that this join can be pushed down safely */
 	fpinfo->pushdown_safe = true;
 
+	/*
+	 * If the join relation contains an UPDATE/DELETE target, either of the
+	 * input relations would have saved the Param representing the remote
+	 * table OID of the target; get the Param and remember it in fpinfo for
+	 * use later.
+	 */
+	if ((root->parse->commandType == CMD_UPDATE ||
+		 root->parse->commandType == CMD_DELETE) &&
+		bms_is_member(root->parse->resultRelation, joinrel->relids))
+	{
+		if (bms_is_member(root->parse->resultRelation,
+						  outerrel->relids))
+		{
+			Assert(fpinfo_o->tableoid_param);
+			fpinfo->tableoid_param = fpinfo_o->tableoid_param;
+		}
+		else
+		{
+			Assert(bms_is_member(root->parse->resultRelation,
+								 innerrel->relids));
+			Assert(fpinfo_i->tableoid_param);
+			fpinfo->tableoid_param = fpinfo_i->tableoid_param;
+		}
+		/*
+		 * Core code should have contained the Param in the join relation's
+		 * reltarget.
+		 */
+		Assert(list_member(joinrel->reltarget->exprs, fpinfo->tableoid_param));
+	}
+	else
+		fpinfo->tableoid_param = NULL;
+
 	/* Get user mapping */
 	if (fpinfo->use_remote_estimate)
 	{
@@ -8434,6 +8721,7 @@ make_tuple_from_result_row(PGresult *res,
 	ErrorContextCallback errcallback;
 	MemoryContext oldcontext;
 	ListCell   *lc;
+	Oid tableoid = InvalidOid;
 	int			j;
 
 	Assert(row < PQntuples(res));
@@ -8516,6 +8804,17 @@ make_tuple_from_result_row(PGresult *res,
 				ctid = (ItemPointer) DatumGetPointer(datum);
 			}
 		}
+		else if (i == TableOidAttributeNumber)
+		{
+			/* tableoid */
+			if (valstr != NULL)
+			{
+				Datum		datum;
+
+				datum = DirectFunctionCall1(oidin, CStringGetDatum(valstr));
+				tableoid = DatumGetObjectId(datum);
+			}
+		}
 		errpos.cur_attno = 0;
 
 		j++;
@@ -8547,6 +8846,9 @@ make_tuple_from_result_row(PGresult *res,
 	if (ctid)
 		tuple->t_self = tuple->t_data->t_ctid = *ctid;
 
+//	if (OidIsValid(tableoid))
+		tuple->t_tableOid = tableoid;
+
 	/*
 	 * Stomp on the xmin, xmax, and cmin fields from the tuple created by
 	 * heap_form_tuple.  heap_form_tuple actually creates the tuple with
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index a2bb1ff352c..376c55d4dd0 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -129,6 +129,9 @@ typedef struct PgFdwRelationInfo
 	 * representing the relation.
 	 */
 	int			relation_index;
+
+	/* PARAM_EXEC Param representing the remote table OID of a target rel */
+	Param	   *tableoid_param;
 } PgFdwRelationInfo;
 
 /*
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index dfc58beb0d2..ba2d753b821 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -4634,6 +4634,50 @@ SELECT server_name,
 \set VERBOSITY default
 RESET debug_discard_caches;
 
+-- ===================================================================
+-- check whether fdw created for partitioned table will delete tuples only from
+-- desired partition
+-- ===================================================================
+
+CREATE TABLE measurement (
+       city_id         int not null,
+       logdate         date not null,
+       peaktemp        int,
+       unitsales       int
+) PARTITION BY RANGE (logdate);
+
+CREATE TABLE measurement_y2006m02 PARTITION OF measurement
+       FOR VALUES FROM ('2006-02-01') TO ('2006-03-01');
+
+CREATE TABLE measurement_y2006m03 PARTITION OF measurement
+       FOR VALUES FROM ('2006-03-01') TO ('2006-04-01');
+
+CREATE TABLE measurement_y2006m04 PARTITION OF measurement
+       FOR VALUES FROM ('2006-04-01') TO ('2006-05-01');
+
+INSERT INTO measurement VALUES (1,'2006-02-01',1,1);
+INSERT INTO measurement VALUES (2,'2006-03-01',1,1);
+INSERT INTO measurement VALUES (3,'2006-04-01',1,1);
+
+create foreign table measurement_fdw (
+       city_id int options (column_name 'city_id') not null,
+       logdate date options (column_name 'logdate') not null,
+       peaktemp text options (column_name 'peaktemp'),
+       unitsales integer options (column_name 'unitsales')
+) SERVER loopback OPTIONS (table_name 'measurement');
+
+DELETE FROM measurement_fdw
+USING (
+       SELECT t1.city_id sub_city_id
+       FROM measurement_fdw t1
+       WHERE t1.city_id=1
+       LIMIT 1000
+) sub
+WHERE measurement_fdw.city_id = sub.sub_city_id
+RETURNING city_id, logdate, peaktemp, unitsales;
+
+SELECT * FROM measurement_fdw;
+
 -- ===================================================================
 -- test cleanup of failed connections on abort
 -- ===================================================================
-- 
2.43.0



^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
@ 2026-05-14 17:22           ` Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Nikita Malakhov @ 2026-05-14 17:22 UTC (permalink / raw)
  To: Etsuro Fujita <etsuro.fujita@gmail.com>; +Cc: Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Hi hackers,

CFbot was unhappy with previous patch set, so here's updated one

-- 
Regards,
Nikita Malakhov
Postgres Professional
The Russian Postgres Company
https://postgrespro.ru/

Attachments:

  [application/octet-stream] v3-0003-teach-fdw-use-remote-tableoid.patch (93.5K, ../../CAN-LCVPgq0zfOU+BLrHnr2Sex_zndNjzWoAiONWD=R4ULQ2BAA@mail.gmail.com/3-v3-0003-teach-fdw-use-remote-tableoid.patch)
  download | inline diff:
From bd2d41c1a14ea20a74d887c970d482aee84a9b38 Mon Sep 17 00:00:00 2001
From: Nikita Malakhov <n.malakhov@postgrespro.ru>
Date: Thu, 14 May 2026 20:20:24 +0300
Subject: [PATCH] DELETE/UPDATE more than one row in partitioned foreign table
 (3/3)

This patch modifies FDW engine to use remote table OID introduced
in previous patch for DELETE and UPDATE of partitioned tables.

[1] Discussion: https://www.postgresql.org/message-id/flat/20250718175314.4513c00a%40karst
---
 contrib/postgres_fdw/deparse.c                |  85 ++-
 .../postgres_fdw/expected/postgres_fdw.out    | 508 ++++++++++--------
 contrib/postgres_fdw/postgres_fdw.c           | 319 ++++++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   3 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  44 ++
 5 files changed, 711 insertions(+), 248 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index 2dcc6c8af1b..8d566bce4bf 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -48,6 +48,7 @@
 #include "catalog/pg_ts_dict.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
+#include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
 #include "nodes/plannodes.h"
 #include "optimizer/optimizer.h"
@@ -132,6 +133,7 @@ static void deparseTargetList(StringInfo buf,
 							  Relation rel,
 							  bool is_returning,
 							  Bitmapset *attrs_used,
+							  bool tableoid_needed,
 							  bool qualify_col,
 							  List **retrieved_attrs);
 static void deparseExplicitTargetList(List *tlist,
@@ -350,10 +352,11 @@ foreign_expr_walker(Node *node,
 					/* Var belongs to foreign table */
 
 					/*
-					 * System columns other than ctid should not be sent to
-					 * the remote, since we don't make any effort to ensure
-					 * that local and remote values match (tableoid, in
-					 * particular, almost certainly doesn't match).
+					 * System columns other than ctid and remote table oid
+					 * should not be sent to the remote, since we don't make
+					 * any effort to ensure that local and remote values
+					 * match (tableoid, in particular, almost certainly
+					 * doesn't match).
 					 */
 					if (var->varattno < 0 &&
 						var->varattno != SelfItemPointerAttributeNumber)
@@ -1235,6 +1238,23 @@ build_tlist_to_deparse(RelOptInfo *foreignrel)
 												  PVC_RECURSE_PLACEHOLDERS));
 	}
 
+	/* Also, add the Param representing the remote table OID, if it exists. */
+	if (fpinfo->tableoid_param)
+	{
+		TargetEntry *tle;
+		/*
+		 * Core code should have contained the Param in the given relation's
+		 * reltarget.
+		 */
+		Assert(list_member(foreignrel->reltarget->exprs,
+						   fpinfo->tableoid_param));
+		tle = makeTargetEntry((Expr *) copyObject(fpinfo->tableoid_param),
+							  list_length(tlist) + 1,
+							  NULL,
+							  false);
+		tlist = lappend(tlist, tle);
+	}
+
 	return tlist;
 }
 
@@ -1390,7 +1410,9 @@ deparseSelectSql(List *tlist, bool is_subquery, List **retrieved_attrs,
 		Relation	rel = table_open(rte->relid, NoLock);
 
 		deparseTargetList(buf, rte, foreignrel->relid, rel, false,
-						  fpinfo->attrs_used, false, retrieved_attrs);
+						  fpinfo->attrs_used,
+						  fpinfo->tableoid_param != NULL,
+						  false, retrieved_attrs);
 		table_close(rel, NoLock);
 	}
 }
@@ -1441,6 +1463,7 @@ deparseTargetList(StringInfo buf,
 				  Relation rel,
 				  bool is_returning,
 				  Bitmapset *attrs_used,
+				  bool tableoid_needed,
 				  bool qualify_col,
 				  List **retrieved_attrs)
 {
@@ -1499,6 +1522,22 @@ deparseTargetList(StringInfo buf,
 									   SelfItemPointerAttributeNumber);
 	}
 
+	if (tableoid_needed &&
+	(bms_is_member(TableOidAttributeNumber - FirstLowInvalidHeapAttributeNumber,
+					  attrs_used)))
+	{
+			Assert(!first);
+			Assert(!is_returning);
+
+			appendStringInfoString(buf, ", ");
+			if (qualify_col)
+				ADD_REL_QUALIFIER(buf, rtindex);
+			appendStringInfoString(buf, "tableoid");
+
+			*retrieved_attrs = lappend_int(*retrieved_attrs,
+											TableOidAttributeNumber);
+	}
+
 	/* Don't generate bad syntax if no undropped columns */
 	if (first && !is_returning)
 		appendStringInfoString(buf, "NULL");
@@ -2259,7 +2298,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 	deparseRelation(buf, rel);
 	appendStringInfoString(buf, " SET ");
 
-	pindex = 2;					/* ctid is always the first param */
+	pindex = 3;					/* ctid is always the first param */
 	first = true;
 	foreach(lc, targetAttrs)
 	{
@@ -2279,7 +2318,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 			pindex++;
 		}
 	}
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_update_after_row,
@@ -2397,7 +2436,7 @@ deparseDeleteSql(StringInfo buf, RangeTblEntry *rte,
 {
 	appendStringInfoString(buf, "DELETE FROM ");
 	deparseRelation(buf, rel);
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_delete_after_row,
@@ -2512,7 +2551,7 @@ deparseReturningList(StringInfo buf, RangeTblEntry *rte,
 	}
 
 	if (attrs_used != NULL)
-		deparseTargetList(buf, rte, rtindex, rel, true, attrs_used, false,
+		deparseTargetList(buf, rte, rtindex, rel, true, attrs_used, false, false,
 						  retrieved_attrs);
 	else
 		*retrieved_attrs = NIL;
@@ -2719,6 +2758,12 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 			ADD_REL_QUALIFIER(buf, varno);
 		appendStringInfoString(buf, "ctid");
 	}
+	else if (varattno == TableOidAttributeNumber)
+	{
+		if (qualify_col)
+			ADD_REL_QUALIFIER(buf, varno);
+		appendStringInfoString(buf, "tableoid");
+	}
 	else if (varattno < 0)
 	{
 		/*
@@ -2730,8 +2775,9 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 		Oid			fetchval = 0;
 
 		if (varattno == TableOidAttributeNumber)
+		{
 			fetchval = rte->relid;
-
+		}
 		if (qualify_col)
 		{
 			appendStringInfoString(buf, "CASE WHEN (");
@@ -2782,7 +2828,7 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 
 		appendStringInfoString(buf, "ROW(");
 		deparseTargetList(buf, rte, varno, rel, false, attrs_used, qualify_col,
-						  &retrieved_attrs);
+						  qualify_col, &retrieved_attrs);
 		appendStringInfoChar(buf, ')');
 
 		/* Complete the CASE WHEN statement started above. */
@@ -3167,6 +3213,23 @@ deparseConst(Const *node, deparse_expr_cxt *context, int showtype)
 static void
 deparseParam(Param *node, deparse_expr_cxt *context)
 {
+	PgFdwRelationInfo *fpinfo = (PgFdwRelationInfo *) context->foreignrel->fdw_private;
+
+	/*
+	 * If the Param is the one representing the remote table OID, the value
+	 * needs to be produced; fetch the remote table OID, instead.
+	 */
+	if (equal(node, (Node *) fpinfo->tableoid_param))
+	{
+		Assert(bms_is_member(context->root->parse->resultRelation,
+							 context->foreignrel->relids));
+		Assert(bms_membership(context->foreignrel->relids) == BMS_MULTIPLE);
+		ADD_REL_QUALIFIER(context->buf, context->root->parse->resultRelation);
+
+		appendStringInfoString(context->buf, "tableoid");
+		return;
+	}
+
 	if (context->params_list)
 	{
 		int			pindex = 0;
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index e90289e4ab1..d8641209acb 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -5275,14 +5275,14 @@ BEGIN;
   EXPLAIN (verbose, costs off)
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
     RETURNING old.*, new.*;                                                         -- can't be pushed down
-                                                                  QUERY PLAN                                                                  
-----------------------------------------------------------------------------------------------------------------------------------------------
+                                                                       QUERY PLAN                                                                       
+--------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.c1, old.c2, old.c3, old.c4, old.c5, old.c6, old.c7, old.c8, new.c1, new.c2, new.c3, new.c4, new.c5, new.c6, new.c7, new.c8
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c3 = $3 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c3 = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan on public.ft2
-         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, ft2.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
+         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, tableoid, $0, ft2.*
+         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
 (6 rows)
 
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
@@ -5429,14 +5429,14 @@ DELETE FROM ft2 WHERE c1 % 10 = 5 RETURNING c1, c4;
 BEGIN;
   EXPLAIN (verbose, costs off)
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;               -- can't be pushed down
-                                                QUERY PLAN                                                 
------------------------------------------------------------------------------------------------------------
+                                                     QUERY PLAN                                                      
+---------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: old.c1, c4
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c4
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c4
    ->  Foreign Scan on public.ft2
-         Output: ctid
-         Remote SQL: SELECT ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
+         Output: ctid, tableoid, $0
+         Remote SQL: SELECT ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
 (6 rows)
 
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;
@@ -6400,27 +6400,27 @@ BEGIN;
     FROM ft4 INNER JOIN ft5 ON (ft4.c1 = ft5.c1)
     WHERE ft2.c1 > 1200 AND ft2.c2 = ft4.c1
     RETURNING old, new, ft2, ft2.*, ft4, ft4.*;  -- can't be pushed down
-                                                                                                                                                                                                                                     QUERY PLAN                                                                                                                                                                                                                                     
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                                                                                  QUERY PLAN                                                                                                                                                                                                                                                  
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.*, new.*, ft2.*, ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.*, ft4.c1, ft4.c2, ft4.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan
-         Output: 'bar'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+         Output: 'bar'::text, ft2.ctid, ft2.tableoid, ($0), ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3, r1.tableoid FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+               Output: ft2.ctid, ft2.tableoid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ($0)
                Join Filter: (ft4.c1 = ft5.c1)
                ->  Sort
-                     Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                     Output: ft2.ctid, ft2.tableoid, ft2.*, ($0), ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                      Sort Key: ft2.c2
                      ->  Hash Join
-                           Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                           Output: ft2.ctid, ft2.tableoid, ft2.*, ($0), ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                            Hash Cond: (ft2.c2 = ft4.c1)
                            ->  Foreign Scan on public.ft2
-                                 Output: ft2.ctid, ft2.*, ft2.c2
-                                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
+                                 Output: ft2.ctid, ft2.tableoid, ft2.*, $0, ft2.c2
+                                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
                            ->  Hash
                                  Output: ft4.*, ft4.c1, ft4.c2, ft4.c3
                                  ->  Foreign Scan on public.ft4
@@ -6498,13 +6498,13 @@ UPDATE ft2 AS target SET (c2, c7) = (
         FROM ft2 AS src
         WHERE target.c1 = src.c1
 ) WHERE c1 > 1100;
-                                                         QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
+                                                                    QUERY PLAN                                                                    
+--------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 target
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c7 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c7 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.ft2 target
-         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, target.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
+         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, target.tableoid, $3, target.*
+         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
          SubPlan multiexpr_1
            ->  Foreign Scan on public.ft2 src
                  Output: (src.c2 * 10), src.c7
@@ -6526,20 +6526,20 @@ UPDATE ft2 AS target SET (c2) = (
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE ft2 d SET c2 = CASE WHEN random() >= 0 THEN d.c2 ELSE 0 END
   FROM ft2 AS t WHERE d.c1 = t.c1 AND d.c1 > 1000;
-                                                                                                                                                                                       QUERY PLAN                                                                                                                                                                                        
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                                    QUERY PLAN                                                                                                                                                                                                     
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 d
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan
-         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, d.*, t.*
+         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, d.tableoid, ($0), d.*, t.*
          Relations: (public.ft2 d) INNER JOIN (public.ft2 t)
-         Remote SQL: SELECT r1.c2, r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.c2, r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END, r1.tableoid FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
          ->  Hash Join
-               Output: d.c2, d.ctid, d.*, t.*
+               Output: d.c2, d.ctid, d.tableoid, d.*, t.*, ($0)
                Hash Cond: (d.c1 = t.c1)
                ->  Foreign Scan on public.ft2 d
-                     Output: d.c2, d.ctid, d.*, d.c1
-                     Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
+                     Output: d.c2, d.ctid, d.tableoid, d.*, $0, d.c1
+                     Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
                ->  Hash
                      Output: t.*, t.c1
                      ->  Foreign Scan on public.ft2 t
@@ -6559,19 +6559,19 @@ EXPLAIN (verbose, costs off)
 WITH cte AS (
   UPDATE ft2 SET c3 = 'bar' WHERE postgres_fdw_abs(c1) > 2000 RETURNING *
 ) SELECT * FROM cte ORDER BY c1;          -- can't be pushed down
-                                                    QUERY PLAN                                                    
-------------------------------------------------------------------------------------------------------------------
+                                                             QUERY PLAN                                                             
+------------------------------------------------------------------------------------------------------------------------------------
  Sort
    Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
    Sort Key: cte.c1
    CTE cte
      ->  Update on public.ft2
            Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8
-           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
            ->  Foreign Scan on public.ft2
-                 Output: 'bar'::text, ft2.ctid, ft2.*
+                 Output: 'bar'::text, ft2.ctid, ft2.tableoid, $0, ft2.*
                  Filter: (postgres_fdw_abs(ft2.c1) > 2000)
-                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" FOR UPDATE
+                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" FOR UPDATE
    ->  CTE Scan on cte
          Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
 (13 rows)
@@ -6602,13 +6602,13 @@ UPDATE ft2 SET c3 = 'baz'
 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Nested Loop
-         Output: 'baz'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
+         Output: 'baz'::text, ft2.ctid, ft2.tableoid, ($0), ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
          Join Filter: (ft2.c2 === ft4.c1)
          ->  Foreign Scan on public.ft2
-               Output: ft2.ctid, ft2.*, ft2.c2
-               Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+               Output: ft2.ctid, ft2.tableoid, ft2.*, $0, ft2.c2
+               Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
          ->  Foreign Scan
                Output: ft4.*, ft4.c1, ft4.c2, ft4.c3, ft5.*, ft5.c1, ft5.c2, ft5.c3
                Relations: (public.ft4) INNER JOIN (public.ft5)
@@ -6640,24 +6640,24 @@ DELETE FROM ft2
   USING ft4 INNER JOIN ft5 ON (ft4.c1 === ft5.c1)
   WHERE ft2.c1 > 2000 AND ft2.c2 = ft4.c1
   RETURNING ft2.c1, ft2.c2, ft2.c3;       -- can't be pushed down
-                                                                                                                                                                     QUERY PLAN                                                                                                                                                                     
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                  QUERY PLAN                                                                                                                                                                                  
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c2, c3
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3
    ->  Foreign Scan
-         Output: ft2.ctid, ft4.*, ft5.*
+         Output: ft2.ctid, ft2.tableoid, ($0), ft4.*, ft5.*
          Filter: (ft4.c1 === ft5.c1)
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1, r1.tableoid FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft4.*, ft5.*, ft4.c1, ft5.c1
+               Output: ft2.ctid, ft2.tableoid, ft4.*, ft5.*, ft4.c1, ft5.c1, ($0)
                ->  Nested Loop
-                     Output: ft2.ctid, ft4.*, ft4.c1
+                     Output: ft2.ctid, ft2.tableoid, ($0), ft4.*, ft4.c1
                      Join Filter: (ft2.c2 = ft4.c1)
                      ->  Foreign Scan on public.ft2
-                           Output: ft2.ctid, ft2.c2
-                           Remote SQL: SELECT c2, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+                           Output: ft2.ctid, ft2.tableoid, $0, ft2.c2
+                           Remote SQL: SELECT c2, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
                      ->  Foreign Scan on public.ft4
                            Output: ft4.*, ft4.c1
                            Remote SQL: SELECT c1, c2, c3 FROM "S 1"."T 3"
@@ -7169,19 +7169,19 @@ SET enable_hashjoin TO false;
 SET enable_material TO false;
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
-                                   QUERY PLAN                                   
---------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.remt2
    Output: remt2.c1, remt2.c2
-   Remote SQL: UPDATE public.loct2 SET c2 = $2 WHERE ctid = $1 RETURNING c1, c2
+   Remote SQL: UPDATE public.loct2 SET c2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING c1, c2
    ->  Nested Loop
-         Output: (remt2.c2 || remt2.c2), remt2.ctid, remt2.*, loct1.ctid
+         Output: (remt2.c2 || remt2.c2), remt2.ctid, remt2.tableoid, ($0), remt2.*, loct1.ctid
          Join Filter: (remt2.c1 = loct1.c1)
          ->  Seq Scan on public.loct1
                Output: loct1.ctid, loct1.c1
          ->  Foreign Scan on public.remt2
-               Output: remt2.c2, remt2.ctid, remt2.*, remt2.c1
-               Remote SQL: SELECT c1, c2, ctid FROM public.loct2 FOR UPDATE
+               Output: remt2.c2, remt2.ctid, remt2.tableoid, remt2.*, $0, remt2.c1
+               Remote SQL: SELECT c1, c2, ctid, tableoid FROM public.loct2 FOR UPDATE
 (11 rows)
 
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
@@ -7342,13 +7342,13 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, foreign_tbl.tableoid, $0, foreign_tbl.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -7356,13 +7356,13 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, foreign_tbl.tableoid, $0, foreign_tbl.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -7455,14 +7455,14 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                         
+---------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -7470,14 +7470,14 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -7526,14 +7526,14 @@ CREATE VIEW rw_view AS SELECT * FROM parent_tbl WHERE a < 5 WITH CHECK OPTION;
 INSERT INTO parent_tbl (a) VALUES(1),(5);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = 'text', c = 123.456;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.child_foreign parent_tbl_1
-     Remote SQL: UPDATE public.child_local SET b = $2, c = $3 WHERE ctid = $1 RETURNING a
+     Remote SQL: UPDATE public.child_local SET b = $3, c = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING a
    ->  Foreign Scan on public.child_foreign parent_tbl_1
-         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT b, c, a, ctid FROM public.child_local WHERE ((a < 5)) FOR UPDATE
+         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT b, c, a, ctid, tableoid FROM public.child_local WHERE ((a < 5)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = 'text', c = 123.456;
@@ -7612,13 +7612,13 @@ insert into grem1 (a) values (1), (2);
 insert into grem1 (a) values (1), (2);
 explain (verbose, costs off)
 update grem1 set a = 22 where a = 2;
-                                       QUERY PLAN                                       
-----------------------------------------------------------------------------------------
+                                                QUERY PLAN                                                
+----------------------------------------------------------------------------------------------------------
  Update on public.grem1
-   Remote SQL: UPDATE public.gloc1 SET a = $2, b = DEFAULT, c = DEFAULT WHERE ctid = $1
+   Remote SQL: UPDATE public.gloc1 SET a = $3, b = DEFAULT, c = DEFAULT WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.grem1
-         Output: 22, ctid, grem1.*
-         Remote SQL: SELECT a, b, c, ctid FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
+         Output: 22, ctid, tableoid, $0, grem1.*
+         Remote SQL: SELECT a, b, c, ctid, tableoid FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
 (5 rows)
 
 update grem1 set a = 22 where a = 2;
@@ -7945,13 +7945,13 @@ SELECT * from loc1;
 
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f1 = 10;          -- all columns should be transmitted
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: 10, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: 10, ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 UPDATE rem1 set f1 = 10;
@@ -8093,12 +8093,12 @@ DELETE FROM rem1;                 -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1 WHERE false;     -- currently can't be pushed down
-                      QUERY PLAN                       
--------------------------------------------------------
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: ctid
+         Output: ctid, tableoid, $0
          Replaces: Scan on rem1
          One-Time Filter: false
 (6 rows)
@@ -8199,13 +8199,13 @@ BEFORE UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -8223,13 +8223,13 @@ AFTER UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                                  QUERY PLAN                                   
--------------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f2 = $2 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: UPDATE public.loc1 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -8257,13 +8257,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                             QUERY PLAN                              
----------------------------------------------------------------------
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_before_delete ON rem1;
@@ -8281,13 +8281,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_after_delete ON rem1;
@@ -8324,28 +8324,28 @@ CONTEXT:  COPY parent_tbl, line 1: "AAA	42"
 ALTER SERVER loopback OPTIONS (DROP batch_size);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                         
+---------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                            QUERY PLAN                            
-------------------------------------------------------------------
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Foreign Delete on public.foreign_tbl parent_tbl_1
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
-         Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
+         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0
+         Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 DELETE FROM parent_tbl;
@@ -8363,39 +8363,41 @@ CREATE TRIGGER parent_tbl_delete_trig
   FOR EACH STATEMENT EXECUTE PROCEDURE trigger_func();
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                              QUERY PLAN                                              
-------------------------------------------------------------------------------------------------------
+                                                           QUERY PLAN                                                            
+---------------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Update on public.parent_tbl parent_tbl_1
    Foreign Update on public.foreign_tbl parent_tbl_2
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::record)
+         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::oid), $0, (NULL::record)
          ->  Append
                ->  Seq Scan on public.parent_tbl parent_tbl_1
-                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::record
+                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.tableoid, parent_tbl_2.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.local_tbl FOR UPDATE
 (12 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Delete on public.parent_tbl parent_tbl_1
    Foreign Delete on public.foreign_tbl parent_tbl_2
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
-   ->  Append
-         ->  Seq Scan on public.parent_tbl parent_tbl_1
-               Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
-         ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-               Output: parent_tbl_2.tableoid, parent_tbl_2.ctid
-               Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
-(10 rows)
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
+   ->  Result
+         Output: parent_tbl.tableoid, parent_tbl.ctid, (NULL::oid), $0
+         ->  Append
+               ->  Seq Scan on public.parent_tbl parent_tbl_1
+                     Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid
+               ->  Foreign Scan on public.foreign_tbl parent_tbl_2
+                     Output: parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.tableoid, $0
+                     Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
+(12 rows)
 
 DELETE FROM parent_tbl;
 ERROR:  cannot collect transition tuples from child foreign tables
@@ -8737,22 +8739,22 @@ drop table foo2child;
 -- Check UPDATE with inherited target and an inherited source table
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 where f1 in (select f1 from foo);
-                                              QUERY PLAN                                               
--------------------------------------------------------------------------------------------------------
+                                                       QUERY PLAN                                                       
+------------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Hash Join
-         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::record), foo.*, foo.tableoid
+         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record), foo.*, foo.tableoid
          Inner Unique: true
          Hash Cond: (bar.f1 = foo.f1)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                     Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 FOR UPDATE
          ->  Hash
                Output: foo.ctid, foo.f1, foo.*, foo.tableoid
                ->  HashAggregate
@@ -8784,24 +8786,24 @@ update bar set f2 = f2 + 100
 from
   ( select f1 from foo union all select f1+3 from foo ) ss
 where bar.f1 = ss.f1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                  QUERY PLAN                                                   
+---------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Merge Join
-         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
          Merge Cond: (bar.f1 = foo.f1)
          ->  Sort
-               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::record)
+               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
                Sort Key: bar.f1
                ->  Append
                      ->  Seq Scan on public.bar bar_1
-                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                      ->  Foreign Scan on public.bar2 bar_2
-                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                           Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                           Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 FOR UPDATE
          ->  Sort
                Output: (ROW(foo.f1)), foo.f1
                Sort Key: foo.f1
@@ -8942,19 +8944,21 @@ ERROR:  WHERE CURRENT OF is not supported for this table type
 rollback;
 explain (verbose, costs off)
 delete from foo where f1 < 5 returning *;
-                                      QUERY PLAN                                      
---------------------------------------------------------------------------------------
+                                         QUERY PLAN                                         
+--------------------------------------------------------------------------------------------
  Delete on public.foo
    Output: foo_1.f1, foo_1.f2
    Delete on public.foo foo_1
    Foreign Delete on public.foo2 foo_2
-   ->  Append
-         ->  Index Scan using i_foo_f1 on public.foo foo_1
-               Output: foo_1.tableoid, foo_1.ctid
-               Index Cond: (foo_1.f1 < 5)
-         ->  Foreign Delete on public.foo2 foo_2
-               Remote SQL: DELETE FROM public.loct1 WHERE ((f1 < 5)) RETURNING f1, f2
-(10 rows)
+   ->  Result
+         Output: foo.tableoid, foo.ctid, (NULL::oid), $0
+         ->  Append
+               ->  Index Scan using i_foo_f1 on public.foo foo_1
+                     Output: foo_1.tableoid, foo_1.ctid, NULL::oid
+                     Index Cond: (foo_1.f1 < 5)
+               ->  Foreign Delete on public.foo2 foo_2
+                     Remote SQL: DELETE FROM public.loct1 WHERE ((f1 < 5)) RETURNING f1, f2
+(12 rows)
 
 delete from foo where f1 < 5 returning *;
  f1 | f2 
@@ -8968,17 +8972,17 @@ delete from foo where f1 < 5 returning *;
 
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 returning *;
-                                        QUERY PLAN                                        
-------------------------------------------------------------------------------------------
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
  Update on public.bar
    Output: bar_1.f1, bar_1.f2
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Update on public.bar2 bar_2
                      Remote SQL: UPDATE public.loct2 SET f2 = (f2 + 100) RETURNING f1, f2
 (11 rows)
@@ -9003,20 +9007,20 @@ AFTER UPDATE OR DELETE ON bar2
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 explain (verbose, costs off)
 update bar set f2 = f2 + 100;
-                                               QUERY PLAN                                               
---------------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                        
+--------------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f1 = $2, f2 = $3, f3 = $4 WHERE ctid = $1 RETURNING f1, f2, f3
+     Remote SQL: UPDATE public.loct2 SET f1 = $3, f2 = $4, f3 = $5 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                     Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 FOR UPDATE
 (12 rows)
 
 update bar set f2 = f2 + 100;
@@ -9034,20 +9038,22 @@ NOTICE:  trig_row_after(23, skidoo) AFTER ROW UPDATE ON bar2
 NOTICE:  OLD: (7,277,77),NEW: (7,377,77)
 explain (verbose, costs off)
 delete from bar where f2 < 400;
-                                            QUERY PLAN                                             
----------------------------------------------------------------------------------------------------
+                                                    QUERY PLAN                                                     
+-------------------------------------------------------------------------------------------------------------------
  Delete on public.bar
    Delete on public.bar bar_1
    Foreign Delete on public.bar2 bar_2
-     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 RETURNING f1, f2, f3
-   ->  Append
-         ->  Seq Scan on public.bar bar_1
-               Output: bar_1.tableoid, bar_1.ctid, NULL::record
-               Filter: (bar_1.f2 < 400)
-         ->  Foreign Scan on public.bar2 bar_2
-               Output: bar_2.tableoid, bar_2.ctid, bar_2.*
-               Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
-(11 rows)
+     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
+   ->  Result
+         Output: bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
+         ->  Append
+               ->  Seq Scan on public.bar bar_1
+                     Output: bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
+                     Filter: (bar_1.f2 < 400)
+               ->  Foreign Scan on public.bar2 bar_2
+                     Output: bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                     Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
+(13 rows)
 
 delete from bar where f2 < 400;
 NOTICE:  trig_row_before(23, skidoo) BEFORE ROW DELETE ON bar2
@@ -9078,22 +9084,22 @@ analyze remt1;
 analyze remt2;
 explain (verbose, costs off)
 update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a returning *;
-                                                   QUERY PLAN                                                   
-----------------------------------------------------------------------------------------------------------------
+                                                           QUERY PLAN                                                            
+---------------------------------------------------------------------------------------------------------------------------------
  Update on public.parent
    Output: parent_1.a, parent_1.b, remt2.a, remt2.b
    Update on public.parent parent_1
    Foreign Update on public.remt1 parent_2
-     Remote SQL: UPDATE public.loct1 SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct1 SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::record)
+         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::oid), $0, (NULL::record)
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::record
+                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.tableoid, parent_2.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.b, remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -9110,22 +9116,22 @@ update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a re
 
 explain (verbose, costs off)
 delete from parent using remt2 where parent.a = remt2.a returning parent;
-                                 QUERY PLAN                                  
------------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Delete on public.parent
    Output: parent_1.*
    Delete on public.parent parent_1
    Foreign Delete on public.remt1 parent_2
-     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: remt2.*, parent.tableoid, parent.ctid
+         Output: remt2.*, parent.tableoid, parent.ctid, (NULL::oid), $0
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid
+                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid
-                     Remote SQL: SELECT a, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.tableoid, $0
+                     Remote SQL: SELECT a, ctid, tableoid FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -9355,7 +9361,7 @@ update utrtest set a = 1 where a = 1 or a = 2 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 WHERE (((a = 1) OR (a = 2))) RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, $0, NULL::record
                Filter: ((utrtest_2.a = 1) OR (utrtest_2.a = 2))
 (10 rows)
 
@@ -9394,8 +9400,8 @@ insert into utrtest values (2, 'qux');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Foreign Update on public.remp utrtest_1
@@ -9404,7 +9410,7 @@ update utrtest set a = 1 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, $0, NULL::record
 (9 rows)
 
 update utrtest set a = 1 returning *;
@@ -9415,22 +9421,22 @@ insert into utrtest values (2, 'qux');
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 from (values (1), (2)) s(x) where a = s.x returning *;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Foreign Update on public.remp utrtest_1
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    Update on public.locp utrtest_2
    ->  Hash Join
-         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, utrtest.*
+         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, utrtest.tableoid, $0, utrtest.*
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Foreign Scan on public.remp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, utrtest_1.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, utrtest_1.tableoid, utrtest_1.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.loct FOR UPDATE
                ->  Seq Scan on public.locp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, NULL::record
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -9454,15 +9460,15 @@ insert into utrtest values (3, 'xyzzy');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
    ->  Append
          ->  Seq Scan on public.locp utrtest_1
-               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, $0, NULL::record
          ->  Foreign Update on public.remp utrtest_2
                Remote SQL: UPDATE public.loct SET a = 3 RETURNING a, b
 (9 rows)
@@ -9472,22 +9478,22 @@ ERROR:  cannot route tuples into foreign table to be updated "remp"
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 from (values (2), (3)) s(x) where a = s.x returning *;
-                                             QUERY PLAN                                              
------------------------------------------------------------------------------------------------------
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Hash Join
-         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::record)
+         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::oid), $0, (NULL::record)
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Seq Scan on public.locp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, utrtest_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, utrtest_2.tableoid, utrtest_2.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.loct FOR UPDATE
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -12374,8 +12380,8 @@ RESET enable_hashjoin;
 -- Test that UPDATE/DELETE with inherited target works with async_capable enabled
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
-                                                QUERY PLAN                                                
-----------------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                          
+-----------------------------------------------------------------------------------------------------------------------------
  Update on public.async_pt
    Output: async_pt_1.a, async_pt_1.b, async_pt_1.c
    Foreign Update on public.async_p1 async_pt_1
@@ -12387,7 +12393,7 @@ UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
          ->  Foreign Update on public.async_p2 async_pt_2
                Remote SQL: UPDATE public.base_tbl2 SET c = (c || c) WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::record
+               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::oid, $0, NULL::record, $1
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -12414,7 +12420,7 @@ DELETE FROM async_pt WHERE b = 0 RETURNING *;
          ->  Foreign Delete on public.async_p2 async_pt_2
                Remote SQL: DELETE FROM public.base_tbl2 WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: async_pt_3.tableoid, async_pt_3.ctid
+               Output: async_pt_3.tableoid, async_pt_3.ctid, NULL::oid, $0, $1
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -12989,6 +12995,52 @@ SELECT server_name,
 -- Clean up
 \set VERBOSITY default
 RESET debug_discard_caches;
+-- ===================================================================
+-- check whether fdw created for partitioned table will delete tuples only from
+-- desired partition
+-- ===================================================================
+CREATE TABLE measurement (
+       city_id         int not null,
+       logdate         date not null,
+       peaktemp        int,
+       unitsales       int
+) PARTITION BY RANGE (logdate);
+CREATE TABLE measurement_y2006m02 PARTITION OF measurement
+       FOR VALUES FROM ('2006-02-01') TO ('2006-03-01');
+CREATE TABLE measurement_y2006m03 PARTITION OF measurement
+       FOR VALUES FROM ('2006-03-01') TO ('2006-04-01');
+CREATE TABLE measurement_y2006m04 PARTITION OF measurement
+       FOR VALUES FROM ('2006-04-01') TO ('2006-05-01');
+INSERT INTO measurement VALUES (1,'2006-02-01',1,1);
+INSERT INTO measurement VALUES (2,'2006-03-01',1,1);
+INSERT INTO measurement VALUES (3,'2006-04-01',1,1);
+create foreign table measurement_fdw (
+       city_id int options (column_name 'city_id') not null,
+       logdate date options (column_name 'logdate') not null,
+       peaktemp text options (column_name 'peaktemp'),
+       unitsales integer options (column_name 'unitsales')
+) SERVER loopback OPTIONS (table_name 'measurement');
+DELETE FROM measurement_fdw
+USING (
+       SELECT t1.city_id sub_city_id
+       FROM measurement_fdw t1
+       WHERE t1.city_id=1
+       LIMIT 1000
+) sub
+WHERE measurement_fdw.city_id = sub.sub_city_id
+RETURNING city_id, logdate, peaktemp, unitsales;
+ city_id |  logdate   | peaktemp | unitsales 
+---------+------------+----------+-----------
+       1 | 02-01-2006 | 1        |         1
+(1 row)
+
+SELECT * FROM measurement_fdw;
+ city_id |  logdate   | peaktemp | unitsales 
+---------+------------+----------+-----------
+       2 | 03-01-2006 | 1        |         1
+       3 | 04-01-2006 | 1        |         1
+(2 rows)
+
 -- ===================================================================
 -- test cleanup of failed connections on abort
 -- ===================================================================
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 85be46eb2a2..8e8d91f6940 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -22,6 +22,7 @@
 #include "commands/explain_format.h"
 #include "commands/explain_state.h"
 #include "commands/vacuum.h"
+#include "common/hashfn.h"
 #include "executor/execAsync.h"
 #include "executor/instrument.h"
 #include "executor/spi.h"
@@ -80,6 +81,8 @@ enum FdwScanPrivateIndex
 	FdwScanPrivateSelectSql,
 	/* Integer list of attribute numbers retrieved by the SELECT */
 	FdwScanPrivateRetrievedAttrs,
+	/* Param ID for remote table OID for target rel (-1 if none) */
+	FdwScanPrivateTableOidParamId,
 	/* Integer representing the desired fetch_size */
 	FdwScanPrivateFetchSize,
 
@@ -178,6 +181,10 @@ typedef struct PgFdwScanState
 	MemoryContext temp_cxt;		/* context for per-tuple temporary data */
 
 	int			fetch_size;		/* number of tuples per fetch */
+
+	int			tableoid_param_id;	/* Param ID for remote table OID */
+	bool		set_tableoid_param; /* Do we need to set the Param? */
+	Oid			remote_tableoid;
 } PgFdwScanState;
 
 /*
@@ -204,6 +211,7 @@ typedef struct PgFdwModifyState
 
 	/* info about parameters for prepared statement */
 	AttrNumber	ctidAttno;		/* attnum of input resjunk ctid column */
+	AttrNumber	tableoidAttno;	/* attnum of input resjunk tableoid column */
 	int			p_nums;			/* number of parameters to transmit */
 	FmgrInfo   *p_flinfo;		/* output conversion functions for them */
 
@@ -304,6 +312,35 @@ typedef struct
 	int64		offset_est;
 } PgFdwPathExtraData;
 
+typedef struct OidMappingEntry
+{
+	Oid		local_oid;	/* key */
+	Oid		remote_oid; /* value */
+	char	status;		/* hash entry status */
+} OidMappingEntry;
+
+#define SH_PREFIX oidmapping
+#define SH_ELEMENT_TYPE OidMappingEntry
+#define SH_KEY_TYPE Oid
+#define SH_KEY local_oid
+#define SH_HASH_KEY(tb, key) murmurhash32(key)
+#define SH_EQUAL(tb, a, b) a == b
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+typedef struct PgFdwRemoteMap
+{
+	oidmapping_hash	*oid_mapping;
+	MemoryContext	 hash_cxt;
+	uint32			 refcount;
+} PgFdwRemoteMap;
+
+static PgFdwRemoteMap *remoteMap;
+
+#define		N_INITIAL_OIDMAPPING_ELEMS	50
+
 /*
  * Identify the attribute where data conversion fails.
  */
@@ -502,6 +539,85 @@ static const Oid attclear_argtypes[ATTCLEAR_SQL_NUM_FIELDS] =
  */
 PG_FUNCTION_INFO_V1(postgres_fdw_handler);
 
+/* Private cache */
+static void
+init_remote_map(void)
+{
+	MemoryContext	remoteMapCxt;
+
+	Assert(remoteMap == NULL);
+
+	remoteMapCxt = AllocSetContextCreate(CacheMemoryContext,
+										 "PgFdwRemoteMap",
+										 ALLOCSET_SMALL_SIZES);
+
+	remoteMap = (PgFdwRemoteMap *)
+		MemoryContextAllocZero(remoteMapCxt, sizeof(PgFdwRemoteMap));
+
+	remoteMap->hash_cxt = remoteMapCxt;
+	remoteMap->oid_mapping = oidmapping_create(remoteMap->hash_cxt,
+											   N_INITIAL_OIDMAPPING_ELEMS,
+											   NULL);
+}
+
+static void
+insert_oid_pair(Oid local_oid, Oid remote_oid)
+{
+	bool			 found;
+	OidMappingEntry	*entry;
+	MemoryContext	 tmp;
+
+	Assert(OidIsValid(local_oid) && OidIsValid(remote_oid));
+
+	if (remoteMap == NULL)
+		init_remote_map();
+
+	tmp = MemoryContextSwitchTo(remoteMap->hash_cxt);
+
+	/* This mapping may already present in the hashtable. */
+	entry = oidmapping_insert(remoteMap->oid_mapping, local_oid, &found);
+
+	if (!found)
+	{
+		remoteMap->refcount++;
+		entry->remote_oid = remote_oid;
+	}
+
+	MemoryContextSwitchTo(tmp);
+}
+
+static Oid
+find_remote_oid(Oid local_oid)
+{
+	OidMappingEntry *entry;
+
+	Assert(OidIsValid(local_oid));
+
+	if (remoteMap == NULL)
+		return InvalidOid;
+
+	entry = oidmapping_lookup(remoteMap->oid_mapping, local_oid);
+
+	return ((entry != NULL) ? entry->remote_oid : InvalidOid);
+}
+
+static void
+destroy_remote_map(void)
+{
+	if (remoteMap == NULL)
+		return;
+
+	Assert(remoteMap->refcount > 0);
+	remoteMap->refcount--;
+
+	if (remoteMap->refcount == 0)
+	{
+		oidmapping_destroy(remoteMap->oid_mapping);
+		MemoryContextDelete(remoteMap->hash_cxt);
+		remoteMap = NULL;
+	}
+}
+
 /*
  * FDW callback routines
  */
@@ -656,6 +772,7 @@ static TupleTableSlot **execute_foreign_modify(EState *estate,
 static void prepare_foreign_modify(PgFdwModifyState *fmstate);
 static const char **convert_prep_stmt_params(PgFdwModifyState *fmstate,
 											 ItemPointer tupleid,
+											 Oid tableoid,
 											 TupleTableSlot **slots,
 											 int numSlots);
 static void store_returning_result(PgFdwModifyState *fmstate,
@@ -1002,6 +1119,23 @@ postgresGetForeignRelSize(PlannerInfo *root,
 	fpinfo->hidden_subquery_rels = NULL;
 	/* Set the relation index. */
 	fpinfo->relation_index = baserel->relid;
+	fpinfo->tableoid_param = NULL;
+
+	/*
+	 * If the table is an UPDATE/DELETE target, the table's reltarget would
+	 * have contained a Param representing the remote table OID of the target;
+	 * get the Param and save a copy of it in fpinfo for use later.
+	 */
+	foreach(lc, baserel->reltarget->exprs)
+	{
+		Param	   *param = (Param *) lfirst(lc);
+		if (IsA(param, Param))
+		{
+			Assert(IS_FOREIGN_PARAM(root, param));
+			fpinfo->tableoid_param = (Param *) copyObject(param);
+			break;
+		}
+	}
 }
 
 /*
@@ -1463,6 +1597,7 @@ postgresGetForeignPlan(PlannerInfo *root,
 	bool		has_final_sort = false;
 	bool		has_limit = false;
 	ListCell   *lc;
+	int			tableoid_param_id = -1;
 
 	/*
 	 * Get FDW private data created by postgresGetForeignUpperPaths(), if any.
@@ -1627,12 +1762,16 @@ postgresGetForeignPlan(PlannerInfo *root,
 	/* Remember remote_exprs for possible use by postgresPlanDirectModify */
 	fpinfo->final_remote_exprs = remote_exprs;
 
+	if (fpinfo->tableoid_param)
+		tableoid_param_id = fpinfo->tableoid_param->paramid;
+
 	/*
 	 * Build the fdw_private list that will be available to the executor.
 	 * Items in the list must match order in enum FdwScanPrivateIndex.
 	 */
-	fdw_private = list_make3(makeString(sql.data),
+	fdw_private = list_make4(makeString(sql.data),
 							 retrieved_attrs,
+							 makeInteger(tableoid_param_id),
 							 makeInteger(fpinfo->fetch_size));
 	if (IS_JOIN_REL(foreignrel) || IS_UPPER_REL(foreignrel))
 		fdw_private = lappend(fdw_private,
@@ -1765,6 +1904,8 @@ postgresBeginForeignScan(ForeignScanState *node, int eflags)
 									 FdwScanPrivateSelectSql));
 	fsstate->retrieved_attrs = (List *) list_nth(fsplan->fdw_private,
 												 FdwScanPrivateRetrievedAttrs);
+	fsstate->tableoid_param_id = intVal(list_nth(fsplan->fdw_private,
+										FdwScanPrivateTableOidParamId));
 	fsstate->fetch_size = intVal(list_nth(fsplan->fdw_private,
 										  FdwScanPrivateFetchSize));
 
@@ -1784,11 +1925,13 @@ postgresBeginForeignScan(ForeignScanState *node, int eflags)
 	{
 		fsstate->rel = node->ss.ss_currentRelation;
 		fsstate->tupdesc = RelationGetDescr(fsstate->rel);
+		fsstate->set_tableoid_param = (fsstate->tableoid_param_id >= 0);
 	}
 	else
 	{
 		fsstate->rel = NULL;
 		fsstate->tupdesc = get_tupdesc_for_join_scan_tuples(node);
+		fsstate->set_tableoid_param = false;
 	}
 
 	fsstate->attinmeta = TupleDescGetAttInMetadata(fsstate->tupdesc);
@@ -1820,6 +1963,7 @@ postgresIterateForeignScan(ForeignScanState *node)
 {
 	PgFdwScanState *fsstate = (PgFdwScanState *) node->fdw_state;
 	TupleTableSlot *slot = node->ss.ss_ScanTupleSlot;
+	HeapTuple		tuple;
 
 	/*
 	 * In sync mode, if this is the first call after Begin or ReScan, we need
@@ -1846,12 +1990,23 @@ postgresIterateForeignScan(ForeignScanState *node)
 			return ExecClearTuple(slot);
 	}
 
+	tuple = fsstate->tuples[fsstate->next_tuple++];
+
+	if (fsstate->set_tableoid_param)
+	{
+		ExprContext *econtext = node->ss.ps.ps_ExprContext;
+		ParamExecData *prm = &(econtext->ecxt_param_exec_vals[fsstate->tableoid_param_id]);
+
+		prm->execPlan = NULL;
+		prm->value = ObjectIdGetDatum(tuple->t_tableOid);
+		prm->isnull = false;
+
+		insert_oid_pair(RelationGetRelid(fsstate->rel), tuple->t_tableOid);
+	}
 	/*
 	 * Return the next tuple.
 	 */
-	ExecStoreHeapTuple(fsstate->tuples[fsstate->next_tuple++],
-					   slot,
-					   false);
+	ExecStoreHeapTuple(tuple, slot, false);
 
 	return slot;
 }
@@ -1966,6 +2121,9 @@ postgresAddForeignUpdateTargets(PlannerInfo *root,
 								Relation target_relation)
 {
 	Var		   *var;
+	Param	   *param;
+	const char *attrname;
+	TargetEntry *tle;
 
 	/*
 	 * In postgres_fdw, what we need is the ctid, same as for a regular table.
@@ -1981,6 +2139,38 @@ postgresAddForeignUpdateTargets(PlannerInfo *root,
 
 	/* Register it as a row-identity column needed by this target rel */
 	add_row_identity_var(root, var, rtindex, "ctid");
+
+	/* Make a Var representing the desired value */
+	var = makeVar(rtindex,
+		TableOidAttributeNumber,
+		OIDOID,
+		-1,
+		InvalidOid,
+		0);
+	/* Register it as a row-identity column needed by this target rel */
+	add_row_identity_var(root, var, rtindex, "remote_tableoid");
+
+	/* Make a Param representing the tableoid value */
+	param = makeNode(Param);
+	param->paramkind = PARAM_EXEC;
+	param->paramtype = OIDOID;
+	param->paramtypmod = -1;
+	param->paramcollid = InvalidOid;
+	param->location = -1;
+	/* paramid will be filled in by fix_foreign_params */
+	param->paramid = -1;
+	param->target_rte = rtindex;
+
+	/* Wrap it in a resjunk TLE with the right name ... */
+	attrname = "remote_tableoid";
+
+	tle = makeTargetEntry((Expr *) param,
+						  list_length(root->processed_tlist) + 1,
+						  pstrdup(attrname),
+						  true);
+
+	/* ... and add it to the query's targetlist */
+	root->processed_tlist = lappend(root->processed_tlist, tle);
 }
 
 /*
@@ -2343,6 +2533,7 @@ postgresExecForeignDelete(EState *estate,
 	rslot = execute_foreign_modify(estate, resultRelInfo, CMD_DELETE,
 								   &slot, &planSlot, &numSlots);
 
+	destroy_remote_map();
 	return rslot ? rslot[0] : NULL;
 }
 
@@ -4228,7 +4419,7 @@ create_foreign_modify(EState *estate,
 		fmstate->attinmeta = TupleDescGetAttInMetadata(tupdesc);
 
 	/* Prepare for output conversion of parameters used in prepared stmt. */
-	n_params = list_length(fmstate->target_attrs) + 1;
+	n_params = list_length(fmstate->target_attrs) + 2;
 	fmstate->p_flinfo = palloc0_array(FmgrInfo, n_params);
 	fmstate->p_nums = 0;
 
@@ -4246,6 +4437,20 @@ create_foreign_modify(EState *estate,
 		getTypeOutputInfo(TIDOID, &typefnoid, &isvarlena);
 		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
 		fmstate->p_nums++;
+
+		/* Find the tableoid resjunk column in the subplan's result */
+		fmstate->tableoidAttno = ExecFindJunkAttributeInTlist(subplan->targetlist,
+															  "remote_tableoid");
+
+		if (!AttributeNumberIsValid(fmstate->tableoidAttno))
+			ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("could not find junk tableoid column")));
+
+		/* Second transmittable parameter will be tableoid */
+		getTypeOutputInfo(OIDOID, &typefnoid, &isvarlena);
+		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
+		fmstate->p_nums++;
 	}
 
 	if (operation == CMD_INSERT || operation == CMD_UPDATE)
@@ -4298,6 +4503,7 @@ execute_foreign_modify(EState *estate,
 {
 	PgFdwModifyState *fmstate = (PgFdwModifyState *) resultRelInfo->ri_FdwState;
 	ItemPointer ctid = NULL;
+	Oid			tableoid = InvalidOid;
 	const char **p_values;
 	PGresult   *res;
 	int			n_rows;
@@ -4343,7 +4549,9 @@ execute_foreign_modify(EState *estate,
 	if (operation == CMD_UPDATE || operation == CMD_DELETE)
 	{
 		Datum		datum;
+		Datum		datum2;
 		bool		isNull;
+		Oid remote_tableoid = InvalidOid;
 
 		datum = ExecGetJunkAttribute(planSlots[0],
 									 fmstate->ctidAttno,
@@ -4352,10 +4560,28 @@ execute_foreign_modify(EState *estate,
 		if (isNull)
 			elog(ERROR, "ctid is NULL");
 		ctid = (ItemPointer) DatumGetPointer(datum);
+
+		/* Get the tableoid that was passed up as a resjunk column */
+		datum2 = ExecGetJunkAttribute(planSlots[0],
+									  fmstate->tableoidAttno,
+									  &isNull);
+		/* shouldn't ever get a null result... */
+		if (isNull)
+			ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("tableoid is NULL")));
+
+		tableoid = DatumGetObjectId(datum2);
+
+		/* Remote OID */
+		remote_tableoid = find_remote_oid(tableoid);
+
+		if(OidIsValid(remote_tableoid))
+			tableoid = remote_tableoid;
 	}
 
 	/* Convert parameters needed by prepared statement to text form */
-	p_values = convert_prep_stmt_params(fmstate, ctid, slots, *numSlots);
+	p_values = convert_prep_stmt_params(fmstate, ctid, tableoid, slots, *numSlots);
 
 	/*
 	 * Execute the prepared statement.
@@ -4460,6 +4686,7 @@ prepare_foreign_modify(PgFdwModifyState *fmstate)
 static const char **
 convert_prep_stmt_params(PgFdwModifyState *fmstate,
 						 ItemPointer tupleid,
+						 Oid tableoid,
 						 TupleTableSlot **slots,
 						 int numSlots)
 {
@@ -4486,6 +4713,16 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 		pindex++;
 	}
 
+	/* 2nd parameter should be tableoid, if it's in use */
+	if (OidIsValid(tableoid))
+	{
+		Assert(tupleid != NULL);
+		/* don't need set_transmission_modes for OID output */
+		p_values[pindex] = OutputFunctionCall(&fmstate->p_flinfo[pindex],
+											  ObjectIdGetDatum(tableoid));
+		pindex++;
+	}
+
 	/* get following parameters from slots */
 	if (slots != NULL && fmstate->target_attrs != NIL)
 	{
@@ -4497,7 +4734,7 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 
 		for (i = 0; i < numSlots; i++)
 		{
-			j = (tupleid != NULL) ? 1 : 0;
+			j = (tupleid != NULL) ? 2 : 0;
 			foreach(lc, fmstate->target_attrs)
 			{
 				int			attnum = lfirst_int(lc);
@@ -4562,9 +4799,10 @@ finish_foreign_modify(PgFdwModifyState *fmstate)
 {
 	Assert(fmstate != NULL);
 
+	destroy_remote_map();
+
 	/* If we created a prepared statement, destroy it */
 	deallocate_query(fmstate);
-
 	/* Release remote connection */
 	ReleaseConnection(fmstate->conn);
 	fmstate->conn = NULL;
@@ -4670,7 +4908,8 @@ build_remote_returning(Index rtindex, Relation rel, List *returningList)
 		if (IsA(var, Var) &&
 			var->varno == rtindex &&
 			var->varattno <= InvalidAttrNumber &&
-			var->varattno != SelfItemPointerAttributeNumber)
+			var->varattno != SelfItemPointerAttributeNumber &&
+			var->varattno != TableOidAttributeNumber)
 			continue;			/* don't need it */
 
 		if (tlist_member((Expr *) var, tlist))
@@ -4876,6 +5115,17 @@ init_returning_filter(PgFdwDirectModifyState *dmstate,
 		TargetEntry *tle = (TargetEntry *) lfirst(lc);
 		Var		   *var = (Var *) tle->expr;
 
+		/*
+		 * No need to set the Param for the remote table OID; ignore it.
+		 */
+		if (IsA(var, Param))
+		{
+			/* We would not retrieve the remote table OID anymore. */
+			Assert(!list_member_int(dmstate->retrieved_attrs, i));
+			i++;
+			continue;
+		}
+
 		Assert(IsA(var, Var));
 
 		/*
@@ -4894,6 +5144,8 @@ init_returning_filter(PgFdwDirectModifyState *dmstate,
 				 */
 				if (attrno == SelfItemPointerAttributeNumber)
 					dmstate->ctidAttno = i;
+				else if (attrno == TableOidAttributeNumber)
+					dmstate->oidAttno = i;
 				else
 					Assert(false);
 				dmstate->hasSystemCols = true;
@@ -4985,10 +5237,13 @@ apply_returning_filter(PgFdwDirectModifyState *dmstate,
 		/* ctid */
 		if (dmstate->ctidAttno)
 		{
+			Oid tableoid = InvalidOid;
 			ItemPointer ctid = NULL;
 
 			ctid = (ItemPointer) DatumGetPointer(old_values[dmstate->ctidAttno - 1]);
+			tableoid = DatumGetObjectId(old_values[dmstate->oidAttno - 1]);
 			resultTup->t_self = *ctid;
+			resultTup->t_tableOid = tableoid;
 		}
 
 		/*
@@ -6901,6 +7156,38 @@ foreign_join_ok(PlannerInfo *root, RelOptInfo *joinrel, JoinType jointype,
 	/* Mark that this join can be pushed down safely */
 	fpinfo->pushdown_safe = true;
 
+	/*
+	 * If the join relation contains an UPDATE/DELETE target, either of the
+	 * input relations would have saved the Param representing the remote
+	 * table OID of the target; get the Param and remember it in fpinfo for
+	 * use later.
+	 */
+	if ((root->parse->commandType == CMD_UPDATE ||
+		 root->parse->commandType == CMD_DELETE) &&
+		bms_is_member(root->parse->resultRelation, joinrel->relids))
+	{
+		if (bms_is_member(root->parse->resultRelation,
+						  outerrel->relids))
+		{
+			Assert(fpinfo_o->tableoid_param);
+			fpinfo->tableoid_param = fpinfo_o->tableoid_param;
+		}
+		else
+		{
+			Assert(bms_is_member(root->parse->resultRelation,
+								 innerrel->relids));
+			Assert(fpinfo_i->tableoid_param);
+			fpinfo->tableoid_param = fpinfo_i->tableoid_param;
+		}
+		/*
+		 * Core code should have contained the Param in the join relation's
+		 * reltarget.
+		 */
+		Assert(list_member(joinrel->reltarget->exprs, fpinfo->tableoid_param));
+	}
+	else
+		fpinfo->tableoid_param = NULL;
+
 	/* Get user mapping */
 	if (fpinfo->use_remote_estimate)
 	{
@@ -8434,6 +8721,7 @@ make_tuple_from_result_row(PGresult *res,
 	ErrorContextCallback errcallback;
 	MemoryContext oldcontext;
 	ListCell   *lc;
+	Oid tableoid = InvalidOid;
 	int			j;
 
 	Assert(row < PQntuples(res));
@@ -8516,6 +8804,17 @@ make_tuple_from_result_row(PGresult *res,
 				ctid = (ItemPointer) DatumGetPointer(datum);
 			}
 		}
+		else if (i == TableOidAttributeNumber)
+		{
+			/* tableoid */
+			if (valstr != NULL)
+			{
+				Datum		datum;
+
+				datum = DirectFunctionCall1(oidin, CStringGetDatum(valstr));
+				tableoid = DatumGetObjectId(datum);
+			}
+		}
 		errpos.cur_attno = 0;
 
 		j++;
@@ -8547,6 +8846,8 @@ make_tuple_from_result_row(PGresult *res,
 	if (ctid)
 		tuple->t_self = tuple->t_data->t_ctid = *ctid;
 
+	tuple->t_tableOid = tableoid;
+
 	/*
 	 * Stomp on the xmin, xmax, and cmin fields from the tuple created by
 	 * heap_form_tuple.  heap_form_tuple actually creates the tuple with
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index a2bb1ff352c..376c55d4dd0 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -129,6 +129,9 @@ typedef struct PgFdwRelationInfo
 	 * representing the relation.
 	 */
 	int			relation_index;
+
+	/* PARAM_EXEC Param representing the remote table OID of a target rel */
+	Param	   *tableoid_param;
 } PgFdwRelationInfo;
 
 /*
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index dfc58beb0d2..ba2d753b821 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -4634,6 +4634,50 @@ SELECT server_name,
 \set VERBOSITY default
 RESET debug_discard_caches;
 
+-- ===================================================================
+-- check whether fdw created for partitioned table will delete tuples only from
+-- desired partition
+-- ===================================================================
+
+CREATE TABLE measurement (
+       city_id         int not null,
+       logdate         date not null,
+       peaktemp        int,
+       unitsales       int
+) PARTITION BY RANGE (logdate);
+
+CREATE TABLE measurement_y2006m02 PARTITION OF measurement
+       FOR VALUES FROM ('2006-02-01') TO ('2006-03-01');
+
+CREATE TABLE measurement_y2006m03 PARTITION OF measurement
+       FOR VALUES FROM ('2006-03-01') TO ('2006-04-01');
+
+CREATE TABLE measurement_y2006m04 PARTITION OF measurement
+       FOR VALUES FROM ('2006-04-01') TO ('2006-05-01');
+
+INSERT INTO measurement VALUES (1,'2006-02-01',1,1);
+INSERT INTO measurement VALUES (2,'2006-03-01',1,1);
+INSERT INTO measurement VALUES (3,'2006-04-01',1,1);
+
+create foreign table measurement_fdw (
+       city_id int options (column_name 'city_id') not null,
+       logdate date options (column_name 'logdate') not null,
+       peaktemp text options (column_name 'peaktemp'),
+       unitsales integer options (column_name 'unitsales')
+) SERVER loopback OPTIONS (table_name 'measurement');
+
+DELETE FROM measurement_fdw
+USING (
+       SELECT t1.city_id sub_city_id
+       FROM measurement_fdw t1
+       WHERE t1.city_id=1
+       LIMIT 1000
+) sub
+WHERE measurement_fdw.city_id = sub.sub_city_id
+RETURNING city_id, logdate, peaktemp, unitsales;
+
+SELECT * FROM measurement_fdw;
+
 -- ===================================================================
 -- test cleanup of failed connections on abort
 -- ===================================================================
-- 
2.43.0



  [application/octet-stream] v3-0002-core-remote-tableoid-param.patch (17.3K, ../../CAN-LCVPgq0zfOU+BLrHnr2Sex_zndNjzWoAiONWD=R4ULQ2BAA@mail.gmail.com/4-v3-0002-core-remote-tableoid-param.patch)
  download | inline diff:
From 5b550edb91978a5250eaff41a9e5c72b8a111d0d Mon Sep 17 00:00:00 2001
From: Nikita Malakhov <n.malakhov@postgrespro.ru>
Date: Thu, 14 May 2026 20:18:34 +0300
Subject: [PATCH] DELETE/UPDATE more than one row in partitioned foreign table
 (2/3)

There is known bug in delete and update rows in partitioned foreign
tables via FDW [1]

Core patch - introduction of remote table OID returned from remote
query and passing it through planner and executor to use in foreign
queries.

[1] Discussion: https://www.postgresql.org/message-id/flat/20250718175314.4513c00a%40karst
---
 src/backend/executor/execMain.c         |  4 ++
 src/backend/executor/execTuples.c       |  4 +-
 src/backend/executor/nodeForeignscan.c  |  2 +
 src/backend/optimizer/path/allpaths.c   | 20 +++++++++
 src/backend/optimizer/plan/createplan.c | 19 +++++++++
 src/backend/optimizer/plan/initsplan.c  | 45 ++++++++++++++++++++
 src/backend/optimizer/plan/planner.c    | 14 ++++---
 src/backend/optimizer/plan/setrefs.c    | 56 +++++++++++++++++++++++++
 src/backend/optimizer/plan/subselect.c  |  7 +++-
 src/backend/optimizer/util/appendinfo.c | 27 ++++++++++++
 src/backend/optimizer/util/inherit.c    |  1 +
 src/backend/optimizer/util/relnode.c    | 21 ++++++++++
 src/backend/utils/adt/ruleutils.c       |  2 +-
 src/include/executor/tuptable.h         |  1 +
 src/include/nodes/pathnodes.h           |  7 ++++
 src/include/nodes/primnodes.h           |  1 +
 16 files changed, 222 insertions(+), 9 deletions(-)

diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index 4b30f768680..b7b3b73941e 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -2621,6 +2621,10 @@ ExecBuildAuxRowMark(ExecRowMark *erm, List *targetlist)
 													   resname);
 		if (!AttributeNumberIsValid(aerm->ctidAttNo))
 			elog(ERROR, "could not find junk %s column", resname);
+
+		snprintf(resname, sizeof(resname), "tableoid%u", erm->rowmarkId);
+		aerm->toidAttNo = ExecFindJunkAttributeInTlist(targetlist,
+													   resname);
 	}
 	else
 	{
diff --git a/src/backend/executor/execTuples.c b/src/backend/executor/execTuples.c
index 6322a8a64b3..f8c66bdaef6 100644
--- a/src/backend/executor/execTuples.c
+++ b/src/backend/executor/execTuples.c
@@ -1911,8 +1911,7 @@ ExecStoreAllNullTuple(TupleTableSlot *slot)
  * Until the slot is materialized, the contents of the slot depend on the
  * datum.
  */
-void
-ExecStoreHeapTupleDatum(Datum data, TupleTableSlot *slot)
+void ExecStoreHeapTupleDatum(Datum data, TupleTableSlot *slot)
 {
 	HeapTupleData tuple = {0};
 	HeapTupleHeader td;
@@ -1922,6 +1921,7 @@ ExecStoreHeapTupleDatum(Datum data, TupleTableSlot *slot)
 	tuple.t_len = HeapTupleHeaderGetDatumLength(td);
 	tuple.t_self = td->t_ctid;
 	tuple.t_data = td;
+	tuple.t_tableOid = InvalidOid;
 
 	ExecClearTuple(slot);
 
diff --git a/src/backend/executor/nodeForeignscan.c b/src/backend/executor/nodeForeignscan.c
index 6f0daddce07..21b3e9a961b 100644
--- a/src/backend/executor/nodeForeignscan.c
+++ b/src/backend/executor/nodeForeignscan.c
@@ -65,6 +65,8 @@ ForeignNext(ForeignScanState *node)
 	 * Insert valid value into tableoid, the only actually-useful system
 	 * column.
 	 */
+
+	slot->tts_remoteOid = slot->tts_tableOid;
 	if (plan->fsSystemCol && !TupIsNull(slot))
 		slot->tts_tableOid = RelationGetRelid(node->ss.ss_currentRelation);
 
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 61093f222a1..040763caf35 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -1161,6 +1161,26 @@ set_append_rel_size(PlannerInfo *root, RelOptInfo *rel,
 								   (Node *) rel->reltarget->exprs,
 								   1, &appinfo);
 
+		/* Do it if fdw is partition */
+		if (planner_rt_fetch(childRTindex, root)->relkind == RELKIND_FOREIGN_TABLE &&
+			!bms_is_empty(root->glob->foreignParamIDs))
+		{
+			foreach(lc, root->processed_tlist)
+			{
+				TargetEntry *tle = (TargetEntry *) lfirst(lc);
+				Param	   *param = (Param *) tle->expr;
+
+				if (tle->resjunk && IsA(param, Param) &&
+					IS_FOREIGN_PARAM(root, param) &&
+					param->target_rte == childRTindex) // TODO same for another case
+				{
+					/* XXX is copyObject necessary here? */
+					childrel->reltarget->exprs =
+						lappend(childrel->reltarget->exprs, copyObject(param));
+				}
+			}
+		}
+
 		/*
 		 * We have to make child entries in the EquivalenceClass data
 		 * structures as well.  This is needed either if the parent
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index de6a183da79..00ad2dcb07e 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -980,6 +980,25 @@ use_physical_tlist(PlannerInfo *root, Path *path, int flags)
 		}
 	}
 
+	/*
+	 * Also, can't do it to a ForeignPath if the path is requested to emit
+	 * Params generated by the FDW.
+	 */
+	if (IsA(path, ForeignPath) &&
+		path->parent->relid == root->parse->resultRelation &&
+		!bms_is_empty(root->glob->foreignParamIDs))
+	{
+		foreach(lc, path->pathtarget->exprs)
+		{
+			Param	   *param = (Param *) lfirst(lc);
+			if (param && IsA(param, Param))
+			{
+				Assert(IS_FOREIGN_PARAM(root, param));
+				return false;
+			}
+		}
+	}
+
 	return true;
 }
 
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index b38422c47a4..cc09065e561 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -32,6 +32,7 @@
 #include "optimizer/planner.h"
 #include "optimizer/restrictinfo.h"
 #include "parser/analyze.h"
+#include "parser/parsetree.h"
 #include "rewrite/rewriteManip.h"
 #include "utils/lsyscache.h"
 #include "utils/rel.h"
@@ -243,6 +244,40 @@ add_other_rels_to_query(PlannerInfo *root)
  *
  *****************************************************************************/
 
+/*
+ * add_params_to_result_rel
+ *	  If the query's final tlist contains Params the FDW generated, add
+ *	  targetlist entries for each such Param to the result relation.
+ */
+static void
+add_params_to_result_rel(PlannerInfo *root, List *final_tlist)
+{
+	RelOptInfo *target_rel = find_base_rel(root, root->parse->resultRelation);
+	ListCell   *lc;
+
+	/*
+	 * If no parameters have been generated by any FDWs, we certainly don't
+	 * need to do anything here.
+	 */
+	if (bms_is_empty(root->glob->foreignParamIDs))
+		return;
+
+	foreach(lc, final_tlist)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+		Param	   *param = (Param *) tle->expr;
+
+		if (tle->resjunk && IsA(param, Param) &&
+			IS_FOREIGN_PARAM(root, param) &&
+			param->target_rte == target_rel->relid)
+		{
+			/* XXX is copyObject necessary here? */
+			target_rel->reltarget->exprs = lappend(target_rel->reltarget->exprs,
+												   copyObject(param));
+		}
+	}
+}
+
 /*
  * build_base_rel_tlists
  *	  Add targetlist entries for each var needed in the query's final tlist
@@ -282,6 +317,16 @@ build_base_rel_tlists(PlannerInfo *root, List *final_tlist)
 			list_free(having_vars);
 		}
 	}
+
+	if (root->parse->commandType == CMD_UPDATE ||
+		root->parse->commandType == CMD_DELETE)
+	{
+		int			result_relation = root->parse->resultRelation;
+
+		if (planner_rt_fetch(result_relation, root)->relkind == RELKIND_FOREIGN_TABLE)
+			add_params_to_result_rel(root, final_tlist);
+
+	}
 }
 
 /*
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index f4689e7c9f8..a560ecfbd8a 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -393,6 +393,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	glob->dependsOnRole = false;
 	glob->partition_directory = NULL;
 	glob->rel_notnullatts_hash = NULL;
+	glob->foreignParamIDs = NULL;
 
 	/*
 	 * Assess whether it's feasible to use parallel mode for this query. We
@@ -621,12 +622,15 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	}
 
 	/*
-	 * If any Params were generated, run through the plan tree and compute
-	 * each plan node's extParam/allParam sets.  Ideally we'd merge this into
-	 * set_plan_references' tree traversal, but for now it has to be separate
-	 * because we need to visit subplans before not after main plan.
+	 * If any Params were generated by the planner not by FDWs, run through
+	 * the plan tree and compute each plan node's extParam/allParam sets.
+	 * (Params added by FDWs are irrelevant for parameter change signaling.)
+	 * Ideally we'd merge this into set_plan_references' tree traversal, but
+	 * for now it has to be separate because we need to visit subplans before
+	 * not after main plan.
 	 */
-	if (glob->paramExecTypes != NIL)
+	if (glob->paramExecTypes != NIL &&
+		bms_num_members(glob->foreignParamIDs) < list_length(glob->paramExecTypes))
 	{
 		Assert(list_length(glob->subplans) == list_length(glob->subroots));
 		forboth(lp, glob->subplans, lr, glob->subroots)
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index ff0e875f2a2..4fd162920db 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -3310,7 +3310,42 @@ fix_join_expr_mutator(Node *node, fix_join_expr_context *context)
 	}
 	/* Special cases (apply only AFTER failing to match to lower tlist) */
 	if (IsA(node, Param))
+	{
+		Param	   *param = (Param *) node;
+
+		/*
+		 * If the Param is a PARAM_EXEC Param generated by an FDW, it should
+		 * have bubbled up from a lower plan node; convert it into a simple
+		 * Var referencing the output of the subplan.
+		 *
+		 * Note: set_join_references() would have kept has_non_vars=true for
+		 * the subplan emitting the Param since it effectively belong to the
+		 * result relation and that relation can never be the nullable side of
+		 * an outer join.
+		 */
+		if (IS_FOREIGN_PARAM(context->root, param))
+		{
+			if (context->outer_itlist && context->outer_itlist->has_non_vars)
+			{
+				newvar = search_indexed_tlist_for_non_var((Expr *) node,
+														  context->outer_itlist,
+														  OUTER_VAR);
+				if (newvar)
+					return (Node *) newvar;
+			}
+			if (context->inner_itlist && context->inner_itlist->has_non_vars)
+			{
+				newvar = search_indexed_tlist_for_non_var((Expr *) node,
+														  context->inner_itlist,
+														  INNER_VAR);
+				if (newvar)
+					return (Node *) newvar;
+			}
+			// XXX Is it an error to be here?
+		}
+		/* If not, do fix_param_node() */
 		return fix_param_node(context->root, (Param *) node);
+	}
 	if (IsA(node, AlternativeSubPlan))
 		return fix_join_expr_mutator(fix_alternative_subplan(context->root,
 															 (AlternativeSubPlan *) node,
@@ -3421,7 +3456,28 @@ fix_upper_expr_mutator(Node *node, fix_upper_expr_context *context)
 	}
 	/* Special cases (apply only AFTER failing to match to lower tlist) */
 	if (IsA(node, Param))
+	{
+		Param	   *param = (Param *) node;
+		/*
+		 * If the Param is a PARAM_EXEC Param generated by an FDW, it should
+		 * have bubbled up from a lower plan node; convert it into a simple
+		 * Var referencing the output of the subplan.
+		 */
+		if (IS_FOREIGN_PARAM(context->root, param))
+		{
+			if (context->subplan_itlist->has_non_vars)
+			{
+				newvar = search_indexed_tlist_for_non_var((Expr *) node,
+														  context->subplan_itlist,
+														  context->newvarno);
+				if (newvar)
+					return (Node *) newvar;
+			}
+			// XXX Is it an error to be here?
+		}
+		/* If not, do fix_param_node() */
 		return fix_param_node(context->root, (Param *) node);
+	}
 	if (IsA(node, Aggref))
 	{
 		Aggref	   *aggref = (Aggref *) node;
diff --git a/src/backend/optimizer/plan/subselect.c b/src/backend/optimizer/plan/subselect.c
index ccec1eaa7fe..375fdf27e53 100644
--- a/src/backend/optimizer/plan/subselect.c
+++ b/src/backend/optimizer/plan/subselect.c
@@ -3192,7 +3192,12 @@ finalize_primnode(Node *node, finalize_primnode_context *context)
 		{
 			int			paramid = ((Param *) node)->paramid;
 
-			context->paramids = bms_add_member(context->paramids, paramid);
+			/*
+			 * Params added by FDWs are irrelevant for parameter change
+			 * signaling.
+			 */
+			if (!bms_is_member(paramid, context->root->glob->foreignParamIDs))
+				context->paramids = bms_add_member(context->paramids, paramid);
 		}
 		return false;			/* no more to do here */
 	}
diff --git a/src/backend/optimizer/util/appendinfo.c b/src/backend/optimizer/util/appendinfo.c
index 778e4662f6e..7089a267125 100644
--- a/src/backend/optimizer/util/appendinfo.c
+++ b/src/backend/optimizer/util/appendinfo.c
@@ -948,6 +948,29 @@ add_row_identity_var(PlannerInfo *root, Var *orig_var,
 	root->processed_tlist = lappend(root->processed_tlist, tle);
 }
 
+static void
+fix_foreign_params(PlannerInfo *root, List *tlist)
+{
+	ListCell   *lc;
+
+	foreach(lc, tlist)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+		Param	   *param = (Param *) tle->expr;
+
+		if (tle->resjunk && IsA(param, Param) &&
+			param->paramkind == PARAM_EXEC &&
+			param->paramid == -1)
+		{
+			param->paramid = list_length(root->glob->paramExecTypes);
+			root->glob->paramExecTypes =
+				lappend_oid(root->glob->paramExecTypes, param->paramtype);
+			root->glob->foreignParamIDs =
+				bms_add_member(root->glob->foreignParamIDs, param->paramid);
+		}
+	}
+}
+
 /*
  * add_row_identity_columns
  *
@@ -992,8 +1015,12 @@ add_row_identity_columns(PlannerInfo *root, Index rtindex,
 		fdwroutine = GetFdwRoutineForRelation(target_relation, false);
 
 		if (fdwroutine->AddForeignUpdateTargets != NULL)
+		{
+
 			fdwroutine->AddForeignUpdateTargets(root, rtindex,
 												target_rte, target_relation);
+			fix_foreign_params(root, root->processed_tlist);
+		}
 
 		/*
 		 * For UPDATE, we need to make the FDW fetch unchanged columns by
diff --git a/src/backend/optimizer/util/inherit.c b/src/backend/optimizer/util/inherit.c
index 6a7b9edff3f..d8f08485e39 100644
--- a/src/backend/optimizer/util/inherit.c
+++ b/src/backend/optimizer/util/inherit.c
@@ -294,6 +294,7 @@ expand_inherited_rtentry(PlannerInfo *root, RelOptInfo *rel,
 						  InvalidOid,
 						  0);
 			snprintf(resname, sizeof(resname), "tableoid%u", oldrc->rowmarkId);
+
 			tle = makeTargetEntry((Expr *) var,
 								  list_length(root->processed_tlist) + 1,
 								  pstrdup(resname),
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 3fc2c2f71d0..655b2b00bd7 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -1320,6 +1320,27 @@ build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 			}
 			continue;
 		}
+		/*
+		 * We allow FDWs to have PARAM_EXEC Params here.
+		 */
+		else if (IsA(var, Param))
+		{
+			Param	   *param = (Param *) var;
+
+			Assert(IS_FOREIGN_PARAM(root, param));
+
+			joinrel->reltarget->exprs =
+				lappend(joinrel->reltarget->exprs, param);
+
+			/*
+			 * Estimate using the type info  (Note: keep this in sync with
+			 * set_rel_width())
+			 */
+			joinrel->reltarget->width +=
+				get_typavgwidth(param->paramtype, param->paramtypmod);
+
+			continue;
+		}
 
 		/*
 		 * Otherwise, anything in a baserel or joinrel targetlist ought to be
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 88de5c0481c..004f22084f8 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -9374,7 +9374,7 @@ get_parameter(Param *param, deparse_context *context)
 	 * It's a bug if we get here for anything except PARAM_EXTERN Params, but
 	 * in production builds printing $N seems more useful than failing.
 	 */
-	Assert(param->paramkind == PARAM_EXTERN);
+	Assert(param->paramkind == PARAM_EXTERN || param->paramkind == PARAM_EXEC);
 
 	appendStringInfo(context->buf, "$%d", param->paramid);
 }
diff --git a/src/include/executor/tuptable.h b/src/include/executor/tuptable.h
index 43018ed4dfa..02ddc4fb79d 100644
--- a/src/include/executor/tuptable.h
+++ b/src/include/executor/tuptable.h
@@ -141,6 +141,7 @@ typedef struct TupleTableSlot
 	MemoryContext tts_mcxt;		/* slot itself is in this context */
 	ItemPointerData tts_tid;	/* stored tuple's tid */
 	Oid			tts_tableOid;	/* table oid of tuple */
+	Oid			tts_remoteOid;
 } TupleTableSlot;
 
 /* routines for a TupleTableSlot implementation */
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 27a2c6815b7..abe4904cc24 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -271,12 +271,19 @@ typedef struct PlannerGlobal
 	/* extension state */
 	void	  **extension_state pg_node_attr(read_write_ignore);
 	int			extension_state_allocated;
+
+	/* PARAM_EXEC Params generated by FDWs */
+	Bitmapset  *foreignParamIDs;
 } PlannerGlobal;
 
 /* macro for fetching the Plan associated with a SubPlan node */
 #define planner_subplan_get_plan(root, subplan) \
 	((Plan *) list_nth((root)->glob->subplans, (subplan)->plan_id - 1))
 
+/* macro for checking if a Param is a PARAM_EXEC Param generated by an FDW */
+#define IS_FOREIGN_PARAM(root, param) \
+       ((param)->paramkind == PARAM_EXEC && \
+        bms_is_member((param)->paramid, (root)->glob->foreignParamIDs))
 
 /*----------
  * PlannerInfo
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index 7977ee24783..cd70f673236 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -402,6 +402,7 @@ typedef struct Param
 	Oid			paramcollid;
 	/* token location, or -1 if unknown */
 	ParseLoc	location;
+	Index		target_rte;
 } Param;
 
 /*
-- 
2.43.0



  [application/octet-stream] v3-0001-copy-slot-tuple-natts.patch (25.0K, ../../CAN-LCVPgq0zfOU+BLrHnr2Sex_zndNjzWoAiONWD=R4ULQ2BAA@mail.gmail.com/5-v3-0001-copy-slot-tuple-natts.patch)
  download | inline diff:
From dccc41a8935d48a30c72b2708e86a39c1c4cf8b2 Mon Sep 17 00:00:00 2001
From: Nikita Malakhov <n.malakhov@postgrespro.ru>
Date: Thu, 14 May 2026 20:00:28 +0300
Subject: [PATCH] DELETE/UPDATE more than one row in partitioned foreign table
 (1/3)

There is known bug in delete and update rows in partitioned foreign
tables via FDW [1]

Current copyTuple and copySlot machinery supposes that
source and destination slots and tuples always match. This patch modifies
copy internals to consider more complex case and copy only actual attributes,
and if destination slot/tuple has more they are set to null. This is a proof
of concept patch which shows such mechanics does not break anything and
allows to use it in cases when we have to pass or receive not matching number
of attributes.

[1] Discussion: https://www.postgresql.org/message-id/flat/20250718175314.4513c00a%40karst
---
 src/backend/access/common/heaptuple.c | 127 ++++++++++++++++++++++++--
 src/backend/executor/execGrouping.c   |   3 +-
 src/backend/executor/execTuples.c     | 103 +++++++++++++++------
 src/backend/optimizer/util/tlist.c    |  39 ++++++--
 src/include/access/htup_details.h     |  22 +++++
 src/include/executor/tuptable.h       |  41 +++++++--
 6 files changed, 278 insertions(+), 57 deletions(-)

diff --git a/src/backend/access/common/heaptuple.c b/src/backend/access/common/heaptuple.c
index f30346469ed..d4fbd962ec1 100644
--- a/src/backend/access/common/heaptuple.c
+++ b/src/backend/access/common/heaptuple.c
@@ -61,6 +61,7 @@
 #include "access/sysattr.h"
 #include "access/tupdesc_details.h"
 #include "common/hashfn.h"
+#include "executor/tuptable.h"
 #include "utils/datum.h"
 #include "utils/expandeddatum.h"
 #include "utils/hsearch.h"
@@ -219,11 +220,30 @@ Size
 heap_compute_data_size(TupleDesc tupleDesc,
 					   const Datum *values,
 					   const bool *isnull)
+{
+	return heap_compute_data_size_ext(tupleDesc, values, isnull,
+									  - 1 /* take natts from tupleDesc */);
+}
+
+/*
+ * heap_compute_data_size_ext
+ *		Same as above, but allows to specify number of attributes explicitly.
+ *
+ * If "natts" <= 0, then number of attributes is taken from the TupleDesc.
+ */
+Size
+heap_compute_data_size_ext(TupleDesc tupleDesc,
+ 					   const Datum *values,
+					   const bool *isnull,
+					   int natts)
 {
 	Size		data_length = 0;
 	int			i;
 	int			numberOfAttributes = tupleDesc->natts;
 
+	if (natts > 0)
+		numberOfAttributes = natts;
+
 	for (i = 0; i < numberOfAttributes; i++)
 	{
 		Datum		val;
@@ -402,6 +422,22 @@ heap_fill_tuple(TupleDesc tupleDesc,
 				const Datum *values, const bool *isnull,
 				char *data, Size data_size,
 				uint16 *infomask, uint8 *bit)
+{
+	heap_fill_tuple_ext(tupleDesc, values, isnull,  data, data_size, infomask,
+						bit, -1 /* take natts from tupleDesc */);
+}
+
+/*
+ * heap_fill_tuple_ext
+ *             Same as above, but allows to specify number of attributes explicitly.
+ *
+ * If "natts" <= 0, then number of attributes is taken from the TupleDesc.
+ */
+void
+heap_fill_tuple_ext(TupleDesc tupleDesc,
+					const Datum *values, const bool *isnull,
+					char *data, Size data_size,
+					uint16 *infomask, uint8 *bit, int natts)
 {
 	uint8	   *bitP;
 	int			bitmask;
@@ -412,6 +448,9 @@ heap_fill_tuple(TupleDesc tupleDesc,
 	char	   *start = data;
 #endif
 
+	if (natts > 0)
+		numberOfAttributes = natts;
+
 	if (bit != NULL)
 	{
 		bitP = &bit[-1];
@@ -684,18 +723,48 @@ heap_getsysattr(HeapTuple tup, int attnum, TupleDesc tupleDesc, bool *isnull)
  */
 HeapTuple
 heap_copytuple(HeapTuple tuple)
+{
+	return heap_copytuple_ext(tuple, NULL, -1);
+}
+
+/* ----------------
+ *		heap_copytuple_ext
+ *
+ *		returns a copy of a tuple consists of attnum atts of original
+ *
+ * The HeapTuple struct, tuple header, and tuple data are all allocated
+ * as a single palloc() block.
+ * ----------------
+ */
+HeapTuple
+heap_copytuple_ext(HeapTuple tuple, TupleTableSlot *slot, int dstnatts)
 {
 	HeapTuple	newTuple;
+	Size		tuplen;
 
 	if (!HeapTupleIsValid(tuple) || tuple->t_data == NULL)
 		return NULL;
 
-	newTuple = (HeapTuple) palloc(HEAPTUPLESIZE + tuple->t_len);
-	newTuple->t_len = tuple->t_len;
+	tuplen = tuple->t_len;
+
+	if (slot != NULL)
+	{
+		if (dstnatts < 0)
+			dstnatts = slot->tts_tupleDescriptor->natts;
+
+		if (dstnatts < slot->tts_tupleDescriptor->natts)
+			tuplen = heap_compute_data_size_ext(slot->tts_tupleDescriptor,
+												slot->tts_values,
+												slot->tts_isnull,
+												dstnatts);
+	}
+
+	newTuple = (HeapTuple) palloc(HEAPTUPLESIZE + tuplen);
+	newTuple->t_len = tuplen;
 	newTuple->t_self = tuple->t_self;
 	newTuple->t_tableOid = tuple->t_tableOid;
 	newTuple->t_data = (HeapTupleHeader) ((char *) newTuple + HEAPTUPLESIZE);
-	memcpy(newTuple->t_data, tuple->t_data, tuple->t_len);
+	memcpy(newTuple->t_data, tuple->t_data, tuplen);
 	return newTuple;
 }
 
@@ -1025,6 +1094,21 @@ HeapTuple
 heap_form_tuple(TupleDesc tupleDescriptor,
 				const Datum *values,
 				const bool *isnull)
+{
+	return heap_form_tuple_ext(tupleDescriptor, values, isnull, -1);
+}
+
+/*
+ * heap_form_tuple_ext
+ *		Same as above, but allows to specify number of attributes explicitly.
+ *
+ * If "natts" <= 0, then number of attributes is taken from the TupleDesc.
+ */
+HeapTuple
+heap_form_tuple_ext(TupleDesc tupleDescriptor,
+					const Datum *values,
+					const bool *isnull,
+					int natts)
 {
 	HeapTuple	tuple;			/* return tuple */
 	HeapTupleHeader td;			/* tuple data */
@@ -1035,6 +1119,9 @@ heap_form_tuple(TupleDesc tupleDescriptor,
 	int			numberOfAttributes = tupleDescriptor->natts;
 	int			i;
 
+	if(natts > 0)
+		numberOfAttributes = natts;
+
 	if (numberOfAttributes > MaxTupleAttributeNumber)
 		ereport(ERROR,
 				(errcode(ERRCODE_TOO_MANY_COLUMNS),
@@ -1063,7 +1150,7 @@ heap_form_tuple(TupleDesc tupleDescriptor,
 
 	hoff = len = MAXALIGN(len); /* align user data safely */
 
-	data_len = heap_compute_data_size(tupleDescriptor, values, isnull);
+	data_len = heap_compute_data_size_ext(tupleDescriptor, values, isnull, natts);
 
 	len += data_len;
 
@@ -1092,13 +1179,14 @@ heap_form_tuple(TupleDesc tupleDescriptor,
 	HeapTupleHeaderSetNatts(td, numberOfAttributes);
 	td->t_hoff = hoff;
 
-	heap_fill_tuple(tupleDescriptor,
+	heap_fill_tuple_ext(tupleDescriptor,
 					values,
 					isnull,
 					(char *) td + hoff,
 					data_len,
 					&td->t_infomask,
-					(hasnull ? td->t_bits : NULL));
+					(hasnull ? td->t_bits : NULL),
+					natts);
 
 	return tuple;
 }
@@ -1391,6 +1479,23 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 						const Datum *values,
 						const bool *isnull,
 						Size extra)
+{
+	return heap_form_minimal_tuple_ext(tupleDescriptor, values, isnull, extra,
+									   -1 /* take natts from tupleDesc */);
+}
+
+/*
+ * heap_form_minimal_tuple_ext
+ *		Same as above, but allows to specify number of attributes explicitly.
+ *
+ * If "natts" <= 0, then number of attributes is taken from the TupleDesc.
+ */
+MinimalTuple
+heap_form_minimal_tuple_ext(TupleDesc tupleDescriptor,
+ 							const Datum *values,
+							const bool *isnull,
+							Size extra,
+							int natts)
 {
 	MinimalTuple tuple;			/* return tuple */
 	char	   *mem;
@@ -1401,6 +1506,9 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 	int			numberOfAttributes = tupleDescriptor->natts;
 	int			i;
 
+	if (natts > 0)
+		numberOfAttributes = natts;
+
 	Assert(extra == MAXALIGN(extra));
 
 	if (numberOfAttributes > MaxTupleAttributeNumber)
@@ -1431,7 +1539,7 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 
 	hoff = len = MAXALIGN(len); /* align user data safely */
 
-	data_len = heap_compute_data_size(tupleDescriptor, values, isnull);
+	data_len = heap_compute_data_size_ext(tupleDescriptor, values, isnull, natts);
 
 	len += data_len;
 
@@ -1448,13 +1556,14 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 	HeapTupleHeaderSetNatts(tuple, numberOfAttributes);
 	tuple->t_hoff = hoff + MINIMAL_TUPLE_OFFSET;
 
-	heap_fill_tuple(tupleDescriptor,
+	heap_fill_tuple_ext(tupleDescriptor,
 					values,
 					isnull,
 					(char *) tuple + hoff,
 					data_len,
 					&tuple->t_infomask,
-					(hasnull ? tuple->t_bits : NULL));
+					(hasnull ? tuple->t_bits : NULL),
+					natts);
 
 	return tuple;
 }
diff --git a/src/backend/executor/execGrouping.c b/src/backend/executor/execGrouping.c
index c107514a85d..7ef7c8c6d9d 100644
--- a/src/backend/executor/execGrouping.c
+++ b/src/backend/executor/execGrouping.c
@@ -583,7 +583,8 @@ LookupTupleHashEntry_internal(TupleHashTable hashtable, TupleTableSlot *slot,
 			 * TupleHashEntryData or allocate an additional chunk.
 			 */
 			entry->firstTuple = ExecCopySlotMinimalTupleExtra(slot,
-															  hashtable->additionalsize);
+															  hashtable->additionalsize,
+															  -1 /* natts */);
 		}
 	}
 	else
diff --git a/src/backend/executor/execTuples.c b/src/backend/executor/execTuples.c
index b0a0028b165..6322a8a64b3 100644
--- a/src/backend/executor/execTuples.c
+++ b/src/backend/executor/execTuples.c
@@ -79,6 +79,9 @@ static inline void tts_buffer_heap_store_tuple(TupleTableSlot *slot,
 											   Buffer buffer,
 											   bool transfer_pin);
 static void tts_heap_store_tuple(TupleTableSlot *slot, HeapTuple tuple, bool shouldFree);
+static void tts_heap_materialize_ext(TupleTableSlot *slot, int natts);
+static void tts_minimal_materialize_ext(TupleTableSlot *slot, int natts);
+static void tts_buffer_heap_materialize_ext(TupleTableSlot *slot, int natts);
 
 
 const TupleTableSlotOps TTSOpsVirtual;
@@ -269,18 +272,29 @@ static void
 tts_virtual_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 {
 	TupleDesc	srcdesc = srcslot->tts_tupleDescriptor;
+	TupleDesc	dstdesc = dstslot->tts_tupleDescriptor;
+	int			min_natts = Min(dstdesc->natts, srcdesc->natts);
 
 	tts_virtual_clear(dstslot);
 
 	slot_getallattrs(srcslot);
 
-	for (int natt = 0; natt < srcdesc->natts; natt++)
+	for (int natt = 0; natt < min_natts; natt++)
 	{
 		dstslot->tts_values[natt] = srcslot->tts_values[natt];
 		dstslot->tts_isnull[natt] = srcslot->tts_isnull[natt];
 	}
 
-	dstslot->tts_nvalid = srcdesc->natts;
+	if (dstdesc->natts > srcdesc->natts)
+	{
+		for (int natt = min_natts; natt < dstdesc->natts; natt++)
+		{
+			dstslot->tts_values[natt] = (Datum) 0;
+			dstslot->tts_isnull[natt] = true;
+		}
+	}
+
+	dstslot->tts_nvalid = dstdesc->natts;
 	dstslot->tts_flags &= ~TTS_FLAG_EMPTY;
 
 	/* make sure storage doesn't depend on external memory */
@@ -288,24 +302,26 @@ tts_virtual_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 }
 
 static HeapTuple
-tts_virtual_copy_heap_tuple(TupleTableSlot *slot)
+tts_virtual_copy_heap_tuple(TupleTableSlot *slot, int natts)
 {
 	Assert(!TTS_EMPTY(slot));
 
-	return heap_form_tuple(slot->tts_tupleDescriptor,
+	return heap_form_tuple_ext(slot->tts_tupleDescriptor,
 						   slot->tts_values,
-						   slot->tts_isnull);
+						   slot->tts_isnull,
+						   natts);
 }
 
 static MinimalTuple
-tts_virtual_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
+tts_virtual_copy_minimal_tuple(TupleTableSlot *slot, Size extra, int natts)
 {
 	Assert(!TTS_EMPTY(slot));
 
-	return heap_form_minimal_tuple(slot->tts_tupleDescriptor,
+	return heap_form_minimal_tuple_ext(slot->tts_tupleDescriptor,
 								   slot->tts_values,
 								   slot->tts_isnull,
-								   extra);
+								   extra,
+								   natts);
 }
 
 
@@ -397,6 +413,12 @@ tts_heap_is_current_xact_tuple(TupleTableSlot *slot)
 
 static void
 tts_heap_materialize(TupleTableSlot *slot)
+{
+	tts_heap_materialize_ext(slot, -1);
+}
+
+static void
+tts_heap_materialize_ext(TupleTableSlot *slot, int natts)
 {
 	HeapTupleTableSlot *hslot = (HeapTupleTableSlot *) slot;
 	MemoryContext oldContext;
@@ -417,9 +439,10 @@ tts_heap_materialize(TupleTableSlot *slot)
 	hslot->off = 0;
 
 	if (!hslot->tuple)
-		hslot->tuple = heap_form_tuple(slot->tts_tupleDescriptor,
+		hslot->tuple = heap_form_tuple_ext(slot->tts_tupleDescriptor,
 									   slot->tts_values,
-									   slot->tts_isnull);
+									   slot->tts_isnull,
+									   natts);
 	else
 	{
 		/*
@@ -427,7 +450,7 @@ tts_heap_materialize(TupleTableSlot *slot)
 		 * context of the given slot (else it would have TTS_FLAG_SHOULDFREE
 		 * set).  Copy the tuple into the given slot's memory context.
 		 */
-		hslot->tuple = heap_copytuple(hslot->tuple);
+		hslot->tuple = heap_copytuple_ext(hslot->tuple, slot, natts);
 	}
 
 	slot->tts_flags |= TTS_FLAG_SHOULDFREE;
@@ -461,24 +484,24 @@ tts_heap_get_heap_tuple(TupleTableSlot *slot)
 }
 
 static HeapTuple
-tts_heap_copy_heap_tuple(TupleTableSlot *slot)
+tts_heap_copy_heap_tuple(TupleTableSlot *slot, int natts)
 {
 	HeapTupleTableSlot *hslot = (HeapTupleTableSlot *) slot;
 
 	Assert(!TTS_EMPTY(slot));
 	if (!hslot->tuple)
-		tts_heap_materialize(slot);
+		tts_heap_materialize_ext(slot, natts);
 
-	return heap_copytuple(hslot->tuple);
+	return heap_copytuple_ext(hslot->tuple, slot, natts);
 }
 
 static MinimalTuple
-tts_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
+tts_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra, int natts)
 {
 	HeapTupleTableSlot *hslot = (HeapTupleTableSlot *) slot;
 
 	if (!hslot->tuple)
-		tts_heap_materialize(slot);
+		tts_heap_materialize_ext(slot, natts);
 
 	return minimal_tuple_from_heap_tuple(hslot->tuple, extra);
 }
@@ -585,6 +608,12 @@ tts_minimal_is_current_xact_tuple(TupleTableSlot *slot)
 
 static void
 tts_minimal_materialize(TupleTableSlot *slot)
+{
+	tts_minimal_materialize_ext(slot, -1);
+}
+
+static void
+tts_minimal_materialize_ext(TupleTableSlot *slot, int natts)
 {
 	MinimalTupleTableSlot *mslot = (MinimalTupleTableSlot *) slot;
 	MemoryContext oldContext;
@@ -606,10 +635,11 @@ tts_minimal_materialize(TupleTableSlot *slot)
 
 	if (!mslot->mintuple)
 	{
-		mslot->mintuple = heap_form_minimal_tuple(slot->tts_tupleDescriptor,
+		mslot->mintuple = heap_form_minimal_tuple_ext(slot->tts_tupleDescriptor,
 												  slot->tts_values,
 												  slot->tts_isnull,
-												  0);
+												  0 /* no extra */,
+												  natts);
 	}
 	else
 	{
@@ -637,9 +667,13 @@ tts_minimal_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 {
 	MemoryContext oldcontext;
 	MinimalTuple mintuple;
+	int			min_natts;
+
+	min_natts = Min(dstslot->tts_tupleDescriptor->natts,
+					srcslot->tts_tupleDescriptor->natts);
 
 	oldcontext = MemoryContextSwitchTo(dstslot->tts_mcxt);
-	mintuple = ExecCopySlotMinimalTuple(srcslot);
+	mintuple = ExecCopySlotMinimalTupleExteded(srcslot, min_natts);
 	MemoryContextSwitchTo(oldcontext);
 
 	ExecStoreMinimalTuple(mintuple, dstslot, true);
@@ -657,7 +691,7 @@ tts_minimal_get_minimal_tuple(TupleTableSlot *slot)
 }
 
 static HeapTuple
-tts_minimal_copy_heap_tuple(TupleTableSlot *slot)
+tts_minimal_copy_heap_tuple(TupleTableSlot *slot, int natts)
 {
 	MinimalTupleTableSlot *mslot = (MinimalTupleTableSlot *) slot;
 
@@ -668,7 +702,7 @@ tts_minimal_copy_heap_tuple(TupleTableSlot *slot)
 }
 
 static MinimalTuple
-tts_minimal_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
+tts_minimal_copy_minimal_tuple(TupleTableSlot *slot, Size extra, int natts)
 {
 	MinimalTupleTableSlot *mslot = (MinimalTupleTableSlot *) slot;
 
@@ -802,6 +836,12 @@ tts_buffer_is_current_xact_tuple(TupleTableSlot *slot)
 
 static void
 tts_buffer_heap_materialize(TupleTableSlot *slot)
+{
+	tts_buffer_heap_materialize_ext(slot, -1 /* natts */);
+}
+
+static void
+tts_buffer_heap_materialize_ext(TupleTableSlot *slot, int natts)
 {
 	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
 	MemoryContext oldContext;
@@ -874,12 +914,16 @@ tts_buffer_heap_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 		TTS_SHOULDFREE(srcslot) ||
 		!bsrcslot->base.tuple)
 	{
+		int		min_natts;
 		MemoryContext oldContext;
 
+		min_natts = Min(dstslot->tts_tupleDescriptor->natts,
+						srcslot->tts_tupleDescriptor->natts);
+
 		ExecClearTuple(dstslot);
 		dstslot->tts_flags &= ~TTS_FLAG_EMPTY;
 		oldContext = MemoryContextSwitchTo(dstslot->tts_mcxt);
-		bdstslot->base.tuple = ExecCopySlotHeapTuple(srcslot);
+		bdstslot->base.tuple = ExecCopySlotHeapTupleExteded(srcslot, min_natts);
 		dstslot->tts_flags |= TTS_FLAG_SHOULDFREE;
 		MemoryContextSwitchTo(oldContext);
 	}
@@ -915,27 +959,27 @@ tts_buffer_heap_get_heap_tuple(TupleTableSlot *slot)
 }
 
 static HeapTuple
-tts_buffer_heap_copy_heap_tuple(TupleTableSlot *slot)
+tts_buffer_heap_copy_heap_tuple(TupleTableSlot *slot, int natts)
 {
 	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
 
 	Assert(!TTS_EMPTY(slot));
 
 	if (!bslot->base.tuple)
-		tts_buffer_heap_materialize(slot);
+		tts_buffer_heap_materialize_ext(slot, natts);
 
-	return heap_copytuple(bslot->base.tuple);
+	return heap_copytuple_ext(bslot->base.tuple, slot, natts);
 }
 
 static MinimalTuple
-tts_buffer_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
+tts_buffer_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra, int natts)
 {
 	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
 
 	Assert(!TTS_EMPTY(slot));
 
 	if (!bslot->base.tuple)
-		tts_buffer_heap_materialize(slot);
+		tts_buffer_heap_materialize_ext(slot, natts);
 
 	return minimal_tuple_from_heap_tuple(bslot->base.tuple, extra);
 }
@@ -1922,7 +1966,7 @@ ExecFetchSlotHeapTuple(TupleTableSlot *slot, bool materialize, bool *shouldFree)
 	{
 		if (shouldFree)
 			*shouldFree = true;
-		return slot->tts_ops->copy_heap_tuple(slot);
+		return slot->tts_ops->copy_heap_tuple(slot, -1 /* natts */);
 	}
 	else
 	{
@@ -1973,7 +2017,8 @@ ExecFetchSlotMinimalTuple(TupleTableSlot *slot,
 	{
 		if (shouldFree)
 			*shouldFree = true;
-		return slot->tts_ops->copy_minimal_tuple(slot, 0);
+		return slot->tts_ops->copy_minimal_tuple(slot, 0 /* no extra */,
+												 -1 /* natts */);
 	}
 }
 
diff --git a/src/backend/optimizer/util/tlist.c b/src/backend/optimizer/util/tlist.c
index 752ea9222f6..e382810b19f 100644
--- a/src/backend/optimizer/util/tlist.c
+++ b/src/backend/optimizer/util/tlist.c
@@ -329,18 +329,37 @@ apply_tlist_labeling(List *dest_tlist, List *src_tlist)
 	ListCell   *ld,
 			   *ls;
 
-	Assert(list_length(dest_tlist) == list_length(src_tlist));
-	forboth(ld, dest_tlist, ls, src_tlist)
+	foreach(ld, dest_tlist)
 	{
+		bool has_src = false;
 		TargetEntry *dest_tle = (TargetEntry *) lfirst(ld);
-		TargetEntry *src_tle = (TargetEntry *) lfirst(ls);
-
-		Assert(dest_tle->resno == src_tle->resno);
-		dest_tle->resname = src_tle->resname;
-		dest_tle->ressortgroupref = src_tle->ressortgroupref;
-		dest_tle->resorigtbl = src_tle->resorigtbl;
-		dest_tle->resorigcol = src_tle->resorigcol;
-		dest_tle->resjunk = src_tle->resjunk;
+
+		foreach(ls, src_tlist)
+		{
+			TargetEntry *src_tle = (TargetEntry *) lfirst(ls);
+
+			if (dest_tle->resno == src_tle->resno)
+			{
+				has_src = true;
+				dest_tle->resname = src_tle->resname;
+				dest_tle->ressortgroupref = src_tle->ressortgroupref;
+				dest_tle->resorigtbl = src_tle->resorigtbl;
+				dest_tle->resorigcol = src_tle->resorigcol;
+				dest_tle->resjunk = src_tle->resjunk;
+				break;
+			}
+		}
+
+		if (!has_src)
+		{
+			TargetEntry *new_entry = palloc0(sizeof(TargetEntry));
+			new_entry->resname = dest_tle->resname;
+			new_entry->ressortgroupref = dest_tle->ressortgroupref;
+			new_entry->resorigtbl = dest_tle->resorigtbl;
+			new_entry->resorigcol = dest_tle->resorigcol;
+			new_entry->resjunk = dest_tle->resjunk;
+			src_tlist = lappend(src_tlist, new_entry);
+		}
 	}
 }
 
diff --git a/src/include/access/htup_details.h b/src/include/access/htup_details.h
index 77a6c48fd71..1f59f363a68 100644
--- a/src/include/access/htup_details.h
+++ b/src/include/access/htup_details.h
@@ -794,10 +794,17 @@ HeapTupleClearHeapOnly(const HeapTupleData *tuple)
 /* prototypes for functions in common/heaptuple.c */
 extern Size heap_compute_data_size(TupleDesc tupleDesc,
 								   const Datum *values, const bool *isnull);
+extern Size heap_compute_data_size_ext(TupleDesc tupleDesc,
+									   const Datum *values, const bool *isnull,
+									   int natts);
 extern void heap_fill_tuple(TupleDesc tupleDesc,
 							const Datum *values, const bool *isnull,
 							char *data, Size data_size,
 							uint16 *infomask, uint8 *bit);
+extern void heap_fill_tuple_ext(TupleDesc tupleDesc,
+								const Datum *values, const bool *isnull,
+								char *data, Size data_size,
+								uint16 *infomask, uint8 *bit, int natts);
 extern bool heap_attisnull(HeapTuple tup, int attnum, TupleDesc tupleDesc);
 extern Datum nocachegetattr(HeapTuple tup, int attnum,
 							TupleDesc tupleDesc);
@@ -806,10 +813,20 @@ extern Datum heap_getsysattr(HeapTuple tup, int attnum, TupleDesc tupleDesc,
 extern Datum getmissingattr(TupleDesc tupleDesc,
 							int attnum, bool *isnull);
 extern HeapTuple heap_copytuple(HeapTuple tuple);
+
+struct TupleTableSlot;
+extern HeapTuple heap_copytuple_ext(HeapTuple tuple,
+									struct TupleTableSlot *slot,
+									int dstnatts);
+
 extern void heap_copytuple_with_tuple(HeapTuple src, HeapTuple dest);
 extern Datum heap_copy_tuple_as_datum(HeapTuple tuple, TupleDesc tupleDesc);
 extern HeapTuple heap_form_tuple(TupleDesc tupleDescriptor,
 								 const Datum *values, const bool *isnull);
+extern HeapTuple heap_form_tuple_ext(TupleDesc tupleDescriptor,
+									 const Datum *values,
+									 const bool *isnull,
+									 int natts);
 extern HeapTuple heap_modify_tuple(HeapTuple tuple,
 								   TupleDesc tupleDesc,
 								   const Datum *replValues,
@@ -827,6 +844,11 @@ extern void heap_freetuple(HeapTuple htup);
 extern MinimalTuple heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 											const Datum *values, const bool *isnull,
 											Size extra);
+extern MinimalTuple heap_form_minimal_tuple_ext(TupleDesc tupleDescriptor,
+												const Datum *values,
+												const bool *isnull,
+												Size extra,
+												int natts);
 extern void heap_free_minimal_tuple(MinimalTuple mtup);
 extern MinimalTuple heap_copy_minimal_tuple(MinimalTuple mtup, Size extra);
 extern HeapTuple heap_tuple_from_minimal_tuple(MinimalTuple mtup);
diff --git a/src/include/executor/tuptable.h b/src/include/executor/tuptable.h
index 3db6c9c9bd0..43018ed4dfa 100644
--- a/src/include/executor/tuptable.h
+++ b/src/include/executor/tuptable.h
@@ -223,8 +223,10 @@ struct TupleTableSlotOps
 	 * meaningful "system columns" in the copy. The copy is not be "owned" by
 	 * the slot i.e. the caller has to take responsibility to free memory
 	 * consumed by the slot.
+	 *
+	 * TODO comment for "natts"
 	 */
-	HeapTuple	(*copy_heap_tuple) (TupleTableSlot *slot);
+	HeapTuple	(*copy_heap_tuple) (TupleTableSlot *slot, int natts);
 
 	/*
 	 * Return a copy of minimal tuple representing the contents of the slot.
@@ -237,8 +239,10 @@ struct TupleTableSlotOps
 	 * The copy has "extra" bytes (maxaligned and zeroed) available before the
 	 * tuple, which is useful so that some callers may store extra data along
 	 * with the minimal tuple without the need for an additional allocation.
+	 *
+	 * TODO comment for "natts"
 	 */
-	MinimalTuple (*copy_minimal_tuple) (TupleTableSlot *slot, Size extra);
+	MinimalTuple (*copy_minimal_tuple) (TupleTableSlot *slot, Size extra, int natts);
 };
 
 /*
@@ -368,6 +372,12 @@ extern void slot_getsomeattrs_int(TupleTableSlot *slot, int attnum);
 
 #ifndef FRONTEND
 
+/* Forward declarations */
+static inline HeapTuple ExecCopySlotHeapTupleExteded(TupleTableSlot *slot,
+													 int natts);
+static inline MinimalTuple ExecCopySlotMinimalTupleExteded(TupleTableSlot *slot,
+														   int natts);
+
 /*
  * This function forces the entries of the slot's Datum/isnull arrays to be
  * valid at least up through the attnum'th entry.
@@ -502,10 +512,16 @@ ExecMaterializeSlot(TupleTableSlot *slot)
  */
 static inline HeapTuple
 ExecCopySlotHeapTuple(TupleTableSlot *slot)
+{
+	return ExecCopySlotHeapTupleExteded(slot, -1 /* natts */);
+}
+
+static inline HeapTuple
+ExecCopySlotHeapTupleExteded(TupleTableSlot *slot, int natts)
 {
 	Assert(!TTS_EMPTY(slot));
 
-	return slot->tts_ops->copy_heap_tuple(slot);
+	return slot->tts_ops->copy_heap_tuple(slot, natts);
 }
 
 /*
@@ -514,7 +530,13 @@ ExecCopySlotHeapTuple(TupleTableSlot *slot)
 static inline MinimalTuple
 ExecCopySlotMinimalTuple(TupleTableSlot *slot)
 {
-	return slot->tts_ops->copy_minimal_tuple(slot, 0);
+	return ExecCopySlotMinimalTupleExteded(slot, -1 /* natts */);
+}
+
+static inline MinimalTuple
+ExecCopySlotMinimalTupleExteded(TupleTableSlot *slot, int natts)
+{
+	return slot->tts_ops->copy_minimal_tuple(slot, 0 /* no extra */, natts);
 }
 
 /*
@@ -524,9 +546,9 @@ ExecCopySlotMinimalTuple(TupleTableSlot *slot)
  * caller to make an additional allocation).
  */
 static inline MinimalTuple
-ExecCopySlotMinimalTupleExtra(TupleTableSlot *slot, Size extra)
+ExecCopySlotMinimalTupleExtra(TupleTableSlot *slot, Size extra, int natts)
 {
-	return slot->tts_ops->copy_minimal_tuple(slot, extra);
+	return slot->tts_ops->copy_minimal_tuple(slot, extra, natts);
 }
 
 /*
@@ -545,9 +567,12 @@ ExecCopySlot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 {
 	Assert(!TTS_EMPTY(srcslot));
 	Assert(srcslot != dstslot);
-	Assert(dstslot->tts_tupleDescriptor->natts ==
-		   srcslot->tts_tupleDescriptor->natts);
 
+	/*
+	 * If source slot has less attributes then target - copy source and
+	 * set target to nulls. Otherwise copy only leading attributes and set
+	 * target natts to source counter.
+	 */
 	dstslot->tts_ops->copyslot(dstslot, srcslot);
 
 	return dstslot;
-- 
2.43.0



^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
@ 2026-06-01 10:44             ` Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-05 11:59               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  0 siblings, 2 replies; 30+ messages in thread

From: Etsuro Fujita @ 2026-06-01 10:44 UTC (permalink / raw)
  To: Nikita Malakhov <hukutoc@gmail.com>; +Cc: Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Hi Nikita,

On Fri, May 15, 2026 at 2:23 AM Nikita Malakhov <hukutoc@gmail.com> wrote:
> CFbot was unhappy with previous patch set, so here's updated one

Thanks for working on this issue!

I took a quick look at the patch set.  IIUC I think it's created based
on what I proposed in the original thread, which is invasive and thus
not back-patchable, so what you are proposing here isn't
back-patchable, either, I think.

I think we should first work on a back-patchable solution.  So I'd
like to re-propose the patch that I proposed in this thread before to
disallow UPDATE/DELETE in problematic cases [1].  Attached is a new
version of the patch.  Changes are:

* Renamed the new table option inherited to remotely_inherited, to
avoid confusion with local inheritance.
* Moved the logic to prevent problematic UPDATE/DELETE from a planner
function to an executor function, to avoid throwing an error
unnecessarily when there are no target rows to update/delete.
* Added docs to postgres-fdw.sgml.

I'm planning to add the postgresImportForeignSchema() support in the
next version.

I think the remotely_inherited option would be useful when adding the
support for the UPDATE/DELETE, as it could be used to address one of
Tom Lane's comments about what I proposed in the original thread that
it adds the tabloid condition to a remote UPDATE/DELETE query whether
the target table is inherited or not: that could be avoid if the
option is set to false.

What do you think about that?

Best regards,
Etsuro Fujita

[1] https://www.postgresql.org/message-id/CAPmGK15CQK-oYFMAyq%2BrR0rQapUHtvAGuGgY5ahERHzZ4tmC8g%40mail.g...

Attachments:

  [application/octet-stream] postgres_fdw-disallow-upddel-in-problematic-cases-v2.patch (11.0K, ../../CAPmGK166P+ngd2ehady=_f-L4MePgBdBNxN5gi5_gSAfmV82QA@mail.gmail.com/2-postgres_fdw-disallow-upddel-in-problematic-cases-v2.patch)
  download | inline diff:
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index aaffcf31271..ef1846c20cc 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -7197,6 +7197,70 @@ RESET enable_material;
 DROP FOREIGN TABLE remt2;
 DROP TABLE loct1;
 DROP TABLE loct2;
+-- Test UPDATE/DELETE on remotely-inherited foreign tables
+CREATE TABLE ritest_pt (a int, b int) PARTITION BY LIST(a);
+CREATE TABLE ritest_pt_p1 PARTITION OF ritest_pt FOR VALUES IN (1);
+CREATE TABLE ritest_pt_p2 PARTITION OF ritest_pt FOR VALUES IN (2);
+CREATE FOREIGN TABLE ritest_ft (a int, b int) SERVER loopback OPTIONS (table_name 'ritest_pt', remotely_inherited 'true');
+INSERT INTO ritest_ft VALUES (1, 10), (2, 20);
+-- Use random() so that UPDATE/DELETE is not pushed down to the remote
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE ritest_ft SET b = 100 WHERE a = 1 AND random() < 1.0;
+                                       QUERY PLAN                                       
+----------------------------------------------------------------------------------------
+ Update on public.ritest_ft
+   Remote SQL: UPDATE public.ritest_pt SET b = $2 WHERE ctid = $1
+   ->  Foreign Scan on public.ritest_ft
+         Output: 100, ctid, ritest_ft.*
+         Filter: (random() < '1'::double precision)
+         Remote SQL: SELECT a, b, ctid FROM public.ritest_pt WHERE ((a = 1)) FOR UPDATE
+(6 rows)
+
+UPDATE ritest_ft SET b = 100 WHERE a = 1 AND random() < 1.0;   -- should fail
+ERROR:  cannot update remotely-inherited foreign table "ritest_ft"
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE ritest_ft SET b = 300 WHERE b = 30 AND random() < 1.0;
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
+ Update on public.ritest_ft
+   Remote SQL: UPDATE public.ritest_pt SET b = $2 WHERE ctid = $1
+   ->  Foreign Scan on public.ritest_ft
+         Output: 300, ctid, ritest_ft.*
+         Filter: (random() < '1'::double precision)
+         Remote SQL: SELECT a, b, ctid FROM public.ritest_pt WHERE ((b = 30)) FOR UPDATE
+(6 rows)
+
+UPDATE ritest_ft SET b = 300 WHERE b = 30 AND random() < 1.0;  -- should work
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM ritest_ft WHERE a = 1 AND random() < 1.0;
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
+ Delete on public.ritest_ft
+   Remote SQL: DELETE FROM public.ritest_pt WHERE ctid = $1
+   ->  Foreign Scan on public.ritest_ft
+         Output: ctid
+         Filter: (random() < '1'::double precision)
+         Remote SQL: SELECT ctid FROM public.ritest_pt WHERE ((a = 1)) FOR UPDATE
+(6 rows)
+
+DELETE FROM ritest_ft WHERE a = 1 AND random() < 1.0;          -- should fail
+ERROR:  cannot delete from remotely-inherited foreign table "ritest_ft"
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM ritest_ft WHERE b = 30 AND random() < 1.0;
+                                    QUERY PLAN                                     
+-----------------------------------------------------------------------------------
+ Delete on public.ritest_ft
+   Remote SQL: DELETE FROM public.ritest_pt WHERE ctid = $1
+   ->  Foreign Scan on public.ritest_ft
+         Output: ctid
+         Filter: (random() < '1'::double precision)
+         Remote SQL: SELECT ctid FROM public.ritest_pt WHERE ((b = 30)) FOR UPDATE
+(6 rows)
+
+DELETE FROM ritest_ft WHERE b = 30 AND random() < 1.0;         -- should work
+-- Cleanup
+DROP FOREIGN TABLE ritest_ft;
+DROP TABLE ritest_pt;
 -- ===================================================================
 -- test check constraints
 -- ===================================================================
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index 3944aedbacc..2341d9e5b69 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -116,6 +116,7 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 		 */
 		if (strcmp(def->defname, "use_remote_estimate") == 0 ||
 			strcmp(def->defname, "updatable") == 0 ||
+			strcmp(def->defname, "remotely_inherited") == 0 ||
 			strcmp(def->defname, "truncatable") == 0 ||
 			strcmp(def->defname, "async_capable") == 0 ||
 			strcmp(def->defname, "parallel_commit") == 0 ||
@@ -255,6 +256,7 @@ InitPgFdwOptions(void)
 		/* updatable is available on both server and table */
 		{"updatable", ForeignServerRelationId, false},
 		{"updatable", ForeignTableRelationId, false},
+		{"remotely_inherited", ForeignTableRelationId, false},
 		/* truncatable is available on both server and table */
 		{"truncatable", ForeignServerRelationId, false},
 		{"truncatable", ForeignTableRelationId, false},
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index c42cb690c7b..52b3da4efbf 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -207,6 +207,9 @@ typedef struct PgFdwModifyState
 	int			p_nums;			/* number of parameters to transmit */
 	FmgrInfo   *p_flinfo;		/* output conversion functions for them */
 
+	/* update/delete operation stuff */
+	bool		resultRelValid;	/* have we checked the result relation? */
+
 	/* batch operation stuff */
 	int			num_slots;		/* number of slots to insert */
 
@@ -654,6 +657,7 @@ static TupleTableSlot **execute_foreign_modify(EState *estate,
 											   TupleTableSlot **planSlots,
 											   int *numSlots);
 static void prepare_foreign_modify(PgFdwModifyState *fmstate);
+static void check_result_rel(PgFdwModifyState *fmstate, CmdType operation);
 static const char **convert_prep_stmt_params(PgFdwModifyState *fmstate,
 											 ItemPointer tupleid,
 											 TupleTableSlot **slots,
@@ -4236,6 +4240,9 @@ create_foreign_modify(EState *estate,
 	{
 		Assert(subplan != NULL);
 
+		/* Initialize valid flag for the result relation */
+		fmstate->resultRelValid = false;
+
 		/* Find the ctid resjunk column in the subplan's result */
 		fmstate->ctidAttno = ExecFindJunkAttributeInTlist(subplan->targetlist,
 														  "ctid");
@@ -4308,6 +4315,11 @@ execute_foreign_modify(EState *estate,
 		   operation == CMD_UPDATE ||
 		   operation == CMD_DELETE);
 
+	/* For UPDATE/DELETE, check the result relation if not yet done. */
+	if ((operation == CMD_UPDATE || operation == CMD_DELETE) &&
+		!fmstate->resultRelValid)
+		check_result_rel(fmstate, operation);
+
 	/* First, process a pending asynchronous request, if any. */
 	if (fmstate->conn_state->pendingAreq)
 		process_pending_request(fmstate->conn_state->pendingAreq);
@@ -4448,6 +4460,52 @@ prepare_foreign_modify(PgFdwModifyState *fmstate)
 	fmstate->p_name = p_name;
 }
 
+/*
+ * check_result_rel
+ *		Check if the target foreign table is safe to update/delete via
+ *		ExecForeignUpdate/ExecForeignDelete.
+ */
+static void
+check_result_rel(PgFdwModifyState *fmstate, CmdType operation)
+{
+	bool		remotely_inherited;
+	ForeignTable *table;
+	ListCell   *lc;
+
+	Assert(!fmstate->resultRelValid);
+	Assert(operation == CMD_UPDATE || operation == CMD_DELETE);
+
+	/*
+	 * By default, any postgres_fdw foreign table isn't assumed
+	 * remotely-inherited.
+	 */
+	remotely_inherited = false;
+
+	table = GetForeignTable(RelationGetRelid(fmstate->rel));
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "remotely_inherited") == 0)
+			remotely_inherited = defGetBoolean(def);
+	}
+
+	/*
+	 * It's unsafe to update/delete remotely-inherited foreign tables via
+	 * ExecForeignUpdate/ExecForeignDelete.
+	 */
+	if (remotely_inherited)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg((operation == CMD_UPDATE) ?
+						"cannot update remotely-inherited foreign table \"%s\"" :
+						"cannot delete from remotely-inherited foreign table \"%s\"",
+						RelationGetRelationName(fmstate->rel))));
+
+	fmstate->resultRelValid = true;
+}
+
 /*
  * convert_prep_stmt_params
  *		Create array of text strings representing parameter values
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 267d3c1a7e7..7fc099bccb7 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -1778,6 +1778,34 @@ DROP FOREIGN TABLE remt2;
 DROP TABLE loct1;
 DROP TABLE loct2;
 
+-- Test UPDATE/DELETE on remotely-inherited foreign tables
+CREATE TABLE ritest_pt (a int, b int) PARTITION BY LIST(a);
+CREATE TABLE ritest_pt_p1 PARTITION OF ritest_pt FOR VALUES IN (1);
+CREATE TABLE ritest_pt_p2 PARTITION OF ritest_pt FOR VALUES IN (2);
+CREATE FOREIGN TABLE ritest_ft (a int, b int) SERVER loopback OPTIONS (table_name 'ritest_pt', remotely_inherited 'true');
+INSERT INTO ritest_ft VALUES (1, 10), (2, 20);
+
+-- Use random() so that UPDATE/DELETE is not pushed down to the remote
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE ritest_ft SET b = 100 WHERE a = 1 AND random() < 1.0;
+UPDATE ritest_ft SET b = 100 WHERE a = 1 AND random() < 1.0;   -- should fail
+
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE ritest_ft SET b = 300 WHERE b = 30 AND random() < 1.0;
+UPDATE ritest_ft SET b = 300 WHERE b = 30 AND random() < 1.0;  -- should work
+
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM ritest_ft WHERE a = 1 AND random() < 1.0;
+DELETE FROM ritest_ft WHERE a = 1 AND random() < 1.0;          -- should fail
+
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM ritest_ft WHERE b = 30 AND random() < 1.0;
+DELETE FROM ritest_ft WHERE b = 30 AND random() < 1.0;         -- should work
+
+-- Cleanup
+DROP FOREIGN TABLE ritest_ft;
+DROP TABLE ritest_pt;
+
 -- ===================================================================
 -- test check constraints
 -- ===================================================================
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index b81f33732fb..f28da502923 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -630,6 +630,29 @@ OPTIONS (ADD password_required 'false');
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><literal>remotely_inherited</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table, determines if
+       the remote table is an inherited/partitioned table on the remote server
+       or a foreign table on it referencing such a table on another remote
+       server.
+       The default is <literal>false</literal>.
+      </para>
+
+      <para>
+       If the <literal>updatable</literal> option is set for a foreign table
+       whose remote table is any of the above,
+       <filename>postgres_fdw</filename> currently cannot properly
+       update/delete it, causing unexpected results, except in cases where the
+       whole <command>UPDATE/DELETE</command> processing is pushed down to the
+       remote server.  Such unsafe modifications can be prevented by setting
+       this option.  This might be imporved in future releases.
+      </para>
+     </listitem>
+    </varlistentry>
+
    </variablelist>
   </sect3>
 


^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
@ 2026-06-05 11:59               ` Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-06 20:33                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-10 05:37                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  1 sibling, 2 replies; 30+ messages in thread

From: Etsuro Fujita @ 2026-06-05 11:59 UTC (permalink / raw)
  To: Nikita Malakhov <hukutoc@gmail.com>; +Cc: Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

On Mon, Jun 1, 2026 at 7:44 PM Etsuro Fujita <etsuro.fujita@gmail.com> wrote:
> I think we should first work on a back-patchable solution.  So I'd
> like to re-propose the patch that I proposed in this thread before to
> disallow UPDATE/DELETE in problematic cases [1].  Attached is a new
> version of the patch.  Changes are:
>
> * Renamed the new table option inherited to remotely_inherited, to
> avoid confusion with local inheritance.
> * Moved the logic to prevent problematic UPDATE/DELETE from a planner
> function to an executor function, to avoid throwing an error
> unnecessarily when there are no target rows to update/delete.
> * Added docs to postgres-fdw.sgml.
>
> I'm planning to add the postgresImportForeignSchema() support in the
> next version.

I created the patch to add that support on top of the patch I sent in
a previous email, which I'm attaching along with the base patch.  It's
the same as before, except that I fixed a typo in docs pointed out by
Michael-san off-list.

Comments welcome!

Best regards,
Etsuro Fujita

Attachments:

  [application/octet-stream] v3-0001-postgres_fdw-Disallow-UPDATE-DELETE-in-problematic-c.patch (11.6K, ../../CAPmGK14KEFMTuQ1vYwWCo8SLks5rXv-56K-V+XMy4q8uQJvq1w@mail.gmail.com/2-v3-0001-postgres_fdw-Disallow-UPDATE-DELETE-in-problematic-c.patch)
  download | inline diff:
From 8a604cfded373265b3c5da0a1d9cda0207997011 Mon Sep 17 00:00:00 2001
From: Etsuro Fujita <efujita@postgresql.org>
Date: Fri, 5 Jun 2026 20:20:42 +0900
Subject: [PATCH 1/2] postgres_fdw: Disallow UPDATE/DELETE in problematic
 cases.

---
 .../postgres_fdw/expected/postgres_fdw.out    | 64 +++++++++++++++++++
 contrib/postgres_fdw/option.c                 |  2 +
 contrib/postgres_fdw/postgres_fdw.c           | 58 +++++++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql     | 28 ++++++++
 doc/src/sgml/postgres-fdw.sgml                | 23 +++++++
 5 files changed, 175 insertions(+)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index e90289e4ab1..ff9c9e878e4 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -7197,6 +7197,70 @@ RESET enable_material;
 DROP FOREIGN TABLE remt2;
 DROP TABLE loct1;
 DROP TABLE loct2;
+-- Test UPDATE/DELETE on remotely-inherited foreign tables
+CREATE TABLE ritest_pt (a int, b int) PARTITION BY LIST(a);
+CREATE TABLE ritest_pt_p1 PARTITION OF ritest_pt FOR VALUES IN (1);
+CREATE TABLE ritest_pt_p2 PARTITION OF ritest_pt FOR VALUES IN (2);
+CREATE FOREIGN TABLE ritest_ft (a int, b int) SERVER loopback OPTIONS (table_name 'ritest_pt', remotely_inherited 'true');
+INSERT INTO ritest_ft VALUES (1, 10), (2, 20);
+-- Use random() so that UPDATE/DELETE is not pushed down to the remote
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE ritest_ft SET b = 100 WHERE a = 1 AND random() < 1.0;
+                                       QUERY PLAN                                       
+----------------------------------------------------------------------------------------
+ Update on public.ritest_ft
+   Remote SQL: UPDATE public.ritest_pt SET b = $2 WHERE ctid = $1
+   ->  Foreign Scan on public.ritest_ft
+         Output: 100, ctid, ritest_ft.*
+         Filter: (random() < '1'::double precision)
+         Remote SQL: SELECT a, b, ctid FROM public.ritest_pt WHERE ((a = 1)) FOR UPDATE
+(6 rows)
+
+UPDATE ritest_ft SET b = 100 WHERE a = 1 AND random() < 1.0;   -- should fail
+ERROR:  cannot update remotely-inherited foreign table "ritest_ft"
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE ritest_ft SET b = 300 WHERE b = 30 AND random() < 1.0;
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
+ Update on public.ritest_ft
+   Remote SQL: UPDATE public.ritest_pt SET b = $2 WHERE ctid = $1
+   ->  Foreign Scan on public.ritest_ft
+         Output: 300, ctid, ritest_ft.*
+         Filter: (random() < '1'::double precision)
+         Remote SQL: SELECT a, b, ctid FROM public.ritest_pt WHERE ((b = 30)) FOR UPDATE
+(6 rows)
+
+UPDATE ritest_ft SET b = 300 WHERE b = 30 AND random() < 1.0;  -- should work
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM ritest_ft WHERE a = 1 AND random() < 1.0;
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
+ Delete on public.ritest_ft
+   Remote SQL: DELETE FROM public.ritest_pt WHERE ctid = $1
+   ->  Foreign Scan on public.ritest_ft
+         Output: ctid
+         Filter: (random() < '1'::double precision)
+         Remote SQL: SELECT ctid FROM public.ritest_pt WHERE ((a = 1)) FOR UPDATE
+(6 rows)
+
+DELETE FROM ritest_ft WHERE a = 1 AND random() < 1.0;          -- should fail
+ERROR:  cannot delete from remotely-inherited foreign table "ritest_ft"
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM ritest_ft WHERE b = 30 AND random() < 1.0;
+                                    QUERY PLAN                                     
+-----------------------------------------------------------------------------------
+ Delete on public.ritest_ft
+   Remote SQL: DELETE FROM public.ritest_pt WHERE ctid = $1
+   ->  Foreign Scan on public.ritest_ft
+         Output: ctid
+         Filter: (random() < '1'::double precision)
+         Remote SQL: SELECT ctid FROM public.ritest_pt WHERE ((b = 30)) FOR UPDATE
+(6 rows)
+
+DELETE FROM ritest_ft WHERE b = 30 AND random() < 1.0;         -- should work
+-- Cleanup
+DROP FOREIGN TABLE ritest_ft;
+DROP TABLE ritest_pt;
 -- ===================================================================
 -- test check constraints
 -- ===================================================================
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index 79b16c3f318..340698e3bca 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -116,6 +116,7 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 		 */
 		if (strcmp(def->defname, "use_remote_estimate") == 0 ||
 			strcmp(def->defname, "updatable") == 0 ||
+			strcmp(def->defname, "remotely_inherited") == 0 ||
 			strcmp(def->defname, "truncatable") == 0 ||
 			strcmp(def->defname, "async_capable") == 0 ||
 			strcmp(def->defname, "parallel_commit") == 0 ||
@@ -256,6 +257,7 @@ InitPgFdwOptions(void)
 		/* updatable is available on both server and table */
 		{"updatable", ForeignServerRelationId, false},
 		{"updatable", ForeignTableRelationId, false},
+		{"remotely_inherited", ForeignTableRelationId, false},
 		/* truncatable is available on both server and table */
 		{"truncatable", ForeignServerRelationId, false},
 		{"truncatable", ForeignTableRelationId, false},
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 0a589f8db74..3466a8e70b5 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -207,6 +207,9 @@ typedef struct PgFdwModifyState
 	int			p_nums;			/* number of parameters to transmit */
 	FmgrInfo   *p_flinfo;		/* output conversion functions for them */
 
+	/* update/delete operation stuff */
+	bool		resultRelValid;	/* have we checked the result relation? */
+
 	/* batch operation stuff */
 	int			num_slots;		/* number of slots to insert */
 
@@ -654,6 +657,7 @@ static TupleTableSlot **execute_foreign_modify(EState *estate,
 											   TupleTableSlot **planSlots,
 											   int *numSlots);
 static void prepare_foreign_modify(PgFdwModifyState *fmstate);
+static void check_result_rel(PgFdwModifyState *fmstate, CmdType operation);
 static const char **convert_prep_stmt_params(PgFdwModifyState *fmstate,
 											 ItemPointer tupleid,
 											 TupleTableSlot **slots,
@@ -4237,6 +4241,9 @@ create_foreign_modify(EState *estate,
 	{
 		Assert(subplan != NULL);
 
+		/* Initialize valid flag for the result relation */
+		fmstate->resultRelValid = false;
+
 		/* Find the ctid resjunk column in the subplan's result */
 		fmstate->ctidAttno = ExecFindJunkAttributeInTlist(subplan->targetlist,
 														  "ctid");
@@ -4309,6 +4316,11 @@ execute_foreign_modify(EState *estate,
 		   operation == CMD_UPDATE ||
 		   operation == CMD_DELETE);
 
+	/* For UPDATE/DELETE, check the result relation if not yet done. */
+	if ((operation == CMD_UPDATE || operation == CMD_DELETE) &&
+		!fmstate->resultRelValid)
+		check_result_rel(fmstate, operation);
+
 	/* First, process a pending asynchronous request, if any. */
 	if (fmstate->conn_state->pendingAreq)
 		process_pending_request(fmstate->conn_state->pendingAreq);
@@ -4449,6 +4461,52 @@ prepare_foreign_modify(PgFdwModifyState *fmstate)
 	fmstate->p_name = p_name;
 }
 
+/*
+ * check_result_rel
+ *		Check if the target foreign table is safe to update/delete via
+ *		ExecForeignUpdate/ExecForeignDelete.
+ */
+static void
+check_result_rel(PgFdwModifyState *fmstate, CmdType operation)
+{
+	bool		remotely_inherited;
+	ForeignTable *table;
+	ListCell   *lc;
+
+	Assert(!fmstate->resultRelValid);
+	Assert(operation == CMD_UPDATE || operation == CMD_DELETE);
+
+	/*
+	 * By default, any postgres_fdw foreign table isn't assumed
+	 * remotely-inherited.
+	 */
+	remotely_inherited = false;
+
+	table = GetForeignTable(RelationGetRelid(fmstate->rel));
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "remotely_inherited") == 0)
+			remotely_inherited = defGetBoolean(def);
+	}
+
+	/*
+	 * It's unsafe to update/delete remotely-inherited foreign tables via
+	 * ExecForeignUpdate/ExecForeignDelete.
+	 */
+	if (remotely_inherited)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg((operation == CMD_UPDATE) ?
+						"cannot update remotely-inherited foreign table \"%s\"" :
+						"cannot delete from remotely-inherited foreign table \"%s\"",
+						RelationGetRelationName(fmstate->rel))));
+
+	fmstate->resultRelValid = true;
+}
+
 /*
  * convert_prep_stmt_params
  *		Create array of text strings representing parameter values
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index dfc58beb0d2..31d5ea8a47d 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -1778,6 +1778,34 @@ DROP FOREIGN TABLE remt2;
 DROP TABLE loct1;
 DROP TABLE loct2;
 
+-- Test UPDATE/DELETE on remotely-inherited foreign tables
+CREATE TABLE ritest_pt (a int, b int) PARTITION BY LIST(a);
+CREATE TABLE ritest_pt_p1 PARTITION OF ritest_pt FOR VALUES IN (1);
+CREATE TABLE ritest_pt_p2 PARTITION OF ritest_pt FOR VALUES IN (2);
+CREATE FOREIGN TABLE ritest_ft (a int, b int) SERVER loopback OPTIONS (table_name 'ritest_pt', remotely_inherited 'true');
+INSERT INTO ritest_ft VALUES (1, 10), (2, 20);
+
+-- Use random() so that UPDATE/DELETE is not pushed down to the remote
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE ritest_ft SET b = 100 WHERE a = 1 AND random() < 1.0;
+UPDATE ritest_ft SET b = 100 WHERE a = 1 AND random() < 1.0;   -- should fail
+
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE ritest_ft SET b = 300 WHERE b = 30 AND random() < 1.0;
+UPDATE ritest_ft SET b = 300 WHERE b = 30 AND random() < 1.0;  -- should work
+
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM ritest_ft WHERE a = 1 AND random() < 1.0;
+DELETE FROM ritest_ft WHERE a = 1 AND random() < 1.0;          -- should fail
+
+EXPLAIN (VERBOSE, COSTS OFF)
+DELETE FROM ritest_ft WHERE b = 30 AND random() < 1.0;
+DELETE FROM ritest_ft WHERE b = 30 AND random() < 1.0;         -- should work
+
+-- Cleanup
+DROP FOREIGN TABLE ritest_ft;
+DROP TABLE ritest_pt;
+
 -- ===================================================================
 -- test check constraints
 -- ===================================================================
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index b9e1b04463e..e6fa87c4b87 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -630,6 +630,29 @@ OPTIONS (ADD password_required 'false');
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><literal>remotely_inherited</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table, determines if
+       the remote table is an inherited/partitioned table on the remote server
+       or a foreign table on it referencing such a table on another remote
+       server.
+       The default is <literal>false</literal>.
+      </para>
+
+      <para>
+       If the <literal>updatable</literal> option is set for a foreign table
+       whose remote table is any of the above,
+       <filename>postgres_fdw</filename> currently cannot properly
+       update/delete it, causing unexpected results, except in cases where the
+       whole <command>UPDATE/DELETE</command> processing is pushed down to the
+       remote server.  Such unsafe modifications can be prevented by setting
+       this option.  This might be improved in future releases.
+      </para>
+     </listitem>
+    </varlistentry>
+
    </variablelist>
   </sect3>
 
-- 
2.50.1 (Apple Git-155)



  [application/octet-stream] v3-0002-postgres_fdw-Add-IMPORT-FOREIGN-SCHEMA-support-for-n.patch (22.1K, ../../CAPmGK14KEFMTuQ1vYwWCo8SLks5rXv-56K-V+XMy4q8uQJvq1w@mail.gmail.com/3-v3-0002-postgres_fdw-Add-IMPORT-FOREIGN-SCHEMA-support-for-n.patch)
  download | inline diff:
From d774c5107fd68ff9d6edb4f9f23b79b9cbf71dc8 Mon Sep 17 00:00:00 2001
From: Etsuro Fujita <efujita@postgresql.org>
Date: Fri, 5 Jun 2026 20:35:19 +0900
Subject: [PATCH 2/2] postgres_fdw: Add IMPORT FOREIGN SCHEMA support for new
 option.

---
 .../postgres_fdw/expected/postgres_fdw.out    | 129 +++++++++-----
 contrib/postgres_fdw/postgres_fdw.c           | 162 ++++++++++++++----
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  17 +-
 3 files changed, 234 insertions(+), 74 deletions(-)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index ff9c9e878e4..a631ea76dc0 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -10092,16 +10092,16 @@ CREATE TABLE import_source.t4_part2 PARTITION OF import_source.t4
 CREATE SCHEMA import_dest1;
 IMPORT FOREIGN SCHEMA import_source FROM SERVER loopback INTO import_dest1;
 \det+ import_dest1.*
-                                     List of foreign tables
-    Schema    | Table |  Server  |                   FDW options                   | Description 
---------------+-------+----------+-------------------------------------------------+-------------
- import_dest1 | t1    | loopback | (schema_name 'import_source', table_name 't1')  | 
- import_dest1 | t2    | loopback | (schema_name 'import_source', table_name 't2')  | 
- import_dest1 | t3    | loopback | (schema_name 'import_source', table_name 't3')  | 
- import_dest1 | t4    | loopback | (schema_name 'import_source', table_name 't4')  | 
- import_dest1 | x 4   | loopback | (schema_name 'import_source', table_name 'x 4') | 
- import_dest1 | x 5   | loopback | (schema_name 'import_source', table_name 'x 5') | 
- import_dest1 | x 6   | loopback | (schema_name 'import_source', table_name 'x 6') | 
+                                                  List of foreign tables
+    Schema    | Table |  Server  |                                FDW options                                | Description 
+--------------+-------+----------+---------------------------------------------------------------------------+-------------
+ import_dest1 | t1    | loopback | (schema_name 'import_source', table_name 't1')                            | 
+ import_dest1 | t2    | loopback | (schema_name 'import_source', table_name 't2')                            | 
+ import_dest1 | t3    | loopback | (schema_name 'import_source', table_name 't3')                            | 
+ import_dest1 | t4    | loopback | (schema_name 'import_source', table_name 't4', remotely_inherited 'true') | 
+ import_dest1 | x 4   | loopback | (schema_name 'import_source', table_name 'x 4')                           | 
+ import_dest1 | x 5   | loopback | (schema_name 'import_source', table_name 'x 5')                           | 
+ import_dest1 | x 6   | loopback | (schema_name 'import_source', table_name 'x 6')                           | 
 (7 rows)
 
 \d import_dest1.*
@@ -10135,7 +10135,7 @@ FDW options: (schema_name 'import_source', table_name 't3')
 --------+---------+-----------+----------+---------+--------------------
  c1     | integer |           |          |         | (column_name 'c1')
 Server: loopback
-FDW options: (schema_name 'import_source', table_name 't4')
+FDW options: (schema_name 'import_source', table_name 't4', remotely_inherited 'true')
 
                            Foreign table "import_dest1.x 4"
  Column |         Type          | Collation | Nullable | Default |     FDW options     
@@ -10165,16 +10165,16 @@ CREATE SCHEMA import_dest2;
 IMPORT FOREIGN SCHEMA import_source FROM SERVER loopback INTO import_dest2
   OPTIONS (import_default 'true');
 \det+ import_dest2.*
-                                     List of foreign tables
-    Schema    | Table |  Server  |                   FDW options                   | Description 
---------------+-------+----------+-------------------------------------------------+-------------
- import_dest2 | t1    | loopback | (schema_name 'import_source', table_name 't1')  | 
- import_dest2 | t2    | loopback | (schema_name 'import_source', table_name 't2')  | 
- import_dest2 | t3    | loopback | (schema_name 'import_source', table_name 't3')  | 
- import_dest2 | t4    | loopback | (schema_name 'import_source', table_name 't4')  | 
- import_dest2 | x 4   | loopback | (schema_name 'import_source', table_name 'x 4') | 
- import_dest2 | x 5   | loopback | (schema_name 'import_source', table_name 'x 5') | 
- import_dest2 | x 6   | loopback | (schema_name 'import_source', table_name 'x 6') | 
+                                                  List of foreign tables
+    Schema    | Table |  Server  |                                FDW options                                | Description 
+--------------+-------+----------+---------------------------------------------------------------------------+-------------
+ import_dest2 | t1    | loopback | (schema_name 'import_source', table_name 't1')                            | 
+ import_dest2 | t2    | loopback | (schema_name 'import_source', table_name 't2')                            | 
+ import_dest2 | t3    | loopback | (schema_name 'import_source', table_name 't3')                            | 
+ import_dest2 | t4    | loopback | (schema_name 'import_source', table_name 't4', remotely_inherited 'true') | 
+ import_dest2 | x 4   | loopback | (schema_name 'import_source', table_name 'x 4')                           | 
+ import_dest2 | x 5   | loopback | (schema_name 'import_source', table_name 'x 5')                           | 
+ import_dest2 | x 6   | loopback | (schema_name 'import_source', table_name 'x 6')                           | 
 (7 rows)
 
 \d import_dest2.*
@@ -10208,7 +10208,7 @@ FDW options: (schema_name 'import_source', table_name 't3')
 --------+---------+-----------+----------+---------+--------------------
  c1     | integer |           |          |         | (column_name 'c1')
 Server: loopback
-FDW options: (schema_name 'import_source', table_name 't4')
+FDW options: (schema_name 'import_source', table_name 't4', remotely_inherited 'true')
 
                            Foreign table "import_dest2.x 4"
  Column |         Type          | Collation | Nullable | Default |     FDW options     
@@ -10237,16 +10237,16 @@ CREATE SCHEMA import_dest3;
 IMPORT FOREIGN SCHEMA import_source FROM SERVER loopback INTO import_dest3
   OPTIONS (import_collate 'false', import_generated 'false', import_not_null 'false');
 \det+ import_dest3.*
-                                     List of foreign tables
-    Schema    | Table |  Server  |                   FDW options                   | Description 
---------------+-------+----------+-------------------------------------------------+-------------
- import_dest3 | t1    | loopback | (schema_name 'import_source', table_name 't1')  | 
- import_dest3 | t2    | loopback | (schema_name 'import_source', table_name 't2')  | 
- import_dest3 | t3    | loopback | (schema_name 'import_source', table_name 't3')  | 
- import_dest3 | t4    | loopback | (schema_name 'import_source', table_name 't4')  | 
- import_dest3 | x 4   | loopback | (schema_name 'import_source', table_name 'x 4') | 
- import_dest3 | x 5   | loopback | (schema_name 'import_source', table_name 'x 5') | 
- import_dest3 | x 6   | loopback | (schema_name 'import_source', table_name 'x 6') | 
+                                                  List of foreign tables
+    Schema    | Table |  Server  |                                FDW options                                | Description 
+--------------+-------+----------+---------------------------------------------------------------------------+-------------
+ import_dest3 | t1    | loopback | (schema_name 'import_source', table_name 't1')                            | 
+ import_dest3 | t2    | loopback | (schema_name 'import_source', table_name 't2')                            | 
+ import_dest3 | t3    | loopback | (schema_name 'import_source', table_name 't3')                            | 
+ import_dest3 | t4    | loopback | (schema_name 'import_source', table_name 't4', remotely_inherited 'true') | 
+ import_dest3 | x 4   | loopback | (schema_name 'import_source', table_name 'x 4')                           | 
+ import_dest3 | x 5   | loopback | (schema_name 'import_source', table_name 'x 5')                           | 
+ import_dest3 | x 6   | loopback | (schema_name 'import_source', table_name 'x 6')                           | 
 (7 rows)
 
 \d import_dest3.*
@@ -10280,7 +10280,7 @@ FDW options: (schema_name 'import_source', table_name 't3')
 --------+---------+-----------+----------+---------+--------------------
  c1     | integer |           |          |         | (column_name 'c1')
 Server: loopback
-FDW options: (schema_name 'import_source', table_name 't4')
+FDW options: (schema_name 'import_source', table_name 't4', remotely_inherited 'true')
 
                            Foreign table "import_dest3.x 4"
  Column |         Type          | Collation | Nullable | Default |     FDW options     
@@ -10320,16 +10320,16 @@ IMPORT FOREIGN SCHEMA import_source LIMIT TO (t1, nonesuch, t4_part)
 IMPORT FOREIGN SCHEMA import_source EXCEPT (t1, "x 4", nonesuch, t4_part)
   FROM SERVER loopback INTO import_dest4;
 \det+ import_dest4.*
-                                        List of foreign tables
-    Schema    |  Table  |  Server  |                     FDW options                     | Description 
---------------+---------+----------+-----------------------------------------------------+-------------
- import_dest4 | t1      | loopback | (schema_name 'import_source', table_name 't1')      | 
- import_dest4 | t2      | loopback | (schema_name 'import_source', table_name 't2')      | 
- import_dest4 | t3      | loopback | (schema_name 'import_source', table_name 't3')      | 
- import_dest4 | t4      | loopback | (schema_name 'import_source', table_name 't4')      | 
- import_dest4 | t4_part | loopback | (schema_name 'import_source', table_name 't4_part') | 
- import_dest4 | x 5     | loopback | (schema_name 'import_source', table_name 'x 5')     | 
- import_dest4 | x 6     | loopback | (schema_name 'import_source', table_name 'x 6')     | 
+                                                   List of foreign tables
+    Schema    |  Table  |  Server  |                                FDW options                                | Description 
+--------------+---------+----------+---------------------------------------------------------------------------+-------------
+ import_dest4 | t1      | loopback | (schema_name 'import_source', table_name 't1')                            | 
+ import_dest4 | t2      | loopback | (schema_name 'import_source', table_name 't2')                            | 
+ import_dest4 | t3      | loopback | (schema_name 'import_source', table_name 't3')                            | 
+ import_dest4 | t4      | loopback | (schema_name 'import_source', table_name 't4', remotely_inherited 'true') | 
+ import_dest4 | t4_part | loopback | (schema_name 'import_source', table_name 't4_part')                       | 
+ import_dest4 | x 5     | loopback | (schema_name 'import_source', table_name 'x 5')                           | 
+ import_dest4 | x 6     | loopback | (schema_name 'import_source', table_name 'x 6')                           | 
 (7 rows)
 
 -- Assorted error cases
@@ -10363,6 +10363,49 @@ QUERY:  CREATE FOREIGN TABLE t5 (
 OPTIONS (schema_name 'import_source', table_name 't5');
 CONTEXT:  importing foreign table "t5"
 ROLLBACK;
+-- Check that the remotely_inherited option is set when needed.
+CREATE TABLE import_source.inhchild (c1 int);
+CREATE TABLE import_source.t6 (c1 int);
+ALTER TABLE import_source.inhchild INHERIT import_source.t6;
+CREATE TABLE import_source.t7 (c1 int);
+ALTER TABLE import_source.inhchild INHERIT import_source.t7;
+ALTER TABLE import_source.inhchild NO INHERIT import_source.t7;
+CREATE FOREIGN TABLE import_source.t8 (c1 int) SERVER loopback
+  OPTIONS (remotely_inherited 'true');
+CREATE SCHEMA import_dest6;
+IMPORT FOREIGN SCHEMA import_source LIMIT TO (t6, t7, t8)
+  FROM SERVER loopback INTO import_dest6;
+\det+ import_dest6.*
+                                                  List of foreign tables
+    Schema    | Table |  Server  |                                FDW options                                | Description 
+--------------+-------+----------+---------------------------------------------------------------------------+-------------
+ import_dest6 | t6    | loopback | (schema_name 'import_source', table_name 't6', remotely_inherited 'true') | 
+ import_dest6 | t7    | loopback | (schema_name 'import_source', table_name 't7')                            | 
+ import_dest6 | t8    | loopback | (schema_name 'import_source', table_name 't8', remotely_inherited 'true') | 
+(3 rows)
+
+\d import_dest6.*
+                    Foreign table "import_dest6.t6"
+ Column |  Type   | Collation | Nullable | Default |    FDW options     
+--------+---------+-----------+----------+---------+--------------------
+ c1     | integer |           |          |         | (column_name 'c1')
+Server: loopback
+FDW options: (schema_name 'import_source', table_name 't6', remotely_inherited 'true')
+
+                    Foreign table "import_dest6.t7"
+ Column |  Type   | Collation | Nullable | Default |    FDW options     
+--------+---------+-----------+----------+---------+--------------------
+ c1     | integer |           |          |         | (column_name 'c1')
+Server: loopback
+FDW options: (schema_name 'import_source', table_name 't7')
+
+                    Foreign table "import_dest6.t8"
+ Column |  Type   | Collation | Nullable | Default |    FDW options     
+--------+---------+-----------+----------+---------+--------------------
+ c1     | integer |           |          |         | (column_name 'c1')
+Server: loopback
+FDW options: (schema_name 'import_source', table_name 't8', remotely_inherited 'true')
+
 BEGIN;
 CREATE SERVER fetch101 FOREIGN DATA WRAPPER postgres_fdw OPTIONS( fetch_size '101' );
 SELECT count(*)
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 3466a8e70b5..8a5471b2b05 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -726,6 +726,9 @@ static bool import_fetched_statistics(const char *schemaname,
 static void map_field_to_arg(PGresult *res, int row, int field,
 							 int arg, Datum *values, char *nulls);
 static bool import_spi_query_ok(void);
+static void append_import_schema_restrictions(StringInfo buf,
+											  ImportForeignSchemaStmt *stmt,
+											  PGconn *conn);
 static void produce_tuple_asynchronously(AsyncRequest *areq, bool fetch);
 static void fetch_more_data_begin(AsyncRequest *areq);
 static void complete_pending_request(AsyncRequest *areq);
@@ -6389,7 +6392,10 @@ postgresImportForeignSchema(ImportForeignSchemaStmt *stmt, Oid serverOid)
 	PGconn	   *conn;
 	StringInfoData buf;
 	PGresult   *res;
-	int			numrows,
+	char	  **inherited = NULL;
+	int			numinherited,
+				inherited_idx,
+				numrows,
 				i;
 	ListCell   *lc;
 
@@ -6444,6 +6450,60 @@ postgresImportForeignSchema(ImportForeignSchemaStmt *stmt, Oid serverOid)
 	PQclear(res);
 	resetStringInfo(&buf);
 
+	/*
+	 * First, fetch/save all remotely-inherited table names from this schema,
+	 * possibly restricted by EXCEPT or LIMIT TO.
+	 */
+	appendStringInfoString(&buf,
+						   "SELECT relname "
+						   "FROM pg_class c "
+						   "  JOIN pg_namespace n ON "
+						   "    relnamespace = n.oid "
+						   "  LEFT JOIN (pg_foreign_table t "
+						   "    JOIN pg_foreign_server s ON "
+						   "      s.oid = t.ftserver "
+						   "    JOIN pg_foreign_data_wrapper w ON "
+						   "      w.oid = s.srvfdw) ON "
+						   "    t.ftrelid = c.oid "
+						   "WHERE (c.relkind = "
+						   CppAsString2(RELKIND_PARTITIONED_TABLE) " "
+						   "  OR (c.relkind IN ("
+						   CppAsString2(RELKIND_RELATION) ","
+						   CppAsString2(RELKIND_FOREIGN_TABLE) ") "
+						   "    AND c.relhassubclass "
+						   "    AND EXISTS (SELECT 1 FROM pg_inherits "
+						   "      WHERE inhparent = c.oid)) "
+						   "  OR (c.relkind = "
+						   CppAsString2(RELKIND_FOREIGN_TABLE) " "
+						   "    AND w.fdwname = \'postgres_fdw\' "
+						   "    AND t.ftoptions @> "
+						   "      ARRAY[\'remotely_inherited=true\'])) "
+						   "  AND n.nspname = ");
+	deparseStringLiteral(&buf, stmt->remote_schema);
+
+	/* Append EXCEPT/LIMIT TO restrictions */
+	append_import_schema_restrictions(&buf, stmt, conn);
+
+	/* Append ORDER BY at the end of query to ensure output ordering */
+	appendStringInfoString(&buf, " ORDER BY c.relname");
+
+	/* Fetch the data */
+	res = pgfdw_exec_query(conn, buf.data, NULL);
+	if (PQresultStatus(res) != PGRES_TUPLES_OK)
+		pgfdw_report_error(res, conn, buf.data);
+
+	/* Save the data */
+	numinherited = PQntuples(res);
+	if (numinherited > 0)
+	{
+		inherited = (char **) palloc0(numinherited * sizeof(char *));
+		for (i = 0; i < numinherited; i++)
+			inherited[i] = pstrdup(PQgetvalue(res, i, 0));
+	}
+
+	PQclear(res);
+	resetStringInfo(&buf);
+
 	/*
 	 * Fetch all table data from this schema, possibly restricted by EXCEPT or
 	 * LIMIT TO.  (We don't actually need to pay any attention to EXCEPT/LIMIT
@@ -6511,35 +6571,8 @@ postgresImportForeignSchema(ImportForeignSchemaStmt *stmt, Oid serverOid)
 						   "  AND n.nspname = ");
 	deparseStringLiteral(&buf, stmt->remote_schema);
 
-	/* Partitions are supported since Postgres 10 */
-	if (PQserverVersion(conn) >= 100000 &&
-		stmt->list_type != FDW_IMPORT_SCHEMA_LIMIT_TO)
-		appendStringInfoString(&buf, " AND NOT c.relispartition ");
-
-	/* Apply restrictions for LIMIT TO and EXCEPT */
-	if (stmt->list_type == FDW_IMPORT_SCHEMA_LIMIT_TO ||
-		stmt->list_type == FDW_IMPORT_SCHEMA_EXCEPT)
-	{
-		bool		first_item = true;
-
-		appendStringInfoString(&buf, " AND c.relname ");
-		if (stmt->list_type == FDW_IMPORT_SCHEMA_EXCEPT)
-			appendStringInfoString(&buf, "NOT ");
-		appendStringInfoString(&buf, "IN (");
-
-		/* Append list of table names within IN clause */
-		foreach(lc, stmt->table_list)
-		{
-			RangeVar   *rv = (RangeVar *) lfirst(lc);
-
-			if (first_item)
-				first_item = false;
-			else
-				appendStringInfoString(&buf, ", ");
-			deparseStringLiteral(&buf, rv->relname);
-		}
-		appendStringInfoChar(&buf, ')');
-	}
+	/* Append EXCEPT/LIMIT TO restrictions */
+	append_import_schema_restrictions(&buf, stmt, conn);
 
 	/* Append ORDER BY at the end of query to ensure output ordering */
 	appendStringInfoString(&buf, " ORDER BY c.relname, a.attnum");
@@ -6551,6 +6584,7 @@ postgresImportForeignSchema(ImportForeignSchemaStmt *stmt, Oid serverOid)
 
 	/* Process results */
 	numrows = PQntuples(res);
+	inherited_idx = 0;
 	/* note: incrementation of i happens in inner loop's while() test */
 	for (i = 0; i < numrows;)
 	{
@@ -6647,17 +6681,85 @@ postgresImportForeignSchema(ImportForeignSchemaStmt *stmt, Oid serverOid)
 		appendStringInfoString(&buf, ", table_name ");
 		deparseStringLiteral(&buf, tablename);
 
+		/*
+		 * Also add the remotely_inherited option if needed, to prevent unsafe
+		 * modifications to the foreign table (see check_result_rel()).
+		 *
+		 * By the definitions of the fetch queries using the same snapshot on
+		 * the remote server, the inherited is guaranteed to be a strictly
+		 * proper subset of the data processed here with the same order as it,
+		 * so we determine whether the foreign table is remotely-inherited or
+		 * not, by doing a merge join to it.
+		 */
+		if (numinherited > 0 && inherited_idx < numinherited &&
+			strcmp(tablename, inherited[inherited_idx]) == 0)
+		{
+			appendStringInfoString(&buf, ", remotely_inherited \'true\'");
+			inherited_idx++;
+		}
+
 		appendStringInfoString(&buf, ");");
 
 		commands = lappend(commands, pstrdup(buf.data));
 	}
 	PQclear(res);
 
+	if (numinherited > 0)
+	{
+		Assert(inherited != NULL);
+		for (i = 0; i < numinherited; i++)
+		{
+			Assert(inherited[i] != NULL);
+			pfree(inherited[i]);
+		}
+		pfree(inherited);
+	}
+
 	ReleaseConnection(conn);
 
 	return commands;
 }
 
+/*
+ * Append EXCEPT/LIMIT TO restrictions to a query.
+ */
+static void
+append_import_schema_restrictions(StringInfo buf,
+								  ImportForeignSchemaStmt *stmt,
+								  PGconn *conn)
+{
+	/* Partitions are supported since Postgres 10 */
+	if (PQserverVersion(conn) >= 100000 &&
+		stmt->list_type != FDW_IMPORT_SCHEMA_LIMIT_TO)
+		appendStringInfoString(buf, " AND NOT c.relispartition ");
+
+	/* Apply restrictions for LIMIT TO and EXCEPT */
+	if (stmt->list_type == FDW_IMPORT_SCHEMA_LIMIT_TO ||
+		stmt->list_type == FDW_IMPORT_SCHEMA_EXCEPT)
+	{
+		bool		first_item = true;
+		ListCell   *lc;
+
+		appendStringInfoString(buf, " AND c.relname ");
+		if (stmt->list_type == FDW_IMPORT_SCHEMA_EXCEPT)
+			appendStringInfoString(buf, "NOT ");
+		appendStringInfoString(buf, "IN (");
+
+		/* Append list of table names within IN clause */
+		foreach(lc, stmt->table_list)
+		{
+			RangeVar   *rv = (RangeVar *) lfirst(lc);
+
+			if (first_item)
+				first_item = false;
+			else
+				appendStringInfoString(buf, ", ");
+			deparseStringLiteral(buf, rv->relname);
+		}
+		appendStringInfoChar(buf, ')');
+	}
+}
+
 /*
  * Check if reltarget is safe enough to push down semi-join.  Reltarget is not
  * safe, if it contains references to inner rel relids, which do not belong to
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 31d5ea8a47d..12b7ad2235b 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -3275,8 +3275,23 @@ IMPORT FOREIGN SCHEMA import_source LIMIT TO (t5)
 
 ROLLBACK;
 
-BEGIN;
+-- Check that the remotely_inherited option is set when needed.
+CREATE TABLE import_source.inhchild (c1 int);
+CREATE TABLE import_source.t6 (c1 int);
+ALTER TABLE import_source.inhchild INHERIT import_source.t6;
+CREATE TABLE import_source.t7 (c1 int);
+ALTER TABLE import_source.inhchild INHERIT import_source.t7;
+ALTER TABLE import_source.inhchild NO INHERIT import_source.t7;
+CREATE FOREIGN TABLE import_source.t8 (c1 int) SERVER loopback
+  OPTIONS (remotely_inherited 'true');
+
+CREATE SCHEMA import_dest6;
+IMPORT FOREIGN SCHEMA import_source LIMIT TO (t6, t7, t8)
+  FROM SERVER loopback INTO import_dest6;
+\det+ import_dest6.*
+\d import_dest6.*
 
+BEGIN;
 
 CREATE SERVER fetch101 FOREIGN DATA WRAPPER postgres_fdw OPTIONS( fetch_size '101' );
 
-- 
2.50.1 (Apple Git-155)



^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-05 11:59               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
@ 2026-06-06 20:33                 ` Nikita Malakhov <hukutoc@gmail.com>
  2026-06-08 12:45                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  1 sibling, 1 reply; 30+ messages in thread

From: Nikita Malakhov @ 2026-06-06 20:33 UTC (permalink / raw)
  To: Etsuro Fujita <etsuro.fujita@gmail.com>; +Cc: Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Hi!

Thanks for working on the subject! I'll try to take a look inside in a
couple of days.

On Fri, Jun 5, 2026 at 2:59 PM Etsuro Fujita <etsuro.fujita@gmail.com>
wrote:

> On Mon, Jun 1, 2026 at 7:44 PM Etsuro Fujita <etsuro.fujita@gmail.com>
> wrote:
> > I think we should first work on a back-patchable solution.  So I'd
> > like to re-propose the patch that I proposed in this thread before to
> > disallow UPDATE/DELETE in problematic cases [1].  Attached is a new
> > version of the patch.  Changes are:
> >
> > * Renamed the new table option inherited to remotely_inherited, to
> > avoid confusion with local inheritance.
> > * Moved the logic to prevent problematic UPDATE/DELETE from a planner
> > function to an executor function, to avoid throwing an error
> > unnecessarily when there are no target rows to update/delete.
> > * Added docs to postgres-fdw.sgml.
> >
> > I'm planning to add the postgresImportForeignSchema() support in the
> > next version.
>
> I created the patch to add that support on top of the patch I sent in
> a previous email, which I'm attaching along with the base patch.  It's
> the same as before, except that I fixed a typo in docs pointed out by
> Michael-san off-list.
>
> Comments welcome!
>
> Best regards,
> Etsuro Fujita
>


-- 
Regards,
Nikita Malakhov
Postgres Professional
The Russian Postgres Company
https://postgrespro.ru/

^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-05 11:59               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-06 20:33                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
@ 2026-06-08 12:45                   ` Etsuro Fujita <etsuro.fujita@gmail.com>
  0 siblings, 0 replies; 30+ messages in thread

From: Etsuro Fujita @ 2026-06-08 12:45 UTC (permalink / raw)
  To: Nikita Malakhov <hukutoc@gmail.com>; +Cc: Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Hi Nikita,

On Sun, Jun 7, 2026 at 5:33 AM Nikita Malakhov <hukutoc@gmail.com> wrote:
> Thanks for working on the subject! I'll try to take a look inside in a couple of days.

Great!

Best regards,
Etsuro Fujita





^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-05 11:59               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
@ 2026-06-10 05:37                 ` Michael Paquier <michael@paquier.xyz>
  2026-06-10 11:22                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  1 sibling, 1 reply; 30+ messages in thread

From: Michael Paquier @ 2026-06-10 05:37 UTC (permalink / raw)
  To: Etsuro Fujita <etsuro.fujita@gmail.com>; +Cc: Nikita Malakhov <hukutoc@gmail.com>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

On Fri, Jun 05, 2026 at 08:59:17PM +0900, Etsuro Fujita wrote:
> I created the patch to add that support on top of the patch I sent in
> a previous email, which I'm attaching along with the base patch.  It's
> the same as before, except that I fixed a typo in docs pointed out by
> Michael-san off-list.

Splitting the patch set into two pieces, as of one for the
introduction of the remotely_inherited option defaulting to the
current HEAD behavior, and one for the modification of the IMPORT
FOREIGN SCHEMA, makes sense here.  A backpatch of the first patch is a
no-brainer, so as it gives a way for users to switch to the new
behavior at will.  I am however on edge regarding the wisdom of
backpatching the second patch, which would force a new behavior of the
postgres_fdw implementation for partitioned tables (based on my
read of the test with "t4") and INHERIT ("t6", "t8") depending on the
relkind or the property of the relation imported.  I can't help but
wonder why you don't take a different, slightly more conservative
approach on HEAD and the stable branches with a new option that can be
specified to the IMPORT FOREIGN SCHEMA query, to make the choice of
setting remotely_inherited for a relation imported an opt-in or
opt-out choice.

I would not object with a switch of the default behavior across major
versions, and perhaps my argument is not sound enough, but I've learnt
my share when it comes to be careful with changes like the one you may
introduce here across a minor release, particularly knowing that
remotely_inherited *can* be set on an option basis when creating a
table *or* when importing a schema.  The designs we have for these
queries allows this kind of flexibility.
--
Michael

Attachments:

  [application/pgp-signature] signature.asc (832B, ../../aij4NSMQYpQxtRMF@paquier.xyz/2-signature.asc)
  download

^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-05 11:59               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 05:37                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
@ 2026-06-10 11:22                   ` Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:25                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  0 siblings, 1 reply; 30+ messages in thread

From: Etsuro Fujita @ 2026-06-10 11:22 UTC (permalink / raw)
  To: Michael Paquier <michael@paquier.xyz>; +Cc: Nikita Malakhov <hukutoc@gmail.com>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

On Wed, Jun 10, 2026 at 2:38 PM Michael Paquier <michael@paquier.xyz> wrote:
> On Fri, Jun 05, 2026 at 08:59:17PM +0900, Etsuro Fujita wrote:
> > I created the patch to add that support on top of the patch I sent in
> > a previous email, which I'm attaching along with the base patch.  It's
> > the same as before, except that I fixed a typo in docs pointed out by
> > Michael-san off-list.
>
> Splitting the patch set into two pieces, as of one for the
> introduction of the remotely_inherited option defaulting to the
> current HEAD behavior, and one for the modification of the IMPORT
> FOREIGN SCHEMA, makes sense here.  A backpatch of the first patch is a
> no-brainer, so as it gives a way for users to switch to the new
> behavior at will.  I am however on edge regarding the wisdom of
> backpatching the second patch, which would force a new behavior of the
> postgres_fdw implementation for partitioned tables (based on my
> read of the test with "t4") and INHERIT ("t6", "t8") depending on the
> relkind or the property of the relation imported.  I can't help but
> wonder why you don't take a different, slightly more conservative
> approach on HEAD and the stable branches with a new option that can be
> specified to the IMPORT FOREIGN SCHEMA query, to make the choice of
> setting remotely_inherited for a relation imported an opt-in or
> opt-out choice.
>
> I would not object with a switch of the default behavior across major
> versions, and perhaps my argument is not sound enough, but I've learnt
> my share when it comes to be careful with changes like the one you may
> introduce here across a minor release, particularly knowing that
> remotely_inherited *can* be set on an option basis when creating a
> table *or* when importing a schema.  The designs we have for these
> queries allows this kind of flexibility.

I agree that we should take a more conservative approach especially on
the stable branches, and I think it's a good idea to add the option to
IMPORT FOREIGN SCHEMA for that, so I will update the patch as such in
the next version.

Thanks for the comments!

Best regards,
Etsuro Fujita





^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-05 11:59               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 05:37                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  2026-06-10 11:22                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
@ 2026-06-10 22:25                     ` Michael Paquier <michael@paquier.xyz>
  0 siblings, 0 replies; 30+ messages in thread

From: Michael Paquier @ 2026-06-10 22:25 UTC (permalink / raw)
  To: Etsuro Fujita <etsuro.fujita@gmail.com>; +Cc: Nikita Malakhov <hukutoc@gmail.com>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

On Wed, Jun 10, 2026 at 08:22:31PM +0900, Etsuro Fujita wrote:
> On Wed, Jun 10, 2026 at 2:38 PM Michael Paquier <michael@paquier.xyz> wrote:
>> I would not object with a switch of the default behavior across major
>> versions, and perhaps my argument is not sound enough, but I've learnt
>> my share when it comes to be careful with changes like the one you may
>> introduce here across a minor release, particularly knowing that
>> remotely_inherited *can* be set on an option basis when creating a
>> table *or* when importing a schema.  The designs we have for these
>> queries allows this kind of flexibility.
> 
> I agree that we should take a more conservative approach especially on
> the stable branches, and I think it's a good idea to add the option to
> IMPORT FOREIGN SCHEMA for that, so I will update the patch as such in
> the next version.

Cool, thanks.
--
Michael

Attachments:

  [application/pgp-signature] signature.asc (832B, ../../ainkWN3s-12__fpo@paquier.xyz/2-signature.asc)
  download

^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
@ 2026-06-10 11:30               ` Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  1 sibling, 1 reply; 30+ messages in thread

From: Etsuro Fujita @ 2026-06-10 11:30 UTC (permalink / raw)
  To: Nikita Malakhov <hukutoc@gmail.com>; +Cc: Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

On Mon, Jun 1, 2026 at 7:44 PM Etsuro Fujita <etsuro.fujita@gmail.com> wrote:
> On Fri, May 15, 2026 at 2:23 AM Nikita Malakhov <hukutoc@gmail.com> wrote:
> > CFbot was unhappy with previous patch set, so here's updated one

> I took a quick look at the patch set.  IIUC I think it's created based
> on what I proposed in the original thread, which is invasive and thus
> not back-patchable, so what you are proposing here isn't
> back-patchable, either, I think.

One thing I noticed about what I proposed in the original thread (but
didn't when working on it) is that it would well handle cases where
the remote table is a (simple) inherited/partitioned table, but
wouldn't cases where it's e.g., a foreign table on the remote server
pointing to such a table on another remote server.  I haven't looked
at your patch in very detail yet, but I tested it as shown below, and
it causes unexpected results, so I suppose it inherits the limitation.

create table pt (a int, b text) partition by list (a);
create table pt_p1 partition of pt for values in (1);
create table pt_p2 partition of pt for values in (2);
create foreign table ft1 (a int, b text) server loopback options
(table_name 'pt');
create foreign table ft2 (a int, b text) server loopback options
(table_name 'ft1');
insert into pt values (1, 'foo'), (2, 'bar');
select ctid, * from ft2;
 ctid  | a |  b
-------+---+-----
 (0,1) | 1 | foo
 (0,1) | 2 | bar
(2 rows)

explain verbose update ft2 set b = b || b where b = 'bar' and random() < 1.0;
                                           QUERY PLAN
------------------------------------------------------------------------------------------------
 Update on public.ft2  (cost=100.00..121.66 rows=0 width=0)
   Remote SQL: UPDATE public.ft1 SET b = $3 WHERE ctid = $1 AND tableoid = $2
   ->  Foreign Scan on public.ft2  (cost=100.00..121.66 rows=1 width=106)
         Output: (b || b), ctid, tableoid, $0, ft2.*
         Filter: (random() < '1'::double precision)
         Remote SQL: SELECT a, b, ctid, tableoid FROM public.ft1 WHERE
((b = 'bar')) FOR UPDATE
(6 rows)

update ft2 set b = b || b where b = 'bar' and random() < 1.0;
UPDATE 1
select ctid, * from ft2;
 ctid  | a |   b
-------+---+--------
 (0,2) | 1 | barbar
 (0,1) | 2 | bar
(2 rows)

The first row belonging to pt_p1 is updated, which is wrong; the
second one belonging to pt_p2 should be updated.

To address this, I think it would be good if we could 1) extend the
concept of inheritance to cover remote inheritances, like pt, and 2)
extend inherited UPDATE/DELETE so that we update/delete leaf tables,
like pt_p2, somehow directly, as done for local inheritances.  I'm not
sure about how to do that, though.

Best regards,
Etsuro Fujita





^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
@ 2026-06-10 22:30                 ` Michael Paquier <michael@paquier.xyz>
  2026-06-13 19:43                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Michael Paquier @ 2026-06-10 22:30 UTC (permalink / raw)
  To: Etsuro Fujita <etsuro.fujita@gmail.com>; +Cc: Nikita Malakhov <hukutoc@gmail.com>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

On Wed, Jun 10, 2026 at 08:30:46PM +0900, Etsuro Fujita wrote:
> To address this, I think it would be good if we could 1) extend the
> concept of inheritance to cover remote inheritances, like pt, and 2)
> extend inherited UPDATE/DELETE so that we update/delete leaf tables,
> like pt_p2, somehow directly, as done for local inheritances.  I'm not
> sure about how to do that, though.

FWIW, I think that there is a good argument for keeping it down to
simpler, and just not care about the option chain in such cases,
leaving it up to users to address that with two imports anyway?  Just 
having the option at one level would solve most historical problems I
could see on this thread.  Good is sometimes a better option than
theoretically perfect.  And good here means a simpler implementation
overall, at least it seems to me so..
--
Michael

Attachments:

  [application/pgp-signature] signature.asc (832B, ../../ainlkoxb4m84ZJRD@paquier.xyz/2-signature.asc)
  download

^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
@ 2026-06-13 19:43                   ` Nikita Malakhov <hukutoc@gmail.com>
  2026-06-15 14:54                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Nikita Malakhov @ 2026-06-13 19:43 UTC (permalink / raw)
  To: Michael Paquier <michael@paquier.xyz>; +Cc: Etsuro Fujita <etsuro.fujita@gmail.com>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Hi!

While testing the proposed solution we've stumbled upon another vanilla bug
related to FDW -
a query with DELETE ... USING selects invalid records from partitioned FDW
tables:

CREATE EXTENSION postgres_fdw;

CREATE SERVER testserver1 FOREIGN DATA WRAPPER postgres_fdw;
DO $d$
    BEGIN
        EXECUTE $$CREATE SERVER loopback FOREIGN DATA WRAPPER postgres_fdw
            OPTIONS (dbname '$$||current_database()||$$',
                     port '$$||current_setting('port')||$$'
            )$$;
    END;
$d$;

CREATE USER MAPPING FOR public SERVER testserver1
OPTIONS (user 'value', password 'value');
CREATE USER MAPPING FOR CURRENT_USER SERVER loopback;
CREATE USER MAPPING FOR CURRENT_USER SERVER loopback2;
CREATE USER MAPPING FOR public SERVER loopback3;

CREATE TABLE acc_entry
(
    id bigint,
    doc_date date,
    impact int,
    amount numeric
) PARTITION BY RANGE (doc_date);

CREATE TABLE acc_entry_p1
PARTITION OF acc_entry
FOR VALUES FROM ('2025-01-01') TO ('2025-07-01');

CREATE TABLE acc_entry_p2
PARTITION OF acc_entry
FOR VALUES FROM ('2025-07-01') TO ('2026-01-01');

CREATE FOREIGN TABLE measurement_fdw
(
    id bigint,
    doc_date date,
    impact int,
    amount numeric
)
SERVER loopback
OPTIONS (table_name 'acc_entry');

INSERT INTO acc_entry
SELECT
    CASE
        WHEN g IN (4,15,26,35,46,55,66,75,86,95)
            THEN 2501020100000124
        ELSE g
    END AS id,
    CASE WHEN g % 2 = 0 THEN timestamp '2025-02-02' ELSE timestamp
'2025-08-08' END,
    1,
    g
FROM generate_series(1,100) g;

DELETE FROM measurement_fdw
USING (
    SELECT id
    FROM measurement_fdw
    WHERE id = 2501020100000124
    LIMIT 1
) s
WHERE measurement_fdw.id = s.id;

The latter query selects and deletes records with invalid ID which should
not be selected at all.
Although rewritten query like
with sub as (
           select t1.id sub_id
           from measurement_fdw t1
           where t1.id=2501020100000124
           limit 1
        )
select m.id, m.doc_date, m.impact, m.amount from measurement_fdw m, sub
        where m.id = sub.sub_id;
works correctly.
Currently I try to figure out what's the cause of this strange behavior and
I'm suspicious about
/*
 * WCO_RLS_MERGE_DELETE_CHECK is used to check DELETE USING quals on
 * the existing target row.
*/
add_with_check_options(rel, rt_index,
                    WCO_RLS_MERGE_DELETE_CHECK,
merge_delete_permissive_policies,
merge_delete_restrictive_policies,
withCheckOptions,
hasSubLinks,
hasSubLinks,
true);

--
Regards,
Nikita Malakhov
Postgres Professional
The Russian Postgres Company
https://postgrespro.ru/

^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  2026-06-13 19:43                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
@ 2026-06-15 14:54                     ` Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-07-09 14:03                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Etsuro Fujita @ 2026-06-15 14:54 UTC (permalink / raw)
  To: Nikita Malakhov <hukutoc@gmail.com>; +Cc: Michael Paquier <michael@paquier.xyz>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

On Sun, Jun 14, 2026 at 4:43 AM Nikita Malakhov <hukutoc@gmail.com> wrote:
> While testing the proposed solution we've stumbled upon another vanilla bug related to FDW -
> a query with DELETE ... USING selects invalid records from partitioned FDW tables:

> CREATE TABLE acc_entry
> (
>     id bigint,
>     doc_date date,
>     impact int,
>     amount numeric
> ) PARTITION BY RANGE (doc_date);
>
> CREATE TABLE acc_entry_p1
> PARTITION OF acc_entry
> FOR VALUES FROM ('2025-01-01') TO ('2025-07-01');
>
> CREATE TABLE acc_entry_p2
> PARTITION OF acc_entry
> FOR VALUES FROM ('2025-07-01') TO ('2026-01-01');
>
> CREATE FOREIGN TABLE measurement_fdw
> (
>     id bigint,
>     doc_date date,
>     impact int,
>     amount numeric
> )
> SERVER loopback
> OPTIONS (table_name 'acc_entry');
>
> INSERT INTO acc_entry
> SELECT
>     CASE
>         WHEN g IN (4,15,26,35,46,55,66,75,86,95)
>             THEN 2501020100000124
>         ELSE g
>     END AS id,
>     CASE WHEN g % 2 = 0 THEN timestamp '2025-02-02' ELSE timestamp '2025-08-08' END,
>     1,
>     g
> FROM generate_series(1,100) g;
>
> DELETE FROM measurement_fdw
> USING (
>     SELECT id
>     FROM measurement_fdw
>     WHERE id = 2501020100000124
>     LIMIT 1
> ) s
> WHERE measurement_fdw.id = s.id;
>
> The latter query selects and deletes records with invalid ID which should not be selected at all.

I think that that would be another example that the bug discussed here
causes unexpected results, as I have this after inserting the data
into the partitioned table:

select tableoid::regclass, ctid, * from acc_entry where ctid in
(select ctid from acc_entry where id = 2501020100000124);
   tableoid   |  ctid  |        id        |  doc_date  | impact | amount
--------------+--------+------------------+------------+--------+--------
 acc_entry_p1 | (0,2)  | 2501020100000124 | 2025-02-02 |      1 |      4
 acc_entry_p1 | (0,8)  |               16 | 2025-02-02 |      1 |     16
 acc_entry_p1 | (0,13) | 2501020100000124 | 2025-02-02 |      1 |     26
 acc_entry_p1 | (0,18) |               36 | 2025-02-02 |      1 |     36
 acc_entry_p1 | (0,23) | 2501020100000124 | 2025-02-02 |      1 |     46
 acc_entry_p1 | (0,28) |               56 | 2025-02-02 |      1 |     56
 acc_entry_p1 | (0,33) | 2501020100000124 | 2025-02-02 |      1 |     66
 acc_entry_p1 | (0,38) |               76 | 2025-02-02 |      1 |     76
 acc_entry_p1 | (0,43) | 2501020100000124 | 2025-02-02 |      1 |     86
 acc_entry_p1 | (0,48) |               96 | 2025-02-02 |      1 |     96
 acc_entry_p2 | (0,2)  |                3 | 2025-08-08 |      1 |      3
 acc_entry_p2 | (0,8)  | 2501020100000124 | 2025-08-08 |      1 |     15
 acc_entry_p2 | (0,13) |               25 | 2025-08-08 |      1 |     25
 acc_entry_p2 | (0,18) | 2501020100000124 | 2025-08-08 |      1 |     35
 acc_entry_p2 | (0,23) |               45 | 2025-08-08 |      1 |     45
 acc_entry_p2 | (0,28) | 2501020100000124 | 2025-08-08 |      1 |     55
 acc_entry_p2 | (0,33) |               65 | 2025-08-08 |      1 |     65
 acc_entry_p2 | (0,38) | 2501020100000124 | 2025-08-08 |      1 |     75
 acc_entry_p2 | (0,43) |               85 | 2025-08-08 |      1 |     85
 acc_entry_p2 | (0,48) | 2501020100000124 | 2025-08-08 |      1 |     95
(20 rows)

Note that the rows with normal ids have the same ctid as the rows with
id=2501020100000124 (for example, ctid of the row with id=3 is (0,2),
which is the same as that of the first row, which has
id=2501020100000124), so the bug would delete such normal-id rows as
well when performing the delete query.

Best regards,
Etsuro Fujita





^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  2026-06-13 19:43                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-15 14:54                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
@ 2026-07-09 14:03                       ` Nikita Malakhov <hukutoc@gmail.com>
  2026-08-27 09:20                         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Nikita Malakhov @ 2026-07-09 14:03 UTC (permalink / raw)
  To: Etsuro Fujita <etsuro.fujita@gmail.com>; +Cc: Michael Paquier <michael@paquier.xyz>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Hi hackers!

I've fixed the issue mentioned in two previous messages, so for anyone
already have taken
my patch set above I'd compiled a fixed patch set. It does not object an
approach
proposed by Etsuro-san.

The first patch file
v4-0001-copy-and-remote-tableoid-param.patch
is a core patch introducing extended copy slot and tuple facilities
and core machinery for remote table OIDs;

The second file
v4-0002-teach-fdw-use-remote-tableoid.patch
is a fdw facilities changes (fixed) to use remote table OID.

On Mon, Jun 15, 2026 at 5:55 PM Etsuro Fujita <etsuro.fujita@gmail.com>
wrote:

> On Sun, Jun 14, 2026 at 4:43 AM Nikita Malakhov <hukutoc@gmail.com> wrote:
> > While testing the proposed solution we've stumbled upon another vanilla
> bug related to FDW -
> > a query with DELETE ... USING selects invalid records from partitioned
> FDW tables:
>
> > CREATE TABLE acc_entry
> > (
> >     id bigint,
> >     doc_date date,
> >     impact int,
> >     amount numeric
> > ) PARTITION BY RANGE (doc_date);
> >
> > CREATE TABLE acc_entry_p1
> > PARTITION OF acc_entry
> > FOR VALUES FROM ('2025-01-01') TO ('2025-07-01');
> >
> > CREATE TABLE acc_entry_p2
> > PARTITION OF acc_entry
> > FOR VALUES FROM ('2025-07-01') TO ('2026-01-01');
> >
> > CREATE FOREIGN TABLE measurement_fdw
> > (
> >     id bigint,
> >     doc_date date,
> >     impact int,
> >     amount numeric
> > )
> > SERVER loopback
> > OPTIONS (table_name 'acc_entry');
> >
> > INSERT INTO acc_entry
> > SELECT
> >     CASE
> >         WHEN g IN (4,15,26,35,46,55,66,75,86,95)
> >             THEN 2501020100000124
> >         ELSE g
> >     END AS id,
> >     CASE WHEN g % 2 = 0 THEN timestamp '2025-02-02' ELSE timestamp
> '2025-08-08' END,
> >     1,
> >     g
> > FROM generate_series(1,100) g;
> >
> > DELETE FROM measurement_fdw
> > USING (
> >     SELECT id
> >     FROM measurement_fdw
> >     WHERE id = 2501020100000124
> >     LIMIT 1
> > ) s
> > WHERE measurement_fdw.id = s.id;
> >
> > The latter query selects and deletes records with invalid ID which
> should not be selected at all.
>
> I think that that would be another example that the bug discussed here
> causes unexpected results, as I have this after inserting the data
> into the partitioned table:
>
> select tableoid::regclass, ctid, * from acc_entry where ctid in
> (select ctid from acc_entry where id = 2501020100000124);
>    tableoid   |  ctid  |        id        |  doc_date  | impact | amount
> --------------+--------+------------------+------------+--------+--------
>  acc_entry_p1 | (0,2)  | 2501020100000124 | 2025-02-02 |      1 |      4
>  acc_entry_p1 | (0,8)  |               16 | 2025-02-02 |      1 |     16
>  acc_entry_p1 | (0,13) | 2501020100000124 | 2025-02-02 |      1 |     26
>  acc_entry_p1 | (0,18) |               36 | 2025-02-02 |      1 |     36
>  acc_entry_p1 | (0,23) | 2501020100000124 | 2025-02-02 |      1 |     46
>  acc_entry_p1 | (0,28) |               56 | 2025-02-02 |      1 |     56
>  acc_entry_p1 | (0,33) | 2501020100000124 | 2025-02-02 |      1 |     66
>  acc_entry_p1 | (0,38) |               76 | 2025-02-02 |      1 |     76
>  acc_entry_p1 | (0,43) | 2501020100000124 | 2025-02-02 |      1 |     86
>  acc_entry_p1 | (0,48) |               96 | 2025-02-02 |      1 |     96
>  acc_entry_p2 | (0,2)  |                3 | 2025-08-08 |      1 |      3
>  acc_entry_p2 | (0,8)  | 2501020100000124 | 2025-08-08 |      1 |     15
>  acc_entry_p2 | (0,13) |               25 | 2025-08-08 |      1 |     25
>  acc_entry_p2 | (0,18) | 2501020100000124 | 2025-08-08 |      1 |     35
>  acc_entry_p2 | (0,23) |               45 | 2025-08-08 |      1 |     45
>  acc_entry_p2 | (0,28) | 2501020100000124 | 2025-08-08 |      1 |     55
>  acc_entry_p2 | (0,33) |               65 | 2025-08-08 |      1 |     65
>  acc_entry_p2 | (0,38) | 2501020100000124 | 2025-08-08 |      1 |     75
>  acc_entry_p2 | (0,43) |               85 | 2025-08-08 |      1 |     85
>  acc_entry_p2 | (0,48) | 2501020100000124 | 2025-08-08 |      1 |     95
> (20 rows)
>
> Note that the rows with normal ids have the same ctid as the rows with
> id=2501020100000124 (for example, ctid of the row with id=3 is (0,2),
> which is the same as that of the first row, which has
> id=2501020100000124), so the bug would delete such normal-id rows as
> well when performing the delete query.
>
> Best regards,
> Etsuro Fujita
>


-- 
Regards,
Nikita Malakhov
Postgres Professional
The Russian Postgres Company
https://postgrespro.ru/

Attachments:

  [application/octet-stream] v4-0001-copy-and-remote-tableoid-param.patch (41.3K, ../../CAN-LCVMboJszNufW+4NRZh4M6c-u74RM4Y7bfjy_8PVmQVktJg@mail.gmail.com/3-v4-0001-copy-and-remote-tableoid-param.patch)
  download | inline diff:
From 7c03cd1156b5e1ecbb5c063a22fba3a047a02c24 Mon Sep 17 00:00:00 2001
From: Nikita Malakhov <n.malakhov@postgrespro.ru>
Date: Thu, 9 Jul 2026 16:21:32 +0300
Subject: [PATCH] DELETE/UPDATE more than one row in partitioned foreign table
 (1/2)

There is known bug in delete and update rows in partitioned foreign
tables via FDW [1]

Current copyTuple and copySlot machinery supposes that
source and destination slots and tuples always match. This patch modifies
copy internals to consider more complex case and copy only actual attributes,
and if destination slot/tuple has more they are set to null. This is a proof
of concept patch which shows such mechanics does not break anything and
allows to use it in cases when we have to pass or receive not matching number
of attributes.

Core patch - introduction of remote table OID returned from remote
query and passing it through planner and executor to use in foreign
queries

[1] Discussion: https://www.postgresql.org/message-id/flat/20250718175314.4513c00a%40karst
---
 src/backend/access/common/heaptuple.c   | 127 ++++++++++++++++++++++--
 src/backend/executor/execGrouping.c     |   3 +-
 src/backend/executor/execMain.c         |   4 +
 src/backend/executor/execTuples.c       | 107 ++++++++++++++------
 src/backend/executor/nodeForeignscan.c  |   2 +
 src/backend/optimizer/path/allpaths.c   |  20 ++++
 src/backend/optimizer/plan/createplan.c |  19 ++++
 src/backend/optimizer/plan/initsplan.c  |  45 +++++++++
 src/backend/optimizer/plan/planner.c    |  14 ++-
 src/backend/optimizer/plan/setrefs.c    |  56 +++++++++++
 src/backend/optimizer/plan/subselect.c  |   7 +-
 src/backend/optimizer/util/appendinfo.c |  27 +++++
 src/backend/optimizer/util/inherit.c    |   1 +
 src/backend/optimizer/util/relnode.c    |  21 ++++
 src/backend/optimizer/util/tlist.c      |  39 ++++++--
 src/backend/utils/adt/ruleutils.c       |   2 +-
 src/include/access/htup_details.h       |  22 ++++
 src/include/executor/tuptable.h         |  42 ++++++--
 src/include/nodes/pathnodes.h           |   7 ++
 src/include/nodes/primnodes.h           |   1 +
 20 files changed, 500 insertions(+), 66 deletions(-)

diff --git a/src/backend/access/common/heaptuple.c b/src/backend/access/common/heaptuple.c
index f30346469ed..d4fbd962ec1 100644
--- a/src/backend/access/common/heaptuple.c
+++ b/src/backend/access/common/heaptuple.c
@@ -61,6 +61,7 @@
 #include "access/sysattr.h"
 #include "access/tupdesc_details.h"
 #include "common/hashfn.h"
+#include "executor/tuptable.h"
 #include "utils/datum.h"
 #include "utils/expandeddatum.h"
 #include "utils/hsearch.h"
@@ -219,11 +220,30 @@ Size
 heap_compute_data_size(TupleDesc tupleDesc,
 					   const Datum *values,
 					   const bool *isnull)
+{
+	return heap_compute_data_size_ext(tupleDesc, values, isnull,
+									  - 1 /* take natts from tupleDesc */);
+}
+
+/*
+ * heap_compute_data_size_ext
+ *		Same as above, but allows to specify number of attributes explicitly.
+ *
+ * If "natts" <= 0, then number of attributes is taken from the TupleDesc.
+ */
+Size
+heap_compute_data_size_ext(TupleDesc tupleDesc,
+ 					   const Datum *values,
+					   const bool *isnull,
+					   int natts)
 {
 	Size		data_length = 0;
 	int			i;
 	int			numberOfAttributes = tupleDesc->natts;
 
+	if (natts > 0)
+		numberOfAttributes = natts;
+
 	for (i = 0; i < numberOfAttributes; i++)
 	{
 		Datum		val;
@@ -402,6 +422,22 @@ heap_fill_tuple(TupleDesc tupleDesc,
 				const Datum *values, const bool *isnull,
 				char *data, Size data_size,
 				uint16 *infomask, uint8 *bit)
+{
+	heap_fill_tuple_ext(tupleDesc, values, isnull,  data, data_size, infomask,
+						bit, -1 /* take natts from tupleDesc */);
+}
+
+/*
+ * heap_fill_tuple_ext
+ *             Same as above, but allows to specify number of attributes explicitly.
+ *
+ * If "natts" <= 0, then number of attributes is taken from the TupleDesc.
+ */
+void
+heap_fill_tuple_ext(TupleDesc tupleDesc,
+					const Datum *values, const bool *isnull,
+					char *data, Size data_size,
+					uint16 *infomask, uint8 *bit, int natts)
 {
 	uint8	   *bitP;
 	int			bitmask;
@@ -412,6 +448,9 @@ heap_fill_tuple(TupleDesc tupleDesc,
 	char	   *start = data;
 #endif
 
+	if (natts > 0)
+		numberOfAttributes = natts;
+
 	if (bit != NULL)
 	{
 		bitP = &bit[-1];
@@ -684,18 +723,48 @@ heap_getsysattr(HeapTuple tup, int attnum, TupleDesc tupleDesc, bool *isnull)
  */
 HeapTuple
 heap_copytuple(HeapTuple tuple)
+{
+	return heap_copytuple_ext(tuple, NULL, -1);
+}
+
+/* ----------------
+ *		heap_copytuple_ext
+ *
+ *		returns a copy of a tuple consists of attnum atts of original
+ *
+ * The HeapTuple struct, tuple header, and tuple data are all allocated
+ * as a single palloc() block.
+ * ----------------
+ */
+HeapTuple
+heap_copytuple_ext(HeapTuple tuple, TupleTableSlot *slot, int dstnatts)
 {
 	HeapTuple	newTuple;
+	Size		tuplen;
 
 	if (!HeapTupleIsValid(tuple) || tuple->t_data == NULL)
 		return NULL;
 
-	newTuple = (HeapTuple) palloc(HEAPTUPLESIZE + tuple->t_len);
-	newTuple->t_len = tuple->t_len;
+	tuplen = tuple->t_len;
+
+	if (slot != NULL)
+	{
+		if (dstnatts < 0)
+			dstnatts = slot->tts_tupleDescriptor->natts;
+
+		if (dstnatts < slot->tts_tupleDescriptor->natts)
+			tuplen = heap_compute_data_size_ext(slot->tts_tupleDescriptor,
+												slot->tts_values,
+												slot->tts_isnull,
+												dstnatts);
+	}
+
+	newTuple = (HeapTuple) palloc(HEAPTUPLESIZE + tuplen);
+	newTuple->t_len = tuplen;
 	newTuple->t_self = tuple->t_self;
 	newTuple->t_tableOid = tuple->t_tableOid;
 	newTuple->t_data = (HeapTupleHeader) ((char *) newTuple + HEAPTUPLESIZE);
-	memcpy(newTuple->t_data, tuple->t_data, tuple->t_len);
+	memcpy(newTuple->t_data, tuple->t_data, tuplen);
 	return newTuple;
 }
 
@@ -1025,6 +1094,21 @@ HeapTuple
 heap_form_tuple(TupleDesc tupleDescriptor,
 				const Datum *values,
 				const bool *isnull)
+{
+	return heap_form_tuple_ext(tupleDescriptor, values, isnull, -1);
+}
+
+/*
+ * heap_form_tuple_ext
+ *		Same as above, but allows to specify number of attributes explicitly.
+ *
+ * If "natts" <= 0, then number of attributes is taken from the TupleDesc.
+ */
+HeapTuple
+heap_form_tuple_ext(TupleDesc tupleDescriptor,
+					const Datum *values,
+					const bool *isnull,
+					int natts)
 {
 	HeapTuple	tuple;			/* return tuple */
 	HeapTupleHeader td;			/* tuple data */
@@ -1035,6 +1119,9 @@ heap_form_tuple(TupleDesc tupleDescriptor,
 	int			numberOfAttributes = tupleDescriptor->natts;
 	int			i;
 
+	if(natts > 0)
+		numberOfAttributes = natts;
+
 	if (numberOfAttributes > MaxTupleAttributeNumber)
 		ereport(ERROR,
 				(errcode(ERRCODE_TOO_MANY_COLUMNS),
@@ -1063,7 +1150,7 @@ heap_form_tuple(TupleDesc tupleDescriptor,
 
 	hoff = len = MAXALIGN(len); /* align user data safely */
 
-	data_len = heap_compute_data_size(tupleDescriptor, values, isnull);
+	data_len = heap_compute_data_size_ext(tupleDescriptor, values, isnull, natts);
 
 	len += data_len;
 
@@ -1092,13 +1179,14 @@ heap_form_tuple(TupleDesc tupleDescriptor,
 	HeapTupleHeaderSetNatts(td, numberOfAttributes);
 	td->t_hoff = hoff;
 
-	heap_fill_tuple(tupleDescriptor,
+	heap_fill_tuple_ext(tupleDescriptor,
 					values,
 					isnull,
 					(char *) td + hoff,
 					data_len,
 					&td->t_infomask,
-					(hasnull ? td->t_bits : NULL));
+					(hasnull ? td->t_bits : NULL),
+					natts);
 
 	return tuple;
 }
@@ -1391,6 +1479,23 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 						const Datum *values,
 						const bool *isnull,
 						Size extra)
+{
+	return heap_form_minimal_tuple_ext(tupleDescriptor, values, isnull, extra,
+									   -1 /* take natts from tupleDesc */);
+}
+
+/*
+ * heap_form_minimal_tuple_ext
+ *		Same as above, but allows to specify number of attributes explicitly.
+ *
+ * If "natts" <= 0, then number of attributes is taken from the TupleDesc.
+ */
+MinimalTuple
+heap_form_minimal_tuple_ext(TupleDesc tupleDescriptor,
+ 							const Datum *values,
+							const bool *isnull,
+							Size extra,
+							int natts)
 {
 	MinimalTuple tuple;			/* return tuple */
 	char	   *mem;
@@ -1401,6 +1506,9 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 	int			numberOfAttributes = tupleDescriptor->natts;
 	int			i;
 
+	if (natts > 0)
+		numberOfAttributes = natts;
+
 	Assert(extra == MAXALIGN(extra));
 
 	if (numberOfAttributes > MaxTupleAttributeNumber)
@@ -1431,7 +1539,7 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 
 	hoff = len = MAXALIGN(len); /* align user data safely */
 
-	data_len = heap_compute_data_size(tupleDescriptor, values, isnull);
+	data_len = heap_compute_data_size_ext(tupleDescriptor, values, isnull, natts);
 
 	len += data_len;
 
@@ -1448,13 +1556,14 @@ heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 	HeapTupleHeaderSetNatts(tuple, numberOfAttributes);
 	tuple->t_hoff = hoff + MINIMAL_TUPLE_OFFSET;
 
-	heap_fill_tuple(tupleDescriptor,
+	heap_fill_tuple_ext(tupleDescriptor,
 					values,
 					isnull,
 					(char *) tuple + hoff,
 					data_len,
 					&tuple->t_infomask,
-					(hasnull ? tuple->t_bits : NULL));
+					(hasnull ? tuple->t_bits : NULL),
+					natts);
 
 	return tuple;
 }
diff --git a/src/backend/executor/execGrouping.c b/src/backend/executor/execGrouping.c
index c107514a85d..7ef7c8c6d9d 100644
--- a/src/backend/executor/execGrouping.c
+++ b/src/backend/executor/execGrouping.c
@@ -583,7 +583,8 @@ LookupTupleHashEntry_internal(TupleHashTable hashtable, TupleTableSlot *slot,
 			 * TupleHashEntryData or allocate an additional chunk.
 			 */
 			entry->firstTuple = ExecCopySlotMinimalTupleExtra(slot,
-															  hashtable->additionalsize);
+															  hashtable->additionalsize,
+															  -1 /* natts */);
 		}
 	}
 	else
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index fde502efd38..5bd766a3a81 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -2658,6 +2658,10 @@ ExecBuildAuxRowMark(ExecRowMark *erm, List *targetlist)
 													   resname);
 		if (!AttributeNumberIsValid(aerm->ctidAttNo))
 			elog(ERROR, "could not find junk %s column", resname);
+//fdwproblem
+			snprintf(resname, sizeof(resname), "tableoid%u", erm->rowmarkId);
+		aerm->toidAttNo = ExecFindJunkAttributeInTlist(targetlist,
+													   resname);
 	}
 	else
 	{
diff --git a/src/backend/executor/execTuples.c b/src/backend/executor/execTuples.c
index 7f4ebf95432..5462d26532a 100644
--- a/src/backend/executor/execTuples.c
+++ b/src/backend/executor/execTuples.c
@@ -79,6 +79,9 @@ static inline void tts_buffer_heap_store_tuple(TupleTableSlot *slot,
 											   Buffer buffer,
 											   bool transfer_pin);
 static void tts_heap_store_tuple(TupleTableSlot *slot, HeapTuple tuple, bool shouldFree);
+static void tts_heap_materialize_ext(TupleTableSlot *slot, int natts);
+static void tts_minimal_materialize_ext(TupleTableSlot *slot, int natts);
+static void tts_buffer_heap_materialize_ext(TupleTableSlot *slot, int natts);
 
 
 const TupleTableSlotOps TTSOpsVirtual;
@@ -269,18 +272,29 @@ static void
 tts_virtual_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 {
 	TupleDesc	srcdesc = srcslot->tts_tupleDescriptor;
+	TupleDesc	dstdesc = dstslot->tts_tupleDescriptor;
+	int			min_natts = Min(dstdesc->natts, srcdesc->natts);
 
 	tts_virtual_clear(dstslot);
 
 	slot_getallattrs(srcslot);
 
-	for (int natt = 0; natt < srcdesc->natts; natt++)
+	for (int natt = 0; natt < min_natts; natt++)
 	{
 		dstslot->tts_values[natt] = srcslot->tts_values[natt];
 		dstslot->tts_isnull[natt] = srcslot->tts_isnull[natt];
 	}
 
-	dstslot->tts_nvalid = srcdesc->natts;
+	if (dstdesc->natts > srcdesc->natts)
+	{
+		for (int natt = min_natts; natt < dstdesc->natts; natt++)
+		{
+			dstslot->tts_values[natt] = (Datum) 0;
+			dstslot->tts_isnull[natt] = true;
+		}
+	}
+
+	dstslot->tts_nvalid = dstdesc->natts;
 	dstslot->tts_flags &= ~TTS_FLAG_EMPTY;
 
 	/* make sure storage doesn't depend on external memory */
@@ -288,24 +302,26 @@ tts_virtual_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 }
 
 static HeapTuple
-tts_virtual_copy_heap_tuple(TupleTableSlot *slot)
+tts_virtual_copy_heap_tuple(TupleTableSlot *slot, int natts)
 {
 	Assert(!TTS_EMPTY(slot));
 
-	return heap_form_tuple(slot->tts_tupleDescriptor,
+	return heap_form_tuple_ext(slot->tts_tupleDescriptor,
 						   slot->tts_values,
-						   slot->tts_isnull);
+						   slot->tts_isnull,
+						   natts);
 }
 
 static MinimalTuple
-tts_virtual_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
+tts_virtual_copy_minimal_tuple(TupleTableSlot *slot, Size extra, int natts)
 {
 	Assert(!TTS_EMPTY(slot));
 
-	return heap_form_minimal_tuple(slot->tts_tupleDescriptor,
+	return heap_form_minimal_tuple_ext(slot->tts_tupleDescriptor,
 								   slot->tts_values,
 								   slot->tts_isnull,
-								   extra);
+								   extra,
+								   natts);
 }
 
 
@@ -397,6 +413,12 @@ tts_heap_is_current_xact_tuple(TupleTableSlot *slot)
 
 static void
 tts_heap_materialize(TupleTableSlot *slot)
+{
+	tts_heap_materialize_ext(slot, -1);
+}
+
+static void
+tts_heap_materialize_ext(TupleTableSlot *slot, int natts)
 {
 	HeapTupleTableSlot *hslot = (HeapTupleTableSlot *) slot;
 	MemoryContext oldContext;
@@ -417,9 +439,10 @@ tts_heap_materialize(TupleTableSlot *slot)
 	hslot->off = 0;
 
 	if (!hslot->tuple)
-		hslot->tuple = heap_form_tuple(slot->tts_tupleDescriptor,
+		hslot->tuple = heap_form_tuple_ext(slot->tts_tupleDescriptor,
 									   slot->tts_values,
-									   slot->tts_isnull);
+									   slot->tts_isnull,
+									   natts);
 	else
 	{
 		/*
@@ -427,7 +450,7 @@ tts_heap_materialize(TupleTableSlot *slot)
 		 * context of the given slot (else it would have TTS_FLAG_SHOULDFREE
 		 * set).  Copy the tuple into the given slot's memory context.
 		 */
-		hslot->tuple = heap_copytuple(hslot->tuple);
+		hslot->tuple = heap_copytuple_ext(hslot->tuple, slot, natts);
 	}
 
 	slot->tts_flags |= TTS_FLAG_SHOULDFREE;
@@ -461,24 +484,24 @@ tts_heap_get_heap_tuple(TupleTableSlot *slot)
 }
 
 static HeapTuple
-tts_heap_copy_heap_tuple(TupleTableSlot *slot)
+tts_heap_copy_heap_tuple(TupleTableSlot *slot, int natts)
 {
 	HeapTupleTableSlot *hslot = (HeapTupleTableSlot *) slot;
 
 	Assert(!TTS_EMPTY(slot));
 	if (!hslot->tuple)
-		tts_heap_materialize(slot);
+		tts_heap_materialize_ext(slot, natts);
 
-	return heap_copytuple(hslot->tuple);
+	return heap_copytuple_ext(hslot->tuple, slot, natts);
 }
 
 static MinimalTuple
-tts_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
+tts_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra, int natts)
 {
 	HeapTupleTableSlot *hslot = (HeapTupleTableSlot *) slot;
 
 	if (!hslot->tuple)
-		tts_heap_materialize(slot);
+		tts_heap_materialize_ext(slot, natts);
 
 	return minimal_tuple_from_heap_tuple(hslot->tuple, extra);
 }
@@ -585,6 +608,12 @@ tts_minimal_is_current_xact_tuple(TupleTableSlot *slot)
 
 static void
 tts_minimal_materialize(TupleTableSlot *slot)
+{
+	tts_minimal_materialize_ext(slot, -1);
+}
+
+static void
+tts_minimal_materialize_ext(TupleTableSlot *slot, int natts)
 {
 	MinimalTupleTableSlot *mslot = (MinimalTupleTableSlot *) slot;
 	MemoryContext oldContext;
@@ -606,10 +635,11 @@ tts_minimal_materialize(TupleTableSlot *slot)
 
 	if (!mslot->mintuple)
 	{
-		mslot->mintuple = heap_form_minimal_tuple(slot->tts_tupleDescriptor,
+		mslot->mintuple = heap_form_minimal_tuple_ext(slot->tts_tupleDescriptor,
 												  slot->tts_values,
 												  slot->tts_isnull,
-												  0);
+												  0 /* no extra */,
+												  natts);
 	}
 	else
 	{
@@ -637,9 +667,13 @@ tts_minimal_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 {
 	MemoryContext oldcontext;
 	MinimalTuple mintuple;
+	int			min_natts;
+
+	min_natts = Min(dstslot->tts_tupleDescriptor->natts,
+					srcslot->tts_tupleDescriptor->natts);
 
 	oldcontext = MemoryContextSwitchTo(dstslot->tts_mcxt);
-	mintuple = ExecCopySlotMinimalTuple(srcslot);
+	mintuple = ExecCopySlotMinimalTupleExteded(srcslot, min_natts);
 	MemoryContextSwitchTo(oldcontext);
 
 	ExecStoreMinimalTuple(mintuple, dstslot, true);
@@ -657,7 +691,7 @@ tts_minimal_get_minimal_tuple(TupleTableSlot *slot)
 }
 
 static HeapTuple
-tts_minimal_copy_heap_tuple(TupleTableSlot *slot)
+tts_minimal_copy_heap_tuple(TupleTableSlot *slot, int natts)
 {
 	MinimalTupleTableSlot *mslot = (MinimalTupleTableSlot *) slot;
 
@@ -668,7 +702,7 @@ tts_minimal_copy_heap_tuple(TupleTableSlot *slot)
 }
 
 static MinimalTuple
-tts_minimal_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
+tts_minimal_copy_minimal_tuple(TupleTableSlot *slot, Size extra, int natts)
 {
 	MinimalTupleTableSlot *mslot = (MinimalTupleTableSlot *) slot;
 
@@ -802,6 +836,12 @@ tts_buffer_is_current_xact_tuple(TupleTableSlot *slot)
 
 static void
 tts_buffer_heap_materialize(TupleTableSlot *slot)
+{
+	tts_buffer_heap_materialize_ext(slot, -1 /* natts */);
+}
+
+static void
+tts_buffer_heap_materialize_ext(TupleTableSlot *slot, int natts)
 {
 	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
 	MemoryContext oldContext;
@@ -874,12 +914,16 @@ tts_buffer_heap_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 		TTS_SHOULDFREE(srcslot) ||
 		!bsrcslot->base.tuple)
 	{
+		int		min_natts;
 		MemoryContext oldContext;
 
+		min_natts = Min(dstslot->tts_tupleDescriptor->natts,
+						srcslot->tts_tupleDescriptor->natts);
+
 		ExecClearTuple(dstslot);
 		dstslot->tts_flags &= ~TTS_FLAG_EMPTY;
 		oldContext = MemoryContextSwitchTo(dstslot->tts_mcxt);
-		bdstslot->base.tuple = ExecCopySlotHeapTuple(srcslot);
+		bdstslot->base.tuple = ExecCopySlotHeapTupleExteded(srcslot, min_natts);
 		dstslot->tts_flags |= TTS_FLAG_SHOULDFREE;
 		MemoryContextSwitchTo(oldContext);
 	}
@@ -915,27 +959,27 @@ tts_buffer_heap_get_heap_tuple(TupleTableSlot *slot)
 }
 
 static HeapTuple
-tts_buffer_heap_copy_heap_tuple(TupleTableSlot *slot)
+tts_buffer_heap_copy_heap_tuple(TupleTableSlot *slot, int natts)
 {
 	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
 
 	Assert(!TTS_EMPTY(slot));
 
 	if (!bslot->base.tuple)
-		tts_buffer_heap_materialize(slot);
+		tts_buffer_heap_materialize_ext(slot, natts);
 
-	return heap_copytuple(bslot->base.tuple);
+	return heap_copytuple_ext(bslot->base.tuple, slot, natts);
 }
 
 static MinimalTuple
-tts_buffer_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra)
+tts_buffer_heap_copy_minimal_tuple(TupleTableSlot *slot, Size extra, int natts)
 {
 	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
 
 	Assert(!TTS_EMPTY(slot));
 
 	if (!bslot->base.tuple)
-		tts_buffer_heap_materialize(slot);
+		tts_buffer_heap_materialize_ext(slot, natts);
 
 	return minimal_tuple_from_heap_tuple(bslot->base.tuple, extra);
 }
@@ -1874,8 +1918,7 @@ ExecStoreAllNullTuple(TupleTableSlot *slot)
  * Until the slot is materialized, the contents of the slot depend on the
  * datum.
  */
-void
-ExecStoreHeapTupleDatum(Datum data, TupleTableSlot *slot)
+void ExecStoreHeapTupleDatum(Datum data, TupleTableSlot *slot)
 {
 	HeapTupleData tuple = {0};
 	HeapTupleHeader td;
@@ -1885,6 +1928,7 @@ ExecStoreHeapTupleDatum(Datum data, TupleTableSlot *slot)
 	tuple.t_len = HeapTupleHeaderGetDatumLength(td);
 	tuple.t_self = td->t_ctid;
 	tuple.t_data = td;
+	tuple.t_tableOid = InvalidOid;
 
 	ExecClearTuple(slot);
 
@@ -1929,7 +1973,7 @@ ExecFetchSlotHeapTuple(TupleTableSlot *slot, bool materialize, bool *shouldFree)
 	{
 		if (shouldFree)
 			*shouldFree = true;
-		return slot->tts_ops->copy_heap_tuple(slot);
+		return slot->tts_ops->copy_heap_tuple(slot, -1 /* natts */);
 	}
 	else
 	{
@@ -1980,7 +2024,8 @@ ExecFetchSlotMinimalTuple(TupleTableSlot *slot,
 	{
 		if (shouldFree)
 			*shouldFree = true;
-		return slot->tts_ops->copy_minimal_tuple(slot, 0);
+		return slot->tts_ops->copy_minimal_tuple(slot, 0 /* no extra */,
+												 -1 /* natts */);
 	}
 }
 
diff --git a/src/backend/executor/nodeForeignscan.c b/src/backend/executor/nodeForeignscan.c
index 6f0daddce07..21b3e9a961b 100644
--- a/src/backend/executor/nodeForeignscan.c
+++ b/src/backend/executor/nodeForeignscan.c
@@ -65,6 +65,8 @@ ForeignNext(ForeignScanState *node)
 	 * Insert valid value into tableoid, the only actually-useful system
 	 * column.
 	 */
+
+	slot->tts_remoteOid = slot->tts_tableOid;
 	if (plan->fsSystemCol && !TupIsNull(slot))
 		slot->tts_tableOid = RelationGetRelid(node->ss.ss_currentRelation);
 
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 9c040ecefc8..eb71d2bb54a 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -1165,6 +1165,26 @@ set_append_rel_size(PlannerInfo *root, RelOptInfo *rel,
 								   (Node *) rel->reltarget->exprs,
 								   1, &appinfo);
 
+		/* Do it if fdw is partition */
+		if (planner_rt_fetch(childRTindex, root)->relkind == RELKIND_FOREIGN_TABLE &&
+			!bms_is_empty(root->glob->foreignParamIDs))
+		{
+			foreach(lc, root->processed_tlist)
+			{
+				TargetEntry *tle = (TargetEntry *) lfirst(lc);
+				Param	   *param = (Param *) tle->expr;
+
+				if (tle->resjunk && IsA(param, Param) &&
+					IS_FOREIGN_PARAM(root, param) &&
+					param->target_rte == childRTindex) // TODO same for another case
+				{
+					/* XXX is copyObject necessary here? */
+					childrel->reltarget->exprs =
+						lappend(childrel->reltarget->exprs, copyObject(param));
+				}
+			}
+		}
+
 		/*
 		 * We have to make child entries in the EquivalenceClass data
 		 * structures as well.  This is needed either if the parent
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 2c696ea0268..ca9226391a5 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -986,6 +986,25 @@ use_physical_tlist(PlannerInfo *root, Path *path, int flags)
 		}
 	}
 
+	/*
+	 * Also, can't do it to a ForeignPath if the path is requested to emit
+	 * Params generated by the FDW.
+	 */
+	if (IsA(path, ForeignPath) &&
+		path->parent->relid == root->parse->resultRelation &&
+		!bms_is_empty(root->glob->foreignParamIDs))
+	{
+		foreach(lc, path->pathtarget->exprs)
+		{
+			Param	   *param = (Param *) lfirst(lc);
+			if (param && IsA(param, Param))
+			{
+				Assert(IS_FOREIGN_PARAM(root, param));
+				return false;
+			}
+		}
+	}
+
 	return true;
 }
 
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index b38422c47a4..cc09065e561 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -32,6 +32,7 @@
 #include "optimizer/planner.h"
 #include "optimizer/restrictinfo.h"
 #include "parser/analyze.h"
+#include "parser/parsetree.h"
 #include "rewrite/rewriteManip.h"
 #include "utils/lsyscache.h"
 #include "utils/rel.h"
@@ -243,6 +244,40 @@ add_other_rels_to_query(PlannerInfo *root)
  *
  *****************************************************************************/
 
+/*
+ * add_params_to_result_rel
+ *	  If the query's final tlist contains Params the FDW generated, add
+ *	  targetlist entries for each such Param to the result relation.
+ */
+static void
+add_params_to_result_rel(PlannerInfo *root, List *final_tlist)
+{
+	RelOptInfo *target_rel = find_base_rel(root, root->parse->resultRelation);
+	ListCell   *lc;
+
+	/*
+	 * If no parameters have been generated by any FDWs, we certainly don't
+	 * need to do anything here.
+	 */
+	if (bms_is_empty(root->glob->foreignParamIDs))
+		return;
+
+	foreach(lc, final_tlist)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+		Param	   *param = (Param *) tle->expr;
+
+		if (tle->resjunk && IsA(param, Param) &&
+			IS_FOREIGN_PARAM(root, param) &&
+			param->target_rte == target_rel->relid)
+		{
+			/* XXX is copyObject necessary here? */
+			target_rel->reltarget->exprs = lappend(target_rel->reltarget->exprs,
+												   copyObject(param));
+		}
+	}
+}
+
 /*
  * build_base_rel_tlists
  *	  Add targetlist entries for each var needed in the query's final tlist
@@ -282,6 +317,16 @@ build_base_rel_tlists(PlannerInfo *root, List *final_tlist)
 			list_free(having_vars);
 		}
 	}
+
+	if (root->parse->commandType == CMD_UPDATE ||
+		root->parse->commandType == CMD_DELETE)
+	{
+		int			result_relation = root->parse->resultRelation;
+
+		if (planner_rt_fetch(result_relation, root)->relkind == RELKIND_FOREIGN_TABLE)
+			add_params_to_result_rel(root, final_tlist);
+
+	}
 }
 
 /*
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 3225185d16f..03439053355 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -388,6 +388,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	glob->dependsOnRole = false;
 	glob->partition_directory = NULL;
 	glob->rel_notnullatts_hash = NULL;
+	glob->foreignParamIDs = NULL;
 
 	/*
 	 * Assess whether it's feasible to use parallel mode for this query. We
@@ -616,12 +617,15 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	}
 
 	/*
-	 * If any Params were generated, run through the plan tree and compute
-	 * each plan node's extParam/allParam sets.  Ideally we'd merge this into
-	 * set_plan_references' tree traversal, but for now it has to be separate
-	 * because we need to visit subplans before not after main plan.
+	 * If any Params were generated by the planner not by FDWs, run through
+	 * the plan tree and compute each plan node's extParam/allParam sets.
+	 * (Params added by FDWs are irrelevant for parameter change signaling.)
+	 * Ideally we'd merge this into set_plan_references' tree traversal, but
+	 * for now it has to be separate because we need to visit subplans before
+	 * not after main plan.
 	 */
-	if (glob->paramExecTypes != NIL)
+	if (glob->paramExecTypes != NIL &&
+		bms_num_members(glob->foreignParamIDs) < list_length(glob->paramExecTypes))
 	{
 		Assert(list_length(glob->subplans) == list_length(glob->subroots));
 		forboth(lp, glob->subplans, lr, glob->subroots)
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index e1182255055..bf8f8fdab4c 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -3277,7 +3277,42 @@ fix_join_expr_mutator(Node *node, fix_join_expr_context *context)
 	}
 	/* Special cases (apply only AFTER failing to match to lower tlist) */
 	if (IsA(node, Param))
+	{
+		Param	   *param = (Param *) node;
+
+		/*
+		 * If the Param is a PARAM_EXEC Param generated by an FDW, it should
+		 * have bubbled up from a lower plan node; convert it into a simple
+		 * Var referencing the output of the subplan.
+		 *
+		 * Note: set_join_references() would have kept has_non_vars=true for
+		 * the subplan emitting the Param since it effectively belong to the
+		 * result relation and that relation can never be the nullable side of
+		 * an outer join.
+		 */
+		if (IS_FOREIGN_PARAM(context->root, param))
+		{
+			if (context->outer_itlist && context->outer_itlist->has_non_vars)
+			{
+				newvar = search_indexed_tlist_for_non_var((Expr *) node,
+														  context->outer_itlist,
+														  OUTER_VAR);
+				if (newvar)
+					return (Node *) newvar;
+			}
+			if (context->inner_itlist && context->inner_itlist->has_non_vars)
+			{
+				newvar = search_indexed_tlist_for_non_var((Expr *) node,
+														  context->inner_itlist,
+														  INNER_VAR);
+				if (newvar)
+					return (Node *) newvar;
+			}
+			// XXX Is it an error to be here?
+		}
+		/* If not, do fix_param_node() */
 		return fix_param_node(context->root, (Param *) node);
+	}
 	if (IsA(node, AlternativeSubPlan))
 		return fix_join_expr_mutator(fix_alternative_subplan(context->root,
 															 (AlternativeSubPlan *) node,
@@ -3388,7 +3423,28 @@ fix_upper_expr_mutator(Node *node, fix_upper_expr_context *context)
 	}
 	/* Special cases (apply only AFTER failing to match to lower tlist) */
 	if (IsA(node, Param))
+	{
+		Param	   *param = (Param *) node;
+		/*
+		 * If the Param is a PARAM_EXEC Param generated by an FDW, it should
+		 * have bubbled up from a lower plan node; convert it into a simple
+		 * Var referencing the output of the subplan.
+		 */
+		if (IS_FOREIGN_PARAM(context->root, param))
+		{
+			if (context->subplan_itlist->has_non_vars)
+			{
+				newvar = search_indexed_tlist_for_non_var((Expr *) node,
+														  context->subplan_itlist,
+														  context->newvarno);
+				if (newvar)
+					return (Node *) newvar;
+			}
+			// XXX Is it an error to be here?
+		}
+		/* If not, do fix_param_node() */
 		return fix_param_node(context->root, (Param *) node);
+	}
 	if (IsA(node, Aggref))
 	{
 		Aggref	   *aggref = (Aggref *) node;
diff --git a/src/backend/optimizer/plan/subselect.c b/src/backend/optimizer/plan/subselect.c
index 6aa8971c95d..67422914aa4 100644
--- a/src/backend/optimizer/plan/subselect.c
+++ b/src/backend/optimizer/plan/subselect.c
@@ -3194,7 +3194,12 @@ finalize_primnode(Node *node, finalize_primnode_context *context)
 		{
 			int			paramid = ((Param *) node)->paramid;
 
-			context->paramids = bms_add_member(context->paramids, paramid);
+			/*
+			 * Params added by FDWs are irrelevant for parameter change
+			 * signaling.
+			 */
+			if (!bms_is_member(paramid, context->root->glob->foreignParamIDs))
+				context->paramids = bms_add_member(context->paramids, paramid);
 		}
 		return false;			/* no more to do here */
 	}
diff --git a/src/backend/optimizer/util/appendinfo.c b/src/backend/optimizer/util/appendinfo.c
index 778e4662f6e..7089a267125 100644
--- a/src/backend/optimizer/util/appendinfo.c
+++ b/src/backend/optimizer/util/appendinfo.c
@@ -948,6 +948,29 @@ add_row_identity_var(PlannerInfo *root, Var *orig_var,
 	root->processed_tlist = lappend(root->processed_tlist, tle);
 }
 
+static void
+fix_foreign_params(PlannerInfo *root, List *tlist)
+{
+	ListCell   *lc;
+
+	foreach(lc, tlist)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+		Param	   *param = (Param *) tle->expr;
+
+		if (tle->resjunk && IsA(param, Param) &&
+			param->paramkind == PARAM_EXEC &&
+			param->paramid == -1)
+		{
+			param->paramid = list_length(root->glob->paramExecTypes);
+			root->glob->paramExecTypes =
+				lappend_oid(root->glob->paramExecTypes, param->paramtype);
+			root->glob->foreignParamIDs =
+				bms_add_member(root->glob->foreignParamIDs, param->paramid);
+		}
+	}
+}
+
 /*
  * add_row_identity_columns
  *
@@ -992,8 +1015,12 @@ add_row_identity_columns(PlannerInfo *root, Index rtindex,
 		fdwroutine = GetFdwRoutineForRelation(target_relation, false);
 
 		if (fdwroutine->AddForeignUpdateTargets != NULL)
+		{
+
 			fdwroutine->AddForeignUpdateTargets(root, rtindex,
 												target_rte, target_relation);
+			fix_foreign_params(root, root->processed_tlist);
+		}
 
 		/*
 		 * For UPDATE, we need to make the FDW fetch unchanged columns by
diff --git a/src/backend/optimizer/util/inherit.c b/src/backend/optimizer/util/inherit.c
index 6a7b9edff3f..d8f08485e39 100644
--- a/src/backend/optimizer/util/inherit.c
+++ b/src/backend/optimizer/util/inherit.c
@@ -294,6 +294,7 @@ expand_inherited_rtentry(PlannerInfo *root, RelOptInfo *rel,
 						  InvalidOid,
 						  0);
 			snprintf(resname, sizeof(resname), "tableoid%u", oldrc->rowmarkId);
+
 			tle = makeTargetEntry((Expr *) var,
 								  list_length(root->processed_tlist) + 1,
 								  pstrdup(resname),
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 687e923c46c..51294420987 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -1320,6 +1320,27 @@ build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 			}
 			continue;
 		}
+		/*
+		 * We allow FDWs to have PARAM_EXEC Params here.
+		 */
+		else if (IsA(var, Param))
+		{
+			Param	   *param = (Param *) var;
+
+			Assert(IS_FOREIGN_PARAM(root, param));
+
+			joinrel->reltarget->exprs =
+				lappend(joinrel->reltarget->exprs, param);
+
+			/*
+			 * Estimate using the type info  (Note: keep this in sync with
+			 * set_rel_width())
+			 */
+			joinrel->reltarget->width +=
+				get_typavgwidth(param->paramtype, param->paramtypmod);
+
+			continue;
+		}
 
 		/*
 		 * Otherwise, anything in a baserel or joinrel targetlist ought to be
diff --git a/src/backend/optimizer/util/tlist.c b/src/backend/optimizer/util/tlist.c
index 752ea9222f6..e382810b19f 100644
--- a/src/backend/optimizer/util/tlist.c
+++ b/src/backend/optimizer/util/tlist.c
@@ -329,18 +329,37 @@ apply_tlist_labeling(List *dest_tlist, List *src_tlist)
 	ListCell   *ld,
 			   *ls;
 
-	Assert(list_length(dest_tlist) == list_length(src_tlist));
-	forboth(ld, dest_tlist, ls, src_tlist)
+	foreach(ld, dest_tlist)
 	{
+		bool has_src = false;
 		TargetEntry *dest_tle = (TargetEntry *) lfirst(ld);
-		TargetEntry *src_tle = (TargetEntry *) lfirst(ls);
-
-		Assert(dest_tle->resno == src_tle->resno);
-		dest_tle->resname = src_tle->resname;
-		dest_tle->ressortgroupref = src_tle->ressortgroupref;
-		dest_tle->resorigtbl = src_tle->resorigtbl;
-		dest_tle->resorigcol = src_tle->resorigcol;
-		dest_tle->resjunk = src_tle->resjunk;
+
+		foreach(ls, src_tlist)
+		{
+			TargetEntry *src_tle = (TargetEntry *) lfirst(ls);
+
+			if (dest_tle->resno == src_tle->resno)
+			{
+				has_src = true;
+				dest_tle->resname = src_tle->resname;
+				dest_tle->ressortgroupref = src_tle->ressortgroupref;
+				dest_tle->resorigtbl = src_tle->resorigtbl;
+				dest_tle->resorigcol = src_tle->resorigcol;
+				dest_tle->resjunk = src_tle->resjunk;
+				break;
+			}
+		}
+
+		if (!has_src)
+		{
+			TargetEntry *new_entry = palloc0(sizeof(TargetEntry));
+			new_entry->resname = dest_tle->resname;
+			new_entry->ressortgroupref = dest_tle->ressortgroupref;
+			new_entry->resorigtbl = dest_tle->resorigtbl;
+			new_entry->resorigcol = dest_tle->resorigcol;
+			new_entry->resjunk = dest_tle->resjunk;
+			src_tlist = lappend(src_tlist, new_entry);
+		}
 	}
 }
 
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index b3aef797e7d..302bf7d2ff5 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -9376,7 +9376,7 @@ get_parameter(Param *param, deparse_context *context)
 	 * It's a bug if we get here for anything except PARAM_EXTERN Params, but
 	 * in production builds printing $N seems more useful than failing.
 	 */
-	Assert(param->paramkind == PARAM_EXTERN);
+	Assert(param->paramkind == PARAM_EXTERN || param->paramkind == PARAM_EXEC);
 
 	appendStringInfo(context->buf, "$%d", param->paramid);
 }
diff --git a/src/include/access/htup_details.h b/src/include/access/htup_details.h
index 77a6c48fd71..1f59f363a68 100644
--- a/src/include/access/htup_details.h
+++ b/src/include/access/htup_details.h
@@ -794,10 +794,17 @@ HeapTupleClearHeapOnly(const HeapTupleData *tuple)
 /* prototypes for functions in common/heaptuple.c */
 extern Size heap_compute_data_size(TupleDesc tupleDesc,
 								   const Datum *values, const bool *isnull);
+extern Size heap_compute_data_size_ext(TupleDesc tupleDesc,
+									   const Datum *values, const bool *isnull,
+									   int natts);
 extern void heap_fill_tuple(TupleDesc tupleDesc,
 							const Datum *values, const bool *isnull,
 							char *data, Size data_size,
 							uint16 *infomask, uint8 *bit);
+extern void heap_fill_tuple_ext(TupleDesc tupleDesc,
+								const Datum *values, const bool *isnull,
+								char *data, Size data_size,
+								uint16 *infomask, uint8 *bit, int natts);
 extern bool heap_attisnull(HeapTuple tup, int attnum, TupleDesc tupleDesc);
 extern Datum nocachegetattr(HeapTuple tup, int attnum,
 							TupleDesc tupleDesc);
@@ -806,10 +813,20 @@ extern Datum heap_getsysattr(HeapTuple tup, int attnum, TupleDesc tupleDesc,
 extern Datum getmissingattr(TupleDesc tupleDesc,
 							int attnum, bool *isnull);
 extern HeapTuple heap_copytuple(HeapTuple tuple);
+
+struct TupleTableSlot;
+extern HeapTuple heap_copytuple_ext(HeapTuple tuple,
+									struct TupleTableSlot *slot,
+									int dstnatts);
+
 extern void heap_copytuple_with_tuple(HeapTuple src, HeapTuple dest);
 extern Datum heap_copy_tuple_as_datum(HeapTuple tuple, TupleDesc tupleDesc);
 extern HeapTuple heap_form_tuple(TupleDesc tupleDescriptor,
 								 const Datum *values, const bool *isnull);
+extern HeapTuple heap_form_tuple_ext(TupleDesc tupleDescriptor,
+									 const Datum *values,
+									 const bool *isnull,
+									 int natts);
 extern HeapTuple heap_modify_tuple(HeapTuple tuple,
 								   TupleDesc tupleDesc,
 								   const Datum *replValues,
@@ -827,6 +844,11 @@ extern void heap_freetuple(HeapTuple htup);
 extern MinimalTuple heap_form_minimal_tuple(TupleDesc tupleDescriptor,
 											const Datum *values, const bool *isnull,
 											Size extra);
+extern MinimalTuple heap_form_minimal_tuple_ext(TupleDesc tupleDescriptor,
+												const Datum *values,
+												const bool *isnull,
+												Size extra,
+												int natts);
 extern void heap_free_minimal_tuple(MinimalTuple mtup);
 extern MinimalTuple heap_copy_minimal_tuple(MinimalTuple mtup, Size extra);
 extern HeapTuple heap_tuple_from_minimal_tuple(MinimalTuple mtup);
diff --git a/src/include/executor/tuptable.h b/src/include/executor/tuptable.h
index 3db6c9c9bd0..02ddc4fb79d 100644
--- a/src/include/executor/tuptable.h
+++ b/src/include/executor/tuptable.h
@@ -141,6 +141,7 @@ typedef struct TupleTableSlot
 	MemoryContext tts_mcxt;		/* slot itself is in this context */
 	ItemPointerData tts_tid;	/* stored tuple's tid */
 	Oid			tts_tableOid;	/* table oid of tuple */
+	Oid			tts_remoteOid;
 } TupleTableSlot;
 
 /* routines for a TupleTableSlot implementation */
@@ -223,8 +224,10 @@ struct TupleTableSlotOps
 	 * meaningful "system columns" in the copy. The copy is not be "owned" by
 	 * the slot i.e. the caller has to take responsibility to free memory
 	 * consumed by the slot.
+	 *
+	 * TODO comment for "natts"
 	 */
-	HeapTuple	(*copy_heap_tuple) (TupleTableSlot *slot);
+	HeapTuple	(*copy_heap_tuple) (TupleTableSlot *slot, int natts);
 
 	/*
 	 * Return a copy of minimal tuple representing the contents of the slot.
@@ -237,8 +240,10 @@ struct TupleTableSlotOps
 	 * The copy has "extra" bytes (maxaligned and zeroed) available before the
 	 * tuple, which is useful so that some callers may store extra data along
 	 * with the minimal tuple without the need for an additional allocation.
+	 *
+	 * TODO comment for "natts"
 	 */
-	MinimalTuple (*copy_minimal_tuple) (TupleTableSlot *slot, Size extra);
+	MinimalTuple (*copy_minimal_tuple) (TupleTableSlot *slot, Size extra, int natts);
 };
 
 /*
@@ -368,6 +373,12 @@ extern void slot_getsomeattrs_int(TupleTableSlot *slot, int attnum);
 
 #ifndef FRONTEND
 
+/* Forward declarations */
+static inline HeapTuple ExecCopySlotHeapTupleExteded(TupleTableSlot *slot,
+													 int natts);
+static inline MinimalTuple ExecCopySlotMinimalTupleExteded(TupleTableSlot *slot,
+														   int natts);
+
 /*
  * This function forces the entries of the slot's Datum/isnull arrays to be
  * valid at least up through the attnum'th entry.
@@ -502,10 +513,16 @@ ExecMaterializeSlot(TupleTableSlot *slot)
  */
 static inline HeapTuple
 ExecCopySlotHeapTuple(TupleTableSlot *slot)
+{
+	return ExecCopySlotHeapTupleExteded(slot, -1 /* natts */);
+}
+
+static inline HeapTuple
+ExecCopySlotHeapTupleExteded(TupleTableSlot *slot, int natts)
 {
 	Assert(!TTS_EMPTY(slot));
 
-	return slot->tts_ops->copy_heap_tuple(slot);
+	return slot->tts_ops->copy_heap_tuple(slot, natts);
 }
 
 /*
@@ -514,7 +531,13 @@ ExecCopySlotHeapTuple(TupleTableSlot *slot)
 static inline MinimalTuple
 ExecCopySlotMinimalTuple(TupleTableSlot *slot)
 {
-	return slot->tts_ops->copy_minimal_tuple(slot, 0);
+	return ExecCopySlotMinimalTupleExteded(slot, -1 /* natts */);
+}
+
+static inline MinimalTuple
+ExecCopySlotMinimalTupleExteded(TupleTableSlot *slot, int natts)
+{
+	return slot->tts_ops->copy_minimal_tuple(slot, 0 /* no extra */, natts);
 }
 
 /*
@@ -524,9 +547,9 @@ ExecCopySlotMinimalTuple(TupleTableSlot *slot)
  * caller to make an additional allocation).
  */
 static inline MinimalTuple
-ExecCopySlotMinimalTupleExtra(TupleTableSlot *slot, Size extra)
+ExecCopySlotMinimalTupleExtra(TupleTableSlot *slot, Size extra, int natts)
 {
-	return slot->tts_ops->copy_minimal_tuple(slot, extra);
+	return slot->tts_ops->copy_minimal_tuple(slot, extra, natts);
 }
 
 /*
@@ -545,9 +568,12 @@ ExecCopySlot(TupleTableSlot *dstslot, TupleTableSlot *srcslot)
 {
 	Assert(!TTS_EMPTY(srcslot));
 	Assert(srcslot != dstslot);
-	Assert(dstslot->tts_tupleDescriptor->natts ==
-		   srcslot->tts_tupleDescriptor->natts);
 
+	/*
+	 * If source slot has less attributes then target - copy source and
+	 * set target to nulls. Otherwise copy only leading attributes and set
+	 * target natts to source counter.
+	 */
 	dstslot->tts_ops->copyslot(dstslot, srcslot);
 
 	return dstslot;
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 27a2c6815b7..abe4904cc24 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -271,12 +271,19 @@ typedef struct PlannerGlobal
 	/* extension state */
 	void	  **extension_state pg_node_attr(read_write_ignore);
 	int			extension_state_allocated;
+
+	/* PARAM_EXEC Params generated by FDWs */
+	Bitmapset  *foreignParamIDs;
 } PlannerGlobal;
 
 /* macro for fetching the Plan associated with a SubPlan node */
 #define planner_subplan_get_plan(root, subplan) \
 	((Plan *) list_nth((root)->glob->subplans, (subplan)->plan_id - 1))
 
+/* macro for checking if a Param is a PARAM_EXEC Param generated by an FDW */
+#define IS_FOREIGN_PARAM(root, param) \
+       ((param)->paramkind == PARAM_EXEC && \
+        bms_is_member((param)->paramid, (root)->glob->foreignParamIDs))
 
 /*----------
  * PlannerInfo
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index cacef7d4151..e0433cd1f5e 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -402,6 +402,7 @@ typedef struct Param
 	Oid			paramcollid;
 	/* token location, or -1 if unknown */
 	ParseLoc	location;
+	Index		target_rte;
 } Param;
 
 /*
-- 
2.43.0



  [application/octet-stream] v4-0002-teach-fdw-use-remote-tableoid.patch (95.5K, ../../CAN-LCVMboJszNufW+4NRZh4M6c-u74RM4Y7bfjy_8PVmQVktJg@mail.gmail.com/4-v4-0002-teach-fdw-use-remote-tableoid.patch)
  download | inline diff:
From df2312765d9c99e675ba69c17640ff1adaee3189 Mon Sep 17 00:00:00 2001
From: Nikita Malakhov <n.malakhov@postgrespro.ru>
Date: Thu, 9 Jul 2026 16:51:27 +0300
Subject: [PATCH] DELETE/UPDATE more than one row in partitioned foreign table
 (2/2)

This patch modifies FDW engine to use remote table OID introduced
in previous patch for DELETE and UPDATE of partitioned tables.

[1] Discussion: https://www.postgresql.org/message-id/flat/20250718175314.4513c00a%40karst
---
 contrib/postgres_fdw/deparse.c                |  85 ++-
 .../postgres_fdw/expected/postgres_fdw.out    | 518 ++++++++++--------
 contrib/postgres_fdw/postgres_fdw.c           | 320 ++++++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   3 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  44 ++
 5 files changed, 717 insertions(+), 253 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index 2dcc6c8af1b..8d566bce4bf 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -48,6 +48,7 @@
 #include "catalog/pg_ts_dict.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
+#include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
 #include "nodes/plannodes.h"
 #include "optimizer/optimizer.h"
@@ -132,6 +133,7 @@ static void deparseTargetList(StringInfo buf,
 							  Relation rel,
 							  bool is_returning,
 							  Bitmapset *attrs_used,
+							  bool tableoid_needed,
 							  bool qualify_col,
 							  List **retrieved_attrs);
 static void deparseExplicitTargetList(List *tlist,
@@ -350,10 +352,11 @@ foreign_expr_walker(Node *node,
 					/* Var belongs to foreign table */
 
 					/*
-					 * System columns other than ctid should not be sent to
-					 * the remote, since we don't make any effort to ensure
-					 * that local and remote values match (tableoid, in
-					 * particular, almost certainly doesn't match).
+					 * System columns other than ctid and remote table oid
+					 * should not be sent to the remote, since we don't make
+					 * any effort to ensure that local and remote values
+					 * match (tableoid, in particular, almost certainly
+					 * doesn't match).
 					 */
 					if (var->varattno < 0 &&
 						var->varattno != SelfItemPointerAttributeNumber)
@@ -1235,6 +1238,23 @@ build_tlist_to_deparse(RelOptInfo *foreignrel)
 												  PVC_RECURSE_PLACEHOLDERS));
 	}
 
+	/* Also, add the Param representing the remote table OID, if it exists. */
+	if (fpinfo->tableoid_param)
+	{
+		TargetEntry *tle;
+		/*
+		 * Core code should have contained the Param in the given relation's
+		 * reltarget.
+		 */
+		Assert(list_member(foreignrel->reltarget->exprs,
+						   fpinfo->tableoid_param));
+		tle = makeTargetEntry((Expr *) copyObject(fpinfo->tableoid_param),
+							  list_length(tlist) + 1,
+							  NULL,
+							  false);
+		tlist = lappend(tlist, tle);
+	}
+
 	return tlist;
 }
 
@@ -1390,7 +1410,9 @@ deparseSelectSql(List *tlist, bool is_subquery, List **retrieved_attrs,
 		Relation	rel = table_open(rte->relid, NoLock);
 
 		deparseTargetList(buf, rte, foreignrel->relid, rel, false,
-						  fpinfo->attrs_used, false, retrieved_attrs);
+						  fpinfo->attrs_used,
+						  fpinfo->tableoid_param != NULL,
+						  false, retrieved_attrs);
 		table_close(rel, NoLock);
 	}
 }
@@ -1441,6 +1463,7 @@ deparseTargetList(StringInfo buf,
 				  Relation rel,
 				  bool is_returning,
 				  Bitmapset *attrs_used,
+				  bool tableoid_needed,
 				  bool qualify_col,
 				  List **retrieved_attrs)
 {
@@ -1499,6 +1522,22 @@ deparseTargetList(StringInfo buf,
 									   SelfItemPointerAttributeNumber);
 	}
 
+	if (tableoid_needed &&
+	(bms_is_member(TableOidAttributeNumber - FirstLowInvalidHeapAttributeNumber,
+					  attrs_used)))
+	{
+			Assert(!first);
+			Assert(!is_returning);
+
+			appendStringInfoString(buf, ", ");
+			if (qualify_col)
+				ADD_REL_QUALIFIER(buf, rtindex);
+			appendStringInfoString(buf, "tableoid");
+
+			*retrieved_attrs = lappend_int(*retrieved_attrs,
+											TableOidAttributeNumber);
+	}
+
 	/* Don't generate bad syntax if no undropped columns */
 	if (first && !is_returning)
 		appendStringInfoString(buf, "NULL");
@@ -2259,7 +2298,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 	deparseRelation(buf, rel);
 	appendStringInfoString(buf, " SET ");
 
-	pindex = 2;					/* ctid is always the first param */
+	pindex = 3;					/* ctid is always the first param */
 	first = true;
 	foreach(lc, targetAttrs)
 	{
@@ -2279,7 +2318,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 			pindex++;
 		}
 	}
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_update_after_row,
@@ -2397,7 +2436,7 @@ deparseDeleteSql(StringInfo buf, RangeTblEntry *rte,
 {
 	appendStringInfoString(buf, "DELETE FROM ");
 	deparseRelation(buf, rel);
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_delete_after_row,
@@ -2512,7 +2551,7 @@ deparseReturningList(StringInfo buf, RangeTblEntry *rte,
 	}
 
 	if (attrs_used != NULL)
-		deparseTargetList(buf, rte, rtindex, rel, true, attrs_used, false,
+		deparseTargetList(buf, rte, rtindex, rel, true, attrs_used, false, false,
 						  retrieved_attrs);
 	else
 		*retrieved_attrs = NIL;
@@ -2719,6 +2758,12 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 			ADD_REL_QUALIFIER(buf, varno);
 		appendStringInfoString(buf, "ctid");
 	}
+	else if (varattno == TableOidAttributeNumber)
+	{
+		if (qualify_col)
+			ADD_REL_QUALIFIER(buf, varno);
+		appendStringInfoString(buf, "tableoid");
+	}
 	else if (varattno < 0)
 	{
 		/*
@@ -2730,8 +2775,9 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 		Oid			fetchval = 0;
 
 		if (varattno == TableOidAttributeNumber)
+		{
 			fetchval = rte->relid;
-
+		}
 		if (qualify_col)
 		{
 			appendStringInfoString(buf, "CASE WHEN (");
@@ -2782,7 +2828,7 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 
 		appendStringInfoString(buf, "ROW(");
 		deparseTargetList(buf, rte, varno, rel, false, attrs_used, qualify_col,
-						  &retrieved_attrs);
+						  qualify_col, &retrieved_attrs);
 		appendStringInfoChar(buf, ')');
 
 		/* Complete the CASE WHEN statement started above. */
@@ -3167,6 +3213,23 @@ deparseConst(Const *node, deparse_expr_cxt *context, int showtype)
 static void
 deparseParam(Param *node, deparse_expr_cxt *context)
 {
+	PgFdwRelationInfo *fpinfo = (PgFdwRelationInfo *) context->foreignrel->fdw_private;
+
+	/*
+	 * If the Param is the one representing the remote table OID, the value
+	 * needs to be produced; fetch the remote table OID, instead.
+	 */
+	if (equal(node, (Node *) fpinfo->tableoid_param))
+	{
+		Assert(bms_is_member(context->root->parse->resultRelation,
+							 context->foreignrel->relids));
+		Assert(bms_membership(context->foreignrel->relids) == BMS_MULTIPLE);
+		ADD_REL_QUALIFIER(context->buf, context->root->parse->resultRelation);
+
+		appendStringInfoString(context->buf, "tableoid");
+		return;
+	}
+
 	if (context->params_list)
 	{
 		int			pindex = 0;
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 5ebae1cedc2..fd3baf8f742 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -5275,14 +5275,14 @@ BEGIN;
   EXPLAIN (verbose, costs off)
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
     RETURNING old.*, new.*;                                                         -- can't be pushed down
-                                                                  QUERY PLAN                                                                  
-----------------------------------------------------------------------------------------------------------------------------------------------
+                                                                       QUERY PLAN                                                                       
+--------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.c1, old.c2, old.c3, old.c4, old.c5, old.c6, old.c7, old.c8, new.c1, new.c2, new.c3, new.c4, new.c5, new.c6, new.c7, new.c8
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c3 = $3 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c3 = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan on public.ft2
-         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, ft2.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
+         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, tableoid, $0, ft2.*
+         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
 (6 rows)
 
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
@@ -5429,14 +5429,14 @@ DELETE FROM ft2 WHERE c1 % 10 = 5 RETURNING c1, c4;
 BEGIN;
   EXPLAIN (verbose, costs off)
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;               -- can't be pushed down
-                                                QUERY PLAN                                                 
------------------------------------------------------------------------------------------------------------
+                                                     QUERY PLAN                                                      
+---------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: old.c1, c4
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c4
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c4
    ->  Foreign Scan on public.ft2
-         Output: ctid
-         Remote SQL: SELECT ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
+         Output: ctid, tableoid, $0
+         Remote SQL: SELECT ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
 (6 rows)
 
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;
@@ -6436,27 +6436,27 @@ BEGIN;
     FROM ft4 INNER JOIN ft5 ON (ft4.c1 = ft5.c1)
     WHERE ft2.c1 > 1200 AND ft2.c2 = ft4.c1
     RETURNING old, new, ft2, ft2.*, ft4, ft4.*;  -- can't be pushed down
-                                                                                                                                                                                                                                     QUERY PLAN                                                                                                                                                                                                                                     
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                                                                                  QUERY PLAN                                                                                                                                                                                                                                                  
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.*, new.*, ft2.*, ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.*, ft4.c1, ft4.c2, ft4.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan
-         Output: 'bar'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+         Output: 'bar'::text, ft2.ctid, ft2.tableoid, ($0), ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3, r1.tableoid FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+               Output: ft2.ctid, ft2.tableoid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ($0)
                Join Filter: (ft4.c1 = ft5.c1)
                ->  Sort
-                     Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                     Output: ft2.ctid, ft2.tableoid, ft2.*, ($0), ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                      Sort Key: ft2.c2
                      ->  Hash Join
-                           Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                           Output: ft2.ctid, ft2.tableoid, ft2.*, ($0), ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                            Hash Cond: (ft2.c2 = ft4.c1)
                            ->  Foreign Scan on public.ft2
-                                 Output: ft2.ctid, ft2.*, ft2.c2
-                                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
+                                 Output: ft2.ctid, ft2.tableoid, ft2.*, $0, ft2.c2
+                                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
                            ->  Hash
                                  Output: ft4.*, ft4.c1, ft4.c2, ft4.c3
                                  ->  Foreign Scan on public.ft4
@@ -6534,13 +6534,13 @@ UPDATE ft2 AS target SET (c2, c7) = (
         FROM ft2 AS src
         WHERE target.c1 = src.c1
 ) WHERE c1 > 1100;
-                                                         QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
+                                                                    QUERY PLAN                                                                    
+--------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 target
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c7 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c7 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.ft2 target
-         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, target.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
+         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, target.tableoid, $3, target.*
+         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
          SubPlan multiexpr_1
            ->  Foreign Scan on public.ft2 src
                  Output: (src.c2 * 10), src.c7
@@ -6562,20 +6562,20 @@ UPDATE ft2 AS target SET (c2) = (
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE ft2 d SET c2 = CASE WHEN random() >= 0 THEN d.c2 ELSE 0 END
   FROM ft2 AS t WHERE d.c1 = t.c1 AND d.c1 > 1000;
-                                                                                                                                                                                       QUERY PLAN                                                                                                                                                                                        
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                                    QUERY PLAN                                                                                                                                                                                                     
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 d
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan
-         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, d.*, t.*
+         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, d.tableoid, ($0), d.*, t.*
          Relations: (public.ft2 d) INNER JOIN (public.ft2 t)
-         Remote SQL: SELECT r1.c2, r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.c2, r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END, r1.tableoid FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
          ->  Hash Join
-               Output: d.c2, d.ctid, d.*, t.*
+               Output: d.c2, d.ctid, d.tableoid, d.*, t.*, ($0)
                Hash Cond: (d.c1 = t.c1)
                ->  Foreign Scan on public.ft2 d
-                     Output: d.c2, d.ctid, d.*, d.c1
-                     Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
+                     Output: d.c2, d.ctid, d.tableoid, d.*, $0, d.c1
+                     Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
                ->  Hash
                      Output: t.*, t.c1
                      ->  Foreign Scan on public.ft2 t
@@ -6595,19 +6595,19 @@ EXPLAIN (verbose, costs off)
 WITH cte AS (
   UPDATE ft2 SET c3 = 'bar' WHERE postgres_fdw_abs(c1) > 2000 RETURNING *
 ) SELECT * FROM cte ORDER BY c1;          -- can't be pushed down
-                                                    QUERY PLAN                                                    
-------------------------------------------------------------------------------------------------------------------
+                                                             QUERY PLAN                                                             
+------------------------------------------------------------------------------------------------------------------------------------
  Sort
    Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
    Sort Key: cte.c1
    CTE cte
      ->  Update on public.ft2
            Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8
-           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
            ->  Foreign Scan on public.ft2
-                 Output: 'bar'::text, ft2.ctid, ft2.*
+                 Output: 'bar'::text, ft2.ctid, ft2.tableoid, $0, ft2.*
                  Filter: (postgres_fdw_abs(ft2.c1) > 2000)
-                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" FOR UPDATE
+                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" FOR UPDATE
    ->  CTE Scan on cte
          Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
 (13 rows)
@@ -6638,13 +6638,13 @@ UPDATE ft2 SET c3 = 'baz'
 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Nested Loop
-         Output: 'baz'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
+         Output: 'baz'::text, ft2.ctid, ft2.tableoid, ($0), ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
          Join Filter: (ft2.c2 === ft4.c1)
          ->  Foreign Scan on public.ft2
-               Output: ft2.ctid, ft2.*, ft2.c2
-               Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+               Output: ft2.ctid, ft2.tableoid, ft2.*, $0, ft2.c2
+               Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
          ->  Foreign Scan
                Output: ft4.*, ft4.c1, ft4.c2, ft4.c3, ft5.*, ft5.c1, ft5.c2, ft5.c3
                Relations: (public.ft4) INNER JOIN (public.ft5)
@@ -6676,24 +6676,24 @@ DELETE FROM ft2
   USING ft4 INNER JOIN ft5 ON (ft4.c1 === ft5.c1)
   WHERE ft2.c1 > 2000 AND ft2.c2 = ft4.c1
   RETURNING ft2.c1, ft2.c2, ft2.c3;       -- can't be pushed down
-                                                                                                                                                                     QUERY PLAN                                                                                                                                                                     
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                  QUERY PLAN                                                                                                                                                                                  
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c2, c3
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3
    ->  Foreign Scan
-         Output: ft2.ctid, ft4.*, ft5.*
+         Output: ft2.ctid, ft2.tableoid, ($0), ft4.*, ft5.*
          Filter: (ft4.c1 === ft5.c1)
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1, r1.tableoid FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft4.*, ft5.*, ft4.c1, ft5.c1
+               Output: ft2.ctid, ft2.tableoid, ft4.*, ft5.*, ft4.c1, ft5.c1, ($0)
                ->  Nested Loop
-                     Output: ft2.ctid, ft4.*, ft4.c1
+                     Output: ft2.ctid, ft2.tableoid, ($0), ft4.*, ft4.c1
                      Join Filter: (ft2.c2 = ft4.c1)
                      ->  Foreign Scan on public.ft2
-                           Output: ft2.ctid, ft2.c2
-                           Remote SQL: SELECT c2, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+                           Output: ft2.ctid, ft2.tableoid, $0, ft2.c2
+                           Remote SQL: SELECT c2, ctid, tableoid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
                      ->  Foreign Scan on public.ft4
                            Output: ft4.*, ft4.c1
                            Remote SQL: SELECT c1, c2, c3 FROM "S 1"."T 3"
@@ -7205,19 +7205,19 @@ SET enable_hashjoin TO false;
 SET enable_material TO false;
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
-                                   QUERY PLAN                                   
---------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.remt2
    Output: remt2.c1, remt2.c2
-   Remote SQL: UPDATE public.loct2 SET c2 = $2 WHERE ctid = $1 RETURNING c1, c2
+   Remote SQL: UPDATE public.loct2 SET c2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING c1, c2
    ->  Nested Loop
-         Output: (remt2.c2 || remt2.c2), remt2.ctid, remt2.*, loct1.ctid
+         Output: (remt2.c2 || remt2.c2), remt2.ctid, remt2.tableoid, ($0), remt2.*, loct1.ctid
          Join Filter: (remt2.c1 = loct1.c1)
          ->  Seq Scan on public.loct1
                Output: loct1.ctid, loct1.c1
          ->  Foreign Scan on public.remt2
-               Output: remt2.c2, remt2.ctid, remt2.*, remt2.c1
-               Remote SQL: SELECT c1, c2, ctid FROM public.loct2 FOR UPDATE
+               Output: remt2.c2, remt2.ctid, remt2.tableoid, remt2.*, $0, remt2.c1
+               Remote SQL: SELECT c1, c2, ctid, tableoid FROM public.loct2 FOR UPDATE
 (11 rows)
 
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
@@ -7274,17 +7274,17 @@ prepare fdw_part_upd2(int) as
     returning tableoid::regclass, a, b;
 explain (verbose, costs off)
     execute fdw_part_upd2(2);
-                                                                       QUERY PLAN                                                                       
---------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                       QUERY PLAN                                                                                       
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.fdw_part_update
    Output: (fdw_part_update_1.tableoid)::regclass, fdw_part_update_1.a, fdw_part_update_1.b
    Foreign Update on public.fdw_part_update_p2 fdw_part_update_2
-     Remote SQL: UPDATE public.fdw_part_update_remote SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.fdw_part_update_remote SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Append
          Subplans Removed: 1
          ->  Foreign Scan on public.fdw_part_update_p2 fdw_part_update_2
-               Output: ((fdw_part_update_2.b + ((random())::integer * 0)) + 1), fdw_part_update_2.tableoid, fdw_part_update_2.ctid, fdw_part_update_2.*
-               Remote SQL: SELECT a, b, ctid FROM public.fdw_part_update_remote WHERE ((a = $1::integer)) FOR UPDATE
+               Output: ((fdw_part_update_2.b + ((random())::integer * 0)) + 1), fdw_part_update_2.tableoid, fdw_part_update_2.ctid, fdw_part_update_2.tableoid, $0, fdw_part_update_2.*
+               Remote SQL: SELECT a, b, ctid, tableoid FROM public.fdw_part_update_remote WHERE ((a = $1::integer)) FOR UPDATE
 (9 rows)
 
 execute fdw_part_upd2(2);
@@ -7442,13 +7442,13 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, foreign_tbl.tableoid, $0, foreign_tbl.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -7456,13 +7456,13 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, foreign_tbl.tableoid, $0, foreign_tbl.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -7555,14 +7555,14 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                         
+---------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -7570,14 +7570,14 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -7626,14 +7626,14 @@ CREATE VIEW rw_view AS SELECT * FROM parent_tbl WHERE a < 5 WITH CHECK OPTION;
 INSERT INTO parent_tbl (a) VALUES(1),(5);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = 'text', c = 123.456;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.child_foreign parent_tbl_1
-     Remote SQL: UPDATE public.child_local SET b = $2, c = $3 WHERE ctid = $1 RETURNING a
+     Remote SQL: UPDATE public.child_local SET b = $3, c = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING a
    ->  Foreign Scan on public.child_foreign parent_tbl_1
-         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT b, c, a, ctid FROM public.child_local WHERE ((a < 5)) FOR UPDATE
+         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT b, c, a, ctid, tableoid FROM public.child_local WHERE ((a < 5)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = 'text', c = 123.456;
@@ -7712,13 +7712,13 @@ insert into grem1 (a) values (1), (2);
 insert into grem1 (a) values (1), (2);
 explain (verbose, costs off)
 update grem1 set a = 22 where a = 2;
-                                       QUERY PLAN                                       
-----------------------------------------------------------------------------------------
+                                                QUERY PLAN                                                
+----------------------------------------------------------------------------------------------------------
  Update on public.grem1
-   Remote SQL: UPDATE public.gloc1 SET a = $2, b = DEFAULT, c = DEFAULT WHERE ctid = $1
+   Remote SQL: UPDATE public.gloc1 SET a = $3, b = DEFAULT, c = DEFAULT WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.grem1
-         Output: 22, ctid, grem1.*
-         Remote SQL: SELECT a, b, c, ctid FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
+         Output: 22, ctid, tableoid, $0, grem1.*
+         Remote SQL: SELECT a, b, c, ctid, tableoid FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
 (5 rows)
 
 update grem1 set a = 22 where a = 2;
@@ -8045,13 +8045,13 @@ SELECT * from loc1;
 
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f1 = 10;          -- all columns should be transmitted
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: 10, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: 10, ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 UPDATE rem1 set f1 = 10;
@@ -8193,12 +8193,12 @@ DELETE FROM rem1;                 -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1 WHERE false;     -- currently can't be pushed down
-                      QUERY PLAN                       
--------------------------------------------------------
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: ctid
+         Output: ctid, tableoid, $0
          Replaces: Scan on rem1
          One-Time Filter: false
 (6 rows)
@@ -8299,13 +8299,13 @@ BEFORE UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -8323,13 +8323,13 @@ AFTER UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                                  QUERY PLAN                                   
--------------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f2 = $2 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: UPDATE public.loc1 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -8357,13 +8357,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                             QUERY PLAN                              
----------------------------------------------------------------------
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_before_delete ON rem1;
@@ -8381,13 +8381,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, tableoid, $0, rem1.*
+         Remote SQL: SELECT f1, f2, ctid, tableoid FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_after_delete ON rem1;
@@ -8424,28 +8424,28 @@ CONTEXT:  COPY parent_tbl, line 1: "AAA	42"
 ALTER SERVER loopback OPTIONS (DROP batch_size);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                         
+---------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0, parent_tbl_1.*
+         Remote SQL: SELECT a, b, ctid, tableoid FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                            QUERY PLAN                            
-------------------------------------------------------------------
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Foreign Delete on public.foreign_tbl parent_tbl_1
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
-         Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
+         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.tableoid, $0
+         Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 DELETE FROM parent_tbl;
@@ -8463,39 +8463,41 @@ CREATE TRIGGER parent_tbl_delete_trig
   FOR EACH STATEMENT EXECUTE PROCEDURE trigger_func();
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                              QUERY PLAN                                              
-------------------------------------------------------------------------------------------------------
+                                                           QUERY PLAN                                                            
+---------------------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Update on public.parent_tbl parent_tbl_1
    Foreign Update on public.foreign_tbl parent_tbl_2
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::record)
+         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::oid), $0, (NULL::record)
          ->  Append
                ->  Seq Scan on public.parent_tbl parent_tbl_1
-                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::record
+                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.tableoid, parent_tbl_2.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.local_tbl FOR UPDATE
 (12 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Delete on public.parent_tbl parent_tbl_1
    Foreign Delete on public.foreign_tbl parent_tbl_2
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
-   ->  Append
-         ->  Seq Scan on public.parent_tbl parent_tbl_1
-               Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
-         ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-               Output: parent_tbl_2.tableoid, parent_tbl_2.ctid
-               Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
-(10 rows)
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
+   ->  Result
+         Output: parent_tbl.tableoid, parent_tbl.ctid, (NULL::oid), $0
+         ->  Append
+               ->  Seq Scan on public.parent_tbl parent_tbl_1
+                     Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid
+               ->  Foreign Scan on public.foreign_tbl parent_tbl_2
+                     Output: parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.tableoid, $0
+                     Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
+(12 rows)
 
 DELETE FROM parent_tbl;
 ERROR:  cannot collect transition tuples from child foreign tables
@@ -8837,22 +8839,22 @@ drop table foo2child;
 -- Check UPDATE with inherited target and an inherited source table
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 where f1 in (select f1 from foo);
-                                              QUERY PLAN                                               
--------------------------------------------------------------------------------------------------------
+                                                       QUERY PLAN                                                       
+------------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Hash Join
-         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::record), foo.*, foo.tableoid
+         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record), foo.*, foo.tableoid
          Inner Unique: true
          Hash Cond: (bar.f1 = foo.f1)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                     Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 FOR UPDATE
          ->  Hash
                Output: foo.ctid, foo.f1, foo.*, foo.tableoid
                ->  HashAggregate
@@ -8884,24 +8886,24 @@ update bar set f2 = f2 + 100
 from
   ( select f1 from foo union all select f1+3 from foo ) ss
 where bar.f1 = ss.f1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                  QUERY PLAN                                                   
+---------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Merge Join
-         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
          Merge Cond: (bar.f1 = foo.f1)
          ->  Sort
-               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::record)
+               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
                Sort Key: bar.f1
                ->  Append
                      ->  Seq Scan on public.bar bar_1
-                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                      ->  Foreign Scan on public.bar2 bar_2
-                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                           Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                           Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 FOR UPDATE
          ->  Sort
                Output: (ROW(foo.f1)), foo.f1
                Sort Key: foo.f1
@@ -9042,19 +9044,21 @@ ERROR:  WHERE CURRENT OF is not supported for this table type
 rollback;
 explain (verbose, costs off)
 delete from foo where f1 < 5 returning *;
-                                      QUERY PLAN                                      
---------------------------------------------------------------------------------------
+                                         QUERY PLAN                                         
+--------------------------------------------------------------------------------------------
  Delete on public.foo
    Output: foo_1.f1, foo_1.f2
    Delete on public.foo foo_1
    Foreign Delete on public.foo2 foo_2
-   ->  Append
-         ->  Index Scan using i_foo_f1 on public.foo foo_1
-               Output: foo_1.tableoid, foo_1.ctid
-               Index Cond: (foo_1.f1 < 5)
-         ->  Foreign Delete on public.foo2 foo_2
-               Remote SQL: DELETE FROM public.loct1 WHERE ((f1 < 5)) RETURNING f1, f2
-(10 rows)
+   ->  Result
+         Output: foo.tableoid, foo.ctid, (NULL::oid), $0
+         ->  Append
+               ->  Index Scan using i_foo_f1 on public.foo foo_1
+                     Output: foo_1.tableoid, foo_1.ctid, NULL::oid
+                     Index Cond: (foo_1.f1 < 5)
+               ->  Foreign Delete on public.foo2 foo_2
+                     Remote SQL: DELETE FROM public.loct1 WHERE ((f1 < 5)) RETURNING f1, f2
+(12 rows)
 
 delete from foo where f1 < 5 returning *;
  f1 | f2 
@@ -9068,17 +9072,17 @@ delete from foo where f1 < 5 returning *;
 
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 returning *;
-                                        QUERY PLAN                                        
-------------------------------------------------------------------------------------------
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
  Update on public.bar
    Output: bar_1.f1, bar_1.f2
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Update on public.bar2 bar_2
                      Remote SQL: UPDATE public.loct2 SET f2 = (f2 + 100) RETURNING f1, f2
 (11 rows)
@@ -9103,20 +9107,20 @@ AFTER UPDATE OR DELETE ON bar2
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 explain (verbose, costs off)
 update bar set f2 = f2 + 100;
-                                               QUERY PLAN                                               
---------------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                        
+--------------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f1 = $2, f2 = $3, f3 = $4 WHERE ctid = $1 RETURNING f1, f2, f3
+     Remote SQL: UPDATE public.loct2 SET f1 = $3, f2 = $4, f3 = $5 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                     Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 FOR UPDATE
 (12 rows)
 
 update bar set f2 = f2 + 100;
@@ -9134,20 +9138,22 @@ NOTICE:  trig_row_after(23, skidoo) AFTER ROW UPDATE ON bar2
 NOTICE:  OLD: (7,277,77),NEW: (7,377,77)
 explain (verbose, costs off)
 delete from bar where f2 < 400;
-                                            QUERY PLAN                                             
----------------------------------------------------------------------------------------------------
+                                                    QUERY PLAN                                                     
+-------------------------------------------------------------------------------------------------------------------
  Delete on public.bar
    Delete on public.bar bar_1
    Foreign Delete on public.bar2 bar_2
-     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 RETURNING f1, f2, f3
-   ->  Append
-         ->  Seq Scan on public.bar bar_1
-               Output: bar_1.tableoid, bar_1.ctid, NULL::record
-               Filter: (bar_1.f2 < 400)
-         ->  Foreign Scan on public.bar2 bar_2
-               Output: bar_2.tableoid, bar_2.ctid, bar_2.*
-               Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
-(11 rows)
+     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
+   ->  Result
+         Output: bar.tableoid, bar.ctid, (NULL::oid), $0, (NULL::record)
+         ->  Append
+               ->  Seq Scan on public.bar bar_1
+                     Output: bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
+                     Filter: (bar_1.f2 < 400)
+               ->  Foreign Scan on public.bar2 bar_2
+                     Output: bar_2.tableoid, bar_2.ctid, bar_2.tableoid, bar_2.*, $0
+                     Remote SQL: SELECT f1, f2, f3, ctid, tableoid FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
+(13 rows)
 
 delete from bar where f2 < 400;
 NOTICE:  trig_row_before(23, skidoo) BEFORE ROW DELETE ON bar2
@@ -9178,22 +9184,22 @@ analyze remt1;
 analyze remt2;
 explain (verbose, costs off)
 update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a returning *;
-                                                   QUERY PLAN                                                   
-----------------------------------------------------------------------------------------------------------------
+                                                           QUERY PLAN                                                            
+---------------------------------------------------------------------------------------------------------------------------------
  Update on public.parent
    Output: parent_1.a, parent_1.b, remt2.a, remt2.b
    Update on public.parent parent_1
    Foreign Update on public.remt1 parent_2
-     Remote SQL: UPDATE public.loct1 SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct1 SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::record)
+         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::oid), $0, (NULL::record)
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::record
+                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.tableoid, parent_2.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.b, remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -9210,22 +9216,22 @@ update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a re
 
 explain (verbose, costs off)
 delete from parent using remt2 where parent.a = remt2.a returning parent;
-                                 QUERY PLAN                                  
------------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Delete on public.parent
    Output: parent_1.*
    Delete on public.parent parent_1
    Foreign Delete on public.remt1 parent_2
-     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: remt2.*, parent.tableoid, parent.ctid
+         Output: remt2.*, parent.tableoid, parent.ctid, (NULL::oid), $0
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid
+                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid
-                     Remote SQL: SELECT a, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.tableoid, $0
+                     Remote SQL: SELECT a, ctid, tableoid FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -9455,7 +9461,7 @@ update utrtest set a = 1 where a = 1 or a = 2 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 WHERE (((a = 1) OR (a = 2))) RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, $0, NULL::record
                Filter: ((utrtest_2.a = 1) OR (utrtest_2.a = 2))
 (10 rows)
 
@@ -9494,8 +9500,8 @@ insert into utrtest values (2, 'qux');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Foreign Update on public.remp utrtest_1
@@ -9504,7 +9510,7 @@ update utrtest set a = 1 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, $0, NULL::record
 (9 rows)
 
 update utrtest set a = 1 returning *;
@@ -9515,22 +9521,22 @@ insert into utrtest values (2, 'qux');
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 from (values (1), (2)) s(x) where a = s.x returning *;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Foreign Update on public.remp utrtest_1
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    Update on public.locp utrtest_2
    ->  Hash Join
-         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, utrtest.*
+         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, utrtest.tableoid, $0, utrtest.*
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Foreign Scan on public.remp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, utrtest_1.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, utrtest_1.tableoid, utrtest_1.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.loct FOR UPDATE
                ->  Seq Scan on public.locp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, NULL::record
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -9554,15 +9560,15 @@ insert into utrtest values (3, 'xyzzy');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
    ->  Append
          ->  Seq Scan on public.locp utrtest_1
-               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, $0, NULL::record
          ->  Foreign Update on public.remp utrtest_2
                Remote SQL: UPDATE public.loct SET a = 3 RETURNING a, b
 (9 rows)
@@ -9572,22 +9578,22 @@ ERROR:  cannot route tuples into foreign table to be updated "remp"
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 from (values (2), (3)) s(x) where a = s.x returning *;
-                                             QUERY PLAN                                              
------------------------------------------------------------------------------------------------------
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Hash Join
-         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::record)
+         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::oid), $0, (NULL::record)
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Seq Scan on public.locp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, utrtest_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, utrtest_2.tableoid, utrtest_2.*, $0
+                     Remote SQL: SELECT a, b, ctid, tableoid FROM public.loct FOR UPDATE
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -12474,8 +12480,8 @@ RESET enable_hashjoin;
 -- Test that UPDATE/DELETE with inherited target works with async_capable enabled
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
-                                                QUERY PLAN                                                
-----------------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                          
+-----------------------------------------------------------------------------------------------------------------------------
  Update on public.async_pt
    Output: async_pt_1.a, async_pt_1.b, async_pt_1.c
    Foreign Update on public.async_p1 async_pt_1
@@ -12487,7 +12493,7 @@ UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
          ->  Foreign Update on public.async_p2 async_pt_2
                Remote SQL: UPDATE public.base_tbl2 SET c = (c || c) WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::record
+               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::oid, $0, NULL::record, $1
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -12514,7 +12520,7 @@ DELETE FROM async_pt WHERE b = 0 RETURNING *;
          ->  Foreign Delete on public.async_p2 async_pt_2
                Remote SQL: DELETE FROM public.base_tbl2 WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: async_pt_3.tableoid, async_pt_3.ctid
+               Output: async_pt_3.tableoid, async_pt_3.ctid, NULL::oid, $0, $1
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -13129,6 +13135,52 @@ SELECT server_name,
 -- Clean up
 \set VERBOSITY default
 RESET debug_discard_caches;
+-- ===================================================================
+-- check whether fdw created for partitioned table will delete tuples only from
+-- desired partition
+-- ===================================================================
+CREATE TABLE measurement (
+       city_id         int not null,
+       logdate         date not null,
+       peaktemp        int,
+       unitsales       int
+) PARTITION BY RANGE (logdate);
+CREATE TABLE measurement_y2006m02 PARTITION OF measurement
+       FOR VALUES FROM ('2006-02-01') TO ('2006-03-01');
+CREATE TABLE measurement_y2006m03 PARTITION OF measurement
+       FOR VALUES FROM ('2006-03-01') TO ('2006-04-01');
+CREATE TABLE measurement_y2006m04 PARTITION OF measurement
+       FOR VALUES FROM ('2006-04-01') TO ('2006-05-01');
+INSERT INTO measurement VALUES (1,'2006-02-01',1,1);
+INSERT INTO measurement VALUES (2,'2006-03-01',1,1);
+INSERT INTO measurement VALUES (3,'2006-04-01',1,1);
+create foreign table measurement_fdw (
+       city_id int options (column_name 'city_id') not null,
+       logdate date options (column_name 'logdate') not null,
+       peaktemp text options (column_name 'peaktemp'),
+       unitsales integer options (column_name 'unitsales')
+) SERVER loopback OPTIONS (table_name 'measurement');
+DELETE FROM measurement_fdw
+USING (
+       SELECT t1.city_id sub_city_id
+       FROM measurement_fdw t1
+       WHERE t1.city_id=1
+       LIMIT 1000
+) sub
+WHERE measurement_fdw.city_id = sub.sub_city_id
+RETURNING city_id, logdate, peaktemp, unitsales;
+ city_id |  logdate   | peaktemp | unitsales 
+---------+------------+----------+-----------
+       1 | 02-01-2006 | 1        |         1
+(1 row)
+
+SELECT * FROM measurement_fdw;
+ city_id |  logdate   | peaktemp | unitsales 
+---------+------------+----------+-----------
+       2 | 03-01-2006 | 1        |         1
+       3 | 04-01-2006 | 1        |         1
+(2 rows)
+
 -- ===================================================================
 -- test cleanup of failed connections on abort
 -- ===================================================================
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 0ea72a64ce5..e5919eb361c 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -22,6 +22,7 @@
 #include "commands/explain_format.h"
 #include "commands/explain_state.h"
 #include "commands/vacuum.h"
+#include "common/hashfn.h"
 #include "executor/execAsync.h"
 #include "executor/instrument.h"
 #include "executor/spi.h"
@@ -82,6 +83,8 @@ enum FdwScanPrivateIndex
 	FdwScanPrivateSelectSql,
 	/* Integer list of attribute numbers retrieved by the SELECT */
 	FdwScanPrivateRetrievedAttrs,
+	/* Param ID for remote table OID for target rel (-1 if none) */
+	FdwScanPrivateTableOidParamId,
 	/* Integer representing the desired fetch_size */
 	FdwScanPrivateFetchSize,
 
@@ -180,6 +183,10 @@ typedef struct PgFdwScanState
 	MemoryContext temp_cxt;		/* context for per-tuple temporary data */
 
 	int			fetch_size;		/* number of tuples per fetch */
+
+	int			tableoid_param_id;	/* Param ID for remote table OID */
+	bool		set_tableoid_param; /* Do we need to set the Param? */
+	Oid			remote_tableoid;
 } PgFdwScanState;
 
 /*
@@ -206,6 +213,7 @@ typedef struct PgFdwModifyState
 
 	/* info about parameters for prepared statement */
 	AttrNumber	ctidAttno;		/* attnum of input resjunk ctid column */
+	AttrNumber	tableoidAttno;	/* attnum of input resjunk tableoid column */
 	int			p_nums;			/* number of parameters to transmit */
 	FmgrInfo   *p_flinfo;		/* output conversion functions for them */
 
@@ -306,6 +314,35 @@ typedef struct
 	int64		offset_est;
 } PgFdwPathExtraData;
 
+typedef struct OidMappingEntry
+{
+	Oid		local_oid;	/* key */
+	Oid		remote_oid; /* value */
+	char	status;		/* hash entry status */
+} OidMappingEntry;
+
+#define SH_PREFIX oidmapping
+#define SH_ELEMENT_TYPE OidMappingEntry
+#define SH_KEY_TYPE Oid
+#define SH_KEY local_oid
+#define SH_HASH_KEY(tb, key) murmurhash32(key)
+#define SH_EQUAL(tb, a, b) a == b
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+typedef struct PgFdwRemoteMap
+{
+	oidmapping_hash	*oid_mapping;
+	MemoryContext	 hash_cxt;
+	uint32			 refcount;
+} PgFdwRemoteMap;
+
+static PgFdwRemoteMap *remoteMap;
+
+#define		N_INITIAL_OIDMAPPING_ELEMS	50
+
 /*
  * Identify the attribute where data conversion fails.
  */
@@ -506,6 +543,86 @@ static const Oid attclear_argtypes[ATTCLEAR_SQL_NUM_FIELDS] =
  */
 PG_FUNCTION_INFO_V1(postgres_fdw_handler);
 
+/* Private cache */
+static void
+init_remote_map(void)
+{
+	MemoryContext	remoteMapCxt;
+
+	Assert(remoteMap == NULL);
+
+	remoteMapCxt = AllocSetContextCreate(CacheMemoryContext,
+										 "PgFdwRemoteMap",
+										 ALLOCSET_SMALL_SIZES);
+
+	remoteMap = (PgFdwRemoteMap *)
+		MemoryContextAllocZero(remoteMapCxt, sizeof(PgFdwRemoteMap));
+
+	remoteMap->hash_cxt = remoteMapCxt;
+	remoteMap->oid_mapping = oidmapping_create(remoteMap->hash_cxt,
+											   N_INITIAL_OIDMAPPING_ELEMS,
+											   NULL);
+}
+
+static void
+insert_oid_pair(Oid local_oid, Oid remote_oid)
+{
+	bool			 found;
+	OidMappingEntry	*entry;
+	MemoryContext	 tmp;
+
+	Assert(OidIsValid(local_oid) && OidIsValid(remote_oid));
+
+	if (remoteMap == NULL)
+		init_remote_map();
+
+	tmp = MemoryContextSwitchTo(remoteMap->hash_cxt);
+
+	/* This mapping may already present in the hashtable. */
+	entry = oidmapping_lookup(remoteMap->oid_mapping, local_oid);
+	if (entry)
+		oidmapping_delete(remoteMap->oid_mapping, local_oid);
+	else
+		remoteMap->refcount++;
+
+	entry = oidmapping_insert(remoteMap->oid_mapping, local_oid, &found);
+	entry->remote_oid = remote_oid;
+
+	MemoryContextSwitchTo(tmp);
+}
+
+static Oid
+find_remote_oid(Oid local_oid)
+{
+	OidMappingEntry *entry;
+
+	Assert(OidIsValid(local_oid));
+
+	if (remoteMap == NULL)
+		return InvalidOid;
+
+	entry = oidmapping_lookup(remoteMap->oid_mapping, local_oid);
+
+	return ((entry != NULL) ? entry->remote_oid : InvalidOid);
+}
+
+static void
+destroy_remote_map(void)
+{
+	if (remoteMap == NULL)
+		return;
+
+	Assert(remoteMap->refcount > 0);
+	remoteMap->refcount--;
+
+	if (remoteMap->refcount == 0)
+	{
+		oidmapping_destroy(remoteMap->oid_mapping);
+		MemoryContextDelete(remoteMap->hash_cxt);
+		remoteMap = NULL;
+	}
+}
+
 /*
  * FDW callback routines
  */
@@ -660,6 +777,7 @@ static TupleTableSlot **execute_foreign_modify(EState *estate,
 static void prepare_foreign_modify(PgFdwModifyState *fmstate);
 static const char **convert_prep_stmt_params(PgFdwModifyState *fmstate,
 											 ItemPointer tupleid,
+											 Oid tableoid,
 											 TupleTableSlot **slots,
 											 int numSlots);
 static void store_returning_result(PgFdwModifyState *fmstate,
@@ -1007,6 +1125,23 @@ postgresGetForeignRelSize(PlannerInfo *root,
 	fpinfo->hidden_subquery_rels = NULL;
 	/* Set the relation index. */
 	fpinfo->relation_index = baserel->relid;
+	fpinfo->tableoid_param = NULL;
+
+	/*
+	 * If the table is an UPDATE/DELETE target, the table's reltarget would
+	 * have contained a Param representing the remote table OID of the target;
+	 * get the Param and save a copy of it in fpinfo for use later.
+	 */
+	foreach(lc, baserel->reltarget->exprs)
+	{
+		Param	   *param = (Param *) lfirst(lc);
+		if (IsA(param, Param))
+		{
+			Assert(IS_FOREIGN_PARAM(root, param));
+			fpinfo->tableoid_param = (Param *) copyObject(param);
+			break;
+		}
+	}
 }
 
 /*
@@ -1468,6 +1603,7 @@ postgresGetForeignPlan(PlannerInfo *root,
 	bool		has_final_sort = false;
 	bool		has_limit = false;
 	ListCell   *lc;
+	int			tableoid_param_id = -1;
 
 	/*
 	 * Get FDW private data created by postgresGetForeignUpperPaths(), if any.
@@ -1632,12 +1768,16 @@ postgresGetForeignPlan(PlannerInfo *root,
 	/* Remember remote_exprs for possible use by postgresPlanDirectModify */
 	fpinfo->final_remote_exprs = remote_exprs;
 
+	if (fpinfo->tableoid_param)
+		tableoid_param_id = fpinfo->tableoid_param->paramid;
+
 	/*
 	 * Build the fdw_private list that will be available to the executor.
 	 * Items in the list must match order in enum FdwScanPrivateIndex.
 	 */
-	fdw_private = list_make3(makeString(sql.data),
+	fdw_private = list_make4(makeString(sql.data),
 							 retrieved_attrs,
+							 makeInteger(tableoid_param_id),
 							 makeInteger(fpinfo->fetch_size));
 	if (IS_JOIN_REL(foreignrel) || IS_UPPER_REL(foreignrel))
 		fdw_private = lappend(fdw_private,
@@ -1770,6 +1910,8 @@ postgresBeginForeignScan(ForeignScanState *node, int eflags)
 									 FdwScanPrivateSelectSql));
 	fsstate->retrieved_attrs = (List *) list_nth(fsplan->fdw_private,
 												 FdwScanPrivateRetrievedAttrs);
+	fsstate->tableoid_param_id = intVal(list_nth(fsplan->fdw_private,
+										FdwScanPrivateTableOidParamId));
 	fsstate->fetch_size = intVal(list_nth(fsplan->fdw_private,
 										  FdwScanPrivateFetchSize));
 
@@ -1789,11 +1931,13 @@ postgresBeginForeignScan(ForeignScanState *node, int eflags)
 	{
 		fsstate->rel = node->ss.ss_currentRelation;
 		fsstate->tupdesc = RelationGetDescr(fsstate->rel);
+		fsstate->set_tableoid_param = (fsstate->tableoid_param_id >= 0);
 	}
 	else
 	{
 		fsstate->rel = NULL;
 		fsstate->tupdesc = get_tupdesc_for_join_scan_tuples(node);
+		fsstate->set_tableoid_param = false;
 	}
 
 	fsstate->attinmeta = TupleDescGetAttInMetadata(fsstate->tupdesc);
@@ -1825,6 +1969,7 @@ postgresIterateForeignScan(ForeignScanState *node)
 {
 	PgFdwScanState *fsstate = (PgFdwScanState *) node->fdw_state;
 	TupleTableSlot *slot = node->ss.ss_ScanTupleSlot;
+	HeapTuple		tuple;
 
 	/*
 	 * In sync mode, if this is the first call after Begin or ReScan, we need
@@ -1851,12 +1996,23 @@ postgresIterateForeignScan(ForeignScanState *node)
 			return ExecClearTuple(slot);
 	}
 
+	tuple = fsstate->tuples[fsstate->next_tuple++];
+
+	if (fsstate->set_tableoid_param)
+	{
+		ExprContext *econtext = node->ss.ps.ps_ExprContext;
+		ParamExecData *prm = &(econtext->ecxt_param_exec_vals[fsstate->tableoid_param_id]);
+
+		prm->execPlan = NULL;
+		prm->value = ObjectIdGetDatum(tuple->t_tableOid);
+		prm->isnull = false;
+
+		insert_oid_pair(RelationGetRelid(fsstate->rel), tuple->t_tableOid);
+	}
 	/*
 	 * Return the next tuple.
 	 */
-	ExecStoreHeapTuple(fsstate->tuples[fsstate->next_tuple++],
-					   slot,
-					   false);
+	ExecStoreHeapTuple(tuple, slot, false);
 
 	return slot;
 }
@@ -1971,6 +2127,9 @@ postgresAddForeignUpdateTargets(PlannerInfo *root,
 								Relation target_relation)
 {
 	Var		   *var;
+	Param	   *param;
+	const char *attrname;
+	TargetEntry *tle;
 
 	/*
 	 * In postgres_fdw, what we need is the ctid, same as for a regular table.
@@ -1986,6 +2145,38 @@ postgresAddForeignUpdateTargets(PlannerInfo *root,
 
 	/* Register it as a row-identity column needed by this target rel */
 	add_row_identity_var(root, var, rtindex, "ctid");
+
+	/* Make a Var representing the desired value */
+	var = makeVar(rtindex,
+		TableOidAttributeNumber,
+		OIDOID,
+		-1,
+		InvalidOid,
+		0);
+	/* Register it as a row-identity column needed by this target rel */
+	add_row_identity_var(root, var, rtindex, "remote_tableoid");
+
+	/* Make a Param representing the tableoid value */
+	param = makeNode(Param);
+	param->paramkind = PARAM_EXEC;
+	param->paramtype = OIDOID;
+	param->paramtypmod = -1;
+	param->paramcollid = InvalidOid;
+	param->location = -1;
+	/* paramid will be filled in by fix_foreign_params */
+	param->paramid = -1;
+	param->target_rte = rtindex;
+
+	/* Wrap it in a resjunk TLE with the right name ... */
+	attrname = "remote_tableoid";
+
+	tle = makeTargetEntry((Expr *) param,
+						  list_length(root->processed_tlist) + 1,
+						  pstrdup(attrname),
+						  true);
+
+	/* ... and add it to the query's targetlist */
+	root->processed_tlist = lappend(root->processed_tlist, tle);
 }
 
 /*
@@ -2348,6 +2539,7 @@ postgresExecForeignDelete(EState *estate,
 	rslot = execute_foreign_modify(estate, resultRelInfo, CMD_DELETE,
 								   &slot, &planSlot, &numSlots);
 
+	destroy_remote_map();
 	return rslot ? rslot[0] : NULL;
 }
 
@@ -4233,7 +4425,7 @@ create_foreign_modify(EState *estate,
 		fmstate->attinmeta = TupleDescGetAttInMetadata(tupdesc);
 
 	/* Prepare for output conversion of parameters used in prepared stmt. */
-	n_params = list_length(fmstate->target_attrs) + 1;
+	n_params = list_length(fmstate->target_attrs) + 2;
 	fmstate->p_flinfo = palloc0_array(FmgrInfo, n_params);
 	fmstate->p_nums = 0;
 
@@ -4251,6 +4443,20 @@ create_foreign_modify(EState *estate,
 		getTypeOutputInfo(TIDOID, &typefnoid, &isvarlena);
 		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
 		fmstate->p_nums++;
+
+		/* Find the tableoid resjunk column in the subplan's result */
+		fmstate->tableoidAttno = ExecFindJunkAttributeInTlist(subplan->targetlist,
+															  "remote_tableoid");
+
+		if (!AttributeNumberIsValid(fmstate->tableoidAttno))
+			ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("could not find junk tableoid column")));
+
+		/* Second transmittable parameter will be tableoid */
+		getTypeOutputInfo(OIDOID, &typefnoid, &isvarlena);
+		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
+		fmstate->p_nums++;
 	}
 
 	if (operation == CMD_INSERT || operation == CMD_UPDATE)
@@ -4303,6 +4509,7 @@ execute_foreign_modify(EState *estate,
 {
 	PgFdwModifyState *fmstate = (PgFdwModifyState *) resultRelInfo->ri_FdwState;
 	ItemPointer ctid = NULL;
+	Oid			tableoid = InvalidOid;
 	const char **p_values;
 	PGresult   *res;
 	int			n_rows;
@@ -4348,7 +4555,9 @@ execute_foreign_modify(EState *estate,
 	if (operation == CMD_UPDATE || operation == CMD_DELETE)
 	{
 		Datum		datum;
+		Datum		datum2;
 		bool		isNull;
+		Oid remote_tableoid = InvalidOid;
 
 		datum = ExecGetJunkAttribute(planSlots[0],
 									 fmstate->ctidAttno,
@@ -4357,10 +4566,28 @@ execute_foreign_modify(EState *estate,
 		if (isNull)
 			elog(ERROR, "ctid is NULL");
 		ctid = (ItemPointer) DatumGetPointer(datum);
+
+		/* Get the tableoid that was passed up as a resjunk column */
+		datum2 = ExecGetJunkAttribute(planSlots[0],
+									  fmstate->tableoidAttno,
+									  &isNull);
+		/* shouldn't ever get a null result... */
+		if (isNull)
+			ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("tableoid is NULL")));
+
+		tableoid = DatumGetObjectId(datum2);
+
+		/* Remote OID */
+		remote_tableoid = find_remote_oid(tableoid);
+
+		if(OidIsValid(remote_tableoid))
+			tableoid = remote_tableoid;
 	}
 
 	/* Convert parameters needed by prepared statement to text form */
-	p_values = convert_prep_stmt_params(fmstate, ctid, slots, *numSlots);
+	p_values = convert_prep_stmt_params(fmstate, ctid, tableoid, slots, *numSlots);
 
 	/*
 	 * Execute the prepared statement.
@@ -4465,6 +4692,7 @@ prepare_foreign_modify(PgFdwModifyState *fmstate)
 static const char **
 convert_prep_stmt_params(PgFdwModifyState *fmstate,
 						 ItemPointer tupleid,
+						 Oid tableoid,
 						 TupleTableSlot **slots,
 						 int numSlots)
 {
@@ -4491,6 +4719,16 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 		pindex++;
 	}
 
+	/* 2nd parameter should be tableoid, if it's in use */
+	if (OidIsValid(tableoid))
+	{
+		Assert(tupleid != NULL);
+		/* don't need set_transmission_modes for OID output */
+		p_values[pindex] = OutputFunctionCall(&fmstate->p_flinfo[pindex],
+											  ObjectIdGetDatum(tableoid));
+		pindex++;
+	}
+
 	/* get following parameters from slots */
 	if (slots != NULL && fmstate->target_attrs != NIL)
 	{
@@ -4502,7 +4740,7 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 
 		for (i = 0; i < numSlots; i++)
 		{
-			j = (tupleid != NULL) ? 1 : 0;
+			j = (tupleid != NULL) ? 2 : 0;
 			foreach(lc, fmstate->target_attrs)
 			{
 				int			attnum = lfirst_int(lc);
@@ -4567,9 +4805,10 @@ finish_foreign_modify(PgFdwModifyState *fmstate)
 {
 	Assert(fmstate != NULL);
 
+	destroy_remote_map();
+
 	/* If we created a prepared statement, destroy it */
 	deallocate_query(fmstate);
-
 	/* Release remote connection */
 	ReleaseConnection(fmstate->conn);
 	fmstate->conn = NULL;
@@ -4675,7 +4914,8 @@ build_remote_returning(Index rtindex, Relation rel, List *returningList)
 		if (IsA(var, Var) &&
 			var->varno == rtindex &&
 			var->varattno <= InvalidAttrNumber &&
-			var->varattno != SelfItemPointerAttributeNumber)
+			var->varattno != SelfItemPointerAttributeNumber &&
+			var->varattno != TableOidAttributeNumber)
 			continue;			/* don't need it */
 
 		if (tlist_member((Expr *) var, tlist))
@@ -4881,6 +5121,17 @@ init_returning_filter(PgFdwDirectModifyState *dmstate,
 		TargetEntry *tle = (TargetEntry *) lfirst(lc);
 		Var		   *var = (Var *) tle->expr;
 
+		/*
+		 * No need to set the Param for the remote table OID; ignore it.
+		 */
+		if (IsA(var, Param))
+		{
+			/* We would not retrieve the remote table OID anymore. */
+			Assert(!list_member_int(dmstate->retrieved_attrs, i));
+			i++;
+			continue;
+		}
+
 		Assert(IsA(var, Var));
 
 		/*
@@ -4899,6 +5150,8 @@ init_returning_filter(PgFdwDirectModifyState *dmstate,
 				 */
 				if (attrno == SelfItemPointerAttributeNumber)
 					dmstate->ctidAttno = i;
+				else if (attrno == TableOidAttributeNumber)
+					dmstate->oidAttno = i;
 				else
 					Assert(false);
 				dmstate->hasSystemCols = true;
@@ -4990,10 +5243,13 @@ apply_returning_filter(PgFdwDirectModifyState *dmstate,
 		/* ctid */
 		if (dmstate->ctidAttno)
 		{
+			Oid tableoid = InvalidOid;
 			ItemPointer ctid = NULL;
 
 			ctid = (ItemPointer) DatumGetPointer(old_values[dmstate->ctidAttno - 1]);
+			tableoid = DatumGetObjectId(old_values[dmstate->oidAttno - 1]);
 			resultTup->t_self = *ctid;
+			resultTup->t_tableOid = tableoid;
 		}
 
 		/*
@@ -6937,6 +7193,38 @@ foreign_join_ok(PlannerInfo *root, RelOptInfo *joinrel, JoinType jointype,
 	/* Mark that this join can be pushed down safely */
 	fpinfo->pushdown_safe = true;
 
+	/*
+	 * If the join relation contains an UPDATE/DELETE target, either of the
+	 * input relations would have saved the Param representing the remote
+	 * table OID of the target; get the Param and remember it in fpinfo for
+	 * use later.
+	 */
+	if ((root->parse->commandType == CMD_UPDATE ||
+		 root->parse->commandType == CMD_DELETE) &&
+		bms_is_member(root->parse->resultRelation, joinrel->relids))
+	{
+		if (bms_is_member(root->parse->resultRelation,
+						  outerrel->relids))
+		{
+			Assert(fpinfo_o->tableoid_param);
+			fpinfo->tableoid_param = fpinfo_o->tableoid_param;
+		}
+		else
+		{
+			Assert(bms_is_member(root->parse->resultRelation,
+								 innerrel->relids));
+			Assert(fpinfo_i->tableoid_param);
+			fpinfo->tableoid_param = fpinfo_i->tableoid_param;
+		}
+		/*
+		 * Core code should have contained the Param in the join relation's
+		 * reltarget.
+		 */
+		Assert(list_member(joinrel->reltarget->exprs, fpinfo->tableoid_param));
+	}
+	else
+		fpinfo->tableoid_param = NULL;
+
 	/* Get user mapping */
 	if (fpinfo->use_remote_estimate)
 	{
@@ -8470,6 +8758,7 @@ make_tuple_from_result_row(PGresult *res,
 	ErrorContextCallback errcallback;
 	MemoryContext oldcontext;
 	ListCell   *lc;
+	Oid tableoid = InvalidOid;
 	int			j;
 
 	Assert(row < PQntuples(res));
@@ -8552,6 +8841,17 @@ make_tuple_from_result_row(PGresult *res,
 				ctid = (ItemPointer) DatumGetPointer(datum);
 			}
 		}
+		else if (i == TableOidAttributeNumber)
+		{
+			/* tableoid */
+			if (valstr != NULL)
+			{
+				Datum		datum;
+
+				datum = DirectFunctionCall1(oidin, CStringGetDatum(valstr));
+				tableoid = DatumGetObjectId(datum);
+			}
+		}
 		errpos.cur_attno = 0;
 
 		j++;
@@ -8583,6 +8883,8 @@ make_tuple_from_result_row(PGresult *res,
 	if (ctid)
 		tuple->t_self = tuple->t_data->t_ctid = *ctid;
 
+	tuple->t_tableOid = tableoid;
+
 	/*
 	 * Stomp on the xmin, xmax, and cmin fields from the tuple created by
 	 * heap_form_tuple.  heap_form_tuple actually creates the tuple with
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index a2bb1ff352c..376c55d4dd0 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -129,6 +129,9 @@ typedef struct PgFdwRelationInfo
 	 * representing the relation.
 	 */
 	int			relation_index;
+
+	/* PARAM_EXEC Param representing the remote table OID of a target rel */
+	Param	   *tableoid_param;
 } PgFdwRelationInfo;
 
 /*
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index e868da00ace..7cb8ed7c337 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -4702,6 +4702,50 @@ SELECT server_name,
 \set VERBOSITY default
 RESET debug_discard_caches;
 
+-- ===================================================================
+-- check whether fdw created for partitioned table will delete tuples only from
+-- desired partition
+-- ===================================================================
+
+CREATE TABLE measurement (
+       city_id         int not null,
+       logdate         date not null,
+       peaktemp        int,
+       unitsales       int
+) PARTITION BY RANGE (logdate);
+
+CREATE TABLE measurement_y2006m02 PARTITION OF measurement
+       FOR VALUES FROM ('2006-02-01') TO ('2006-03-01');
+
+CREATE TABLE measurement_y2006m03 PARTITION OF measurement
+       FOR VALUES FROM ('2006-03-01') TO ('2006-04-01');
+
+CREATE TABLE measurement_y2006m04 PARTITION OF measurement
+       FOR VALUES FROM ('2006-04-01') TO ('2006-05-01');
+
+INSERT INTO measurement VALUES (1,'2006-02-01',1,1);
+INSERT INTO measurement VALUES (2,'2006-03-01',1,1);
+INSERT INTO measurement VALUES (3,'2006-04-01',1,1);
+
+create foreign table measurement_fdw (
+       city_id int options (column_name 'city_id') not null,
+       logdate date options (column_name 'logdate') not null,
+       peaktemp text options (column_name 'peaktemp'),
+       unitsales integer options (column_name 'unitsales')
+) SERVER loopback OPTIONS (table_name 'measurement');
+
+DELETE FROM measurement_fdw
+USING (
+       SELECT t1.city_id sub_city_id
+       FROM measurement_fdw t1
+       WHERE t1.city_id=1
+       LIMIT 1000
+) sub
+WHERE measurement_fdw.city_id = sub.sub_city_id
+RETURNING city_id, logdate, peaktemp, unitsales;
+
+SELECT * FROM measurement_fdw;
+
 -- ===================================================================
 -- test cleanup of failed connections on abort
 -- ===================================================================
-- 
2.43.0



^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  2026-06-13 19:43                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-15 14:54                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-07-09 14:03                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
@ 2026-08-27 09:20                         ` Ajit Awekar <ajitpostgres@gmail.com>
  2026-08-27 14:36                           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Ajit Awekar @ 2026-08-27 09:20 UTC (permalink / raw)
  To: Nikita Malakhov <hukutoc@gmail.com>; +Cc: Etsuro Fujita <etsuro.fujita@gmail.com>; Michael Paquier <michael@paquier.xyz>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Hi hackers,

This attached patch relies on fdw_scan_tlist to carry the new
remote-tableoid row-identity column.

 fdw_scan_tlist is a targetlist describing the contents of the scan tuple
 returned by the FDW; it can be NIL if the scan tuple matches the declared
 rowtype of the foreign table, which is the normal case for a simple foreign
 table scan.  (If the plan node represents a foreign join, fdw_scan_tlist
 is required since there is no rowtype available from the system catalogs.)

A base-relation foreign scan normally fetches columns positionally: the
scan tuple is assumed to match the foreign table's own rowtype 1:1, so
nothing above the scan needs to know what the remote query actually
returned.

The remote tableoid doesn't fit the positional world at all - it isn't a
column the local foreign table has, so there's no local attribute number
for it to occupy. Reusing the ordinary tableoid system column doesn't
work either, since that resolves locally to the foreign table's own OID,
not the remote row's. So it has to travel as an invented pseudo-column,
under an out-of-range attribute number - RemoteTableOidAttributeNumber,
defined as MaxHeapAttributeNumber + 1, i.e. one past anything a real
catalog column could ever have, so it can never collide with a genuine
attribute - and that forces the scan onto the same explicit-tlist path
joins already use.

Please review the attached patch and let me know your comments.

Thanks & Best Regards,
Ajit


On Thu, 9 Jul 2026 at 19:34, Nikita Malakhov <hukutoc@gmail.com> wrote:

> Hi hackers!
>
> I've fixed the issue mentioned in two previous messages, so for anyone
> already have taken
> my patch set above I'd compiled a fixed patch set. It does not object an
> approach
> proposed by Etsuro-san.
>
> The first patch file
> v4-0001-copy-and-remote-tableoid-param.patch
> is a core patch introducing extended copy slot and tuple facilities
> and core machinery for remote table OIDs;
>
> The second file
> v4-0002-teach-fdw-use-remote-tableoid.patch
> is a fdw facilities changes (fixed) to use remote table OID.
>
> On Mon, Jun 15, 2026 at 5:55 PM Etsuro Fujita <etsuro.fujita@gmail.com>
> wrote:
>
>> On Sun, Jun 14, 2026 at 4:43 AM Nikita Malakhov <hukutoc@gmail.com>
>> wrote:
>> > While testing the proposed solution we've stumbled upon another vanilla
>> bug related to FDW -
>> > a query with DELETE ... USING selects invalid records from partitioned
>> FDW tables:
>>
>> > CREATE TABLE acc_entry
>> > (
>> >     id bigint,
>> >     doc_date date,
>> >     impact int,
>> >     amount numeric
>> > ) PARTITION BY RANGE (doc_date);
>> >
>> > CREATE TABLE acc_entry_p1
>> > PARTITION OF acc_entry
>> > FOR VALUES FROM ('2025-01-01') TO ('2025-07-01');
>> >
>> > CREATE TABLE acc_entry_p2
>> > PARTITION OF acc_entry
>> > FOR VALUES FROM ('2025-07-01') TO ('2026-01-01');
>> >
>> > CREATE FOREIGN TABLE measurement_fdw
>> > (
>> >     id bigint,
>> >     doc_date date,
>> >     impact int,
>> >     amount numeric
>> > )
>> > SERVER loopback
>> > OPTIONS (table_name 'acc_entry');
>> >
>> > INSERT INTO acc_entry
>> > SELECT
>> >     CASE
>> >         WHEN g IN (4,15,26,35,46,55,66,75,86,95)
>> >             THEN 2501020100000124
>> >         ELSE g
>> >     END AS id,
>> >     CASE WHEN g % 2 = 0 THEN timestamp '2025-02-02' ELSE timestamp
>> '2025-08-08' END,
>> >     1,
>> >     g
>> > FROM generate_series(1,100) g;
>> >
>> > DELETE FROM measurement_fdw
>> > USING (
>> >     SELECT id
>> >     FROM measurement_fdw
>> >     WHERE id = 2501020100000124
>> >     LIMIT 1
>> > ) s
>> > WHERE measurement_fdw.id = s.id;
>> >
>> > The latter query selects and deletes records with invalid ID which
>> should not be selected at all.
>>
>> I think that that would be another example that the bug discussed here
>> causes unexpected results, as I have this after inserting the data
>> into the partitioned table:
>>
>> select tableoid::regclass, ctid, * from acc_entry where ctid in
>> (select ctid from acc_entry where id = 2501020100000124);
>>    tableoid   |  ctid  |        id        |  doc_date  | impact | amount
>> --------------+--------+------------------+------------+--------+--------
>>  acc_entry_p1 | (0,2)  | 2501020100000124 | 2025-02-02 |      1 |      4
>>  acc_entry_p1 | (0,8)  |               16 | 2025-02-02 |      1 |     16
>>  acc_entry_p1 | (0,13) | 2501020100000124 | 2025-02-02 |      1 |     26
>>  acc_entry_p1 | (0,18) |               36 | 2025-02-02 |      1 |     36
>>  acc_entry_p1 | (0,23) | 2501020100000124 | 2025-02-02 |      1 |     46
>>  acc_entry_p1 | (0,28) |               56 | 2025-02-02 |      1 |     56
>>  acc_entry_p1 | (0,33) | 2501020100000124 | 2025-02-02 |      1 |     66
>>  acc_entry_p1 | (0,38) |               76 | 2025-02-02 |      1 |     76
>>  acc_entry_p1 | (0,43) | 2501020100000124 | 2025-02-02 |      1 |     86
>>  acc_entry_p1 | (0,48) |               96 | 2025-02-02 |      1 |     96
>>  acc_entry_p2 | (0,2)  |                3 | 2025-08-08 |      1 |      3
>>  acc_entry_p2 | (0,8)  | 2501020100000124 | 2025-08-08 |      1 |     15
>>  acc_entry_p2 | (0,13) |               25 | 2025-08-08 |      1 |     25
>>  acc_entry_p2 | (0,18) | 2501020100000124 | 2025-08-08 |      1 |     35
>>  acc_entry_p2 | (0,23) |               45 | 2025-08-08 |      1 |     45
>>  acc_entry_p2 | (0,28) | 2501020100000124 | 2025-08-08 |      1 |     55
>>  acc_entry_p2 | (0,33) |               65 | 2025-08-08 |      1 |     65
>>  acc_entry_p2 | (0,38) | 2501020100000124 | 2025-08-08 |      1 |     75
>>  acc_entry_p2 | (0,43) |               85 | 2025-08-08 |      1 |     85
>>  acc_entry_p2 | (0,48) | 2501020100000124 | 2025-08-08 |      1 |     95
>> (20 rows)
>>
>> Note that the rows with normal ids have the same ctid as the rows with
>> id=2501020100000124 (for example, ctid of the row with id=3 is (0,2),
>> which is the same as that of the first row, which has
>> id=2501020100000124), so the bug would delete such normal-id rows as
>> well when performing the delete query.
>>
>> Best regards,
>> Etsuro Fujita
>>
>
>
> --
> Regards,
> Nikita Malakhov
> Postgres Professional
> The Russian Postgres Company
> https://postgrespro.ru/
>

Attachments:

  [application/octet-stream] 0001-postgres_fdw-Disambiguate-row-identity-by-remote-tab.patch (95.4K, ../../CAER375N8yyZhdvJMMU1fx2ZUcQmMbgj=8Vf9Qj3Qv_KFeczFZQ@mail.gmail.com/3-0001-postgres_fdw-Disambiguate-row-identity-by-remote-tab.patch)
  download | inline diff:
From 42e2c2f8e3ec21a9be3765c1a61acf004ae159e5 Mon Sep 17 00:00:00 2001
From: Ajit Awekar <ajit.awekar@enterprisedb.com>
Date: Wed, 26 Aug 2026 12:42:37 +0530
Subject: [PATCH] postgres_fdw: Disambiguate row identity by remote tableoid
 too

Non-direct UPDATE/DELETE on a foreign table identified the target row
on the remote server by ctid alone: "WHERE ctid = $1".  A ctid is only
unique within a single heap, so if the foreign table maps to a
partitioned (or inherited) table on the remote side, the same ctid can
exist in more than one partition, and the wrong row could be updated
or deleted.

Fix this by also fetching and transmitting the remote tableoid, so the
remote command becomes "WHERE ctid = $1 AND tableoid = $2".

The remote tableoid is not a real column of the local foreign table,
and TableOidAttributeNumber would resolve to the local foreign table's
own OID rather than the remote row's, so it is carried as a pseudo-
column using an out-of-range attribute number
(RemoteTableOidAttributeNumber = MaxHeapAttributeNumber + 1), flowing
through the foreign scan's fdw_scan_tlist like any other output
column.  This requires several optimizer/ruleutils.c call sites that
assume attribute numbers stay within a relation's normal range to
instead treat such an out-of-range attno as an FDW row-identity
pseudo-column, generically, the same way ROWID_VAR is already handled.

This adds an extra output column and WHERE clause to every non-direct
UPDATE/DELETE performed through postgres_fdw, including against
remote tables that are not partitioned, since postgres_fdw has no
cheap way to know that without an extra remote round trip.

Building an explicit fdw_scan_tlist for the above also exposes any
pre-existing, unrelated need for the local tableoid (e.g. RETURNING
tableoid, or reconstructing a whole row for a cross-partition update)
to the same deparse machinery, which would otherwise ship it to the
remote server as a literal Const for no reason: that value is already
known locally.  Skip fetching it from the remote server in that case
and fill it in afterward instead, so the remote query is not changed
by needs that have nothing to do with row identity.
---
 contrib/postgres_fdw/deparse.c                |  52 +-
 .../postgres_fdw/expected/postgres_fdw.out    | 467 ++++++++++--------
 contrib/postgres_fdw/postgres_fdw.c           | 191 ++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   9 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  32 ++
 src/backend/optimizer/path/costsize.c         |  14 +
 src/backend/optimizer/plan/initsplan.c        |  19 +
 src/backend/optimizer/plan/setrefs.c          |   9 +-
 src/backend/optimizer/util/relnode.c          |  40 +-
 src/backend/utils/adt/ruleutils.c             |  23 +
 10 files changed, 607 insertions(+), 249 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index 673b678826c..91a3f818f1a 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -1391,11 +1391,14 @@ deparseSelectSql(List *tlist, bool is_subquery, List **retrieved_attrs,
 		 */
 		deparseSubqueryTargetList(context);
 	}
-	else if (IS_JOIN_REL(foreignrel) || IS_UPPER_REL(foreignrel))
+	else if (IS_JOIN_REL(foreignrel) || IS_UPPER_REL(foreignrel) ||
+			 tlist != NIL)
 	{
 		/*
 		 * For a join or upper relation the input tlist gives the list of
-		 * columns required to be fetched from the foreign server.
+		 * columns required to be fetched from the foreign server; likewise
+		 * for a base-relation scan given an explicit tlist (fdw_scan_tlist)
+		 * to fetch the remote tableoid (see postgresGetForeignPlan()).
 		 */
 		deparseExplicitTargetList(tlist, false, retrieved_attrs, context);
 	}
@@ -1726,8 +1729,14 @@ get_jointype_name(JoinType jointype)
  *
  * tlist is list of TargetEntry's which in turn contain Var nodes.
  *
- * retrieved_attrs is the list of continuously increasing integers starting
- * from 1. It has same number of entries as tlist.
+ * retrieved_attrs is normally the list of continuously increasing integers
+ * starting from 1, with the same number of entries as tlist.  The one
+ * exception is a base relation's SELECT list containing the local-tableoid
+ * pseudo-entry (a Var on TableOidAttributeNumber, added when building an
+ * explicit fdw_scan_tlist for row-identity purposes): its value is already
+ * known at plan time, so instead of shipping it to the remote server as a
+ * literal Const, it's skipped here and filled in locally afterward, by
+ * make_tuple_from_result_row().
  *
  * This is used for both SELECT and RETURNING targetlists; the is_returning
  * parameter is true only for a RETURNING targetlist.
@@ -1740,7 +1749,9 @@ deparseExplicitTargetList(List *tlist,
 {
 	ListCell   *lc;
 	StringInfo	buf = context->buf;
+	bool		is_base_rel = !is_returning && IS_SIMPLE_REL(context->scanrel);
 	int			i = 0;
+	bool		first = true;
 
 	*retrieved_attrs = NIL;
 
@@ -1748,18 +1759,26 @@ deparseExplicitTargetList(List *tlist,
 	{
 		TargetEntry *tle = lfirst_node(TargetEntry, lc);
 
-		if (i > 0)
+		i++;
+
+		/* Local tableoid: known locally, skip the remote round trip. */
+		if (is_base_rel &&
+			IsA(tle->expr, Var) &&
+			((Var *) tle->expr)->varattno == TableOidAttributeNumber)
+			continue;
+
+		if (!first)
 			appendStringInfoString(buf, ", ");
 		else if (is_returning)
 			appendStringInfoString(buf, " RETURNING ");
+		first = false;
 
 		deparseExpr((Expr *) tle->expr, context);
 
-		*retrieved_attrs = lappend_int(*retrieved_attrs, i + 1);
-		i++;
+		*retrieved_attrs = lappend_int(*retrieved_attrs, i);
 	}
 
-	if (i == 0 && !is_returning)
+	if (first && !is_returning)
 		appendStringInfoString(buf, "NULL");
 }
 
@@ -2364,7 +2383,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 	deparseRelation(buf, rel);
 	appendStringInfoString(buf, " SET ");
 
-	pindex = 2;					/* ctid is always the first param */
+	pindex = 3;					/* ctid ($1) and tableoid ($2) come first */
 	first = true;
 	foreach(lc, targetAttrs)
 	{
@@ -2384,7 +2403,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 			pindex++;
 		}
 	}
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_update_after_row,
@@ -2502,7 +2521,7 @@ deparseDeleteSql(StringInfo buf, RangeTblEntry *rte,
 {
 	appendStringInfoString(buf, "DELETE FROM ");
 	deparseRelation(buf, rel);
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_delete_after_row,
@@ -2886,6 +2905,17 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 			ADD_REL_QUALIFIER(buf, varno);
 		appendStringInfoString(buf, "ctid");
 	}
+	else if (varattno == RemoteTableOidAttributeNumber)
+	{
+		/*
+		 * Pseudo-column carrying the remote table OID as part of the row
+		 * identity for UPDATE/DELETE (see postgresAddForeignUpdateTargets);
+		 * fetch it as the remote "tableoid" system column.
+		 */
+		if (qualify_col)
+			ADD_REL_QUALIFIER(buf, varno);
+		appendStringInfoString(buf, "tableoid");
+	}
 	else if (varattno < 0)
 	{
 		/*
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 517d15cf1fa..a3b34abca30 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -5975,14 +5975,14 @@ BEGIN;
   EXPLAIN (verbose, costs off)
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
     RETURNING old.*, new.*;                                                         -- can't be pushed down
-                                                                  QUERY PLAN                                                                  
-----------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                 QUERY PLAN                                                                                 
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.c1, old.c2, old.c3, old.c4, old.c5, old.c6, old.c7, old.c8, new.c1, new.c2, new.c3, new.c4, new.c5, new.c6, new.c7, new.c8
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c3 = $3 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c3 = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan on public.ft2
-         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, ft2.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
+         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, remotetableoid, ft2.*
+         Remote SQL: SELECT c2, c3, ctid, tableoid, ROW("C 1", c2, c3, c4, c5, c6, c7, c8), "C 1" FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
 (6 rows)
 
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
@@ -6129,14 +6129,14 @@ DELETE FROM ft2 WHERE c1 % 10 = 5 RETURNING c1, c4;
 BEGIN;
   EXPLAIN (verbose, costs off)
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;               -- can't be pushed down
-                                                QUERY PLAN                                                 
------------------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: old.c1, c4
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c4
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c4
    ->  Foreign Scan on public.ft2
-         Output: ctid
-         Remote SQL: SELECT ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
+         Output: ctid, remotetableoid
+         Remote SQL: SELECT ctid, tableoid, "C 1" FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
 (6 rows)
 
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;
@@ -7136,27 +7136,27 @@ BEGIN;
     FROM ft4 INNER JOIN ft5 ON (ft4.c1 = ft5.c1)
     WHERE ft2.c1 > 1200 AND ft2.c2 = ft4.c1
     RETURNING old, new, ft2, ft2.*, ft4, ft4.*;  -- can't be pushed down
-                                                                                                                                                                                                                                     QUERY PLAN                                                                                                                                                                                                                                     
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                                                                           QUERY PLAN                                                                                                                                                                                                                                            
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.*, new.*, ft2.*, ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.*, ft4.c1, ft4.c2, ft4.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan
-         Output: 'bar'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+         Output: 'bar'::text, ft2.ctid, remotetableoid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+               Output: ft2.ctid, remotetableoid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
                Join Filter: (ft4.c1 = ft5.c1)
                ->  Sort
-                     Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                     Output: ft2.ctid, remotetableoid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                      Sort Key: ft2.c2
                      ->  Hash Join
-                           Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                           Output: ft2.ctid, remotetableoid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                            Hash Cond: (ft2.c2 = ft4.c1)
                            ->  Foreign Scan on public.ft2
-                                 Output: ft2.ctid, ft2.*, ft2.c2
-                                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
+                                 Output: ft2.ctid, remotetableoid, ft2.*, ft2.c2
+                                 Remote SQL: SELECT ctid, tableoid, ROW("C 1", c2, c3, c4, c5, c6, c7, c8), c2, "C 1" FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
                            ->  Hash
                                  Output: ft4.*, ft4.c1, ft4.c2, ft4.c3
                                  ->  Foreign Scan on public.ft4
@@ -7234,13 +7234,13 @@ UPDATE ft2 AS target SET (c2, c7) = (
         FROM ft2 AS src
         WHERE target.c1 = src.c1
 ) WHERE c1 > 1100;
-                                                         QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
+                                                                 QUERY PLAN                                                                  
+---------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 target
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c7 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c7 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.ft2 target
-         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, target.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
+         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, remotetableoid, target.*
+         Remote SQL: SELECT "C 1", ctid, tableoid, ROW("C 1", c2, c3, c4, c5, c6, c7, c8) FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
          SubPlan multiexpr_1
            ->  Foreign Scan on public.ft2 src
                  Output: (src.c2 * 10), src.c7
@@ -7262,20 +7262,20 @@ UPDATE ft2 AS target SET (c2) = (
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE ft2 d SET c2 = CASE WHEN random() >= 0 THEN d.c2 ELSE 0 END
   FROM ft2 AS t WHERE d.c1 = t.c1 AND d.c1 > 1000;
-                                                                                                                                                                                       QUERY PLAN                                                                                                                                                                                        
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                              QUERY PLAN                                                                                                                                                                                              
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 d
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan
-         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, d.*, t.*
+         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, remotetableoid, d.*, t.*
          Relations: (public.ft2 d) INNER JOIN (public.ft2 t)
-         Remote SQL: SELECT r1.c2, r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.c2, r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
          ->  Hash Join
-               Output: d.c2, d.ctid, d.*, t.*
+               Output: d.c2, d.ctid, remotetableoid, d.*, t.*
                Hash Cond: (d.c1 = t.c1)
                ->  Foreign Scan on public.ft2 d
-                     Output: d.c2, d.ctid, d.*, d.c1
-                     Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
+                     Output: d.c2, d.ctid, remotetableoid, d.*, d.c1
+                     Remote SQL: SELECT c2, ctid, tableoid, ROW("C 1", c2, c3, c4, c5, c6, c7, c8), "C 1" FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
                ->  Hash
                      Output: t.*, t.c1
                      ->  Foreign Scan on public.ft2 t
@@ -7295,19 +7295,19 @@ EXPLAIN (verbose, costs off)
 WITH cte AS (
   UPDATE ft2 SET c3 = 'bar' WHERE postgres_fdw_abs(c1) > 2000 RETURNING *
 ) SELECT * FROM cte ORDER BY c1;          -- can't be pushed down
-                                                    QUERY PLAN                                                    
-------------------------------------------------------------------------------------------------------------------
+                                                             QUERY PLAN                                                             
+------------------------------------------------------------------------------------------------------------------------------------
  Sort
    Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
    Sort Key: cte.c1
    CTE cte
      ->  Update on public.ft2
            Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8
-           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
            ->  Foreign Scan on public.ft2
-                 Output: 'bar'::text, ft2.ctid, ft2.*
+                 Output: 'bar'::text, ft2.ctid, remotetableoid, ft2.*
                  Filter: (postgres_fdw_abs(ft2.c1) > 2000)
-                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" FOR UPDATE
+                 Remote SQL: SELECT ctid, tableoid, ROW("C 1", c2, c3, c4, c5, c6, c7, c8), "C 1" FROM "S 1"."T 1" FOR UPDATE
    ->  CTE Scan on cte
          Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
 (13 rows)
@@ -7338,13 +7338,13 @@ UPDATE ft2 SET c3 = 'baz'
 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Nested Loop
-         Output: 'baz'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
+         Output: 'baz'::text, ft2.ctid, remotetableoid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
          Join Filter: (ft2.c2 === ft4.c1)
          ->  Foreign Scan on public.ft2
-               Output: ft2.ctid, ft2.*, ft2.c2
-               Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+               Output: ft2.ctid, remotetableoid, ft2.*, ft2.c2
+               Remote SQL: SELECT ctid, tableoid, ROW("C 1", c2, c3, c4, c5, c6, c7, c8), c2, "C 1" FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
          ->  Foreign Scan
                Output: ft4.*, ft4.c1, ft4.c2, ft4.c3, ft5.*, ft5.c1, ft5.c2, ft5.c3
                Relations: (public.ft4) INNER JOIN (public.ft5)
@@ -7376,24 +7376,24 @@ DELETE FROM ft2
   USING ft4 INNER JOIN ft5 ON (ft4.c1 === ft5.c1)
   WHERE ft2.c1 > 2000 AND ft2.c2 = ft4.c1
   RETURNING ft2.c1, ft2.c2, ft2.c3;       -- can't be pushed down
-                                                                                                                                                                     QUERY PLAN                                                                                                                                                                     
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                           QUERY PLAN                                                                                                                                                                            
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c2, c3
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3
    ->  Foreign Scan
-         Output: ft2.ctid, ft4.*, ft5.*
+         Output: ft2.ctid, remotetableoid, ft4.*, ft5.*
          Filter: (ft4.c1 === ft5.c1)
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft4.*, ft5.*, ft4.c1, ft5.c1
+               Output: ft2.ctid, remotetableoid, ft4.*, ft5.*, ft4.c1, ft5.c1
                ->  Nested Loop
-                     Output: ft2.ctid, ft4.*, ft4.c1
+                     Output: ft2.ctid, remotetableoid, ft4.*, ft4.c1
                      Join Filter: (ft2.c2 = ft4.c1)
                      ->  Foreign Scan on public.ft2
-                           Output: ft2.ctid, ft2.c2
-                           Remote SQL: SELECT c2, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+                           Output: ft2.ctid, remotetableoid, ft2.c2
+                           Remote SQL: SELECT ctid, tableoid, c2, "C 1" FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
                      ->  Foreign Scan on public.ft4
                            Output: ft4.*, ft4.c1
                            Remote SQL: SELECT c1, c2, c3 FROM "S 1"."T 3"
@@ -7905,19 +7905,19 @@ SET enable_hashjoin TO false;
 SET enable_material TO false;
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
-                                   QUERY PLAN                                   
---------------------------------------------------------------------------------
+                                            QUERY PLAN                                             
+---------------------------------------------------------------------------------------------------
  Update on public.remt2
    Output: remt2.c1, remt2.c2
-   Remote SQL: UPDATE public.loct2 SET c2 = $2 WHERE ctid = $1 RETURNING c1, c2
+   Remote SQL: UPDATE public.loct2 SET c2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING c1, c2
    ->  Nested Loop
-         Output: (remt2.c2 || remt2.c2), remt2.ctid, remt2.*, loct1.ctid
+         Output: (remt2.c2 || remt2.c2), remt2.ctid, remotetableoid, remt2.*, loct1.ctid
          Join Filter: (remt2.c1 = loct1.c1)
          ->  Seq Scan on public.loct1
                Output: loct1.ctid, loct1.c1
          ->  Foreign Scan on public.remt2
-               Output: remt2.c2, remt2.ctid, remt2.*, remt2.c1
-               Remote SQL: SELECT c1, c2, ctid FROM public.loct2 FOR UPDATE
+               Output: remt2.c2, remt2.ctid, remotetableoid, remt2.*, remt2.c1
+               Remote SQL: SELECT c2, ctid, tableoid, ROW(c1, c2), c1 FROM public.loct2 FOR UPDATE
 (11 rows)
 
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
@@ -7974,17 +7974,17 @@ prepare fdw_part_upd2(int) as
     returning tableoid::regclass, a, b;
 explain (verbose, costs off)
     execute fdw_part_upd2(2);
-                                                                       QUERY PLAN                                                                       
---------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                               QUERY PLAN                                                                               
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.fdw_part_update
    Output: (fdw_part_update_1.tableoid)::regclass, fdw_part_update_1.a, fdw_part_update_1.b
    Foreign Update on public.fdw_part_update_p2 fdw_part_update_2
-     Remote SQL: UPDATE public.fdw_part_update_remote SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.fdw_part_update_remote SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Append
          Subplans Removed: 1
          ->  Foreign Scan on public.fdw_part_update_p2 fdw_part_update_2
-               Output: ((fdw_part_update_2.b + ((random())::integer * 0)) + 1), fdw_part_update_2.tableoid, fdw_part_update_2.ctid, fdw_part_update_2.*
-               Remote SQL: SELECT a, b, ctid FROM public.fdw_part_update_remote WHERE ((a = $1::integer)) FOR UPDATE
+               Output: ((fdw_part_update_2.b + ((random())::integer * 0)) + 1), fdw_part_update_2.tableoid, fdw_part_update_2.ctid, remotetableoid, fdw_part_update_2.*
+               Remote SQL: SELECT b, ctid, tableoid, ROW(a, b), a FROM public.fdw_part_update_remote WHERE ((a = $1::integer)) FOR UPDATE
 (9 rows)
 
 execute fdw_part_upd2(2);
@@ -8142,13 +8142,13 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                                 QUERY PLAN                                                 
+------------------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, remotetableoid, foreign_tbl.*
+         Remote SQL: SELECT b, ctid, tableoid, ROW(a, b), a FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -8156,13 +8156,13 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                                 QUERY PLAN                                                 
+------------------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, remotetableoid, foreign_tbl.*
+         Remote SQL: SELECT b, ctid, tableoid, ROW(a, b), a FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -8255,14 +8255,14 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                   
+----------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid, parent_tbl_1.*
+         Remote SQL: SELECT b, ctid, tableoid, ROW(a, b), a FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -8270,14 +8270,14 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                    
+-----------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid, parent_tbl_1.*
+         Remote SQL: SELECT b, ctid, tableoid, ROW(a, b), a FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -8326,14 +8326,14 @@ CREATE VIEW rw_view AS SELECT * FROM parent_tbl WHERE a < 5 WITH CHECK OPTION;
 INSERT INTO parent_tbl (a) VALUES(1),(5);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = 'text', c = 123.456;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                    
+-----------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.child_foreign parent_tbl_1
-     Remote SQL: UPDATE public.child_local SET b = $2, c = $3 WHERE ctid = $1 RETURNING a
+     Remote SQL: UPDATE public.child_local SET b = $3, c = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING a
    ->  Foreign Scan on public.child_foreign parent_tbl_1
-         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT b, c, a, ctid FROM public.child_local WHERE ((a < 5)) FOR UPDATE
+         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid, parent_tbl_1.*
+         Remote SQL: SELECT ctid, tableoid, ROW(b, c, a), a FROM public.child_local WHERE ((a < 5)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = 'text', c = 123.456;
@@ -8412,13 +8412,13 @@ insert into grem1 (a) values (1), (2);
 insert into grem1 (a) values (1), (2);
 explain (verbose, costs off)
 update grem1 set a = 22 where a = 2;
-                                       QUERY PLAN                                       
-----------------------------------------------------------------------------------------
+                                                QUERY PLAN                                                
+----------------------------------------------------------------------------------------------------------
  Update on public.grem1
-   Remote SQL: UPDATE public.gloc1 SET a = $2, b = DEFAULT, c = DEFAULT WHERE ctid = $1
+   Remote SQL: UPDATE public.gloc1 SET a = $3, b = DEFAULT, c = DEFAULT WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.grem1
-         Output: 22, ctid, grem1.*
-         Remote SQL: SELECT a, b, c, ctid FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
+         Output: 22, ctid, remotetableoid, grem1.*
+         Remote SQL: SELECT ctid, tableoid, ROW(a, b, c), a FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
 (5 rows)
 
 update grem1 set a = 22 where a = 2;
@@ -8745,13 +8745,13 @@ SELECT * from loc1;
 
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f1 = 10;          -- all columns should be transmitted
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: 10, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: 10, ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, ROW(f1, f2) FROM public.loc1 FOR UPDATE
 (5 rows)
 
 UPDATE rem1 set f1 = 10;
@@ -8893,12 +8893,12 @@ DELETE FROM rem1;                 -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1 WHERE false;     -- currently can't be pushed down
-                      QUERY PLAN                       
--------------------------------------------------------
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: ctid
+         Output: ctid, NULL::oid
          Replaces: Scan on rem1
          One-Time Filter: false
 (6 rows)
@@ -8999,13 +8999,13 @@ BEFORE UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, ROW(f1, f2) FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -9023,13 +9023,13 @@ AFTER UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                                  QUERY PLAN                                   
--------------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f2 = $2 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: UPDATE public.loc1 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, ROW(f1, f2) FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -9057,13 +9057,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                             QUERY PLAN                              
----------------------------------------------------------------------
+                                     QUERY PLAN                                     
+------------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, ROW(f1, f2) FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_before_delete ON rem1;
@@ -9081,13 +9081,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, ROW(f1, f2) FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_after_delete ON rem1;
@@ -9124,28 +9124,28 @@ CONTEXT:  COPY parent_tbl, line 1: "AAA	42"
 ALTER SERVER loopback OPTIONS (DROP batch_size);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                   
+----------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid, parent_tbl_1.*
+         Remote SQL: SELECT b, ctid, tableoid, ROW(a, b) FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                            QUERY PLAN                            
-------------------------------------------------------------------
+                                   QUERY PLAN                                   
+--------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Foreign Delete on public.foreign_tbl parent_tbl_1
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
-         Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
+         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid
+         Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 DELETE FROM parent_tbl;
@@ -9163,38 +9163,38 @@ CREATE TRIGGER parent_tbl_delete_trig
   FOR EACH STATEMENT EXECUTE PROCEDURE trigger_func();
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                              QUERY PLAN                                              
-------------------------------------------------------------------------------------------------------
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Update on public.parent_tbl parent_tbl_1
    Foreign Update on public.foreign_tbl parent_tbl_2
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::record)
+         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::oid), (NULL::record)
          ->  Append
                ->  Seq Scan on public.parent_tbl parent_tbl_1
-                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::record
+                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, remotetableoid, parent_tbl_2.*
+                     Remote SQL: SELECT b, ctid, tableoid, ROW(a, b) FROM public.local_tbl FOR UPDATE
 (12 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Delete on public.parent_tbl parent_tbl_1
    Foreign Delete on public.foreign_tbl parent_tbl_2
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
    ->  Append
          ->  Seq Scan on public.parent_tbl parent_tbl_1
-               Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
+               Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid
          ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-               Output: parent_tbl_2.tableoid, parent_tbl_2.ctid
-               Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
+               Output: parent_tbl_2.tableoid, parent_tbl_2.ctid, remotetableoid
+               Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
 (10 rows)
 
 DELETE FROM parent_tbl;
@@ -9537,22 +9537,22 @@ drop table foo2child;
 -- Check UPDATE with inherited target and an inherited source table
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 where f1 in (select f1 from foo);
-                                              QUERY PLAN                                               
--------------------------------------------------------------------------------------------------------
+                                                     QUERY PLAN                                                     
+--------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Hash Join
-         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::record), foo.*, foo.tableoid
+         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::oid), (NULL::record), foo.*, foo.tableoid
          Inner Unique: true
          Hash Cond: (bar.f1 = foo.f1)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, remotetableoid, bar_2.*
+                     Remote SQL: SELECT f2, f1, ctid, tableoid, ROW(f1, f2, f3) FROM public.loct2 FOR UPDATE
          ->  Hash
                Output: foo.ctid, foo.f1, foo.*, foo.tableoid
                ->  HashAggregate
@@ -9584,24 +9584,24 @@ update bar set f2 = f2 + 100
 from
   ( select f1 from foo union all select f1+3 from foo ) ss
 where bar.f1 = ss.f1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                    QUERY PLAN                                                     
+-------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Merge Join
-         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
          Merge Cond: (bar.f1 = foo.f1)
          ->  Sort
-               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::record)
+               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
                Sort Key: bar.f1
                ->  Append
                      ->  Seq Scan on public.bar bar_1
-                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                      ->  Foreign Scan on public.bar2 bar_2
-                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                           Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, remotetableoid, bar_2.*
+                           Remote SQL: SELECT f2, f1, ctid, tableoid, ROW(f1, f2, f3) FROM public.loct2 FOR UPDATE
          ->  Sort
                Output: (ROW(foo.f1)), foo.f1
                Sort Key: foo.f1
@@ -9750,7 +9750,7 @@ delete from foo where f1 < 5 returning *;
    Foreign Delete on public.foo2 foo_2
    ->  Append
          ->  Index Scan using i_foo_f1 on public.foo foo_1
-               Output: foo_1.tableoid, foo_1.ctid
+               Output: foo_1.tableoid, foo_1.ctid, NULL::oid
                Index Cond: (foo_1.f1 < 5)
          ->  Foreign Delete on public.foo2 foo_2
                Remote SQL: DELETE FROM public.loct1 WHERE ((f1 < 5)) RETURNING f1, f2
@@ -9768,17 +9768,17 @@ delete from foo where f1 < 5 returning *;
 
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 returning *;
-                                        QUERY PLAN                                        
-------------------------------------------------------------------------------------------
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
  Update on public.bar
    Output: bar_1.f1, bar_1.f2
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Update on public.bar2 bar_2
                      Remote SQL: UPDATE public.loct2 SET f2 = (f2 + 100) RETURNING f1, f2
 (11 rows)
@@ -9803,20 +9803,20 @@ AFTER UPDATE OR DELETE ON bar2
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 explain (verbose, costs off)
 update bar set f2 = f2 + 100;
-                                               QUERY PLAN                                               
---------------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                        
+--------------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f1 = $2, f2 = $3, f3 = $4 WHERE ctid = $1 RETURNING f1, f2, f3
+     Remote SQL: UPDATE public.loct2 SET f1 = $3, f2 = $4, f3 = $5 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, remotetableoid, bar_2.*
+                     Remote SQL: SELECT f2, ctid, tableoid, ROW(f1, f2, f3) FROM public.loct2 FOR UPDATE
 (12 rows)
 
 update bar set f2 = f2 + 100;
@@ -9834,19 +9834,19 @@ NOTICE:  trig_row_after(23, skidoo) AFTER ROW UPDATE ON bar2
 NOTICE:  OLD: (7,277,77),NEW: (7,377,77)
 explain (verbose, costs off)
 delete from bar where f2 < 400;
-                                            QUERY PLAN                                             
----------------------------------------------------------------------------------------------------
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
  Delete on public.bar
    Delete on public.bar bar_1
    Foreign Delete on public.bar2 bar_2
-     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 RETURNING f1, f2, f3
+     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
    ->  Append
          ->  Seq Scan on public.bar bar_1
-               Output: bar_1.tableoid, bar_1.ctid, NULL::record
+               Output: bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                Filter: (bar_1.f2 < 400)
          ->  Foreign Scan on public.bar2 bar_2
-               Output: bar_2.tableoid, bar_2.ctid, bar_2.*
-               Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
+               Output: bar_2.tableoid, bar_2.ctid, remotetableoid, bar_2.*
+               Remote SQL: SELECT ctid, tableoid, ROW(f1, f2, f3), f2 FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
 (11 rows)
 
 delete from bar where f2 < 400;
@@ -9878,22 +9878,22 @@ analyze remt1;
 analyze remt2;
 explain (verbose, costs off)
 update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a returning *;
-                                                   QUERY PLAN                                                   
-----------------------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                          
+-----------------------------------------------------------------------------------------------------------------------------
  Update on public.parent
    Output: parent_1.a, parent_1.b, remt2.a, remt2.b
    Update on public.parent parent_1
    Foreign Update on public.remt1 parent_2
-     Remote SQL: UPDATE public.loct1 SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct1 SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::record)
+         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::oid), (NULL::record)
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::record
+                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, remotetableoid, parent_2.*
+                     Remote SQL: SELECT b, a, ctid, tableoid, ROW(a, b) FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.b, remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -9910,22 +9910,22 @@ update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a re
 
 explain (verbose, costs off)
 delete from parent using remt2 where parent.a = remt2.a returning parent;
-                                 QUERY PLAN                                  
------------------------------------------------------------------------------
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
  Delete on public.parent
    Output: parent_1.*
    Delete on public.parent parent_1
    Foreign Delete on public.remt1 parent_2
-     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: remt2.*, parent.tableoid, parent.ctid
+         Output: remt2.*, parent.tableoid, parent.ctid, (NULL::oid)
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid
+                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid
-                     Remote SQL: SELECT a, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid, remotetableoid
+                     Remote SQL: SELECT a, ctid, tableoid FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -10155,7 +10155,7 @@ update utrtest set a = 1 where a = 1 or a = 2 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 WHERE (((a = 1) OR (a = 2))) RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, NULL::record
                Filter: ((utrtest_2.a = 1) OR (utrtest_2.a = 2))
 (10 rows)
 
@@ -10194,8 +10194,8 @@ insert into utrtest values (2, 'qux');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Foreign Update on public.remp utrtest_1
@@ -10204,7 +10204,7 @@ update utrtest set a = 1 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, NULL::record
 (9 rows)
 
 update utrtest set a = 1 returning *;
@@ -10215,22 +10215,22 @@ insert into utrtest values (2, 'qux');
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 from (values (1), (2)) s(x) where a = s.x returning *;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                   
+----------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Foreign Update on public.remp utrtest_1
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    Update on public.locp utrtest_2
    ->  Hash Join
-         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, utrtest.*
+         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, remotetableoid, utrtest.*
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Foreign Scan on public.remp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, utrtest_1.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, remotetableoid, utrtest_1.*
+                     Remote SQL: SELECT a, ctid, tableoid, ROW(a, b) FROM public.loct FOR UPDATE
                ->  Seq Scan on public.locp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, NULL::record
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -10254,15 +10254,15 @@ insert into utrtest values (3, 'xyzzy');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
    ->  Append
          ->  Seq Scan on public.locp utrtest_1
-               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, NULL::record
          ->  Foreign Update on public.remp utrtest_2
                Remote SQL: UPDATE public.loct SET a = 3 RETURNING a, b
 (9 rows)
@@ -10272,22 +10272,22 @@ ERROR:  cannot route tuples into foreign table to be updated "remp"
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 from (values (2), (3)) s(x) where a = s.x returning *;
-                                             QUERY PLAN                                              
------------------------------------------------------------------------------------------------------
+                                                    QUERY PLAN                                                    
+------------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Hash Join
-         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::record)
+         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::oid), (NULL::record)
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Seq Scan on public.locp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, utrtest_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, remotetableoid, utrtest_2.*
+                     Remote SQL: SELECT a, ctid, tableoid, ROW(a, b) FROM public.loct FOR UPDATE
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -13240,8 +13240,8 @@ RESET enable_hashjoin;
 -- Test that UPDATE/DELETE with inherited target works with async_capable enabled
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
-                                                QUERY PLAN                                                
-----------------------------------------------------------------------------------------------------------
+                                                     QUERY PLAN                                                      
+---------------------------------------------------------------------------------------------------------------------
  Update on public.async_pt
    Output: async_pt_1.a, async_pt_1.b, async_pt_1.c
    Foreign Update on public.async_p1 async_pt_1
@@ -13253,7 +13253,7 @@ UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
          ->  Foreign Update on public.async_p2 async_pt_2
                Remote SQL: UPDATE public.base_tbl2 SET c = (c || c) WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::record
+               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::oid, NULL::record
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -13280,7 +13280,7 @@ DELETE FROM async_pt WHERE b = 0 RETURNING *;
          ->  Foreign Delete on public.async_p2 async_pt_2
                Remote SQL: DELETE FROM public.base_tbl2 WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: async_pt_3.tableoid, async_pt_3.ctid
+               Output: async_pt_3.tableoid, async_pt_3.ctid, NULL::oid
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -14052,3 +14052,38 @@ RESET client_min_messages;
 DROP FUNCTION wait_for_backend_termination(int);
 DROP FOREIGN TABLE remote_backend_pid;
 DROP VIEW my_backend_pid;
+-- ===================================================================
+-- test UPDATE/DELETE on a foreign table whose remote counterpart is
+-- itself a partitioned table
+-- ===================================================================
+-- The two rows below land in separate partitions and, being the first
+-- row in each partition's heap, collide on ctid (0,1).  A row-identity
+-- scheme that relies on ctid alone can't tell them apart.
+CREATE TABLE parted_remote (a int, b int) PARTITION BY LIST (a);
+CREATE TABLE parted_remote_p1 PARTITION OF parted_remote FOR VALUES IN (1);
+CREATE TABLE parted_remote_p2 PARTITION OF parted_remote FOR VALUES IN (2);
+INSERT INTO parted_remote_p1 VALUES (1, 100);
+INSERT INTO parted_remote_p2 VALUES (2, 200);
+CREATE FOREIGN TABLE foreign_parted_remote (a int, b int)
+  SERVER loopback OPTIONS (table_name 'parted_remote');
+-- Force a non-direct-modify UPDATE (random() isn't shippable), so the
+-- ctid/tableoid row-identity path below is actually exercised.
+UPDATE foreign_parted_remote SET b = 999 WHERE a = 1 AND random() <= 1;
+-- Only the a = 1 row (in parted_remote_p1) should have changed.
+SELECT tableoid::regclass, a, b FROM parted_remote ORDER BY a;
+     tableoid     | a |  b  
+------------------+---+-----
+ parted_remote_p1 | 1 | 999
+ parted_remote_p2 | 2 | 200
+(2 rows)
+
+DELETE FROM foreign_parted_remote WHERE a = 2 AND random() <= 1;
+-- Only the a = 2 row (in parted_remote_p2) should be gone.
+SELECT tableoid::regclass, a, b FROM parted_remote ORDER BY a;
+     tableoid     | a |  b  
+------------------+---+-----
+ parted_remote_p1 | 1 | 999
+(1 row)
+
+DROP FOREIGN TABLE foreign_parted_remote;
+DROP TABLE parted_remote;
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 9269418a074..71b8b57f00a 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -232,6 +232,8 @@ typedef struct PgFdwModifyState
 
 	/* info about parameters for prepared statement */
 	AttrNumber	ctidAttno;		/* attnum of input resjunk ctid column */
+	AttrNumber	tableoidAttno;	/* attnum of input resjunk remote tableoid
+								 * column, or 0 if none */
 	int			p_nums;			/* number of parameters to transmit */
 	FmgrInfo   *p_flinfo;		/* output conversion functions for them */
 
@@ -556,6 +558,7 @@ static TupleTableSlot **execute_foreign_modify(EState *estate,
 static void prepare_foreign_modify(PgFdwModifyState *fmstate);
 static const char **convert_prep_stmt_params(PgFdwModifyState *fmstate,
 											 ItemPointer tupleid,
+											 Oid tableoid,
 											 TupleTableSlot **slots,
 											 int numSlots);
 static void store_returning_result(PgFdwModifyState *fmstate,
@@ -565,6 +568,7 @@ static void deallocate_query(PgFdwModifyState *fmstate);
 static List *build_remote_returning(Index rtindex, Relation rel,
 									List *returningList);
 static void rebuild_fdw_scan_tlist(ForeignScan *fscan, List *tlist);
+static void set_remote_tableoid_resnames(List *fdw_scan_tlist);
 static void execute_dml_stmt(ForeignScanState *node);
 static TupleTableSlot *get_returning_data(ForeignScanState *node);
 static void init_returning_filter(PgFdwDirectModifyState *dmstate,
@@ -1431,6 +1435,35 @@ postgresGetForeignPlan(PlannerInfo *root,
 		 * should recheck all the remote quals.
 		 */
 		fdw_recheck_quals = remote_exprs;
+
+		/*
+		 * If a non-direct UPDATE/DELETE needs the remote tableoid (flagged by
+		 * a pseudo-column Var in the rel's targetlist), build an explicit
+		 * fdw_scan_tlist as for a join instead of scanning positionally.
+		 */
+		foreach(lc, foreignrel->reltarget->exprs)
+		{
+			Var		   *var = (Var *) lfirst(lc);
+
+			if (IsA(var, Var) &&
+				var->varattno == RemoteTableOidAttributeNumber)
+			{
+				fdw_scan_tlist = build_tlist_to_deparse(foreignrel);
+
+				/*
+				 * Vars in the EPQ recheck and local quals now resolve against
+				 * the scan output, so add any not already covered.
+				 */
+				fdw_scan_tlist =
+					add_to_flat_tlist(fdw_scan_tlist,
+									  pull_var_clause((Node *) list_concat_copy(fdw_recheck_quals,
+																				local_exprs),
+													  PVC_RECURSE_PLACEHOLDERS));
+
+				set_remote_tableoid_resnames(fdw_scan_tlist);
+				break;
+			}
+		}
 	}
 	else
 	{
@@ -1466,6 +1499,7 @@ postgresGetForeignPlan(PlannerInfo *root,
 
 		/* Build the list of columns to be fetched from the foreign server. */
 		fdw_scan_tlist = build_tlist_to_deparse(foreignrel);
+		set_remote_tableoid_resnames(fdw_scan_tlist);
 
 		/*
 		 * Ensure that the outer plan produces a tuple whose descriptor
@@ -1779,8 +1813,12 @@ postgresBeginForeignScan(ForeignScanState *node, int eflags)
 	/*
 	 * Get info we'll need for converting data fetched from the foreign server
 	 * into local representation and error reporting during that process.
+	 *
+	 * Key the choice off fdw_scan_tlist, not scanrelid: an explicit
+	 * fdw_scan_tlist (built to fetch the remote tableoid) fetches into the
+	 * scan tuple slot like a join, even for a base-relation scan.
 	 */
-	if (fsplan->scan.scanrelid > 0)
+	if (fsplan->scan.scanrelid > 0 && fsplan->fdw_scan_tlist == NIL)
 	{
 		fsstate->rel = node->ss.ss_currentRelation;
 		fsstate->tupdesc = RelationGetDescr(fsstate->rel);
@@ -1987,6 +2025,21 @@ postgresAddForeignUpdateTargets(PlannerInfo *root,
 
 	/* Register it as a row-identity column needed by this target rel */
 	add_row_identity_var(root, var, rtindex, "ctid");
+
+	/*
+	 * ctid alone isn't unique if the foreign table maps to a partitioned
+	 * table remotely, so also fetch the remote tableoid, via an out-of-range
+	 * attnum rather than TableOidAttributeNumber (see deparseColumnRef).
+	 */
+	var = makeVar(rtindex,
+				  RemoteTableOidAttributeNumber,
+				  OIDOID,
+				  -1,
+				  InvalidOid,
+				  0);
+
+	/* Register it as a second row-identity column needed by this target rel */
+	add_row_identity_var(root, var, rtindex, "remotetableoid");
 }
 
 /*
@@ -2851,6 +2904,31 @@ postgresPlanDirectModify(PlannerInfo *root,
 		if (returningList)
 			rebuild_fdw_scan_tlist(fscan, returningList);
 	}
+	else
+	{
+		ListCell   *lc;
+
+		/*
+		 * A direct modification identifies rows by pushed-down qualifiers, so
+		 * discard any fdw_scan_tlist built to fetch the remote tableoid,
+		 * falling back to the positional path.
+		 */
+		fscan->fdw_scan_tlist = NIL;
+
+		/*
+		 * The tlist's remote-tableoid Var likewise isn't needed; replace it
+		 * with a NULL const rather than dropping the (positionally indexed)
+		 * entry.
+		 */
+		foreach(lc, fscan->scan.plan.targetlist)
+		{
+			TargetEntry *tle = lfirst_node(TargetEntry, lc);
+
+			if (IsA(tle->expr, Var) &&
+				((Var *) tle->expr)->varattno == RemoteTableOidAttributeNumber)
+				tle->expr = (Expr *) makeNullConst(OIDOID, -1, InvalidOid);
+		}
+	}
 
 	/*
 	 * Finally, unset the async-capable flag if it is set, as we currently
@@ -4331,8 +4409,12 @@ create_foreign_modify(EState *estate,
 	if (fmstate->has_returning)
 		fmstate->attinmeta = TupleDescGetAttInMetadata(tupdesc);
 
-	/* Prepare for output conversion of parameters used in prepared stmt. */
-	n_params = list_length(fmstate->target_attrs) + 1;
+	/*
+	 * Prepare for output conversion of parameters used in prepared stmt.
+	 * UPDATE/DELETE transmit two extra leading parameters (ctid, tableoid) to
+	 * identify the row; INSERT transmits none.
+	 */
+	n_params = list_length(fmstate->target_attrs) + 2;
 	fmstate->p_flinfo = palloc0_array(FmgrInfo, n_params);
 	fmstate->p_nums = 0;
 
@@ -4350,6 +4432,19 @@ create_foreign_modify(EState *estate,
 		getTypeOutputInfo(TIDOID, &typefnoid, &isvarlena);
 		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
 		fmstate->p_nums++;
+
+		/*
+		 * Find the remote tableoid resjunk column; it's the second
+		 * transmittable parameter, disambiguating ctid across partitions.
+		 */
+		fmstate->tableoidAttno =
+			ExecFindJunkAttributeInTlist(subplan->targetlist, "remotetableoid");
+		if (!AttributeNumberIsValid(fmstate->tableoidAttno))
+			elog(ERROR, "could not find junk remotetableoid column");
+
+		getTypeOutputInfo(OIDOID, &typefnoid, &isvarlena);
+		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
+		fmstate->p_nums++;
 	}
 
 	if (operation == CMD_INSERT || operation == CMD_UPDATE)
@@ -4402,6 +4497,7 @@ execute_foreign_modify(EState *estate,
 {
 	PgFdwModifyState *fmstate = (PgFdwModifyState *) resultRelInfo->ri_FdwState;
 	ItemPointer ctid = NULL;
+	Oid			tableoid = InvalidOid;
 	const char **p_values;
 	PGresult   *res;
 	int			n_rows;
@@ -4442,7 +4538,8 @@ execute_foreign_modify(EState *estate,
 		prepare_foreign_modify(fmstate);
 
 	/*
-	 * For UPDATE/DELETE, get the ctid that was passed up as a resjunk column
+	 * For UPDATE/DELETE, get the ctid and remote tableoid that were passed up
+	 * as resjunk columns; together they identify the remote row to modify.
 	 */
 	if (operation == CMD_UPDATE || operation == CMD_DELETE)
 	{
@@ -4456,10 +4553,19 @@ execute_foreign_modify(EState *estate,
 		if (isNull)
 			elog(ERROR, "ctid is NULL");
 		ctid = (ItemPointer) DatumGetPointer(datum);
+
+		datum = ExecGetJunkAttribute(planSlots[0],
+									 fmstate->tableoidAttno,
+									 &isNull);
+		/* shouldn't ever get a null result... */
+		if (isNull)
+			elog(ERROR, "remote tableoid is NULL");
+		tableoid = DatumGetObjectId(datum);
 	}
 
 	/* Convert parameters needed by prepared statement to text form */
-	p_values = convert_prep_stmt_params(fmstate, ctid, slots, *numSlots);
+	p_values = convert_prep_stmt_params(fmstate, ctid, tableoid,
+										slots, *numSlots);
 
 	/*
 	 * Execute the prepared statement.
@@ -4557,6 +4663,7 @@ prepare_foreign_modify(PgFdwModifyState *fmstate)
  *		Create array of text strings representing parameter values
  *
  * tupleid is ctid to send, or NULL if none
+ * tableoid is the remote tableoid to send; used only when tupleid != NULL
  * slot is slot to get remaining parameters from, or NULL if none
  *
  * Data is constructed in temp_cxt; caller should reset that after use.
@@ -4564,6 +4671,7 @@ prepare_foreign_modify(PgFdwModifyState *fmstate)
 static const char **
 convert_prep_stmt_params(PgFdwModifyState *fmstate,
 						 ItemPointer tupleid,
+						 Oid tableoid,
 						 TupleTableSlot **slots,
 						 int numSlots)
 {
@@ -4580,7 +4688,7 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 	/* ctid is provided only for UPDATE/DELETE, which don't allow batching */
 	Assert(!(tupleid != NULL && numSlots > 1));
 
-	/* 1st parameter should be ctid, if it's in use */
+	/* 1st and 2nd parameters should be ctid and tableoid, if in use */
 	if (tupleid != NULL)
 	{
 		Assert(numSlots == 1);
@@ -4588,6 +4696,10 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 		p_values[pindex] = OutputFunctionCall(&fmstate->p_flinfo[pindex],
 											  PointerGetDatum(tupleid));
 		pindex++;
+		/* don't need set_transmission_modes for OID output */
+		p_values[pindex] = OutputFunctionCall(&fmstate->p_flinfo[pindex],
+											  ObjectIdGetDatum(tableoid));
+		pindex++;
 	}
 
 	/* get following parameters from slots */
@@ -4601,7 +4713,8 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 
 		for (i = 0; i < numSlots; i++)
 		{
-			j = (tupleid != NULL) ? 1 : 0;
+			/* ctid and tableoid occupy the first two parameter slots */
+			j = (tupleid != NULL) ? 2 : 0;
 			foreach(lc, fmstate->target_attrs)
 			{
 				int			attnum = lfirst_int(lc);
@@ -4827,6 +4940,26 @@ rebuild_fdw_scan_tlist(ForeignScan *fscan, List *tlist)
 	fscan->fdw_scan_tlist = new_tlist;
 }
 
+/*
+ * set_remote_tableoid_resnames
+ *		Name fdw_scan_tlist's remote-tableoid pseudo-column entries
+ *		"remotetableoid", since their out-of-range attno has no catalog name.
+ */
+static void
+set_remote_tableoid_resnames(List *fdw_scan_tlist)
+{
+	ListCell   *lc;
+
+	foreach(lc, fdw_scan_tlist)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+
+		if (IsA(tle->expr, Var) &&
+			((Var *) tle->expr)->varattno == RemoteTableOidAttributeNumber)
+			tle->resname = pstrdup("remotetableoid");
+	}
+}
+
 /*
  * Execute a direct UPDATE/DELETE statement.
  */
@@ -8986,6 +9119,36 @@ make_tuple_from_result_row(PGresult *res,
 		j++;
 	}
 
+	/*
+	 * Fill in any local-tableoid pseudo-entry that deparseExplicitTargetList()
+	 * skipped fetching from the remote server (see there): its value is
+	 * simply this scan's own relation OID, already known locally.
+	 */
+	if (fsstate)
+	{
+		ForeignScan *fsplan = castNode(ForeignScan, fsstate->ss.ps.plan);
+
+		if (fsplan->scan.scanrelid > 0 && fsplan->fdw_scan_tlist != NIL)
+		{
+			Oid			reloid = RelationGetRelid(fsstate->ss.ss_currentRelation);
+			ListCell   *lc2;
+			int			pos = 0;
+
+			foreach(lc2, fsplan->fdw_scan_tlist)
+			{
+				TargetEntry *tle = lfirst_node(TargetEntry, lc2);
+
+				pos++;
+				if (IsA(tle->expr, Var) &&
+					((Var *) tle->expr)->varattno == TableOidAttributeNumber)
+				{
+					values[pos - 1] = ObjectIdGetDatum(reloid);
+					nulls[pos - 1] = false;
+				}
+			}
+		}
+	}
+
 	/* Uninstall error context callback. */
 	error_context_stack = errcallback.previous;
 
@@ -9060,15 +9223,21 @@ conversion_error_callback(void *arg)
 		int			varno = 0;
 		AttrNumber	colno = 0;
 
-		if (fsplan->scan.scanrelid > 0)
+		if (fsplan->scan.scanrelid > 0 && fsplan->fdw_scan_tlist == NIL)
 		{
-			/* error occurred in a scan against a foreign table */
+			/*
+			 * Error occurred in a scan against a foreign table, fetched
+			 * positionally, so cur_attno is the table's attribute number.
+			 */
 			varno = fsplan->scan.scanrelid;
 			colno = errpos->cur_attno;
 		}
 		else
 		{
-			/* error occurred in a scan against a foreign join */
+			/*
+			 * Error occurred in a scan against a foreign join, or a base
+			 * relation with an explicit fdw_scan_tlist; cur_attno indexes it.
+			 */
 			TargetEntry *tle;
 
 			tle = list_nth_node(TargetEntry, fsplan->fdw_scan_tlist,
@@ -9101,6 +9270,8 @@ conversion_error_callback(void *arg)
 				attname = strVal(list_nth(rte->eref->colnames, colno - 1));
 			else if (colno == SelfItemPointerAttributeNumber)
 				attname = "ctid";
+			else if (colno == RemoteTableOidAttributeNumber)
+				attname = "tableoid";
 		}
 	}
 	else if (rel)
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index da7da1c2ea9..eafcf42844f 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -13,6 +13,7 @@
 #ifndef POSTGRES_FDW_H
 #define POSTGRES_FDW_H
 
+#include "access/htup_details.h"
 #include "foreign/foreign.h"
 #include "lib/stringinfo.h"
 #include "libpq/libpq-be-fe.h"
@@ -20,6 +21,14 @@
 #include "nodes/pathnodes.h"
 #include "utils/relcache.h"
 
+/*
+ * Pseudo-attribute number for the remote table OID, used to identify a row for
+ * UPDATE/DELETE when a foreign table maps to a remote partitioned table (see
+ * postgresAddForeignUpdateTargets).  Larger than any real attno, so it never
+ * collides with a genuine column; fetched as an ordinary fdw_scan_tlist column.
+ */
+#define RemoteTableOidAttributeNumber	(MaxHeapAttributeNumber + 1)
+
 /*
  * FDW-specific planner information kept in RelOptInfo.fdw_private for a
  * postgres_fdw foreign table.  For a baserel, this struct is created by
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index ec766e2b28a..6a488fbfd31 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -5129,3 +5129,35 @@ RESET client_min_messages;
 DROP FUNCTION wait_for_backend_termination(int);
 DROP FOREIGN TABLE remote_backend_pid;
 DROP VIEW my_backend_pid;
+
+-- ===================================================================
+-- test UPDATE/DELETE on a foreign table whose remote counterpart is
+-- itself a partitioned table
+-- ===================================================================
+
+-- The two rows below land in separate partitions and, being the first
+-- row in each partition's heap, collide on ctid (0,1).  A row-identity
+-- scheme that relies on ctid alone can't tell them apart.
+CREATE TABLE parted_remote (a int, b int) PARTITION BY LIST (a);
+CREATE TABLE parted_remote_p1 PARTITION OF parted_remote FOR VALUES IN (1);
+CREATE TABLE parted_remote_p2 PARTITION OF parted_remote FOR VALUES IN (2);
+INSERT INTO parted_remote_p1 VALUES (1, 100);
+INSERT INTO parted_remote_p2 VALUES (2, 200);
+
+CREATE FOREIGN TABLE foreign_parted_remote (a int, b int)
+  SERVER loopback OPTIONS (table_name 'parted_remote');
+
+-- Force a non-direct-modify UPDATE (random() isn't shippable), so the
+-- ctid/tableoid row-identity path below is actually exercised.
+UPDATE foreign_parted_remote SET b = 999 WHERE a = 1 AND random() <= 1;
+
+-- Only the a = 1 row (in parted_remote_p1) should have changed.
+SELECT tableoid::regclass, a, b FROM parted_remote ORDER BY a;
+
+DELETE FROM foreign_parted_remote WHERE a = 2 AND random() <= 1;
+
+-- Only the a = 2 row (in parted_remote_p2) should be gone.
+SELECT tableoid::regclass, a, b FROM parted_remote ORDER BY a;
+
+DROP FOREIGN TABLE foreign_parted_remote;
+DROP TABLE parted_remote;
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 7bbddb8bee4..1f7e2c34ef1 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -6493,6 +6493,20 @@ set_rel_width(PlannerInfo *root, RelOptInfo *rel)
 			int			ndx;
 			int32		item_width;
 
+			/*
+			 * An out-of-range attno (> max_attr) is an FDW row-identity
+			 * pseudo-column (postgres_fdw's remote table OID for
+			 * UPDATE/DELETE; see add_vars_to_targetlist()) with no
+			 * attr_widths[] slot; estimate its width from the type.
+			 */
+			if (var->varattno > rel->max_attr)
+			{
+				/* Only an FDW can inject an out-of-range row-identity column */
+				Assert(rel->fdwroutine != NULL);
+				tuple_width += get_typavgwidth(var->vartype, var->vartypmod);
+				continue;
+			}
+
 			Assert(var->varattno >= rel->min_attr);
 			Assert(var->varattno <= rel->max_attr);
 
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index f08a918146c..ee17aedcf43 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -318,6 +318,25 @@ add_vars_to_targetlist(PlannerInfo *root, List *vars,
 
 			if (bms_is_subset(where_needed, rel->relids))
 				continue;
+
+			/*
+			 * An out-of-range attno (> max_attr) is an FDW row-identity
+			 * pseudo-column (e.g. postgres_fdw's remote table OID for
+			 * UPDATE/DELETE) with no attr_needed[] slot; like a ROWID_VAR
+			 * it's always needed, so just add it to the reltarget once.
+			 */
+			if (attno > rel->max_attr)
+			{
+				/* Only an FDW can inject an out-of-range row-identity column */
+				Assert(rel->fdwroutine != NULL);
+				var = copyObject(var);
+				var->varnullingrels = NULL;
+				if (!list_member(rel->reltarget->exprs, var))
+					rel->reltarget->exprs = lappend(rel->reltarget->exprs,
+													var);
+				continue;
+			}
+
 			Assert(attno >= rel->min_attr && attno <= rel->max_attr);
 			attno -= rel->min_attr;
 			if (rel->attr_needed[attno] == NULL)
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index 8a641402a96..f333ee9e1fd 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -15,6 +15,7 @@
  */
 #include "postgres.h"
 
+#include "access/htup_details.h"
 #include "access/transam.h"
 #include "catalog/pg_type.h"
 #include "nodes/makefuncs.h"
@@ -1053,6 +1054,11 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 					 * it here and keep the assertions that ROWID_VARs
 					 * shouldn't be seen by fix_scan_expr.
 					 *
+					 * The same applies to an FDW row-identity pseudo-column
+					 * (attno > MaxHeapAttributeNumber; see
+					 * add_vars_to_targetlist()): no scan node exists here to
+					 * give it a value or a name, so replace it the same way.
+					 *
 					 * We also must handle the case where set operations have
 					 * been short-circuited resulting in a dummy Result node.
 					 * prepunion.c uses varno==0 for the set op targetlist.
@@ -1069,7 +1075,8 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 
 						if (var && IsA(var, Var))
 						{
-							if (var->varno == ROWID_VAR)
+							if (var->varno == ROWID_VAR ||
+								var->varattno > MaxHeapAttributeNumber)
 								tle->expr = (Expr *) makeNullConst(var->vartype,
 																   var->vartypmod,
 																   var->varcollid);
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index ee69f81945f..feaf4df81dc 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -1295,6 +1295,7 @@ build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 	foreach(vars, input_rel->reltarget->exprs)
 	{
 		Var		   *var = (Var *) lfirst(vars);
+		RelOptInfo *baserel = NULL;
 
 		/*
 		 * For a PlaceHolderVar, we have to look up the PlaceHolderInfo.
@@ -1366,28 +1367,45 @@ build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 		}
 		else
 		{
-			RelOptInfo *baserel;
-			int			ndx;
-
 			/* Get the Var's original base rel */
 			baserel = find_base_rel(root, var->varno);
 
-			/* Is it still needed above this joinrel? */
-			ndx = var->varattno - baserel->min_attr;
-			if (!bms_nonempty_difference(baserel->attr_needed[ndx], relids))
-				continue;		/* nope, skip it */
+			if (var->varattno > baserel->max_attr)
+			{
+				/*
+				 * An out-of-range attno (> max_attr) is an FDW row-identity
+				 * pseudo-column (postgres_fdw's remote table OID for
+				 * UPDATE/DELETE; see add_vars_to_targetlist()) with no
+				 * attr_needed[]/attr_widths[] slot; like a ROWID_VAR, always
+				 * needed above every join.
+				 */
+				Assert(baserel->fdwroutine != NULL);
+				tuple_width += get_typavgwidth(var->vartype, var->vartypmod);
+			}
+			else
+			{
+				int			ndx;
 
-			/* Update reltarget width estimate from baserel's attr_widths */
-			tuple_width += baserel->attr_widths[ndx];
+				/* Is it still needed above this joinrel? */
+				ndx = var->varattno - baserel->min_attr;
+				if (!bms_nonempty_difference(baserel->attr_needed[ndx], relids))
+					continue;	/* nope, skip it */
+
+				/* Update reltarget width estimate from baserel's attr_widths */
+				tuple_width += baserel->attr_widths[ndx];
+			}
 		}
 
 		/*
 		 * Add the Var to the output.  If this join potentially nulls this
 		 * input, we have to update the Var's varnullingrels, which means
 		 * making a copy.  But note that we don't ever add nullingrel bits to
-		 * row identity Vars (cf. comments in setrefs.c).
+		 * row identity Vars (cf. comments in setrefs.c); that includes the
+		 * FDW row-identity pseudo-columns handled just above, which have an
+		 * out-of-range attribute number.
 		 */
-		if (can_null && var->varno != ROWID_VAR)
+		if (can_null && var->varno != ROWID_VAR &&
+			var->varattno <= baserel->max_attr)
 		{
 			var = copyObject(var);
 			/* See comments above to understand this logic */
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 6506bf12e3c..e39cce9dbc6 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -8541,6 +8541,29 @@ resolve_special_varno(Node *node, deparse_context *context,
 		if (!tle)
 			elog(ERROR, "bogus varattno for INDEX_VAR var: %d", var->varattno);
 
+		/*
+		 * A row-identity pseudo-column in fdw_scan_tlist has an out-of-range
+		 * attno and no catalog column to name, so when deparsing it as a
+		 * variable (get_special_variable) print its scan-tlist name rather
+		 * than chasing a non-existent attribute (which fails in
+		 * get_variable).
+		 */
+		if (callback == get_special_variable && tle->resname &&
+			IsA(tle->expr, Var))
+		{
+			Var		   *itvar = (Var *) tle->expr;
+
+			if (itvar->varno >= 1 &&
+				itvar->varno <= list_length(dpns->rtable) &&
+				itvar->varattno >
+				deparse_columns_fetch(itvar->varno, dpns)->num_cols)
+			{
+				appendStringInfoString(context->buf,
+									   quote_identifier(tle->resname));
+				return;
+			}
+		}
+
 		resolve_special_varno((Node *) tle->expr, context,
 							  callback, callback_arg);
 		return;
-- 
2.52.0



^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  2026-06-13 19:43                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-15 14:54                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-07-09 14:03                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-27 09:20                         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
@ 2026-08-27 14:36                           ` Nikita Malakhov <hukutoc@gmail.com>
  2026-08-28 05:43                             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Nikita Malakhov @ 2026-08-27 14:36 UTC (permalink / raw)
  To: Ajit Awekar <ajitpostgres@gmail.com>; +Cc: Etsuro Fujita <etsuro.fujita@gmail.com>; Michael Paquier <michael@paquier.xyz>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Hi Ajit!
Thank you very much for your work on this subject.
Your patch corrects my clumsy proposal and fixes problematic cases.
I'm reviewing it now and vote to put it in the closest commitfest and
commit.

On Thu, Aug 27, 2026 at 12:21 PM Ajit Awekar <ajitpostgres@gmail.com> wrote:

> Hi hackers,
>
> This attached patch relies on fdw_scan_tlist to carry the new
> remote-tableoid row-identity column.
>
>  fdw_scan_tlist is a targetlist describing the contents of the scan tuple
>  returned by the FDW; it can be NIL if the scan tuple matches the declared
>  rowtype of the foreign table, which is the normal case for a simple
> foreign
>  table scan.  (If the plan node represents a foreign join, fdw_scan_tlist
>  is required since there is no rowtype available from the system catalogs.)
>
> A base-relation foreign scan normally fetches columns positionally: the
> scan tuple is assumed to match the foreign table's own rowtype 1:1, so
> nothing above the scan needs to know what the remote query actually
> returned.
>
> The remote tableoid doesn't fit the positional world at all - it isn't a
> column the local foreign table has, so there's no local attribute number
> for it to occupy. Reusing the ordinary tableoid system column doesn't
> work either, since that resolves locally to the foreign table's own OID,
> not the remote row's. So it has to travel as an invented pseudo-column,
> under an out-of-range attribute number - RemoteTableOidAttributeNumber,
> defined as MaxHeapAttributeNumber + 1, i.e. one past anything a real
> catalog column could ever have, so it can never collide with a genuine
> attribute - and that forces the scan onto the same explicit-tlist path
> joins already use.
>
> Please review the attached patch and let me know your comments.
>
> Thanks & Best Regards,
> Ajit
>
>

-- 
Regards,
Nikita Malakhov
Postgres Professional
The Russian Postgres Company
https://postgrespro.ru/

^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  2026-06-13 19:43                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-15 14:54                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-07-09 14:03                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-27 09:20                         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-08-27 14:36                           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
@ 2026-08-28 05:43                             ` Ajit Awekar <ajitpostgres@gmail.com>
  2026-09-04 13:37                               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jakub Wartak <jakub.wartak@enterprisedb.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Ajit Awekar @ 2026-08-28 05:43 UTC (permalink / raw)
  To: Nikita Malakhov <hukutoc@gmail.com>; +Cc: Etsuro Fujita <etsuro.fujita@gmail.com>; Michael Paquier <michael@paquier.xyz>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Hi Nikita,

I have added it in the commitfest:
https://commitfest.postgresql.org/patch/7211/

Requesting a review.

Thanks & Best Regards,
Ajit

On Thu, 27 Aug 2026 at 20:06, Nikita Malakhov <hukutoc@gmail.com> wrote:

> Hi Ajit!
> Thank you very much for your work on this subject.
> Your patch corrects my clumsy proposal and fixes problematic cases.
> I'm reviewing it now and vote to put it in the closest commitfest and
> commit.
>
> On Thu, Aug 27, 2026 at 12:21 PM Ajit Awekar <ajitpostgres@gmail.com>
> wrote:
>
>> Hi hackers,
>>
>> This attached patch relies on fdw_scan_tlist to carry the new
>> remote-tableoid row-identity column.
>>
>>  fdw_scan_tlist is a targetlist describing the contents of the scan tuple
>>  returned by the FDW; it can be NIL if the scan tuple matches the declared
>>  rowtype of the foreign table, which is the normal case for a simple
>> foreign
>>  table scan.  (If the plan node represents a foreign join, fdw_scan_tlist
>>  is required since there is no rowtype available from the system
>> catalogs.)
>>
>> A base-relation foreign scan normally fetches columns positionally: the
>> scan tuple is assumed to match the foreign table's own rowtype 1:1, so
>> nothing above the scan needs to know what the remote query actually
>> returned.
>>
>> The remote tableoid doesn't fit the positional world at all - it isn't a
>> column the local foreign table has, so there's no local attribute number
>> for it to occupy. Reusing the ordinary tableoid system column doesn't
>> work either, since that resolves locally to the foreign table's own OID,
>> not the remote row's. So it has to travel as an invented pseudo-column,
>> under an out-of-range attribute number - RemoteTableOidAttributeNumber,
>> defined as MaxHeapAttributeNumber + 1, i.e. one past anything a real
>> catalog column could ever have, so it can never collide with a genuine
>> attribute - and that forces the scan onto the same explicit-tlist path
>> joins already use.
>>
>> Please review the attached patch and let me know your comments.
>>
>> Thanks & Best Regards,
>> Ajit
>>
>>
>
> --
> Regards,
> Nikita Malakhov
> Postgres Professional
> The Russian Postgres Company
> https://postgrespro.ru/
>

^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  2026-06-13 19:43                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-15 14:54                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-07-09 14:03                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-27 09:20                         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-08-27 14:36                           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-28 05:43                             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
@ 2026-09-04 13:37                               ` Jakub Wartak <jakub.wartak@enterprisedb.com>
  2026-09-07 03:07                                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table shihao zhong <zhong950419@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Jakub Wartak @ 2026-09-04 13:37 UTC (permalink / raw)
  To: Ajit Awekar <ajitpostgres@gmail.com>; +Cc: Nikita Malakhov <hukutoc@gmail.com>; Etsuro Fujita <etsuro.fujita@gmail.com>; Michael Paquier <michael@paquier.xyz>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

On Fri, Aug 28, 2026 at 7:43 AM Ajit Awekar <ajitpostgres@gmail.com> wrote:
>
> Hi Nikita,
>
> I have added it in the commitfest: https://commitfest.postgresql.org/patch/7211/
>
> Requesting a review.

Hi Ajit,

respect for taking working such ambitious bug without proper solution since
2018. I've just tried playing devil's advocate and tried to test & break this
patch (I'm not that familiar with the optimizer guts changed (so findings are
written based mostly from performance and end user kind of angle).

First and most important, the patch fixes the logical corruption bug
reported and known since at least 2018, so that's good. Even without
solving below I think it's good.

Stuff that I've discovered (let's call those optimization opporutnities):

1. The patch adds ROW(), but then also duplicates the cols (here it is 4
   column table), c1/c2/c3 are sent twice over the wire:

   postgres=# explain verbose UPDATE fplt SET c2 = (CASE WHEN random() <= 1
   THEN 10 ELSE 20 END) WHERE c1 = 1 AND c2 = 2 AND c3 = 3;
   [..]
   Remote SQL: SELECT ctid, tableoid, ROW(c1, c2, c3, c4), c1, c2, c3 FROM[..]

   Do we need to transfer them twice over the network?
   The ROW() seems to be pretty taxing in terms of CPU/time. Measuring just
   using ROW(c1, ...) vs pure c1, c2, ... shows that it costs ~2x (in
   statement duration) and it is sent also for non-partittioned tables
   (because postgres_fdw does know if the non-part table is involved or
   not). However to my measuremnet that does NOT seem to result in degraded
   overall timing, because even if ROW() returns plenty of rows (and gets
   regression in timing) that's tiny cost compared to issuing all those
   UPDATEs with binds for each of those fetched and then updated rows.

   The only problem I is that indirect UPDATEs on all postgres_fdw foreign
   tables are affected by this. So maybe this should be somhow called out in
   the docs or commit more loudly? (I take no logical corruption over
   performance any day).

2. If I create 500 partitions it will significatnly show how we lack partition
   prunning based on the "tableoid" special column:

   DROP TABLE plt;
   DROP FOREIGN TABLE fplt;
   CREATE TABLE plt (c1 int, c2 int) PARTITION BY RANGE (c1);
   SELECT format('CREATE TABLE plt%s PARTITION OF plt FOR VALUES
      FROM(%s)TO(%s)', i, 10*(i-1), 10*i) FROM generate_series(1,500) i;
   \gexec
   INSERT INTO plt SELECT MOD(s, 5000-1), s
      FROM generate_series(1, 5000000) s;
   ANALYZE plt;
   CREATE FOREIGN TABLE fplt (c1 int, c2 int) SERVER loopback
      OPTIONS (table_name 'plt');

   explain verbose
      UPDATE fplt SET c2 = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END)
      WHERE c1 = 1;

   Update on public.fplt
   Remote SQL: UPDATE public.fplt SET c2 = $3 WHERE ctid = $1 AND tableoid =$2
   ->  Foreign Scan on public.fplt
         Output: CASE WHEN (random() <= '1'::double precision) THEN 10 [..]
         Remote SQL: SELECT ctid, tableoid, ROW(c1, c2), c1 FROM public.fplt
                     WHERE ((c1 = 1)) FOR UPDATE

   and we are going to execute apparently around 1k UPDATEs like that (because
   that's hhow many values we have for c1 = 1):

   LOG:  execute pgsql_fdw_prep_3: UPDATE public.plt SET c2 = $3
         WHERE ctid = $1 AND tableoid = $2
   DETAIL:  Parameters: $1 = '(26,15)', $2 = '24246', $3 = '10'
   LOG:  execute pgsql_fdw_prep_3: UPDATE public.plt SET c2 = $3
         WHERE ctid = $1 AND tableoid = $2
   DETAIL:  Parameters: $1 = '(26,25)', $2 = '24246', $3 = '10'
   [..]

   and those cannot benefit from partition prunning at all, e.g. (but also
   explain analyze too):

   postgres=# explain (verbose, generic_plan) UPDATE public.plt SET c2 = $3
              WHERE ctid = $1 AND tableoid = $2 ;
                                        QUERY PLAN
   --------------------------------------------------------------------------
   Update on public.plt  (cost=0.00..2010.00 rows=0 width=0)
      Update on public.plt1 plt_1
      Update on public.plt2 plt_2
      Update on public.plt3 plt_3
   [..]
      Update on public.plt500 plt_500
      ->  Append  (cost=0.00..2010.00 rows=500 width=14)
           ->  Tid Scan on public.plt1 plt_1  (cost=0.00..4.02 rows=1 width=14)
                 Output: $3, plt_1.tableoid, plt_1.ctid
                 TID Cond: (plt_1.ctid = $1)
                 Filter: (plt_1.tableoid = $2)
           ->  Tid Scan on public.plt2 plt_2  (cost=0.00..4.02 rows=1 width=14)
                 Output: $3, plt_2.tableoid, plt_2.ctid
                 TID Cond: (plt_2.ctid = $1)
                 Filter: (plt_2.tableoid = $2)
   [..]

   Of course the more such individual rows (R) will be returned by the SELECT
   to be updated, and the more partitions (P) there the worse the problem(R*P)
   EXPLAIN ANALYZE for it here shows that with 500 partitions I get single
   "local" UPDATE costs around this per single updated row:
     Planning Time: 8.786 ms
     Execution Time: 3.873 ms

   This patch could at least add some single statement in documentation that
   there is current known performance inhibitor. Currently docs have
   "F.38.5.Remote Query Optimization" and we could simply document it as known
   limitation that complex (indirect?) UPDATES/DELETing referencing
   postgres_fdw tables should limit themselves to modifing low number of
   rows in case of refering to remote root of partition, because issued
   UPDATE/DELETE may not prune optimally based on "tableoid" (but that might
   be subject to change?).

3. If there's type mismatch between foreign table and real remote
table like below:

   DROP TABLE terr;
   DROP FOREIGN TABLE fterr;
   CREATE TABLE terr (id int, a text);
   INSERT INTO terr VALUES (1, 'sometext');
   -- a is int, but should be "ext"
   CREATE FOREIGN TABLE fterr (id int, a int)
      SERVER loopback OPTIONS (table_name 'terr');
   UPDATE fterr SET a = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END)
      WHERE  id = 1 AND random() <= 1;

   we get with the patch:
     ERROR:  invalid input syntax for type integer: "sometext"
     CONTEXT:  whole-row reference to foreign table "fterr"
   (this is cast failure from our postgres_fdw side, try guessing which column
   is wrong with hundreths of those!), but without the patch it was way
   better then:
    ERROR:  invalid input syntax for type integer: "sometext"
    CONTEXT:  column "a" of foreign table "fterr"

   No idea if that can be fixed.

4. Nested loop involving foreign scan with this ROW() patch seems to be
   adding to transfer something additional that is not necessary ($1)?

   Given:

   DROP TABLE big;
   DROP TABLE tiny;
   DROP FOREIGN TABLE fbig;
   CREATE TABLE big (id int primary key, a int, b text);
   INSERT INTO big SELECT s, s, 'x'||s FROM generate_series(1,200000) s;
   ANALYZE big;
   CREATE FOREIGN TABLE fbig (id int, a int, b text) SERVER loopback
      OPTIONS (table_name 'big', use_remote_estimate 'true');
   CREATE TABLE tiny (id int primary key);
   INSERT INTO tiny VALUES (1234);
   ANALYZE tiny, fbig;
   SET enable_hashjoin = off;
   SET enable_mergejoin = off;

   The fbig.b like '%' below is just to highlight where stuff is put inside
   the complex remote SQL:

   postgres=# explain (costs off, verbose) UPDATE fbig SET b = fbig.b||'!'
     FROM tiny WHERE fbig.id = tiny.id AND random() <= 1 AND fbig.b like '%';

   Update on public.fbig
   Remote SQL: UPDATE public.big SET b = $3 WHERE ctid = $1 AND tableoid = $2
   ->Nested Loop
      Output: (fbig.b || '!'::text), fbig.ctid, remotetableoid, fbig.*,
        tiny.ctid
      Join Filter: (random() <= '1'::double precision)
      ->  Seq Scan on public.tiny
            Output: tiny.ctid, tiny.id
      ->  Foreign Scan on public.fbig
            Output: fbig.b, fbig.ctid, remotetableoid, fbig.*, fbig.id
          Remote SQL: SELECT b, ctid, tableoid, ROW(id, a, b), id, $1::integer
              FROM public.big WHERE ((b ~~ '%')) AND ((id = $1::integer))
              FOR UPDATE

   I understand the "WHERE id = $1::integer" (it's value from "tiny" passed),
   but why we are asking remote to return back the same $1:integer we are
   asking for? Exactly I'm asking for this:
   Remote SQL: SELECT b, ctid, tableoid, ROW(id, a, b), id, >> $1::integer <<

   This somehow works fine, because $1 is somehow amazingly passed correctly by
   the executor (Parameters: $1 = '1234') see below:

   [112269] LOG:  execute <unnamed>: DECLARE c1 CURSOR FOR
     SELECT b, ctid, tableoid, ROW(id, a, b), id, $1::integer FROM
public.big WHERE ((b ~~ '%')) AND ((id = $1::integer)) FOR UPDATE
   [112269] DETAIL:  Parameters: $1 = '1234'
   [112269] LOG:  statement: FETCH 100 FROM c1
   [..]
   but isn't that unnecessary leak? (if that join condition would be huge and
   repeated milion times, why we do we need to transfer it back with every
   returned row?). Without patch it is not happening.

-J.






^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  2026-06-13 19:43                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-15 14:54                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-07-09 14:03                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-27 09:20                         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-08-27 14:36                           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-28 05:43                             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-09-04 13:37                               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jakub Wartak <jakub.wartak@enterprisedb.com>
@ 2026-09-07 03:07                                 ` shihao zhong <zhong950419@gmail.com>
  2026-09-10 12:21                                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: shihao zhong @ 2026-09-07 03:07 UTC (permalink / raw)
  To: Jakub Wartak <jakub.wartak@enterprisedb.com>; +Cc: Ajit Awekar <ajitpostgres@gmail.com>; Nikita Malakhov <hukutoc@gmail.com>; Etsuro Fujita <etsuro.fujita@gmail.com>; Michael Paquier <michael@paquier.xyz>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Hi Ajit, Nikita,

A quick note as commitfest manager: this thread currently has two
commitfest entries pointing at it, #6770 and #7211.  Since Ajit's patch
continues Nikita's work rather than competing with it, I have merged them
into a single entry:

  https://commitfest.postgresql.org/patch/6770/

Ajit, you are now listed as an author there alongside Nikita, and #7211 has
been withdrawn as a duplicate.  Nothing about your patch changed -- it is
still the version under review, and cfbot tracks it as before.  Please use
#6770 from now on, and feel free to add reviewers or update the status
there.

Thanks,
Shihao

^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  2026-06-13 19:43                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-15 14:54                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-07-09 14:03                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-27 09:20                         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-08-27 14:36                           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-28 05:43                             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-09-04 13:37                               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jakub Wartak <jakub.wartak@enterprisedb.com>
  2026-09-07 03:07                                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table shihao zhong <zhong950419@gmail.com>
@ 2026-09-10 12:21                                   ` Ajit Awekar <ajitpostgres@gmail.com>
  2026-09-16 12:02                                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jakub Wartak <jakub.wartak@enterprisedb.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Ajit Awekar @ 2026-09-10 12:21 UTC (permalink / raw)
  To: shihao zhong <zhong950419@gmail.com>; +Cc: Jakub Wartak <jakub.wartak@enterprisedb.com>; Nikita Malakhov <hukutoc@gmail.com>; Etsuro Fujita <etsuro.fujita@gmail.com>; Michael Paquier <michael@paquier.xyz>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Hi Jakub,

Thanks for the detailed repros. Attached patch fixes #1, #3, and #4;
#2 as doc modification.

#1/#3: The whole-row Var and per-column Vars overlapped, duplicating
columns on the wire and misattributing conversion errors to the whole
row instead of the failing column. Fixed by skipping the whole-row Var
during deparse, fetching each column individually, and reconstructing
the row locally -- restores per-column error context and drops the
duplicate transfer.

#4: Fixed by filtering to Vars of the scanned relation. Also fixed a
zero-column-table assertion hit while testing this.

#2:  Added a note on this as a known limitation to the
postgres_fdw "Remote Query Optimization" docs, recommending
whole-query shipping where possible and limiting affected row counts
otherwise.

Verified against your repros plus the full regression.

Thanks & Best Regards,
Ajit



On Mon, 7 Sept 2026 at 08:37, shihao zhong <zhong950419@gmail.com> wrote:

> Hi Ajit, Nikita,
>
> A quick note as commitfest manager: this thread currently has two
> commitfest entries pointing at it, #6770 and #7211.  Since Ajit's patch
> continues Nikita's work rather than competing with it, I have merged them
> into a single entry:
>
>   https://commitfest.postgresql.org/patch/6770/
>
> Ajit, you are now listed as an author there alongside Nikita, and #7211 has
> been withdrawn as a duplicate.  Nothing about your patch changed -- it is
> still the version under review, and cfbot tracks it as before.  Please use
> #6770 from now on, and feel free to add reviewers or update the status
> there.
>
> Thanks,
> Shihao
>

Attachments:

  [application/octet-stream] v2-0001-postgres_fdw-Disambiguate-row-identity-by-remote-tableoid.patch (104.0K, ../../CAER375PRkMwNkPNHSKuEswY0gv6KWm0Fzz0GDDJXaDPBU-k2EQ@mail.gmail.com/3-v2-0001-postgres_fdw-Disambiguate-row-identity-by-remote-tableoid.patch)
  download | inline diff:
From 397b0617cab7b2471d41f2e9bca7a271af65a982 Mon Sep 17 00:00:00 2001
From: Ajit Awekar <ajit.awekar@enterprisedb.com>
Date: Wed, 26 Aug 2026 12:42:37 +0530
Subject: [PATCH] postgres_fdw: Disambiguate row identity by remote tableoid
 too

Non-direct UPDATE/DELETE on a foreign table identified the target row
on the remote server by ctid alone: "WHERE ctid = $1".  A ctid is only
unique within a single heap, so if the foreign table maps to a
partitioned (or inherited) table on the remote side, the same ctid can
exist in more than one partition, and the wrong row could be updated
or deleted.

Fix this by also fetching and transmitting the remote tableoid, so the
remote command becomes "WHERE ctid = $1 AND tableoid = $2".

The remote tableoid is not a real column of the local foreign table,
and TableOidAttributeNumber would resolve to the local foreign table's
own OID rather than the remote row's, so it is carried as a pseudo-
column using an out-of-range attribute number
(RemoteTableOidAttributeNumber = MaxHeapAttributeNumber + 1), flowing
through the foreign scan's fdw_scan_tlist like any other output
column.  This requires several optimizer/ruleutils.c call sites that
assume attribute numbers stay within a relation's normal range to
instead treat such an out-of-range attno as an FDW row-identity
pseudo-column, generically, the same way ROWID_VAR is already handled.

This adds an extra output column and WHERE clause to every non-direct
UPDATE/DELETE performed through postgres_fdw, including against
remote tables that are not partitioned, since postgres_fdw has no
cheap way to know that without an extra remote round trip.

Building an explicit fdw_scan_tlist for the above also exposes any
pre-existing, unrelated need for the local tableoid (e.g. RETURNING
tableoid, or reconstructing a whole row for a cross-partition update)
to the same deparse machinery, which would otherwise ship it to the
remote server as a literal Const for no reason: that value is already
known locally.  Skip fetching it from the remote server in that case
and fill it in afterward instead, so the remote query is not changed
by needs that have nothing to do with row identity.

Building that same explicit fdw_scan_tlist also exposed two more
pre-existing needs to the same deparse machinery, both of which were
handled naively at first and are fixed here:

- A genuine whole-row reference to the scanned relation (needed for
  EPQ recheck or a locally-reconstructed cross-partition update row)
  was deparsed as a literal ROW(...) of all its columns, while each of
  those same columns was, separately, also being fetched individually
  for other reasons (e.g. pushed-down quals).  That shipped every such
  column twice.  Skip shipping the whole row as ROW(...) in that case,
  fetch every real column of the relation individually instead, and
  reconstruct the whole-row value locally afterward from those already
  -decoded column values, mirroring the local-tableoid pseudo-entry
  handling above.  This also fixes a regression in the CONTEXT line of
  a local input-conversion error against such a whole row: converting
  each column individually again means the error is attributed to a
  specific column, not to a generic "whole-row reference".

- The Vars pulled from the recheck quals and local quals to complete
  the explicit tlist were added indiscriminately, including a Var
  belonging to a different relation than the one being scanned (e.g.
  the other side of a pushed-down join qual in a parameterized scan).
  Such a Var is already correctly handled as a bound parameter
  wherever it appears inside a qual; it has no business also being an
  output column of this scan.  Filter that Var collection down to Vars
  that are actually columns of the relation being scanned.

The whole-row reconstruction added above also assumed there was always
at least one individually-fetched column to reconstruct from, which
does not hold for a relation with no columns at all (a degenerate but
legal case).  Skip the "reconstruct locally" optimization when the
relation has no other column in the tlist to reconstruct from, and
fall back to shipping the whole row as a literal ROW(...) as before,
since there is then nothing to save by doing otherwise.

Since each such per-row remote command targets the partitioned root
and the remote planner has no way to prune based on the tableoid
parameter, this is also documented as a known limitation in the
postgres_fdw "Remote Query Optimization" section.
---
 contrib/postgres_fdw/deparse.c                |  97 +++-
 .../postgres_fdw/expected/postgres_fdw.out    | 467 ++++++++++--------
 contrib/postgres_fdw/postgres_fdw.c           | 301 ++++++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   9 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  32 ++
 doc/src/sgml/postgres-fdw.sgml                |  16 +
 src/backend/optimizer/path/costsize.c         |  14 +
 src/backend/optimizer/plan/initsplan.c        |  19 +
 src/backend/optimizer/plan/setrefs.c          |   9 +-
 src/backend/optimizer/util/relnode.c          |  40 +-
 src/backend/utils/adt/ruleutils.c             |  23 +
 11 files changed, 778 insertions(+), 249 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index ff9fe0f87e4..249e980b1bf 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -1391,11 +1391,14 @@ deparseSelectSql(List *tlist, bool is_subquery, List **retrieved_attrs,
 		 */
 		deparseSubqueryTargetList(context);
 	}
-	else if (IS_JOIN_REL(foreignrel) || IS_UPPER_REL(foreignrel))
+	else if (IS_JOIN_REL(foreignrel) || IS_UPPER_REL(foreignrel) ||
+			 tlist != NIL)
 	{
 		/*
 		 * For a join or upper relation the input tlist gives the list of
-		 * columns required to be fetched from the foreign server.
+		 * columns required to be fetched from the foreign server; likewise
+		 * for a base-relation scan given an explicit tlist (fdw_scan_tlist)
+		 * to fetch the remote tableoid (see postgresGetForeignPlan()).
 		 */
 		deparseExplicitTargetList(tlist, false, retrieved_attrs, context);
 	}
@@ -1721,13 +1724,52 @@ get_jointype_name(JoinType jointype)
 	return NULL;
 }
 
+/*
+ * Does tlist contain an individual real column (varattno > 0) of relid,
+ * besides a whole-row entry for it?  Used to tell whether a whole-row
+ * reference has anything to be locally reconstructed from (see
+ * deparseExplicitTargetList()).
+ */
+static bool
+tlist_has_other_column_of_rel(List *tlist, Index relid)
+{
+	ListCell   *lc;
+
+	foreach(lc, tlist)
+	{
+		TargetEntry *tle = lfirst_node(TargetEntry, lc);
+
+		if (IsA(tle->expr, Var) &&
+			((Var *) tle->expr)->varno == relid &&
+			((Var *) tle->expr)->varattno > 0)
+			return true;
+	}
+	return false;
+}
+
 /*
  * Deparse given targetlist and append it to context->buf.
  *
  * tlist is list of TargetEntry's which in turn contain Var nodes.
  *
- * retrieved_attrs is the list of continuously increasing integers starting
- * from 1. It has same number of entries as tlist.
+ * retrieved_attrs is normally the list of continuously increasing integers
+ * starting from 1, with the same number of entries as tlist.  There are two
+ * exceptions, both specific to a base relation's SELECT list built as an
+ * explicit fdw_scan_tlist for row-identity purposes:
+ *
+ * - the local-tableoid pseudo-entry (a Var on TableOidAttributeNumber): its
+ *   value is already known at plan time, so instead of shipping it to the
+ *   remote server as a literal Const, it's skipped here and filled in
+ *   locally afterward, by make_tuple_from_result_row().
+ *
+ * - a plain whole-row reference to the relation itself (a Var on attno 0),
+ *   when its columns are also being fetched individually elsewhere in this
+ *   tlist (e.g. for EPQ recheck): shipping the whole row too would transfer
+ *   those columns twice, so it's skipped here and reconstructed locally
+ *   afterward instead, again by make_tuple_from_result_row().  Absent such
+ *   sibling columns (e.g. the relation has none), it is shipped as a
+ *   literal ROW(...) as usual, since there is nothing to reconstruct it
+ *   from.
  *
  * This is used for both SELECT and RETURNING targetlists; the is_returning
  * parameter is true only for a RETURNING targetlist.
@@ -1740,7 +1782,9 @@ deparseExplicitTargetList(List *tlist,
 {
 	ListCell   *lc;
 	StringInfo	buf = context->buf;
+	bool		is_base_rel = !is_returning && IS_SIMPLE_REL(context->scanrel);
 	int			i = 0;
+	bool		first = true;
 
 	*retrieved_attrs = NIL;
 
@@ -1748,18 +1792,38 @@ deparseExplicitTargetList(List *tlist,
 	{
 		TargetEntry *tle = lfirst_node(TargetEntry, lc);
 
-		if (i > 0)
+		i++;
+
+		/* Local tableoid: known locally, skip the remote round trip. */
+		if (is_base_rel &&
+			IsA(tle->expr, Var) &&
+			((Var *) tle->expr)->varattno == TableOidAttributeNumber)
+			continue;
+
+		/*
+		 * Whole-row reference to this relation with a sibling column
+		 * elsewhere in tlist: skip the redundant ROW(...) and reconstruct
+		 * it locally instead (see make_tuple_from_result_row()).
+		 */
+		if (is_base_rel &&
+			IsA(tle->expr, Var) &&
+			((Var *) tle->expr)->varattno == InvalidAttrNumber &&
+			((Var *) tle->expr)->varno == context->scanrel->relid &&
+			tlist_has_other_column_of_rel(tlist, context->scanrel->relid))
+			continue;
+
+		if (!first)
 			appendStringInfoString(buf, ", ");
 		else if (is_returning)
 			appendStringInfoString(buf, " RETURNING ");
+		first = false;
 
 		deparseExpr((Expr *) tle->expr, context);
 
-		*retrieved_attrs = lappend_int(*retrieved_attrs, i + 1);
-		i++;
+		*retrieved_attrs = lappend_int(*retrieved_attrs, i);
 	}
 
-	if (i == 0 && !is_returning)
+	if (first && !is_returning)
 		appendStringInfoString(buf, "NULL");
 }
 
@@ -2364,7 +2428,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 	deparseRelation(buf, rel);
 	appendStringInfoString(buf, " SET ");
 
-	pindex = 2;					/* ctid is always the first param */
+	pindex = 3;					/* ctid ($1) and tableoid ($2) come first */
 	first = true;
 	foreach(lc, targetAttrs)
 	{
@@ -2384,7 +2448,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 			pindex++;
 		}
 	}
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_update_after_row,
@@ -2502,7 +2566,7 @@ deparseDeleteSql(StringInfo buf, RangeTblEntry *rte,
 {
 	appendStringInfoString(buf, "DELETE FROM ");
 	deparseRelation(buf, rel);
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_delete_after_row,
@@ -2889,6 +2953,17 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 			ADD_REL_QUALIFIER(buf, varno);
 		appendStringInfoString(buf, "ctid");
 	}
+	else if (varattno == RemoteTableOidAttributeNumber)
+	{
+		/*
+		 * Pseudo-column carrying the remote table OID as part of the row
+		 * identity for UPDATE/DELETE (see postgresAddForeignUpdateTargets);
+		 * fetch it as the remote "tableoid" system column.
+		 */
+		if (qualify_col)
+			ADD_REL_QUALIFIER(buf, varno);
+		appendStringInfoString(buf, "tableoid");
+	}
 	else if (varattno < 0)
 	{
 		/*
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index a6295674daf..ec1ae5e634f 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -5975,14 +5975,14 @@ BEGIN;
   EXPLAIN (verbose, costs off)
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
     RETURNING old.*, new.*;                                                         -- can't be pushed down
-                                                                  QUERY PLAN                                                                  
-----------------------------------------------------------------------------------------------------------------------------------------------
+                                                                       QUERY PLAN                                                                       
+--------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.c1, old.c2, old.c3, old.c4, old.c5, old.c6, old.c7, old.c8, new.c1, new.c2, new.c3, new.c4, new.c5, new.c6, new.c7, new.c8
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c3 = $3 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c3 = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan on public.ft2
-         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, ft2.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
+         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, remotetableoid, ft2.*
+         Remote SQL: SELECT c2, c3, ctid, tableoid, "C 1", c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
 (6 rows)
 
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
@@ -6129,14 +6129,14 @@ DELETE FROM ft2 WHERE c1 % 10 = 5 RETURNING c1, c4;
 BEGIN;
   EXPLAIN (verbose, costs off)
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;               -- can't be pushed down
-                                                QUERY PLAN                                                 
------------------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: old.c1, c4
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c4
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c4
    ->  Foreign Scan on public.ft2
-         Output: ctid
-         Remote SQL: SELECT ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
+         Output: ctid, remotetableoid
+         Remote SQL: SELECT ctid, tableoid, "C 1" FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
 (6 rows)
 
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;
@@ -7136,27 +7136,27 @@ BEGIN;
     FROM ft4 INNER JOIN ft5 ON (ft4.c1 = ft5.c1)
     WHERE ft2.c1 > 1200 AND ft2.c2 = ft4.c1
     RETURNING old, new, ft2, ft2.*, ft4, ft4.*;  -- can't be pushed down
-                                                                                                                                                                                                                                     QUERY PLAN                                                                                                                                                                                                                                     
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                                                                           QUERY PLAN                                                                                                                                                                                                                                            
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.*, new.*, ft2.*, ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.*, ft4.c1, ft4.c2, ft4.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan
-         Output: 'bar'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+         Output: 'bar'::text, ft2.ctid, remotetableoid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+               Output: ft2.ctid, remotetableoid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
                Join Filter: (ft4.c1 = ft5.c1)
                ->  Sort
-                     Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                     Output: ft2.ctid, remotetableoid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                      Sort Key: ft2.c2
                      ->  Hash Join
-                           Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                           Output: ft2.ctid, remotetableoid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                            Hash Cond: (ft2.c2 = ft4.c1)
                            ->  Foreign Scan on public.ft2
-                                 Output: ft2.ctid, ft2.*, ft2.c2
-                                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
+                                 Output: ft2.ctid, remotetableoid, ft2.*, ft2.c2
+                                 Remote SQL: SELECT ctid, tableoid, c2, "C 1", c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
                            ->  Hash
                                  Output: ft4.*, ft4.c1, ft4.c2, ft4.c3
                                  ->  Foreign Scan on public.ft4
@@ -7234,13 +7234,13 @@ UPDATE ft2 AS target SET (c2, c7) = (
         FROM ft2 AS src
         WHERE target.c1 = src.c1
 ) WHERE c1 > 1100;
-                                                         QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
+                                                                 QUERY PLAN                                                                  
+---------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 target
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c7 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c7 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.ft2 target
-         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, target.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
+         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, remotetableoid, target.*
+         Remote SQL: SELECT "C 1", ctid, tableoid, c2, c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
          SubPlan multiexpr_1
            ->  Foreign Scan on public.ft2 src
                  Output: (src.c2 * 10), src.c7
@@ -7262,20 +7262,20 @@ UPDATE ft2 AS target SET (c2) = (
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE ft2 d SET c2 = CASE WHEN random() >= 0 THEN d.c2 ELSE 0 END
   FROM ft2 AS t WHERE d.c1 = t.c1 AND d.c1 > 1000;
-                                                                                                                                                                                       QUERY PLAN                                                                                                                                                                                        
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                              QUERY PLAN                                                                                                                                                                                              
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 d
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan
-         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, d.*, t.*
+         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, remotetableoid, d.*, t.*
          Relations: (public.ft2 d) INNER JOIN (public.ft2 t)
-         Remote SQL: SELECT r1.c2, r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.c2, r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
          ->  Hash Join
-               Output: d.c2, d.ctid, d.*, t.*
+               Output: d.c2, d.ctid, remotetableoid, d.*, t.*
                Hash Cond: (d.c1 = t.c1)
                ->  Foreign Scan on public.ft2 d
-                     Output: d.c2, d.ctid, d.*, d.c1
-                     Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
+                     Output: d.c2, d.ctid, remotetableoid, d.*, d.c1
+                     Remote SQL: SELECT c2, ctid, tableoid, "C 1", c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
                ->  Hash
                      Output: t.*, t.c1
                      ->  Foreign Scan on public.ft2 t
@@ -7295,19 +7295,19 @@ EXPLAIN (verbose, costs off)
 WITH cte AS (
   UPDATE ft2 SET c3 = 'bar' WHERE postgres_fdw_abs(c1) > 2000 RETURNING *
 ) SELECT * FROM cte ORDER BY c1;          -- can't be pushed down
-                                                    QUERY PLAN                                                    
-------------------------------------------------------------------------------------------------------------------
+                                                             QUERY PLAN                                                             
+------------------------------------------------------------------------------------------------------------------------------------
  Sort
    Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
    Sort Key: cte.c1
    CTE cte
      ->  Update on public.ft2
            Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8
-           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
            ->  Foreign Scan on public.ft2
-                 Output: 'bar'::text, ft2.ctid, ft2.*
+                 Output: 'bar'::text, ft2.ctid, remotetableoid, ft2.*
                  Filter: (postgres_fdw_abs(ft2.c1) > 2000)
-                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" FOR UPDATE
+                 Remote SQL: SELECT ctid, tableoid, "C 1", c2, c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" FOR UPDATE
    ->  CTE Scan on cte
          Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
 (13 rows)
@@ -7338,13 +7338,13 @@ UPDATE ft2 SET c3 = 'baz'
 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Nested Loop
-         Output: 'baz'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
+         Output: 'baz'::text, ft2.ctid, remotetableoid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
          Join Filter: (ft2.c2 === ft4.c1)
          ->  Foreign Scan on public.ft2
-               Output: ft2.ctid, ft2.*, ft2.c2
-               Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+               Output: ft2.ctid, remotetableoid, ft2.*, ft2.c2
+               Remote SQL: SELECT ctid, tableoid, c2, "C 1", c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
          ->  Foreign Scan
                Output: ft4.*, ft4.c1, ft4.c2, ft4.c3, ft5.*, ft5.c1, ft5.c2, ft5.c3
                Relations: (public.ft4) INNER JOIN (public.ft5)
@@ -7376,24 +7376,24 @@ DELETE FROM ft2
   USING ft4 INNER JOIN ft5 ON (ft4.c1 === ft5.c1)
   WHERE ft2.c1 > 2000 AND ft2.c2 = ft4.c1
   RETURNING ft2.c1, ft2.c2, ft2.c3;       -- can't be pushed down
-                                                                                                                                                                     QUERY PLAN                                                                                                                                                                     
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                           QUERY PLAN                                                                                                                                                                            
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c2, c3
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3
    ->  Foreign Scan
-         Output: ft2.ctid, ft4.*, ft5.*
+         Output: ft2.ctid, remotetableoid, ft4.*, ft5.*
          Filter: (ft4.c1 === ft5.c1)
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft4.*, ft5.*, ft4.c1, ft5.c1
+               Output: ft2.ctid, remotetableoid, ft4.*, ft5.*, ft4.c1, ft5.c1
                ->  Nested Loop
-                     Output: ft2.ctid, ft4.*, ft4.c1
+                     Output: ft2.ctid, remotetableoid, ft4.*, ft4.c1
                      Join Filter: (ft2.c2 = ft4.c1)
                      ->  Foreign Scan on public.ft2
-                           Output: ft2.ctid, ft2.c2
-                           Remote SQL: SELECT c2, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+                           Output: ft2.ctid, remotetableoid, ft2.c2
+                           Remote SQL: SELECT ctid, tableoid, c2, "C 1" FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
                      ->  Foreign Scan on public.ft4
                            Output: ft4.*, ft4.c1
                            Remote SQL: SELECT c1, c2, c3 FROM "S 1"."T 3"
@@ -7905,19 +7905,19 @@ SET enable_hashjoin TO false;
 SET enable_material TO false;
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
-                                   QUERY PLAN                                   
---------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.remt2
    Output: remt2.c1, remt2.c2
-   Remote SQL: UPDATE public.loct2 SET c2 = $2 WHERE ctid = $1 RETURNING c1, c2
+   Remote SQL: UPDATE public.loct2 SET c2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING c1, c2
    ->  Nested Loop
-         Output: (remt2.c2 || remt2.c2), remt2.ctid, remt2.*, loct1.ctid
+         Output: (remt2.c2 || remt2.c2), remt2.ctid, remotetableoid, remt2.*, loct1.ctid
          Join Filter: (remt2.c1 = loct1.c1)
          ->  Seq Scan on public.loct1
                Output: loct1.ctid, loct1.c1
          ->  Foreign Scan on public.remt2
-               Output: remt2.c2, remt2.ctid, remt2.*, remt2.c1
-               Remote SQL: SELECT c1, c2, ctid FROM public.loct2 FOR UPDATE
+               Output: remt2.c2, remt2.ctid, remotetableoid, remt2.*, remt2.c1
+               Remote SQL: SELECT c2, ctid, tableoid, c1 FROM public.loct2 FOR UPDATE
 (11 rows)
 
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
@@ -7974,17 +7974,17 @@ prepare fdw_part_upd2(int) as
     returning tableoid::regclass, a, b;
 explain (verbose, costs off)
     execute fdw_part_upd2(2);
-                                                                       QUERY PLAN                                                                       
---------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                               QUERY PLAN                                                                               
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.fdw_part_update
    Output: (fdw_part_update_1.tableoid)::regclass, fdw_part_update_1.a, fdw_part_update_1.b
    Foreign Update on public.fdw_part_update_p2 fdw_part_update_2
-     Remote SQL: UPDATE public.fdw_part_update_remote SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.fdw_part_update_remote SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Append
          Subplans Removed: 1
          ->  Foreign Scan on public.fdw_part_update_p2 fdw_part_update_2
-               Output: ((fdw_part_update_2.b + ((random())::integer * 0)) + 1), fdw_part_update_2.tableoid, fdw_part_update_2.ctid, fdw_part_update_2.*
-               Remote SQL: SELECT a, b, ctid FROM public.fdw_part_update_remote WHERE ((a = $1::integer)) FOR UPDATE
+               Output: ((fdw_part_update_2.b + ((random())::integer * 0)) + 1), fdw_part_update_2.tableoid, fdw_part_update_2.ctid, remotetableoid, fdw_part_update_2.*
+               Remote SQL: SELECT b, ctid, tableoid, a FROM public.fdw_part_update_remote WHERE ((a = $1::integer)) FOR UPDATE
 (9 rows)
 
 execute fdw_part_upd2(2);
@@ -8142,13 +8142,13 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, remotetableoid, foreign_tbl.*
+         Remote SQL: SELECT b, ctid, tableoid, a FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -8156,13 +8156,13 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, remotetableoid, foreign_tbl.*
+         Remote SQL: SELECT b, ctid, tableoid, a FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -8255,14 +8255,14 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                   
+----------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid, parent_tbl_1.*
+         Remote SQL: SELECT b, ctid, tableoid, a FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -8270,14 +8270,14 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                    
+-----------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid, parent_tbl_1.*
+         Remote SQL: SELECT b, ctid, tableoid, a FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -8326,14 +8326,14 @@ CREATE VIEW rw_view AS SELECT * FROM parent_tbl WHERE a < 5 WITH CHECK OPTION;
 INSERT INTO parent_tbl (a) VALUES(1),(5);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = 'text', c = 123.456;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                    
+-----------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.child_foreign parent_tbl_1
-     Remote SQL: UPDATE public.child_local SET b = $2, c = $3 WHERE ctid = $1 RETURNING a
+     Remote SQL: UPDATE public.child_local SET b = $3, c = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING a
    ->  Foreign Scan on public.child_foreign parent_tbl_1
-         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT b, c, a, ctid FROM public.child_local WHERE ((a < 5)) FOR UPDATE
+         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid, parent_tbl_1.*
+         Remote SQL: SELECT ctid, tableoid, a, b, c FROM public.child_local WHERE ((a < 5)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = 'text', c = 123.456;
@@ -8412,13 +8412,13 @@ insert into grem1 (a) values (1), (2);
 insert into grem1 (a) values (1), (2);
 explain (verbose, costs off)
 update grem1 set a = 22 where a = 2;
-                                       QUERY PLAN                                       
-----------------------------------------------------------------------------------------
+                                                QUERY PLAN                                                
+----------------------------------------------------------------------------------------------------------
  Update on public.grem1
-   Remote SQL: UPDATE public.gloc1 SET a = $2, b = DEFAULT, c = DEFAULT WHERE ctid = $1
+   Remote SQL: UPDATE public.gloc1 SET a = $3, b = DEFAULT, c = DEFAULT WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.grem1
-         Output: 22, ctid, grem1.*
-         Remote SQL: SELECT a, b, c, ctid FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
+         Output: 22, ctid, remotetableoid, grem1.*
+         Remote SQL: SELECT ctid, tableoid, a, b, c FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
 (5 rows)
 
 update grem1 set a = 22 where a = 2;
@@ -8745,13 +8745,13 @@ SELECT * from loc1;
 
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f1 = 10;          -- all columns should be transmitted
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: 10, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: 10, ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, f1, f2 FROM public.loc1 FOR UPDATE
 (5 rows)
 
 UPDATE rem1 set f1 = 10;
@@ -8893,12 +8893,12 @@ DELETE FROM rem1;                 -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1 WHERE false;     -- currently can't be pushed down
-                      QUERY PLAN                       
--------------------------------------------------------
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: ctid
+         Output: ctid, NULL::oid
          Replaces: Scan on rem1
          One-Time Filter: false
 (6 rows)
@@ -8999,13 +8999,13 @@ BEFORE UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, f1, f2 FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -9023,13 +9023,13 @@ AFTER UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                                  QUERY PLAN                                   
--------------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f2 = $2 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: UPDATE public.loc1 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, f1, f2 FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -9057,13 +9057,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                             QUERY PLAN                              
----------------------------------------------------------------------
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, f1, f2 FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_before_delete ON rem1;
@@ -9081,13 +9081,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, f1, f2 FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_after_delete ON rem1;
@@ -9124,28 +9124,28 @@ CONTEXT:  COPY parent_tbl, line 1: "AAA	42"
 ALTER SERVER loopback OPTIONS (DROP batch_size);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                   
+----------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid, parent_tbl_1.*
+         Remote SQL: SELECT b, ctid, tableoid, a FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                            QUERY PLAN                            
-------------------------------------------------------------------
+                                   QUERY PLAN                                   
+--------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Foreign Delete on public.foreign_tbl parent_tbl_1
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
-         Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
+         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid
+         Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 DELETE FROM parent_tbl;
@@ -9163,38 +9163,38 @@ CREATE TRIGGER parent_tbl_delete_trig
   FOR EACH STATEMENT EXECUTE PROCEDURE trigger_func();
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                              QUERY PLAN                                              
-------------------------------------------------------------------------------------------------------
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Update on public.parent_tbl parent_tbl_1
    Foreign Update on public.foreign_tbl parent_tbl_2
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::record)
+         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::oid), (NULL::record)
          ->  Append
                ->  Seq Scan on public.parent_tbl parent_tbl_1
-                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::record
+                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, remotetableoid, parent_tbl_2.*
+                     Remote SQL: SELECT b, ctid, tableoid, a FROM public.local_tbl FOR UPDATE
 (12 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Delete on public.parent_tbl parent_tbl_1
    Foreign Delete on public.foreign_tbl parent_tbl_2
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
    ->  Append
          ->  Seq Scan on public.parent_tbl parent_tbl_1
-               Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
+               Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid
          ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-               Output: parent_tbl_2.tableoid, parent_tbl_2.ctid
-               Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
+               Output: parent_tbl_2.tableoid, parent_tbl_2.ctid, remotetableoid
+               Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
 (10 rows)
 
 DELETE FROM parent_tbl;
@@ -9537,22 +9537,22 @@ drop table foo2child;
 -- Check UPDATE with inherited target and an inherited source table
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 where f1 in (select f1 from foo);
-                                              QUERY PLAN                                               
--------------------------------------------------------------------------------------------------------
+                                                     QUERY PLAN                                                     
+--------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Hash Join
-         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::record), foo.*, foo.tableoid
+         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::oid), (NULL::record), foo.*, foo.tableoid
          Inner Unique: true
          Hash Cond: (bar.f1 = foo.f1)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, remotetableoid, bar_2.*
+                     Remote SQL: SELECT f2, f1, ctid, tableoid, f3 FROM public.loct2 FOR UPDATE
          ->  Hash
                Output: foo.ctid, foo.f1, foo.*, foo.tableoid
                ->  HashAggregate
@@ -9584,24 +9584,24 @@ update bar set f2 = f2 + 100
 from
   ( select f1 from foo union all select f1+3 from foo ) ss
 where bar.f1 = ss.f1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                QUERY PLAN                                                 
+-----------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Merge Join
-         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
          Merge Cond: (bar.f1 = foo.f1)
          ->  Sort
-               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::record)
+               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
                Sort Key: bar.f1
                ->  Append
                      ->  Seq Scan on public.bar bar_1
-                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                      ->  Foreign Scan on public.bar2 bar_2
-                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                           Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, remotetableoid, bar_2.*
+                           Remote SQL: SELECT f2, f1, ctid, tableoid, f3 FROM public.loct2 FOR UPDATE
          ->  Sort
                Output: (ROW(foo.f1)), foo.f1
                Sort Key: foo.f1
@@ -9750,7 +9750,7 @@ delete from foo where f1 < 5 returning *;
    Foreign Delete on public.foo2 foo_2
    ->  Append
          ->  Index Scan using i_foo_f1 on public.foo foo_1
-               Output: foo_1.tableoid, foo_1.ctid
+               Output: foo_1.tableoid, foo_1.ctid, NULL::oid
                Index Cond: (foo_1.f1 < 5)
          ->  Foreign Delete on public.foo2 foo_2
                Remote SQL: DELETE FROM public.loct1 WHERE ((f1 < 5)) RETURNING f1, f2
@@ -9768,17 +9768,17 @@ delete from foo where f1 < 5 returning *;
 
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 returning *;
-                                        QUERY PLAN                                        
-------------------------------------------------------------------------------------------
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
  Update on public.bar
    Output: bar_1.f1, bar_1.f2
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Update on public.bar2 bar_2
                      Remote SQL: UPDATE public.loct2 SET f2 = (f2 + 100) RETURNING f1, f2
 (11 rows)
@@ -9803,20 +9803,20 @@ AFTER UPDATE OR DELETE ON bar2
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 explain (verbose, costs off)
 update bar set f2 = f2 + 100;
-                                               QUERY PLAN                                               
---------------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                        
+--------------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f1 = $2, f2 = $3, f3 = $4 WHERE ctid = $1 RETURNING f1, f2, f3
+     Remote SQL: UPDATE public.loct2 SET f1 = $3, f2 = $4, f3 = $5 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, remotetableoid, bar_2.*
+                     Remote SQL: SELECT f2, ctid, tableoid, f1, f3 FROM public.loct2 FOR UPDATE
 (12 rows)
 
 update bar set f2 = f2 + 100;
@@ -9834,19 +9834,19 @@ NOTICE:  trig_row_after(23, skidoo) AFTER ROW UPDATE ON bar2
 NOTICE:  OLD: (7,277,77),NEW: (7,377,77)
 explain (verbose, costs off)
 delete from bar where f2 < 400;
-                                            QUERY PLAN                                             
----------------------------------------------------------------------------------------------------
+                                                 QUERY PLAN                                                  
+-------------------------------------------------------------------------------------------------------------
  Delete on public.bar
    Delete on public.bar bar_1
    Foreign Delete on public.bar2 bar_2
-     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 RETURNING f1, f2, f3
+     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
    ->  Append
          ->  Seq Scan on public.bar bar_1
-               Output: bar_1.tableoid, bar_1.ctid, NULL::record
+               Output: bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                Filter: (bar_1.f2 < 400)
          ->  Foreign Scan on public.bar2 bar_2
-               Output: bar_2.tableoid, bar_2.ctid, bar_2.*
-               Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
+               Output: bar_2.tableoid, bar_2.ctid, remotetableoid, bar_2.*
+               Remote SQL: SELECT ctid, tableoid, f2, f1, f3 FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
 (11 rows)
 
 delete from bar where f2 < 400;
@@ -9878,22 +9878,22 @@ analyze remt1;
 analyze remt2;
 explain (verbose, costs off)
 update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a returning *;
-                                                   QUERY PLAN                                                   
-----------------------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                          
+-----------------------------------------------------------------------------------------------------------------------------
  Update on public.parent
    Output: parent_1.a, parent_1.b, remt2.a, remt2.b
    Update on public.parent parent_1
    Foreign Update on public.remt1 parent_2
-     Remote SQL: UPDATE public.loct1 SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct1 SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::record)
+         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::oid), (NULL::record)
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::record
+                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, remotetableoid, parent_2.*
+                     Remote SQL: SELECT b, a, ctid, tableoid FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.b, remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -9910,22 +9910,22 @@ update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a re
 
 explain (verbose, costs off)
 delete from parent using remt2 where parent.a = remt2.a returning parent;
-                                 QUERY PLAN                                  
------------------------------------------------------------------------------
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
  Delete on public.parent
    Output: parent_1.*
    Delete on public.parent parent_1
    Foreign Delete on public.remt1 parent_2
-     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: remt2.*, parent.tableoid, parent.ctid
+         Output: remt2.*, parent.tableoid, parent.ctid, (NULL::oid)
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid
+                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid
-                     Remote SQL: SELECT a, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid, remotetableoid
+                     Remote SQL: SELECT a, ctid, tableoid FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -10155,7 +10155,7 @@ update utrtest set a = 1 where a = 1 or a = 2 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 WHERE (((a = 1) OR (a = 2))) RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, NULL::record
                Filter: ((utrtest_2.a = 1) OR (utrtest_2.a = 2))
 (10 rows)
 
@@ -10194,8 +10194,8 @@ insert into utrtest values (2, 'qux');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Foreign Update on public.remp utrtest_1
@@ -10204,7 +10204,7 @@ update utrtest set a = 1 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, NULL::record
 (9 rows)
 
 update utrtest set a = 1 returning *;
@@ -10215,22 +10215,22 @@ insert into utrtest values (2, 'qux');
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 from (values (1), (2)) s(x) where a = s.x returning *;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                   
+----------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Foreign Update on public.remp utrtest_1
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    Update on public.locp utrtest_2
    ->  Hash Join
-         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, utrtest.*
+         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, remotetableoid, utrtest.*
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Foreign Scan on public.remp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, utrtest_1.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, remotetableoid, utrtest_1.*
+                     Remote SQL: SELECT a, ctid, tableoid, b FROM public.loct FOR UPDATE
                ->  Seq Scan on public.locp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, NULL::record
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -10254,15 +10254,15 @@ insert into utrtest values (3, 'xyzzy');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
    ->  Append
          ->  Seq Scan on public.locp utrtest_1
-               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, NULL::record
          ->  Foreign Update on public.remp utrtest_2
                Remote SQL: UPDATE public.loct SET a = 3 RETURNING a, b
 (9 rows)
@@ -10272,22 +10272,22 @@ ERROR:  cannot route tuples into foreign table to be updated "remp"
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 from (values (2), (3)) s(x) where a = s.x returning *;
-                                             QUERY PLAN                                              
------------------------------------------------------------------------------------------------------
+                                                    QUERY PLAN                                                    
+------------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Hash Join
-         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::record)
+         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::oid), (NULL::record)
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Seq Scan on public.locp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, utrtest_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, remotetableoid, utrtest_2.*
+                     Remote SQL: SELECT a, ctid, tableoid, b FROM public.loct FOR UPDATE
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -13240,8 +13240,8 @@ RESET enable_hashjoin;
 -- Test that UPDATE/DELETE with inherited target works with async_capable enabled
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
-                                                QUERY PLAN                                                
-----------------------------------------------------------------------------------------------------------
+                                                     QUERY PLAN                                                      
+---------------------------------------------------------------------------------------------------------------------
  Update on public.async_pt
    Output: async_pt_1.a, async_pt_1.b, async_pt_1.c
    Foreign Update on public.async_p1 async_pt_1
@@ -13253,7 +13253,7 @@ UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
          ->  Foreign Update on public.async_p2 async_pt_2
                Remote SQL: UPDATE public.base_tbl2 SET c = (c || c) WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::record
+               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::oid, NULL::record
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -13280,7 +13280,7 @@ DELETE FROM async_pt WHERE b = 0 RETURNING *;
          ->  Foreign Delete on public.async_p2 async_pt_2
                Remote SQL: DELETE FROM public.base_tbl2 WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: async_pt_3.tableoid, async_pt_3.ctid
+               Output: async_pt_3.tableoid, async_pt_3.ctid, NULL::oid
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -14079,3 +14079,38 @@ RESET client_min_messages;
 DROP FUNCTION wait_for_backend_termination(int);
 DROP FOREIGN TABLE remote_backend_pid;
 DROP VIEW my_backend_pid;
+-- ===================================================================
+-- test UPDATE/DELETE on a foreign table whose remote counterpart is
+-- itself a partitioned table
+-- ===================================================================
+-- The two rows below land in separate partitions and, being the first
+-- row in each partition's heap, collide on ctid (0,1).  A row-identity
+-- scheme that relies on ctid alone can't tell them apart.
+CREATE TABLE parted_remote (a int, b int) PARTITION BY LIST (a);
+CREATE TABLE parted_remote_p1 PARTITION OF parted_remote FOR VALUES IN (1);
+CREATE TABLE parted_remote_p2 PARTITION OF parted_remote FOR VALUES IN (2);
+INSERT INTO parted_remote_p1 VALUES (1, 100);
+INSERT INTO parted_remote_p2 VALUES (2, 200);
+CREATE FOREIGN TABLE foreign_parted_remote (a int, b int)
+  SERVER loopback OPTIONS (table_name 'parted_remote');
+-- Force a non-direct-modify UPDATE (random() isn't shippable), so the
+-- ctid/tableoid row-identity path below is actually exercised.
+UPDATE foreign_parted_remote SET b = 999 WHERE a = 1 AND random() <= 1;
+-- Only the a = 1 row (in parted_remote_p1) should have changed.
+SELECT tableoid::regclass, a, b FROM parted_remote ORDER BY a;
+     tableoid     | a |  b  
+------------------+---+-----
+ parted_remote_p1 | 1 | 999
+ parted_remote_p2 | 2 | 200
+(2 rows)
+
+DELETE FROM foreign_parted_remote WHERE a = 2 AND random() <= 1;
+-- Only the a = 2 row (in parted_remote_p2) should be gone.
+SELECT tableoid::regclass, a, b FROM parted_remote ORDER BY a;
+     tableoid     | a |  b  
+------------------+---+-----
+ parted_remote_p1 | 1 | 999
+(1 row)
+
+DROP FOREIGN TABLE foreign_parted_remote;
+DROP TABLE parted_remote;
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index adfdb91cc20..65610aa4855 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -232,6 +232,8 @@ typedef struct PgFdwModifyState
 
 	/* info about parameters for prepared statement */
 	AttrNumber	ctidAttno;		/* attnum of input resjunk ctid column */
+	AttrNumber	tableoidAttno;	/* attnum of input resjunk remote tableoid
+								 * column, or 0 if none */
 	int			p_nums;			/* number of parameters to transmit */
 	FmgrInfo   *p_flinfo;		/* output conversion functions for them */
 
@@ -557,6 +559,7 @@ static TupleTableSlot **execute_foreign_modify(EState *estate,
 static void prepare_foreign_modify(PgFdwModifyState *fmstate);
 static const char **convert_prep_stmt_params(PgFdwModifyState *fmstate,
 											 ItemPointer tupleid,
+											 Oid tableoid,
 											 TupleTableSlot **slots,
 											 int numSlots);
 static void store_returning_result(PgFdwModifyState *fmstate,
@@ -566,6 +569,7 @@ static void deallocate_query(PgFdwModifyState *fmstate);
 static List *build_remote_returning(Index rtindex, Relation rel,
 									List *returningList);
 static void rebuild_fdw_scan_tlist(ForeignScan *fscan, List *tlist);
+static void set_remote_tableoid_resnames(List *fdw_scan_tlist);
 static void execute_dml_stmt(ForeignScanState *node);
 static TupleTableSlot *get_returning_data(ForeignScanState *node);
 static void init_returning_filter(PgFdwDirectModifyState *dmstate,
@@ -1432,6 +1436,101 @@ postgresGetForeignPlan(PlannerInfo *root,
 		 * should recheck all the remote quals.
 		 */
 		fdw_recheck_quals = remote_exprs;
+
+		/*
+		 * If a non-direct UPDATE/DELETE needs the remote tableoid (flagged by
+		 * a pseudo-column Var in the rel's targetlist), build an explicit
+		 * fdw_scan_tlist as for a join instead of scanning positionally.
+		 */
+		foreach(lc, foreignrel->reltarget->exprs)
+		{
+			Var		   *var = (Var *) lfirst(lc);
+
+			if (IsA(var, Var) &&
+				var->varattno == RemoteTableOidAttributeNumber)
+			{
+				List	   *epq_vars;
+				List	   *scan_vars = NIL;
+				ListCell   *lc2;
+
+				fdw_scan_tlist = build_tlist_to_deparse(foreignrel);
+
+				/*
+				 * Vars in the EPQ recheck and local quals now resolve against
+				 * the scan output, so add any not already covered.  Skip any
+				 * Var that isn't a column of this relation (e.g. a join
+				 * partner's column referenced by a pushed-down join qual):
+				 * that isn't ours to fetch, and it's already handled as a
+				 * bound parameter wherever it appears inside a qual.
+				 */
+				epq_vars = pull_var_clause((Node *) list_concat_copy(fdw_recheck_quals,
+																	  local_exprs),
+										   PVC_RECURSE_PLACEHOLDERS);
+				foreach(lc2, epq_vars)
+				{
+					Var		   *epq_var = lfirst_node(Var, lc2);
+
+					if (bms_is_member(epq_var->varno, foreignrel->relids))
+						scan_vars = lappend(scan_vars, epq_var);
+				}
+				fdw_scan_tlist = add_to_flat_tlist(fdw_scan_tlist, scan_vars);
+
+				/*
+				 * If this relation's whole row is referenced anywhere in
+				 * the scan output, every one of its real columns must be
+				 * fetched too, so make_tuple_from_result_row() can
+				 * reconstruct it locally afterward instead of the remote
+				 * side building and shipping a second, redundant ROW(...)
+				 * (see deparseExplicitTargetList()).
+				 */
+				{
+					bool		has_wholerow = false;
+					ListCell   *lc3;
+
+					foreach(lc3, fdw_scan_tlist)
+					{
+						TargetEntry *tle = lfirst_node(TargetEntry, lc3);
+
+						if (IsA(tle->expr, Var) &&
+							((Var *) tle->expr)->varattno == InvalidAttrNumber &&
+							((Var *) tle->expr)->varno == foreignrel->relid)
+						{
+							has_wholerow = true;
+							break;
+						}
+					}
+
+					if (has_wholerow)
+					{
+						Relation	rel = table_open(foreigntableid, NoLock);
+						TupleDesc	tupdesc = RelationGetDescr(rel);
+						List	   *relcols = NIL;
+						int			attnum;
+
+						for (attnum = 1; attnum <= tupdesc->natts; attnum++)
+						{
+							Form_pg_attribute attr = TupleDescAttr(tupdesc, attnum - 1);
+							Var		   *cvar;
+
+							if (attr->attisdropped)
+								continue;
+
+							cvar = makeVar(foreignrel->relid, attnum,
+										   attr->atttypid, attr->atttypmod,
+										   attr->attcollation, 0);
+							relcols = lappend(relcols, cvar);
+						}
+
+						fdw_scan_tlist = add_to_flat_tlist(fdw_scan_tlist, relcols);
+
+						table_close(rel, NoLock);
+					}
+				}
+
+				set_remote_tableoid_resnames(fdw_scan_tlist);
+				break;
+			}
+		}
 	}
 	else
 	{
@@ -1467,6 +1566,7 @@ postgresGetForeignPlan(PlannerInfo *root,
 
 		/* Build the list of columns to be fetched from the foreign server. */
 		fdw_scan_tlist = build_tlist_to_deparse(foreignrel);
+		set_remote_tableoid_resnames(fdw_scan_tlist);
 
 		/*
 		 * Ensure that the outer plan produces a tuple whose descriptor
@@ -1780,8 +1880,12 @@ postgresBeginForeignScan(ForeignScanState *node, int eflags)
 	/*
 	 * Get info we'll need for converting data fetched from the foreign server
 	 * into local representation and error reporting during that process.
+	 *
+	 * Key the choice off fdw_scan_tlist, not scanrelid: an explicit
+	 * fdw_scan_tlist (built to fetch the remote tableoid) fetches into the
+	 * scan tuple slot like a join, even for a base-relation scan.
 	 */
-	if (fsplan->scan.scanrelid > 0)
+	if (fsplan->scan.scanrelid > 0 && fsplan->fdw_scan_tlist == NIL)
 	{
 		fsstate->rel = node->ss.ss_currentRelation;
 		fsstate->tupdesc = RelationGetDescr(fsstate->rel);
@@ -1988,6 +2092,21 @@ postgresAddForeignUpdateTargets(PlannerInfo *root,
 
 	/* Register it as a row-identity column needed by this target rel */
 	add_row_identity_var(root, var, rtindex, "ctid");
+
+	/*
+	 * ctid alone isn't unique if the foreign table maps to a partitioned
+	 * table remotely, so also fetch the remote tableoid, via an out-of-range
+	 * attnum rather than TableOidAttributeNumber (see deparseColumnRef).
+	 */
+	var = makeVar(rtindex,
+				  RemoteTableOidAttributeNumber,
+				  OIDOID,
+				  -1,
+				  InvalidOid,
+				  0);
+
+	/* Register it as a second row-identity column needed by this target rel */
+	add_row_identity_var(root, var, rtindex, "remotetableoid");
 }
 
 /*
@@ -2852,6 +2971,31 @@ postgresPlanDirectModify(PlannerInfo *root,
 		if (returningList)
 			rebuild_fdw_scan_tlist(fscan, returningList);
 	}
+	else
+	{
+		ListCell   *lc;
+
+		/*
+		 * A direct modification identifies rows by pushed-down qualifiers, so
+		 * discard any fdw_scan_tlist built to fetch the remote tableoid,
+		 * falling back to the positional path.
+		 */
+		fscan->fdw_scan_tlist = NIL;
+
+		/*
+		 * The tlist's remote-tableoid Var likewise isn't needed; replace it
+		 * with a NULL const rather than dropping the (positionally indexed)
+		 * entry.
+		 */
+		foreach(lc, fscan->scan.plan.targetlist)
+		{
+			TargetEntry *tle = lfirst_node(TargetEntry, lc);
+
+			if (IsA(tle->expr, Var) &&
+				((Var *) tle->expr)->varattno == RemoteTableOidAttributeNumber)
+				tle->expr = (Expr *) makeNullConst(OIDOID, -1, InvalidOid);
+		}
+	}
 
 	/*
 	 * Finally, unset the async-capable flag if it is set, as we currently
@@ -4332,8 +4476,12 @@ create_foreign_modify(EState *estate,
 	if (fmstate->has_returning)
 		fmstate->attinmeta = TupleDescGetAttInMetadata(tupdesc);
 
-	/* Prepare for output conversion of parameters used in prepared stmt. */
-	n_params = list_length(fmstate->target_attrs) + 1;
+	/*
+	 * Prepare for output conversion of parameters used in prepared stmt.
+	 * UPDATE/DELETE transmit two extra leading parameters (ctid, tableoid) to
+	 * identify the row; INSERT transmits none.
+	 */
+	n_params = list_length(fmstate->target_attrs) + 2;
 	fmstate->p_flinfo = palloc0_array(FmgrInfo, n_params);
 	fmstate->p_nums = 0;
 
@@ -4351,6 +4499,19 @@ create_foreign_modify(EState *estate,
 		getTypeOutputInfo(TIDOID, &typefnoid, &isvarlena);
 		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
 		fmstate->p_nums++;
+
+		/*
+		 * Find the remote tableoid resjunk column; it's the second
+		 * transmittable parameter, disambiguating ctid across partitions.
+		 */
+		fmstate->tableoidAttno =
+			ExecFindJunkAttributeInTlist(subplan->targetlist, "remotetableoid");
+		if (!AttributeNumberIsValid(fmstate->tableoidAttno))
+			elog(ERROR, "could not find junk remotetableoid column");
+
+		getTypeOutputInfo(OIDOID, &typefnoid, &isvarlena);
+		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
+		fmstate->p_nums++;
 	}
 
 	if (operation == CMD_INSERT || operation == CMD_UPDATE)
@@ -4403,6 +4564,7 @@ execute_foreign_modify(EState *estate,
 {
 	PgFdwModifyState *fmstate = (PgFdwModifyState *) resultRelInfo->ri_FdwState;
 	ItemPointer ctid = NULL;
+	Oid			tableoid = InvalidOid;
 	const char **p_values;
 	PGresult   *res;
 	int			n_rows;
@@ -4443,7 +4605,8 @@ execute_foreign_modify(EState *estate,
 		prepare_foreign_modify(fmstate);
 
 	/*
-	 * For UPDATE/DELETE, get the ctid that was passed up as a resjunk column
+	 * For UPDATE/DELETE, get the ctid and remote tableoid that were passed up
+	 * as resjunk columns; together they identify the remote row to modify.
 	 */
 	if (operation == CMD_UPDATE || operation == CMD_DELETE)
 	{
@@ -4457,10 +4620,19 @@ execute_foreign_modify(EState *estate,
 		if (isNull)
 			elog(ERROR, "ctid is NULL");
 		ctid = (ItemPointer) DatumGetPointer(datum);
+
+		datum = ExecGetJunkAttribute(planSlots[0],
+									 fmstate->tableoidAttno,
+									 &isNull);
+		/* shouldn't ever get a null result... */
+		if (isNull)
+			elog(ERROR, "remote tableoid is NULL");
+		tableoid = DatumGetObjectId(datum);
 	}
 
 	/* Convert parameters needed by prepared statement to text form */
-	p_values = convert_prep_stmt_params(fmstate, ctid, slots, *numSlots);
+	p_values = convert_prep_stmt_params(fmstate, ctid, tableoid,
+										slots, *numSlots);
 
 	/*
 	 * Execute the prepared statement.
@@ -4558,6 +4730,7 @@ prepare_foreign_modify(PgFdwModifyState *fmstate)
  *		Create array of text strings representing parameter values
  *
  * tupleid is ctid to send, or NULL if none
+ * tableoid is the remote tableoid to send; used only when tupleid != NULL
  * slot is slot to get remaining parameters from, or NULL if none
  *
  * Data is constructed in temp_cxt; caller should reset that after use.
@@ -4565,6 +4738,7 @@ prepare_foreign_modify(PgFdwModifyState *fmstate)
 static const char **
 convert_prep_stmt_params(PgFdwModifyState *fmstate,
 						 ItemPointer tupleid,
+						 Oid tableoid,
 						 TupleTableSlot **slots,
 						 int numSlots)
 {
@@ -4581,7 +4755,7 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 	/* ctid is provided only for UPDATE/DELETE, which don't allow batching */
 	Assert(!(tupleid != NULL && numSlots > 1));
 
-	/* 1st parameter should be ctid, if it's in use */
+	/* 1st and 2nd parameters should be ctid and tableoid, if in use */
 	if (tupleid != NULL)
 	{
 		Assert(numSlots == 1);
@@ -4589,6 +4763,10 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 		p_values[pindex] = OutputFunctionCall(&fmstate->p_flinfo[pindex],
 											  PointerGetDatum(tupleid));
 		pindex++;
+		/* don't need set_transmission_modes for OID output */
+		p_values[pindex] = OutputFunctionCall(&fmstate->p_flinfo[pindex],
+											  ObjectIdGetDatum(tableoid));
+		pindex++;
 	}
 
 	/* get following parameters from slots */
@@ -4602,7 +4780,8 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 
 		for (i = 0; i < numSlots; i++)
 		{
-			j = (tupleid != NULL) ? 1 : 0;
+			/* ctid and tableoid occupy the first two parameter slots */
+			j = (tupleid != NULL) ? 2 : 0;
 			foreach(lc, fmstate->target_attrs)
 			{
 				int			attnum = lfirst_int(lc);
@@ -4828,6 +5007,26 @@ rebuild_fdw_scan_tlist(ForeignScan *fscan, List *tlist)
 	fscan->fdw_scan_tlist = new_tlist;
 }
 
+/*
+ * set_remote_tableoid_resnames
+ *		Name fdw_scan_tlist's remote-tableoid pseudo-column entries
+ *		"remotetableoid", since their out-of-range attno has no catalog name.
+ */
+static void
+set_remote_tableoid_resnames(List *fdw_scan_tlist)
+{
+	ListCell   *lc;
+
+	foreach(lc, fdw_scan_tlist)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+
+		if (IsA(tle->expr, Var) &&
+			((Var *) tle->expr)->varattno == RemoteTableOidAttributeNumber)
+			tle->resname = pstrdup("remotetableoid");
+	}
+}
+
 /*
  * Execute a direct UPDATE/DELETE statement.
  */
@@ -9001,6 +9200,80 @@ make_tuple_from_result_row(PGresult *res,
 		j++;
 	}
 
+	/*
+	 * Fill in any local-tableoid pseudo-entry that deparseExplicitTargetList()
+	 * skipped fetching from the remote server (see there): its value is
+	 * simply this scan's own relation OID, already known locally.
+	 *
+	 * Similarly, reconstruct any plain whole-row reference to this same
+	 * relation that deparseExplicitTargetList() also skipped, since its
+	 * columns are already being fetched individually elsewhere in this same
+	 * tlist: build it here from the values already decoded above, instead of
+	 * having the remote side ship a second, redundant ROW(...) for it.
+	 */
+	if (fsstate)
+	{
+		ForeignScan *fsplan = castNode(ForeignScan, fsstate->ss.ps.plan);
+
+		if (fsplan->scan.scanrelid > 0 && fsplan->fdw_scan_tlist != NIL)
+		{
+			Oid			reloid = RelationGetRelid(fsstate->ss.ss_currentRelation);
+			TupleDesc	reltupdesc = RelationGetDescr(fsstate->ss.ss_currentRelation);
+			Datum	   *relvalues = NULL;
+			bool	   *relnulls = NULL;
+			int			wholerow_pos = -1;
+			ListCell   *lc2;
+			int			pos = 0;
+
+			foreach(lc2, fsplan->fdw_scan_tlist)
+			{
+				TargetEntry *tle = lfirst_node(TargetEntry, lc2);
+				Var		   *tvar;
+
+				pos++;
+
+				if (!IsA(tle->expr, Var))
+					continue;
+				tvar = (Var *) tle->expr;
+
+				if (tvar->varattno == TableOidAttributeNumber)
+				{
+					values[pos - 1] = ObjectIdGetDatum(reloid);
+					nulls[pos - 1] = false;
+				}
+				else if (tvar->varno == fsplan->scan.scanrelid &&
+						 tvar->varattno == InvalidAttrNumber)
+				{
+					/* Whole-row Var: fill in after collecting its columns. */
+					wholerow_pos = pos;
+				}
+				else if (tvar->varno == fsplan->scan.scanrelid &&
+						 tvar->varattno > InvalidAttrNumber &&
+						 tvar->varattno <= reltupdesc->natts)
+				{
+					if (relvalues == NULL)
+					{
+						relvalues = palloc0_array(Datum, reltupdesc->natts);
+						relnulls = palloc_array(bool, reltupdesc->natts);
+						memset(relnulls, true, reltupdesc->natts * sizeof(bool));
+					}
+					relvalues[tvar->varattno - 1] = values[pos - 1];
+					relnulls[tvar->varattno - 1] = nulls[pos - 1];
+				}
+			}
+
+			if (wholerow_pos >= 0)
+			{
+				HeapTuple	rowtup;
+
+				Assert(relvalues != NULL);
+				rowtup = heap_form_tuple(reltupdesc, relvalues, relnulls);
+				values[wholerow_pos - 1] = heap_copy_tuple_as_datum(rowtup, reltupdesc);
+				nulls[wholerow_pos - 1] = false;
+			}
+		}
+	}
+
 	/* Uninstall error context callback. */
 	error_context_stack = errcallback.previous;
 
@@ -9075,15 +9348,21 @@ conversion_error_callback(void *arg)
 		int			varno = 0;
 		AttrNumber	colno = 0;
 
-		if (fsplan->scan.scanrelid > 0)
+		if (fsplan->scan.scanrelid > 0 && fsplan->fdw_scan_tlist == NIL)
 		{
-			/* error occurred in a scan against a foreign table */
+			/*
+			 * Error occurred in a scan against a foreign table, fetched
+			 * positionally, so cur_attno is the table's attribute number.
+			 */
 			varno = fsplan->scan.scanrelid;
 			colno = errpos->cur_attno;
 		}
 		else
 		{
-			/* error occurred in a scan against a foreign join */
+			/*
+			 * Error occurred in a scan against a foreign join, or a base
+			 * relation with an explicit fdw_scan_tlist; cur_attno indexes it.
+			 */
 			TargetEntry *tle;
 
 			tle = list_nth_node(TargetEntry, fsplan->fdw_scan_tlist,
@@ -9116,6 +9395,8 @@ conversion_error_callback(void *arg)
 				attname = strVal(list_nth(rte->eref->colnames, colno - 1));
 			else if (colno == SelfItemPointerAttributeNumber)
 				attname = "ctid";
+			else if (colno == RemoteTableOidAttributeNumber)
+				attname = "tableoid";
 		}
 	}
 	else if (rel)
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index da7da1c2ea9..eafcf42844f 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -13,6 +13,7 @@
 #ifndef POSTGRES_FDW_H
 #define POSTGRES_FDW_H
 
+#include "access/htup_details.h"
 #include "foreign/foreign.h"
 #include "lib/stringinfo.h"
 #include "libpq/libpq-be-fe.h"
@@ -20,6 +21,14 @@
 #include "nodes/pathnodes.h"
 #include "utils/relcache.h"
 
+/*
+ * Pseudo-attribute number for the remote table OID, used to identify a row for
+ * UPDATE/DELETE when a foreign table maps to a remote partitioned table (see
+ * postgresAddForeignUpdateTargets).  Larger than any real attno, so it never
+ * collides with a genuine column; fetched as an ordinary fdw_scan_tlist column.
+ */
+#define RemoteTableOidAttributeNumber	(MaxHeapAttributeNumber + 1)
+
 /*
  * FDW-specific planner information kept in RelOptInfo.fdw_private for a
  * postgres_fdw foreign table.  For a baserel, this struct is created by
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index eaeb90485e8..e171e56b5ed 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -5146,3 +5146,35 @@ RESET client_min_messages;
 DROP FUNCTION wait_for_backend_termination(int);
 DROP FOREIGN TABLE remote_backend_pid;
 DROP VIEW my_backend_pid;
+
+-- ===================================================================
+-- test UPDATE/DELETE on a foreign table whose remote counterpart is
+-- itself a partitioned table
+-- ===================================================================
+
+-- The two rows below land in separate partitions and, being the first
+-- row in each partition's heap, collide on ctid (0,1).  A row-identity
+-- scheme that relies on ctid alone can't tell them apart.
+CREATE TABLE parted_remote (a int, b int) PARTITION BY LIST (a);
+CREATE TABLE parted_remote_p1 PARTITION OF parted_remote FOR VALUES IN (1);
+CREATE TABLE parted_remote_p2 PARTITION OF parted_remote FOR VALUES IN (2);
+INSERT INTO parted_remote_p1 VALUES (1, 100);
+INSERT INTO parted_remote_p2 VALUES (2, 200);
+
+CREATE FOREIGN TABLE foreign_parted_remote (a int, b int)
+  SERVER loopback OPTIONS (table_name 'parted_remote');
+
+-- Force a non-direct-modify UPDATE (random() isn't shippable), so the
+-- ctid/tableoid row-identity path below is actually exercised.
+UPDATE foreign_parted_remote SET b = 999 WHERE a = 1 AND random() <= 1;
+
+-- Only the a = 1 row (in parted_remote_p1) should have changed.
+SELECT tableoid::regclass, a, b FROM parted_remote ORDER BY a;
+
+DELETE FROM foreign_parted_remote WHERE a = 2 AND random() <= 1;
+
+-- Only the a = 2 row (in parted_remote_p2) should be gone.
+SELECT tableoid::regclass, a, b FROM parted_remote ORDER BY a;
+
+DROP FOREIGN TABLE foreign_parted_remote;
+DROP TABLE parted_remote;
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index 80f79c017d6..7f1278a7cc5 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -1197,6 +1197,22 @@ CREATE SUBSCRIPTION my_subscription SERVER subscription_server PUBLICATION testp
    to reduce the risk of misexecution of the query.
   </para>
 
+  <para>
+   When such optimized whole-query shipping is not possible, an
+   <command>UPDATE</command> or <command>DELETE</command> is instead executed
+   by first selecting the affected rows from the remote server and then
+   issuing a separate remote <command>UPDATE</command> or
+   <command>DELETE</command> for each row, identified by its
+   <literal>ctid</literal> and remote <literal>tableoid</literal>.  If the
+   remote table is the root of a partitioning hierarchy, each such per-row
+   command targets that root and so cannot benefit from partition pruning,
+   which can make this considerably slower than the whole-query case when
+   many rows are affected.  For best performance, prefer whole-query
+   shipping when possible, and otherwise try to limit the number of rows
+   affected by an <command>UPDATE</command> or <command>DELETE</command> on
+   a foreign table that maps to a partitioned table on the remote server.
+  </para>
+
   <para>
    When <filename>postgres_fdw</filename> encounters a join between foreign tables on
    the same foreign server, it sends the entire join to the foreign server,
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 7bbddb8bee4..1f7e2c34ef1 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -6493,6 +6493,20 @@ set_rel_width(PlannerInfo *root, RelOptInfo *rel)
 			int			ndx;
 			int32		item_width;
 
+			/*
+			 * An out-of-range attno (> max_attr) is an FDW row-identity
+			 * pseudo-column (postgres_fdw's remote table OID for
+			 * UPDATE/DELETE; see add_vars_to_targetlist()) with no
+			 * attr_widths[] slot; estimate its width from the type.
+			 */
+			if (var->varattno > rel->max_attr)
+			{
+				/* Only an FDW can inject an out-of-range row-identity column */
+				Assert(rel->fdwroutine != NULL);
+				tuple_width += get_typavgwidth(var->vartype, var->vartypmod);
+				continue;
+			}
+
 			Assert(var->varattno >= rel->min_attr);
 			Assert(var->varattno <= rel->max_attr);
 
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index fb6f81453ea..2ea6ae6c35b 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -315,6 +315,25 @@ add_vars_to_targetlist(PlannerInfo *root, List *vars,
 
 			if (bms_is_subset(where_needed, rel->relids))
 				continue;
+
+			/*
+			 * An out-of-range attno (> max_attr) is an FDW row-identity
+			 * pseudo-column (e.g. postgres_fdw's remote table OID for
+			 * UPDATE/DELETE) with no attr_needed[] slot; like a ROWID_VAR
+			 * it's always needed, so just add it to the reltarget once.
+			 */
+			if (attno > rel->max_attr)
+			{
+				/* Only an FDW can inject an out-of-range row-identity column */
+				Assert(rel->fdwroutine != NULL);
+				var = copyObject(var);
+				var->varnullingrels = NULL;
+				if (!list_member(rel->reltarget->exprs, var))
+					rel->reltarget->exprs = lappend(rel->reltarget->exprs,
+													var);
+				continue;
+			}
+
 			Assert(attno >= rel->min_attr && attno <= rel->max_attr);
 			attno -= rel->min_attr;
 			if (rel->attr_needed[attno] == NULL)
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index 8a641402a96..f333ee9e1fd 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -15,6 +15,7 @@
  */
 #include "postgres.h"
 
+#include "access/htup_details.h"
 #include "access/transam.h"
 #include "catalog/pg_type.h"
 #include "nodes/makefuncs.h"
@@ -1053,6 +1054,11 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 					 * it here and keep the assertions that ROWID_VARs
 					 * shouldn't be seen by fix_scan_expr.
 					 *
+					 * The same applies to an FDW row-identity pseudo-column
+					 * (attno > MaxHeapAttributeNumber; see
+					 * add_vars_to_targetlist()): no scan node exists here to
+					 * give it a value or a name, so replace it the same way.
+					 *
 					 * We also must handle the case where set operations have
 					 * been short-circuited resulting in a dummy Result node.
 					 * prepunion.c uses varno==0 for the set op targetlist.
@@ -1069,7 +1075,8 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 
 						if (var && IsA(var, Var))
 						{
-							if (var->varno == ROWID_VAR)
+							if (var->varno == ROWID_VAR ||
+								var->varattno > MaxHeapAttributeNumber)
 								tle->expr = (Expr *) makeNullConst(var->vartype,
 																   var->vartypmod,
 																   var->varcollid);
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 8e2409ccd9d..89c697b96f4 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -1298,6 +1298,7 @@ build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 	foreach(vars, input_rel->reltarget->exprs)
 	{
 		Var		   *var = (Var *) lfirst(vars);
+		RelOptInfo *baserel = NULL;
 
 		/*
 		 * For a PlaceHolderVar, we have to look up the PlaceHolderInfo.
@@ -1369,28 +1370,45 @@ build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 		}
 		else
 		{
-			RelOptInfo *baserel;
-			int			ndx;
-
 			/* Get the Var's original base rel */
 			baserel = find_base_rel(root, var->varno);
 
-			/* Is it still needed above this joinrel? */
-			ndx = var->varattno - baserel->min_attr;
-			if (!bms_nonempty_difference(baserel->attr_needed[ndx], relids))
-				continue;		/* nope, skip it */
+			if (var->varattno > baserel->max_attr)
+			{
+				/*
+				 * An out-of-range attno (> max_attr) is an FDW row-identity
+				 * pseudo-column (postgres_fdw's remote table OID for
+				 * UPDATE/DELETE; see add_vars_to_targetlist()) with no
+				 * attr_needed[]/attr_widths[] slot; like a ROWID_VAR, always
+				 * needed above every join.
+				 */
+				Assert(baserel->fdwroutine != NULL);
+				tuple_width += get_typavgwidth(var->vartype, var->vartypmod);
+			}
+			else
+			{
+				int			ndx;
 
-			/* Update reltarget width estimate from baserel's attr_widths */
-			tuple_width += baserel->attr_widths[ndx];
+				/* Is it still needed above this joinrel? */
+				ndx = var->varattno - baserel->min_attr;
+				if (!bms_nonempty_difference(baserel->attr_needed[ndx], relids))
+					continue;	/* nope, skip it */
+
+				/* Update reltarget width estimate from baserel's attr_widths */
+				tuple_width += baserel->attr_widths[ndx];
+			}
 		}
 
 		/*
 		 * Add the Var to the output.  If this join potentially nulls this
 		 * input, we have to update the Var's varnullingrels, which means
 		 * making a copy.  But note that we don't ever add nullingrel bits to
-		 * row identity Vars (cf. comments in setrefs.c).
+		 * row identity Vars (cf. comments in setrefs.c); that includes the
+		 * FDW row-identity pseudo-columns handled just above, which have an
+		 * out-of-range attribute number.
 		 */
-		if (can_null && var->varno != ROWID_VAR)
+		if (can_null && var->varno != ROWID_VAR &&
+			var->varattno <= baserel->max_attr)
 		{
 			var = copyObject(var);
 			/* See comments above to understand this logic */
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index f42263bf9c9..773dffb11f3 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -8020,6 +8020,29 @@ resolve_special_varno(Node *node, deparse_context *context,
 		if (!tle)
 			elog(ERROR, "bogus varattno for INDEX_VAR var: %d", var->varattno);
 
+		/*
+		 * A row-identity pseudo-column in fdw_scan_tlist has an out-of-range
+		 * attno and no catalog column to name, so when deparsing it as a
+		 * variable (get_special_variable) print its scan-tlist name rather
+		 * than chasing a non-existent attribute (which fails in
+		 * get_variable).
+		 */
+		if (callback == get_special_variable && tle->resname &&
+			IsA(tle->expr, Var))
+		{
+			Var		   *itvar = (Var *) tle->expr;
+
+			if (itvar->varno >= 1 &&
+				itvar->varno <= list_length(dpns->rtable) &&
+				itvar->varattno >
+				deparse_columns_fetch(itvar->varno, dpns)->num_cols)
+			{
+				appendStringInfoString(context->buf,
+									   quote_identifier(tle->resname));
+				return;
+			}
+		}
+
 		resolve_special_varno((Node *) tle->expr, context,
 							  callback, callback_arg);
 		return;
-- 
2.52.0



^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  2026-06-13 19:43                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-15 14:54                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-07-09 14:03                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-27 09:20                         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-08-27 14:36                           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-28 05:43                             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-09-04 13:37                               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jakub Wartak <jakub.wartak@enterprisedb.com>
  2026-09-07 03:07                                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table shihao zhong <zhong950419@gmail.com>
  2026-09-10 12:21                                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
@ 2026-09-16 12:02                                     ` Jakub Wartak <jakub.wartak@enterprisedb.com>
  2026-09-16 13:15                                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Jakub Wartak @ 2026-09-16 12:02 UTC (permalink / raw)
  To: Ajit Awekar <ajitpostgres@gmail.com>; Etsuro Fujita <etsuro.fujita@gmail.com>; Michael Paquier <michael@paquier.xyz>; +Cc: shihao zhong <zhong950419@gmail.com>; Nikita Malakhov <hukutoc@gmail.com>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

On Thu, Sep 10, 2026 at 2:21 PM Ajit Awekar <ajitpostgres@gmail.com> wrote:
>
> Hi Jakub,
>
> Thanks for the detailed repros. Attached patch fixes #1, #3, and #4;
> #2 as doc modification.
>
> #1/#3: The whole-row Var and per-column Vars overlapped, duplicating
> columns on the wire and misattributing conversion errors to the whole
> row instead of the failing column. Fixed by skipping the whole-row Var
> during deparse, fetching each column individually, and reconstructing
> the row locally -- restores per-column error context and drops the
> duplicate transfer.
>
> #4: Fixed by filtering to Vars of the scanned relation. Also fixed a
> zero-column-table assertion hit while testing this.
>
> #2:  Added a note on this as a known limitation to the
> postgres_fdw "Remote Query Optimization" docs, recommending
> whole-query shipping where possible and limiting affected row counts
> otherwise.
>
> Verified against your repros plus the full regression.
>

Hi Ajit,

I've took a quick look:

0. This thing crashes with edge case of no having no columns (sic!) and using
   trigger:

   DROP TABLE t1;
   DROP FOREIGN TABLE ft1;
   CREATE TABLE t1 (a int);
   INSERT INTO t1 VALUES (1),(2);
   CREATE FOREIGN TABLE ft1 (a int) SERVER loopback OPTIONS (table_name 't1');
   ALTER FOREIGN TABLE ft1 DROP COLUMN a; -- yay!
   CREATE OR REPLACE FUNCTION trg() RETURNS trigger LANGUAGE plpgsql
AS $$ BEGIN RAISE NOTICE 'trg % old=% new=%', TG_OP, OLD, NEW; IF
TG_OP = 'DELETE' THEN RETURN OLD; END IF; RETURN NEW; END $$;
   CREATE TRIGGER trg BEFORE DELETE ON ft1 FOR EACH ROW EXECUTE FUNCTION trg();
   EXPLAIN (VERBOSE, COSTS OFF) DELETE FROM ft1 WHERE random() <= 1;
   DELETE FROM ft1 WHERE random() <= 1;

   causes
   TRAP: failed Assert("relvalues != NULL"), File:
"../contrib/postgres_fdw/postgres_fdw.c", Line: 9268, PID: 382254
   postgres: test postgres [local]
DELETE(ExceptionalCondition+0x74)[0x5ebb9182d4e4]
   postgres_fdw.so(+0x1c0e6)[0x7ddec0d860e6]
   postgres_fdw.so(+0x1c25f)[0x7ddec0d8625f]
   postgres_fdw.so(+0x1f118)[0x7ddec0d89118]
   postgres: test postgres [local] DELETE(+0x37e092)[0x5ebb91513092]
   postgres: test postgres [local] DELETE(ExecScan+0x242)[0x5ebb914ffeb2]
   [..]

   I haven't investigated it further other than the EXPLAIN showing the
   difference with trigger and without it like this:
   -    Output: ctid, remotetableoid
   +    Output: ctid, remotetableoid, ft1.*
   which seems to be caused by the trigger itself (w/o trigger it wont
fetch "ft1.*
   but there are no columns there and so it will crash). Dunno if anybody would
   use something like that (what's the point), but someone by accident could
   remove all columns from fdw during maintenance and crashing whole server is
   not good.

1. In doc/src/sgml/postgres-fdw.sgml
   [..]
   ++  by first selecting the affected rows from the remote server and then
   ++  issuing a separate remote <command>UPDATE</command> or
   ++  <command>DELETE</command> for each row, identified by its
   ++  <literal>ctid</literal> and remote <literal>tableoid</literal>.  If the
   ++  remote table is the root of a partitioning hierarchy, each such per-row
   ++  command targets that root and so cannot benefit from partition pruning,
   ++  which can make this considerably slower than the whole-query case when

   perhaps it would read provide more details if the that last statement would
   go refering tableoid explictly, I mean:

   "If the remote table is the root of a partitioning hierarchy, each such per
   -row command targets that root and so cannot benefit from partition pruning
   >> (based on tableoid) <<, which can make this considerably slower than the
   whole-query case (..)" (see those new statement between >> and <<)

2. The commitmsg is a bit way too verbose to my taste and covers everything.
   As long as the source code provides explanations, I don't think we need to
   duplicate information that much about implementation in the commitmsg. It
   should also at least have Discussion: link, Authors , etc.

3. Wider question to the community: an open question persists that was earlier
   raised by Etsuro and Michael, should this be backpatched or not (altough
   they were discussing earlier iterations of the solution). With this patch
   the varnullingrels used by this patch seems to be PG16+, and this thing
   might alter the optimizer in some other ways. If that is not backpatchable
   then what we should do in earlier versions? Etsuro had patch in [1] to
   block dangerous thing (because it's bug/and we should block logical
   corruption of the remote side, shouldn't we?).

BTW: this still needs to be reviewed by "the elders of the internet"^H^H^H^H^H
people that understand the optimizer :)

-J.

[1] - https://www.postgresql.org/message-id/CAPmGK14KEFMTuQ1vYwWCo8SLks5rXv-56K-V%2BXMy4q8uQJvq1w%40mail.g...






^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  2026-06-13 19:43                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-15 14:54                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-07-09 14:03                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-27 09:20                         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-08-27 14:36                           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-28 05:43                             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-09-04 13:37                               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jakub Wartak <jakub.wartak@enterprisedb.com>
  2026-09-07 03:07                                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table shihao zhong <zhong950419@gmail.com>
  2026-09-10 12:21                                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-09-16 12:02                                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jakub Wartak <jakub.wartak@enterprisedb.com>
@ 2026-09-16 13:15                                       ` Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
  2026-09-22 08:44                                         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Manuel Reyes Bravo @ 2026-09-16 13:15 UTC (permalink / raw)
  To: Jakub Wartak <jakub.wartak@enterprisedb.com>; +Cc: Ajit Awekar <ajitpostgres@gmail.com>; Etsuro Fujita <etsuro.fujita@gmail.com>; Michael Paquier <michael@paquier.xyz>; shihao zhong <zhong950419@gmail.com>; Nikita Malakhov <hukutoc@gmail.com>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org

Ajit Awekar <ajitpostgres@gmail.com> wrote:
> Verified against your repros plus the full regression.

I tested v2 (cfbot branch for CF 6770, on master at 862092932c9) against
the same master without it, both with --enable-cassert, using a loopback
server.  The attached run_cases.sh runs each case file on a fresh
cluster.

What v2 fixes
-------------

- Etsuro's DELETE ... USING case: master deletes 20 rows instead of 10;
  with v2, exactly the 10 matching rows.

- The same corruption through triggers (case_d): remote partitioned
  table, foreign table with a dropped column in the middle, a column
  mapped with column_name, a different column order, a BEFORE UPDATE
  trigger changing NEW and an AFTER UPDATE trigger reading OLD.  On
  master, updating ids 1 and 2 leaves two rows with id 2 and none with
  id 1.  With v2 both rows are right, and OLD and NEW seen by the
  triggers are right, so the local whole-row reconstruction handles the
  dropped and renamed columns.

- A local partitioned table with one local and one foreign partition,
  UPDATE and DELETE with RETURNING tableoid and the whole row (case_g),
  and a self-join UPDATE (case_h): wrong rows on master, right with v2.

Jakub's zero-column case with a BEFORE DELETE trigger still fails the
assertion (postgres_fdw.c:9281 on this tree), while master runs it
fine.  Without the trigger, DELETE ... RETURNING ft1 on the same table
works on both.

What v2 does not fix: chained foreign tables
--------------------------------------------

When a foreign table points to another foreign table, which points to
the partitioned table, the wrong rows are still updated and deleted,
exactly as on master (case_f):

    CREATE TABLE r (id int, grp int, v text) PARTITION BY LIST (grp);
    CREATE TABLE r1 PARTITION OF r FOR VALUES IN (1);
    CREATE TABLE r2 PARTITION OF r FOR VALUES IN (2);
    INSERT INTO r VALUES (1, 1, 'one'), (2, 2, 'two');
    CREATE FOREIGN TABLE ft_mid (id int, grp int, v text)
      SERVER loopback OPTIONS (table_name 'r');
    CREATE FOREIGN TABLE ft_outer (id int, grp int, v text)
      SERVER loopback OPTIONS (table_name 'ft_mid');

    UPDATE ft_outer SET v = v || '!' WHERE id = 1 AND random() <= 1;

    SELECT tableoid::regclass, ctid, * FROM r ORDER BY id;
     tableoid | ctid  | id | grp |  v
    ----------+-------+----+-----+------
     r1       | (0,2) |  1 |   1 | one!
     r2       | (0,2) |  2 |   2 | one!

With log_statement = all the cause is visible.  The outer hop sends

    UPDATE public.ft_mid SET v = $3 WHERE ctid = $1 AND tableoid = $2
    Parameters: $1 = '(0,1)', $2 = '16408', $3 = 'one!'

where 16408 is ft_mid itself, so the tableoid condition matches every
row of ft_mid.  The middle hop then finds (0,1) in both partitions and
sends two updates, one per remote tableoid:

    UPDATE public.r SET v = $3 WHERE ctid = $1 AND tableoid = $2
    Parameters: $1 = '(0,1)', $2 = '16398', $3 = 'one!'
    UPDATE public.r SET v = $3 WHERE ctid = $1 AND tableoid = $2
    Parameters: $1 = '(0,1)', $2 = '16403', $3 = 'one!'

In the same case, DELETE ... WHERE id = 3 also deletes id 4.

So once the remote table is itself a foreign table, (ctid, tableoid)
is no longer a row identity.  I don't see how the outer hop could fix
that without knowing the remote relkind.  Maybe that is worth a
sentence next to the new partitioning note in postgres-fdw.sgml, or a
check of the remote relkind that refuses the non-direct path for it,
but I would rather ask what you think than propose one.  The EXPLAIN
output in this case now shows "WHERE ctid = $1 AND tableoid = $2",
which makes it look protected when it is not.

Regards,
Manu

Attachments:

  [application/x-shellscript] run_cases.sh (908B, ../../CA+bCEdCo-w5_34AAm6BTZ0PucNg3sycpM8-7L6Qn_KCwxSR30Q@mail.gmail.com/2-run_cases.sh)
  download

  [application/sql] case_f_chained_fdw.sql (1011B, ../../CA+bCEdCo-w5_34AAm6BTZ0PucNg3sycpM8-7L6Qn_KCwxSR30Q@mail.gmail.com/3-case_f_chained_fdw.sql)
  download

  [application/sql] case_d_dropped_and_renamed_columns.sql (1.5K, ../../CA+bCEdCo-w5_34AAm6BTZ0PucNg3sycpM8-7L6Qn_KCwxSR30Q@mail.gmail.com/4-case_d_dropped_and_renamed_columns.sql)
  download

  [application/sql] case_g_mixed_local_foreign_partitions.sql (1.2K, ../../CA+bCEdCo-w5_34AAm6BTZ0PucNg3sycpM8-7L6Qn_KCwxSR30Q@mail.gmail.com/5-case_g_mixed_local_foreign_partitions.sql)
  download

  [application/sql] case_h_self_join.sql (658B, ../../CA+bCEdCo-w5_34AAm6BTZ0PucNg3sycpM8-7L6Qn_KCwxSR30Q@mail.gmail.com/6-case_h_self_join.sql)
  download

^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  2026-06-13 19:43                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-15 14:54                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-07-09 14:03                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-27 09:20                         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-08-27 14:36                           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-28 05:43                             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-09-04 13:37                               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jakub Wartak <jakub.wartak@enterprisedb.com>
  2026-09-07 03:07                                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table shihao zhong <zhong950419@gmail.com>
  2026-09-10 12:21                                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-09-16 12:02                                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jakub Wartak <jakub.wartak@enterprisedb.com>
  2026-09-16 13:15                                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
@ 2026-09-22 08:44                                         ` Ajit Awekar <ajitpostgres@gmail.com>
  2026-09-22 14:45                                           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Manu <manuelreyesbravo@gmail.com>
  0 siblings, 1 reply; 30+ messages in thread

From: Ajit Awekar @ 2026-09-22 08:44 UTC (permalink / raw)
  To: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>; Jakub Wartak <jakub.wartak@enterprisedb.com>; +Cc: Etsuro Fujita <etsuro.fujita@gmail.com>; Michael Paquier <michael@paquier.xyz>; shihao zhong <zhong950419@gmail.com>; Nikita Malakhov <hukutoc@gmail.com>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; pgsql-hackers@lists.postgresql.org; rhaas@postgresql.org

 Thanks Jakub and Manu for your reviews.

Thanks to Robert Haas for suggesting the approach of carrying
the remote tableoid through fdw_scan_tlist offline.

  @Jakub: the zero-column+trigger crash is fixed (guarded in
  make_tuple_from_result_row(), falls back to shipping ROW() when
  there's nothing local to reconstruct from) — verified against your
  exact repro plus a partitioned variant. Commit message is updated.

  @Manu: went with your option (1) rather than (2) for case_f — a
  remote-relkind check to refuse the non-direct path adds a round trip
  for every foreign table, on every query, to guard a case most
  installations won't hit, so postgres-fdw.sgml now has an explicit
  paragraph: chaining foreign tables (ft -> ft -> partitioned) is not
  detected, the remote tableoid is just the middle table's own OID, and
  correctness there is still on the user.


Please find attached V3. Request a review.


Thanks & Best Regards,
Ajit

On Wed, 16 Sept 2026 at 18:45, Manuel Reyes Bravo <
manuelreyesbravo@gmail.com> wrote:

> Ajit Awekar <ajitpostgres@gmail.com> wrote:
> > Verified against your repros plus the full regression.
>
> I tested v2 (cfbot branch for CF 6770, on master at 862092932c9) against
> the same master without it, both with --enable-cassert, using a loopback
> server.  The attached run_cases.sh runs each case file on a fresh
> cluster.
>
> What v2 fixes
> -------------
>
> - Etsuro's DELETE ... USING case: master deletes 20 rows instead of 10;
>   with v2, exactly the 10 matching rows.
>
> - The same corruption through triggers (case_d): remote partitioned
>   table, foreign table with a dropped column in the middle, a column
>   mapped with column_name, a different column order, a BEFORE UPDATE
>   trigger changing NEW and an AFTER UPDATE trigger reading OLD.  On
>   master, updating ids 1 and 2 leaves two rows with id 2 and none with
>   id 1.  With v2 both rows are right, and OLD and NEW seen by the
>   triggers are right, so the local whole-row reconstruction handles the
>   dropped and renamed columns.
>
> - A local partitioned table with one local and one foreign partition,
>   UPDATE and DELETE with RETURNING tableoid and the whole row (case_g),
>   and a self-join UPDATE (case_h): wrong rows on master, right with v2.
>
> Jakub's zero-column case with a BEFORE DELETE trigger still fails the
> assertion (postgres_fdw.c:9281 on this tree), while master runs it
> fine.  Without the trigger, DELETE ... RETURNING ft1 on the same table
> works on both.
>
> What v2 does not fix: chained foreign tables
> --------------------------------------------
>
> When a foreign table points to another foreign table, which points to
> the partitioned table, the wrong rows are still updated and deleted,
> exactly as on master (case_f):
>
>     CREATE TABLE r (id int, grp int, v text) PARTITION BY LIST (grp);
>     CREATE TABLE r1 PARTITION OF r FOR VALUES IN (1);
>     CREATE TABLE r2 PARTITION OF r FOR VALUES IN (2);
>     INSERT INTO r VALUES (1, 1, 'one'), (2, 2, 'two');
>     CREATE FOREIGN TABLE ft_mid (id int, grp int, v text)
>       SERVER loopback OPTIONS (table_name 'r');
>     CREATE FOREIGN TABLE ft_outer (id int, grp int, v text)
>       SERVER loopback OPTIONS (table_name 'ft_mid');
>
>     UPDATE ft_outer SET v = v || '!' WHERE id = 1 AND random() <= 1;
>
>     SELECT tableoid::regclass, ctid, * FROM r ORDER BY id;
>      tableoid | ctid  | id | grp |  v
>     ----------+-------+----+-----+------
>      r1       | (0,2) |  1 |   1 | one!
>      r2       | (0,2) |  2 |   2 | one!
>
> With log_statement = all the cause is visible.  The outer hop sends
>
>     UPDATE public.ft_mid SET v = $3 WHERE ctid = $1 AND tableoid = $2
>     Parameters: $1 = '(0,1)', $2 = '16408', $3 = 'one!'
>
> where 16408 is ft_mid itself, so the tableoid condition matches every
> row of ft_mid.  The middle hop then finds (0,1) in both partitions and
> sends two updates, one per remote tableoid:
>
>     UPDATE public.r SET v = $3 WHERE ctid = $1 AND tableoid = $2
>     Parameters: $1 = '(0,1)', $2 = '16398', $3 = 'one!'
>     UPDATE public.r SET v = $3 WHERE ctid = $1 AND tableoid = $2
>     Parameters: $1 = '(0,1)', $2 = '16403', $3 = 'one!'
>
> In the same case, DELETE ... WHERE id = 3 also deletes id 4.
>
> So once the remote table is itself a foreign table, (ctid, tableoid)
> is no longer a row identity.  I don't see how the outer hop could fix
> that without knowing the remote relkind.  Maybe that is worth a
> sentence next to the new partitioning note in postgres-fdw.sgml, or a
> check of the remote relkind that refuses the non-direct path for it,
> but I would rather ask what you think than propose one.  The EXPLAIN
> output in this case now shows "WHERE ctid = $1 AND tableoid = $2",
> which makes it look protected when it is not.
>
> Regards,
> Manu
>

Attachments:

  [application/octet-stream] v3-0001-postgres_fdw-Disambiguate-row-identity-by-remote-tableoid.patch (105.2K, ../../CAER375NQEqXLQ5eqFJ8QpnpnYKCHeyi8YhG6CdAm6m=QGc6unw@mail.gmail.com/3-v3-0001-postgres_fdw-Disambiguate-row-identity-by-remote-tableoid.patch)
  download | inline diff:
From 5c73fa2290c48ba9d8fd20ba3303732bbb70f722 Mon Sep 17 00:00:00 2001
From: Ajit Awekar <ajit.awekar@enterprisedb.com>
Date: Wed, 26 Aug 2026 12:42:37 +0530
Subject: [PATCH] postgres_fdw: Disambiguate row identity by remote tableoid
 too

Non-direct UPDATE/DELETE on a foreign table identified the target row
on the remote server by ctid alone: "WHERE ctid = $1".  A ctid is only
unique within a single heap, so if the foreign table maps to a
partitioned (or inherited) table on the remote side, the same ctid can
exist in more than one partition, and the wrong row could be updated
or deleted.

Fix this by also fetching and transmitting the remote tableoid, so the
remote command becomes "WHERE ctid = $1 AND tableoid = $2".  The
tableoid is carried through the foreign scan's fdw_scan_tlist as a new
kind of FDW row-identity pseudo-column, generalizing the existing
handling of ROWID_VAR; see the comments at
RemoteTableOidAttributeNumber and its call sites for how this plugs
into the optimizer and ruleutils.c.

This adds an extra output column and WHERE clause to every non-direct
UPDATE/DELETE performed through postgres_fdw, including against remote
tables that are not partitioned, since postgres_fdw has no cheap way
to know that without an extra remote round trip.  This is documented
as a known limitation in the postgres_fdw "Remote Query Optimization"
section.

Suggested-by: Robert Haas <rhaas@postgresql.org>
Reviewed-by: Jakub Wartak <jakub.wartak@enterprisedb.com>
Reviewed-by: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Discussion: https://www.postgresql.org/message-id/flat/20250718175314.4513c00a%40karst
---
 contrib/postgres_fdw/deparse.c                | 100 +++-
 .../postgres_fdw/expected/postgres_fdw.out    | 467 ++++++++++--------
 contrib/postgres_fdw/postgres_fdw.c           | 376 +++++++++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   9 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  32 ++
 doc/src/sgml/postgres-fdw.sgml                |  34 ++
 src/backend/optimizer/path/costsize.c         |  14 +
 src/backend/optimizer/plan/initsplan.c        |  19 +
 src/backend/optimizer/plan/setrefs.c          |   9 +-
 src/backend/optimizer/util/relnode.c          |  40 +-
 src/backend/utils/adt/ruleutils.c             |  23 +
 11 files changed, 874 insertions(+), 249 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index ff9fe0f87e4..40d9a47336d 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -1391,11 +1391,14 @@ deparseSelectSql(List *tlist, bool is_subquery, List **retrieved_attrs,
 		 */
 		deparseSubqueryTargetList(context);
 	}
-	else if (IS_JOIN_REL(foreignrel) || IS_UPPER_REL(foreignrel))
+	else if (IS_JOIN_REL(foreignrel) || IS_UPPER_REL(foreignrel) ||
+			 tlist != NIL)
 	{
 		/*
 		 * For a join or upper relation the input tlist gives the list of
-		 * columns required to be fetched from the foreign server.
+		 * columns required to be fetched from the foreign server; likewise
+		 * for a base-relation scan given an explicit tlist (fdw_scan_tlist)
+		 * to fetch the remote tableoid (see postgresGetForeignPlan()).
 		 */
 		deparseExplicitTargetList(tlist, false, retrieved_attrs, context);
 	}
@@ -1721,13 +1724,55 @@ get_jointype_name(JoinType jointype)
 	return NULL;
 }
 
+/*
+ * Does tlist contain an individual real column (varattno > 0, excluding the
+ * RemoteTableOidAttributeNumber pseudo-column) of relid, besides a whole-row
+ * entry for it?  Used to tell whether a whole-row reference has anything to
+ * be locally reconstructed from (see deparseExplicitTargetList()).
+ */
+static bool
+tlist_has_other_column_of_rel(List *tlist, Index relid)
+{
+	ListCell   *lc;
+
+	foreach(lc, tlist)
+	{
+		TargetEntry *tle = lfirst_node(TargetEntry, lc);
+
+		if (IsA(tle->expr, Var) &&
+			((Var *) tle->expr)->varno == relid &&
+			((Var *) tle->expr)->varattno > 0 &&
+			((Var *) tle->expr)->varattno != RemoteTableOidAttributeNumber)
+			return true;
+	}
+	return false;
+}
+
 /*
  * Deparse given targetlist and append it to context->buf.
  *
  * tlist is list of TargetEntry's which in turn contain Var nodes.
  *
- * retrieved_attrs is the list of continuously increasing integers starting
- * from 1. It has same number of entries as tlist.
+ * retrieved_attrs is normally the list of continuously increasing integers
+ * starting from 1, with the same number of entries as tlist.  There are two
+ * exceptions, both specific to a base relation's SELECT list built as an
+ * explicit fdw_scan_tlist for row-identity purposes:
+ *
+ * - the local-tableoid pseudo-entry (a Var on TableOidAttributeNumber): its
+ *   value is already known at plan time, so instead of shipping it to the
+ *   remote server as a literal Const, it's skipped here and filled in
+ *   locally afterward, by make_tuple_from_result_row().
+ *
+ * - a plain whole-row reference to the relation itself (a Var on attno 0),
+ *   when its columns are also being fetched individually elsewhere in this
+ *   tlist (e.g. for EPQ recheck): shipping the whole row too would transfer
+ *   those columns twice, so it's skipped here and reconstructed locally
+ *   afterward instead, again by make_tuple_from_result_row().  Absent such
+ *   sibling columns (e.g. the relation has none), it is shipped as a
+ *   literal ROW(...) as usual, since there is nothing to reconstruct it
+ *   from.  There can be more than one such whole-row entry for the same
+ *   relation (e.g. one from the query itself, another added by core code
+ *   for row identity); each is skipped and reconstructed independently.
  *
  * This is used for both SELECT and RETURNING targetlists; the is_returning
  * parameter is true only for a RETURNING targetlist.
@@ -1740,7 +1785,9 @@ deparseExplicitTargetList(List *tlist,
 {
 	ListCell   *lc;
 	StringInfo	buf = context->buf;
+	bool		is_base_rel = !is_returning && IS_SIMPLE_REL(context->scanrel);
 	int			i = 0;
+	bool		first = true;
 
 	*retrieved_attrs = NIL;
 
@@ -1748,18 +1795,38 @@ deparseExplicitTargetList(List *tlist,
 	{
 		TargetEntry *tle = lfirst_node(TargetEntry, lc);
 
-		if (i > 0)
+		i++;
+
+		/* Local tableoid: known locally, skip the remote round trip. */
+		if (is_base_rel &&
+			IsA(tle->expr, Var) &&
+			((Var *) tle->expr)->varattno == TableOidAttributeNumber)
+			continue;
+
+		/*
+		 * Whole-row reference to this relation with a sibling column
+		 * elsewhere in tlist: skip the redundant ROW(...) and reconstruct
+		 * it locally instead (see make_tuple_from_result_row()).
+		 */
+		if (is_base_rel &&
+			IsA(tle->expr, Var) &&
+			((Var *) tle->expr)->varattno == InvalidAttrNumber &&
+			((Var *) tle->expr)->varno == context->scanrel->relid &&
+			tlist_has_other_column_of_rel(tlist, context->scanrel->relid))
+			continue;
+
+		if (!first)
 			appendStringInfoString(buf, ", ");
 		else if (is_returning)
 			appendStringInfoString(buf, " RETURNING ");
+		first = false;
 
 		deparseExpr((Expr *) tle->expr, context);
 
-		*retrieved_attrs = lappend_int(*retrieved_attrs, i + 1);
-		i++;
+		*retrieved_attrs = lappend_int(*retrieved_attrs, i);
 	}
 
-	if (i == 0 && !is_returning)
+	if (first && !is_returning)
 		appendStringInfoString(buf, "NULL");
 }
 
@@ -2364,7 +2431,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 	deparseRelation(buf, rel);
 	appendStringInfoString(buf, " SET ");
 
-	pindex = 2;					/* ctid is always the first param */
+	pindex = 3;					/* ctid ($1) and tableoid ($2) come first */
 	first = true;
 	foreach(lc, targetAttrs)
 	{
@@ -2384,7 +2451,7 @@ deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 			pindex++;
 		}
 	}
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_update_after_row,
@@ -2502,7 +2569,7 @@ deparseDeleteSql(StringInfo buf, RangeTblEntry *rte,
 {
 	appendStringInfoString(buf, "DELETE FROM ");
 	deparseRelation(buf, rel);
-	appendStringInfoString(buf, " WHERE ctid = $1");
+	appendStringInfoString(buf, " WHERE ctid = $1 AND tableoid = $2");
 
 	deparseReturningList(buf, rte, rtindex, rel,
 						 rel->trigdesc && rel->trigdesc->trig_delete_after_row,
@@ -2889,6 +2956,17 @@ deparseColumnRef(StringInfo buf, int varno, int varattno, RangeTblEntry *rte,
 			ADD_REL_QUALIFIER(buf, varno);
 		appendStringInfoString(buf, "ctid");
 	}
+	else if (varattno == RemoteTableOidAttributeNumber)
+	{
+		/*
+		 * Pseudo-column carrying the remote table OID as part of the row
+		 * identity for UPDATE/DELETE (see postgresAddForeignUpdateTargets);
+		 * fetch it as the remote "tableoid" system column.
+		 */
+		if (qualify_col)
+			ADD_REL_QUALIFIER(buf, varno);
+		appendStringInfoString(buf, "tableoid");
+	}
 	else if (varattno < 0)
 	{
 		/*
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 739f43af7bb..068ec29921a 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -5953,14 +5953,14 @@ BEGIN;
   EXPLAIN (verbose, costs off)
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
     RETURNING old.*, new.*;                                                         -- can't be pushed down
-                                                                  QUERY PLAN                                                                  
-----------------------------------------------------------------------------------------------------------------------------------------------
+                                                                       QUERY PLAN                                                                       
+--------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.c1, old.c2, old.c3, old.c4, old.c5, old.c6, old.c7, old.c8, new.c1, new.c2, new.c3, new.c4, new.c5, new.c6, new.c7, new.c8
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c3 = $3 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c3 = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan on public.ft2
-         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, ft2.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
+         Output: (c2 + 400), (c3 || '_update7b'::text), ctid, remotetableoid, ft2.*
+         Remote SQL: SELECT c2, c3, ctid, tableoid, "C 1", c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 7)) FOR UPDATE
 (6 rows)
 
   UPDATE ft2 SET c2 = c2 + 400, c3 = c3 || '_update7b' WHERE c1 % 10 = 7 AND c1 < 40
@@ -6107,14 +6107,14 @@ DELETE FROM ft2 WHERE c1 % 10 = 5 RETURNING c1, c4;
 BEGIN;
   EXPLAIN (verbose, costs off)
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;               -- can't be pushed down
-                                                QUERY PLAN                                                 
------------------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: old.c1, c4
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c4
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c4
    ->  Foreign Scan on public.ft2
-         Output: ctid
-         Remote SQL: SELECT ctid FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
+         Output: ctid, remotetableoid
+         Remote SQL: SELECT ctid, tableoid, "C 1" FROM "S 1"."T 1" WHERE (("C 1" < 40)) AND ((("C 1" % 10) = 6)) FOR UPDATE
 (6 rows)
 
   DELETE FROM ft2 WHERE c1 % 10 = 6 AND c1 < 40 RETURNING old.c1, c4;
@@ -7057,27 +7057,27 @@ BEGIN;
     FROM ft4 INNER JOIN ft5 ON (ft4.c1 = ft5.c1)
     WHERE ft2.c1 > 1200 AND ft2.c2 = ft4.c1
     RETURNING old, new, ft2, ft2.*, ft4, ft4.*;  -- can't be pushed down
-                                                                                                                                                                                                                                     QUERY PLAN                                                                                                                                                                                                                                     
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                                                                           QUERY PLAN                                                                                                                                                                                                                                            
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: old.*, new.*, ft2.*, ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.*, ft4.c1, ft4.c2, ft4.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Foreign Scan
-         Output: 'bar'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+         Output: 'bar'::text, ft2.ctid, remotetableoid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r2.c2, r2.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 1200)))) INNER JOIN "S 1"."T 4" r3 ON (((r2.c1 = r3.c1)))) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
+               Output: ft2.ctid, remotetableoid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3
                Join Filter: (ft4.c1 = ft5.c1)
                ->  Sort
-                     Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                     Output: ft2.ctid, remotetableoid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                      Sort Key: ft2.c2
                      ->  Hash Join
-                           Output: ft2.ctid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
+                           Output: ft2.ctid, remotetableoid, ft2.*, ft2.c2, ft4.*, ft4.c1, ft4.c2, ft4.c3
                            Hash Cond: (ft2.c2 = ft4.c1)
                            ->  Foreign Scan on public.ft2
-                                 Output: ft2.ctid, ft2.*, ft2.c2
-                                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
+                                 Output: ft2.ctid, remotetableoid, ft2.*, ft2.c2
+                                 Remote SQL: SELECT ctid, tableoid, c2, "C 1", c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" > 1200)) FOR UPDATE
                            ->  Hash
                                  Output: ft4.*, ft4.c1, ft4.c2, ft4.c3
                                  ->  Foreign Scan on public.ft4
@@ -7155,13 +7155,13 @@ UPDATE ft2 AS target SET (c2, c7) = (
         FROM ft2 AS src
         WHERE target.c1 = src.c1
 ) WHERE c1 > 1100;
-                                                         QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
+                                                                 QUERY PLAN                                                                  
+---------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 target
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2, c7 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3, c7 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.ft2 target
-         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, target.*
-         Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
+         Output: (SubPlan multiexpr_1).col1, (SubPlan multiexpr_1).col2, (rescan SubPlan multiexpr_1), target.ctid, remotetableoid, target.*
+         Remote SQL: SELECT "C 1", ctid, tableoid, c2, c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" > 1100)) FOR UPDATE
          SubPlan multiexpr_1
            ->  Foreign Scan on public.ft2 src
                  Output: (src.c2 * 10), src.c7
@@ -7183,20 +7183,20 @@ UPDATE ft2 AS target SET (c2) = (
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE ft2 d SET c2 = CASE WHEN random() >= 0 THEN d.c2 ELSE 0 END
   FROM ft2 AS t WHERE d.c1 = t.c1 AND d.c1 > 1000;
-                                                                                                                                                                                       QUERY PLAN                                                                                                                                                                                        
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                                              QUERY PLAN                                                                                                                                                                                              
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2 d
-   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2 WHERE ctid = $1
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan
-         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, d.*, t.*
+         Output: CASE WHEN (random() >= '0'::double precision) THEN d.c2 ELSE 0 END, d.ctid, remotetableoid, d.*, t.*
          Relations: (public.ft2 d) INNER JOIN (public.ft2 t)
-         Remote SQL: SELECT r1.c2, r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.c2, r1.ctid, r1.tableoid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1."C 1" > 1000)))) FOR UPDATE OF r1
          ->  Hash Join
-               Output: d.c2, d.ctid, d.*, t.*
+               Output: d.c2, d.ctid, remotetableoid, d.*, t.*
                Hash Cond: (d.c1 = t.c1)
                ->  Foreign Scan on public.ft2 d
-                     Output: d.c2, d.ctid, d.*, d.c1
-                     Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
+                     Output: d.c2, d.ctid, remotetableoid, d.*, d.c1
+                     Remote SQL: SELECT c2, ctid, tableoid, "C 1", c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" > 1000)) ORDER BY "C 1" ASC NULLS LAST FOR UPDATE
                ->  Hash
                      Output: t.*, t.c1
                      ->  Foreign Scan on public.ft2 t
@@ -7216,19 +7216,19 @@ EXPLAIN (verbose, costs off)
 WITH cte AS (
   UPDATE ft2 SET c3 = 'bar' WHERE postgres_fdw_abs(c1) > 2000 RETURNING *
 ) SELECT * FROM cte ORDER BY c1;          -- can't be pushed down
-                                                    QUERY PLAN                                                    
-------------------------------------------------------------------------------------------------------------------
+                                                             QUERY PLAN                                                             
+------------------------------------------------------------------------------------------------------------------------------------
  Sort
    Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
    Sort Key: cte.c1
    CTE cte
      ->  Update on public.ft2
            Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8
-           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+           Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
            ->  Foreign Scan on public.ft2
-                 Output: 'bar'::text, ft2.ctid, ft2.*
+                 Output: 'bar'::text, ft2.ctid, remotetableoid, ft2.*
                  Filter: (postgres_fdw_abs(ft2.c1) > 2000)
-                 Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" FOR UPDATE
+                 Remote SQL: SELECT ctid, tableoid, "C 1", c2, c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" FOR UPDATE
    ->  CTE Scan on cte
          Output: cte.c1, cte.c2, cte.c3, cte.c4, cte.c5, cte.c6, cte.c7, cte.c8
 (13 rows)
@@ -7259,13 +7259,13 @@ UPDATE ft2 SET c3 = 'baz'
 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
-   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
+   Remote SQL: UPDATE "S 1"."T 1" SET c3 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3, c4, c5, c6, c7, c8
    ->  Nested Loop
-         Output: 'baz'::text, ft2.ctid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
+         Output: 'baz'::text, ft2.ctid, remotetableoid, ft2.*, ft4.*, ft5.*, ft4.c1, ft4.c2, ft4.c3, ft5.c1, ft5.c2, ft5.c3
          Join Filter: (ft2.c2 === ft4.c1)
          ->  Foreign Scan on public.ft2
-               Output: ft2.ctid, ft2.*, ft2.c2
-               Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+               Output: ft2.ctid, remotetableoid, ft2.*, ft2.c2
+               Remote SQL: SELECT ctid, tableoid, c2, "C 1", c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
          ->  Foreign Scan
                Output: ft4.*, ft4.c1, ft4.c2, ft4.c3, ft5.*, ft5.c1, ft5.c2, ft5.c3
                Relations: (public.ft4) INNER JOIN (public.ft5)
@@ -7297,24 +7297,24 @@ DELETE FROM ft2
   USING ft4 INNER JOIN ft5 ON (ft4.c1 === ft5.c1)
   WHERE ft2.c1 > 2000 AND ft2.c2 = ft4.c1
   RETURNING ft2.c1, ft2.c2, ft2.c3;       -- can't be pushed down
-                                                                                                                                                                     QUERY PLAN                                                                                                                                                                     
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                                                                                                                           QUERY PLAN                                                                                                                                                                            
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Delete on public.ft2
    Output: ft2.c1, ft2.c2, ft2.c3
-   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 RETURNING "C 1", c2, c3
+   Remote SQL: DELETE FROM "S 1"."T 1" WHERE ctid = $1 AND tableoid = $2 RETURNING "C 1", c2, c3
    ->  Foreign Scan
-         Output: ft2.ctid, ft4.*, ft5.*
+         Output: ft2.ctid, remotetableoid, ft4.*, ft5.*
          Filter: (ft4.c1 === ft5.c1)
          Relations: ((public.ft2) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-         Remote SQL: SELECT r1.ctid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
+         Remote SQL: SELECT r1.ctid, r1.tableoid, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2.c1, r2.c2, r2.c3) END, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r2.c1, r3.c1 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r2 ON (((r1.c2 = r2.c1)) AND ((r1."C 1" > 2000)))) INNER JOIN "S 1"."T 4" r3 ON (TRUE)) FOR UPDATE OF r1
          ->  Nested Loop
-               Output: ft2.ctid, ft4.*, ft5.*, ft4.c1, ft5.c1
+               Output: ft2.ctid, remotetableoid, ft4.*, ft5.*, ft4.c1, ft5.c1
                ->  Nested Loop
-                     Output: ft2.ctid, ft4.*, ft4.c1
+                     Output: ft2.ctid, remotetableoid, ft4.*, ft4.c1
                      Join Filter: (ft2.c2 = ft4.c1)
                      ->  Foreign Scan on public.ft2
-                           Output: ft2.ctid, ft2.c2
-                           Remote SQL: SELECT c2, ctid FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
+                           Output: ft2.ctid, remotetableoid, ft2.c2
+                           Remote SQL: SELECT ctid, tableoid, c2, "C 1" FROM "S 1"."T 1" WHERE (("C 1" > 2000)) FOR UPDATE
                      ->  Foreign Scan on public.ft4
                            Output: ft4.*, ft4.c1
                            Remote SQL: SELECT c1, c2, c3 FROM "S 1"."T 3"
@@ -7826,19 +7826,19 @@ SET enable_hashjoin TO false;
 SET enable_material TO false;
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
-                                   QUERY PLAN                                   
---------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.remt2
    Output: remt2.c1, remt2.c2
-   Remote SQL: UPDATE public.loct2 SET c2 = $2 WHERE ctid = $1 RETURNING c1, c2
+   Remote SQL: UPDATE public.loct2 SET c2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING c1, c2
    ->  Nested Loop
-         Output: (remt2.c2 || remt2.c2), remt2.ctid, remt2.*, loct1.ctid
+         Output: (remt2.c2 || remt2.c2), remt2.ctid, remotetableoid, remt2.*, loct1.ctid
          Join Filter: (remt2.c1 = loct1.c1)
          ->  Seq Scan on public.loct1
                Output: loct1.ctid, loct1.c1
          ->  Foreign Scan on public.remt2
-               Output: remt2.c2, remt2.ctid, remt2.*, remt2.c1
-               Remote SQL: SELECT c1, c2, ctid FROM public.loct2 FOR UPDATE
+               Output: remt2.c2, remt2.ctid, remotetableoid, remt2.*, remt2.c1
+               Remote SQL: SELECT c2, ctid, tableoid, c1 FROM public.loct2 FOR UPDATE
 (11 rows)
 
 UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1 RETURNING remt2.*;
@@ -7895,17 +7895,17 @@ prepare fdw_part_upd2(int) as
     returning tableoid::regclass, a, b;
 explain (verbose, costs off)
     execute fdw_part_upd2(2);
-                                                                       QUERY PLAN                                                                       
---------------------------------------------------------------------------------------------------------------------------------------------------------
+                                                                               QUERY PLAN                                                                               
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Update on public.fdw_part_update
    Output: (fdw_part_update_1.tableoid)::regclass, fdw_part_update_1.a, fdw_part_update_1.b
    Foreign Update on public.fdw_part_update_p2 fdw_part_update_2
-     Remote SQL: UPDATE public.fdw_part_update_remote SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.fdw_part_update_remote SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Append
          Subplans Removed: 1
          ->  Foreign Scan on public.fdw_part_update_p2 fdw_part_update_2
-               Output: ((fdw_part_update_2.b + ((random())::integer * 0)) + 1), fdw_part_update_2.tableoid, fdw_part_update_2.ctid, fdw_part_update_2.*
-               Remote SQL: SELECT a, b, ctid FROM public.fdw_part_update_remote WHERE ((a = $1::integer)) FOR UPDATE
+               Output: ((fdw_part_update_2.b + ((random())::integer * 0)) + 1), fdw_part_update_2.tableoid, fdw_part_update_2.ctid, remotetableoid, fdw_part_update_2.*
+               Remote SQL: SELECT b, ctid, tableoid, a FROM public.fdw_part_update_remote WHERE ((a = $1::integer)) FOR UPDATE
 (9 rows)
 
 execute fdw_part_upd2(2);
@@ -8063,13 +8063,13 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 5), foreign_tbl.ctid, remotetableoid, foreign_tbl.*
+         Remote SQL: SELECT b, ctid, tableoid, a FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -8077,13 +8077,13 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                      QUERY PLAN                                       
----------------------------------------------------------------------------------------
+                                            QUERY PLAN                                            
+--------------------------------------------------------------------------------------------------
  Update on public.foreign_tbl
-   Remote SQL: UPDATE public.base_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+   Remote SQL: UPDATE public.base_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl
-         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, foreign_tbl.*
-         Remote SQL: SELECT a, b, ctid FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (foreign_tbl.b + 15), foreign_tbl.ctid, remotetableoid, foreign_tbl.*
+         Remote SQL: SELECT b, ctid, tableoid, a FROM public.base_tbl WHERE ((a < b)) FOR UPDATE
 (5 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -8176,14 +8176,14 @@ SELECT * FROM foreign_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 5;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                   
+----------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 5), parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid, parent_tbl_1.*
+         Remote SQL: SELECT b, ctid, tableoid, a FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 5; -- should fail
@@ -8191,14 +8191,14 @@ ERROR:  new row violates check option for view "rw_view"
 DETAIL:  Failing row contains (20, 20).
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = b + 15;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                    
+-----------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.child_tbl SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.child_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
+         Output: (parent_tbl_1.b + 15), parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid, parent_tbl_1.*
+         Remote SQL: SELECT b, ctid, tableoid, a FROM public.child_tbl WHERE ((a < b)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = b + 15; -- ok
@@ -8247,14 +8247,14 @@ CREATE VIEW rw_view AS SELECT * FROM parent_tbl WHERE a < 5 WITH CHECK OPTION;
 INSERT INTO parent_tbl (a) VALUES(1),(5);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE rw_view SET b = 'text', c = 123.456;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                    
+-----------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.child_foreign parent_tbl_1
-     Remote SQL: UPDATE public.child_local SET b = $2, c = $3 WHERE ctid = $1 RETURNING a
+     Remote SQL: UPDATE public.child_local SET b = $3, c = $4 WHERE ctid = $1 AND tableoid = $2 RETURNING a
    ->  Foreign Scan on public.child_foreign parent_tbl_1
-         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT b, c, a, ctid FROM public.child_local WHERE ((a < 5)) FOR UPDATE
+         Output: 'text'::text, 123.456, parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid, parent_tbl_1.*
+         Remote SQL: SELECT ctid, tableoid, a, b, c FROM public.child_local WHERE ((a < 5)) FOR UPDATE
 (6 rows)
 
 UPDATE rw_view SET b = 'text', c = 123.456;
@@ -8333,13 +8333,13 @@ insert into grem1 (a) values (1), (2);
 insert into grem1 (a) values (1), (2);
 explain (verbose, costs off)
 update grem1 set a = 22 where a = 2;
-                                       QUERY PLAN                                       
-----------------------------------------------------------------------------------------
+                                                QUERY PLAN                                                
+----------------------------------------------------------------------------------------------------------
  Update on public.grem1
-   Remote SQL: UPDATE public.gloc1 SET a = $2, b = DEFAULT, c = DEFAULT WHERE ctid = $1
+   Remote SQL: UPDATE public.gloc1 SET a = $3, b = DEFAULT, c = DEFAULT WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.grem1
-         Output: 22, ctid, grem1.*
-         Remote SQL: SELECT a, b, c, ctid FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
+         Output: 22, ctid, remotetableoid, grem1.*
+         Remote SQL: SELECT ctid, tableoid, a, b, c FROM public.gloc1 WHERE ((a = 2)) FOR UPDATE
 (5 rows)
 
 update grem1 set a = 22 where a = 2;
@@ -8666,13 +8666,13 @@ SELECT * from loc1;
 
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f1 = 10;          -- all columns should be transmitted
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: 10, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: 10, ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, f1, f2 FROM public.loc1 FOR UPDATE
 (5 rows)
 
 UPDATE rem1 set f1 = 10;
@@ -8814,12 +8814,12 @@ DELETE FROM rem1;                 -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1 WHERE false;     -- currently can't be pushed down
-                      QUERY PLAN                       
--------------------------------------------------------
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: ctid
+         Output: ctid, NULL::oid
          Replaces: Scan on rem1
          One-Time Filter: false
 (6 rows)
@@ -8920,13 +8920,13 @@ BEFORE UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                              QUERY PLAN                               
------------------------------------------------------------------------
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f1 = $2, f2 = $3 WHERE ctid = $1
+   Remote SQL: UPDATE public.loc1 SET f1 = $3, f2 = $4 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, f1, f2 FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -8944,13 +8944,13 @@ AFTER UPDATE ON rem1
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 EXPLAIN (verbose, costs off)
 UPDATE rem1 set f2 = '';          -- can't be pushed down
-                                  QUERY PLAN                                   
--------------------------------------------------------------------------------
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
  Update on public.rem1
-   Remote SQL: UPDATE public.loc1 SET f2 = $2 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: UPDATE public.loc1 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ''::text, ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ''::text, ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, f1, f2 FROM public.loc1 FOR UPDATE
 (5 rows)
 
 EXPLAIN (verbose, costs off)
@@ -8978,13 +8978,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                             QUERY PLAN                              
----------------------------------------------------------------------
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, f1, f2 FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_before_delete ON rem1;
@@ -9002,13 +9002,13 @@ UPDATE rem1 set f2 = '';          -- can be pushed down
 
 EXPLAIN (verbose, costs off)
 DELETE FROM rem1;                 -- can't be pushed down
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
  Delete on public.rem1
-   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 RETURNING f1, f2
+   Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2
    ->  Foreign Scan on public.rem1
-         Output: ctid, rem1.*
-         Remote SQL: SELECT f1, f2, ctid FROM public.loc1 FOR UPDATE
+         Output: ctid, remotetableoid, rem1.*
+         Remote SQL: SELECT ctid, tableoid, f1, f2 FROM public.loc1 FOR UPDATE
 (5 rows)
 
 DROP TRIGGER trig_row_after_delete ON rem1;
@@ -9045,28 +9045,28 @@ CONTEXT:  COPY parent_tbl, line 1: "AAA	42"
 ALTER SERVER loopback OPTIONS (DROP batch_size);
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                   
+----------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Foreign Update on public.foreign_tbl parent_tbl_1
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, parent_tbl_1.*
-         Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+         Output: (parent_tbl_1.b + 1), parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid, parent_tbl_1.*
+         Remote SQL: SELECT b, ctid, tableoid, a FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                            QUERY PLAN                            
-------------------------------------------------------------------
+                                   QUERY PLAN                                   
+--------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Foreign Delete on public.foreign_tbl parent_tbl_1
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
    ->  Foreign Scan on public.foreign_tbl parent_tbl_1
-         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
-         Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
+         Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, remotetableoid
+         Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
 (6 rows)
 
 DELETE FROM parent_tbl;
@@ -9084,38 +9084,38 @@ CREATE TRIGGER parent_tbl_delete_trig
   FOR EACH STATEMENT EXECUTE PROCEDURE trigger_func();
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE parent_tbl SET b = b + 1;
-                                              QUERY PLAN                                              
-------------------------------------------------------------------------------------------------------
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
  Update on public.parent_tbl
    Update on public.parent_tbl parent_tbl_1
    Foreign Update on public.foreign_tbl parent_tbl_2
-     Remote SQL: UPDATE public.local_tbl SET b = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.local_tbl SET b = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Result
-         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::record)
+         Output: (parent_tbl.b + 1), parent_tbl.tableoid, parent_tbl.ctid, (NULL::oid), (NULL::record)
          ->  Append
                ->  Seq Scan on public.parent_tbl parent_tbl_1
-                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::record
+                     Output: parent_tbl_1.b, parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, parent_tbl_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.local_tbl FOR UPDATE
+                     Output: parent_tbl_2.b, parent_tbl_2.tableoid, parent_tbl_2.ctid, remotetableoid, parent_tbl_2.*
+                     Remote SQL: SELECT b, ctid, tableoid, a FROM public.local_tbl FOR UPDATE
 (12 rows)
 
 UPDATE parent_tbl SET b = b + 1;
 ERROR:  cannot collect transition tuples from child foreign tables
 EXPLAIN (VERBOSE, COSTS OFF)
 DELETE FROM parent_tbl;
-                               QUERY PLAN                               
-------------------------------------------------------------------------
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
  Delete on public.parent_tbl
    Delete on public.parent_tbl parent_tbl_1
    Foreign Delete on public.foreign_tbl parent_tbl_2
-     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1
+     Remote SQL: DELETE FROM public.local_tbl WHERE ctid = $1 AND tableoid = $2
    ->  Append
          ->  Seq Scan on public.parent_tbl parent_tbl_1
-               Output: parent_tbl_1.tableoid, parent_tbl_1.ctid
+               Output: parent_tbl_1.tableoid, parent_tbl_1.ctid, NULL::oid
          ->  Foreign Scan on public.foreign_tbl parent_tbl_2
-               Output: parent_tbl_2.tableoid, parent_tbl_2.ctid
-               Remote SQL: SELECT ctid FROM public.local_tbl FOR UPDATE
+               Output: parent_tbl_2.tableoid, parent_tbl_2.ctid, remotetableoid
+               Remote SQL: SELECT ctid, tableoid FROM public.local_tbl FOR UPDATE
 (10 rows)
 
 DELETE FROM parent_tbl;
@@ -9458,22 +9458,22 @@ drop table foo2child;
 -- Check UPDATE with inherited target and an inherited source table
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 where f1 in (select f1 from foo);
-                                              QUERY PLAN                                               
--------------------------------------------------------------------------------------------------------
+                                                     QUERY PLAN                                                     
+--------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Hash Join
-         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::record), foo.*, foo.tableoid
+         Output: (bar.f2 + 100), foo.ctid, bar.tableoid, bar.ctid, (NULL::oid), (NULL::record), foo.*, foo.tableoid
          Inner Unique: true
          Hash Cond: (bar.f1 = foo.f1)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, remotetableoid, bar_2.*
+                     Remote SQL: SELECT f2, f1, ctid, tableoid, f3 FROM public.loct2 FOR UPDATE
          ->  Hash
                Output: foo.ctid, foo.f1, foo.*, foo.tableoid
                ->  HashAggregate
@@ -9505,24 +9505,24 @@ update bar set f2 = f2 + 100
 from
   ( select f1 from foo union all select f1+3 from foo ) ss
 where bar.f1 = ss.f1;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                QUERY PLAN                                                 
+-----------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f2 = $2 WHERE ctid = $1
+     Remote SQL: UPDATE public.loct2 SET f2 = $3 WHERE ctid = $1 AND tableoid = $2
    ->  Merge Join
-         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), (ROW(foo.f1)), bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
          Merge Cond: (bar.f1 = foo.f1)
          ->  Sort
-               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::record)
+               Output: bar.f2, bar.f1, bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
                Sort Key: bar.f1
                ->  Append
                      ->  Seq Scan on public.bar bar_1
-                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::record
+                           Output: bar_1.f2, bar_1.f1, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                      ->  Foreign Scan on public.bar2 bar_2
-                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, bar_2.*
-                           Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                           Output: bar_2.f2, bar_2.f1, bar_2.tableoid, bar_2.ctid, remotetableoid, bar_2.*
+                           Remote SQL: SELECT f2, f1, ctid, tableoid, f3 FROM public.loct2 FOR UPDATE
          ->  Sort
                Output: (ROW(foo.f1)), foo.f1
                Sort Key: foo.f1
@@ -9671,7 +9671,7 @@ delete from foo where f1 < 5 returning *;
    Foreign Delete on public.foo2 foo_2
    ->  Append
          ->  Index Scan using i_foo_f1 on public.foo foo_1
-               Output: foo_1.tableoid, foo_1.ctid
+               Output: foo_1.tableoid, foo_1.ctid, NULL::oid
                Index Cond: (foo_1.f1 < 5)
          ->  Foreign Delete on public.foo2 foo_2
                Remote SQL: DELETE FROM public.loct1 WHERE ((f1 < 5)) RETURNING f1, f2
@@ -9689,17 +9689,17 @@ delete from foo where f1 < 5 returning *;
 
 explain (verbose, costs off)
 update bar set f2 = f2 + 100 returning *;
-                                        QUERY PLAN                                        
-------------------------------------------------------------------------------------------
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
  Update on public.bar
    Output: bar_1.f1, bar_1.f2
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Update on public.bar2 bar_2
                      Remote SQL: UPDATE public.loct2 SET f2 = (f2 + 100) RETURNING f1, f2
 (11 rows)
@@ -9724,20 +9724,20 @@ AFTER UPDATE OR DELETE ON bar2
 FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
 explain (verbose, costs off)
 update bar set f2 = f2 + 100;
-                                               QUERY PLAN                                               
---------------------------------------------------------------------------------------------------------
+                                                        QUERY PLAN                                                        
+--------------------------------------------------------------------------------------------------------------------------
  Update on public.bar
    Update on public.bar bar_1
    Foreign Update on public.bar2 bar_2
-     Remote SQL: UPDATE public.loct2 SET f1 = $2, f2 = $3, f3 = $4 WHERE ctid = $1 RETURNING f1, f2, f3
+     Remote SQL: UPDATE public.loct2 SET f1 = $3, f2 = $4, f3 = $5 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
    ->  Result
-         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::record)
+         Output: (bar.f2 + 100), bar.tableoid, bar.ctid, (NULL::oid), (NULL::record)
          ->  Append
                ->  Seq Scan on public.bar bar_1
-                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::record
+                     Output: bar_1.f2, bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.bar2 bar_2
-                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, bar_2.*
-                     Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 FOR UPDATE
+                     Output: bar_2.f2, bar_2.tableoid, bar_2.ctid, remotetableoid, bar_2.*
+                     Remote SQL: SELECT f2, ctid, tableoid, f1, f3 FROM public.loct2 FOR UPDATE
 (12 rows)
 
 update bar set f2 = f2 + 100;
@@ -9755,19 +9755,19 @@ NOTICE:  trig_row_after(23, skidoo) AFTER ROW UPDATE ON bar2
 NOTICE:  OLD: (7,277,77),NEW: (7,377,77)
 explain (verbose, costs off)
 delete from bar where f2 < 400;
-                                            QUERY PLAN                                             
----------------------------------------------------------------------------------------------------
+                                                 QUERY PLAN                                                  
+-------------------------------------------------------------------------------------------------------------
  Delete on public.bar
    Delete on public.bar bar_1
    Foreign Delete on public.bar2 bar_2
-     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 RETURNING f1, f2, f3
+     Remote SQL: DELETE FROM public.loct2 WHERE ctid = $1 AND tableoid = $2 RETURNING f1, f2, f3
    ->  Append
          ->  Seq Scan on public.bar bar_1
-               Output: bar_1.tableoid, bar_1.ctid, NULL::record
+               Output: bar_1.tableoid, bar_1.ctid, NULL::oid, NULL::record
                Filter: (bar_1.f2 < 400)
          ->  Foreign Scan on public.bar2 bar_2
-               Output: bar_2.tableoid, bar_2.ctid, bar_2.*
-               Remote SQL: SELECT f1, f2, f3, ctid FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
+               Output: bar_2.tableoid, bar_2.ctid, remotetableoid, bar_2.*
+               Remote SQL: SELECT ctid, tableoid, f2, f1, f3 FROM public.loct2 WHERE ((f2 < 400)) FOR UPDATE
 (11 rows)
 
 delete from bar where f2 < 400;
@@ -9799,22 +9799,22 @@ analyze remt1;
 analyze remt2;
 explain (verbose, costs off)
 update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a returning *;
-                                                   QUERY PLAN                                                   
-----------------------------------------------------------------------------------------------------------------
+                                                         QUERY PLAN                                                          
+-----------------------------------------------------------------------------------------------------------------------------
  Update on public.parent
    Output: parent_1.a, parent_1.b, remt2.a, remt2.b
    Update on public.parent parent_1
    Foreign Update on public.remt1 parent_2
-     Remote SQL: UPDATE public.loct1 SET b = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct1 SET b = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::record)
+         Output: (parent.b || remt2.b), remt2.*, remt2.a, remt2.b, parent.tableoid, parent.ctid, (NULL::oid), (NULL::record)
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::record
+                     Output: parent_1.b, parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, parent_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.b, parent_2.a, parent_2.tableoid, parent_2.ctid, remotetableoid, parent_2.*
+                     Remote SQL: SELECT b, a, ctid, tableoid FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.b, remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -9831,22 +9831,22 @@ update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a re
 
 explain (verbose, costs off)
 delete from parent using remt2 where parent.a = remt2.a returning parent;
-                                 QUERY PLAN                                  
------------------------------------------------------------------------------
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
  Delete on public.parent
    Output: parent_1.*
    Delete on public.parent parent_1
    Foreign Delete on public.remt1 parent_2
-     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: DELETE FROM public.loct1 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Nested Loop
-         Output: remt2.*, parent.tableoid, parent.ctid
+         Output: remt2.*, parent.tableoid, parent.ctid, (NULL::oid)
          Join Filter: (parent.a = remt2.a)
          ->  Append
                ->  Seq Scan on public.parent parent_1
-                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid
+                     Output: parent_1.a, parent_1.tableoid, parent_1.ctid, NULL::oid
                ->  Foreign Scan on public.remt1 parent_2
-                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid
-                     Remote SQL: SELECT a, ctid FROM public.loct1 FOR UPDATE
+                     Output: parent_2.a, parent_2.tableoid, parent_2.ctid, remotetableoid
+                     Remote SQL: SELECT a, ctid, tableoid FROM public.loct1 FOR UPDATE
          ->  Materialize
                Output: remt2.*, remt2.a
                ->  Foreign Scan on public.remt2
@@ -10076,7 +10076,7 @@ update utrtest set a = 1 where a = 1 or a = 2 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 WHERE (((a = 1) OR (a = 2))) RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, NULL::record
                Filter: ((utrtest_2.a = 1) OR (utrtest_2.a = 2))
 (10 rows)
 
@@ -10115,8 +10115,8 @@ insert into utrtest values (2, 'qux');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Foreign Update on public.remp utrtest_1
@@ -10125,7 +10125,7 @@ update utrtest set a = 1 returning *;
          ->  Foreign Update on public.remp utrtest_1
                Remote SQL: UPDATE public.loct SET a = 1 RETURNING a, b
          ->  Seq Scan on public.locp utrtest_2
-               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+               Output: 1, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, NULL::record
 (9 rows)
 
 update utrtest set a = 1 returning *;
@@ -10136,22 +10136,22 @@ insert into utrtest values (2, 'qux');
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 1 from (values (1), (2)) s(x) where a = s.x returning *;
-                                           QUERY PLAN                                           
-------------------------------------------------------------------------------------------------
+                                                   QUERY PLAN                                                   
+----------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Foreign Update on public.remp utrtest_1
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    Update on public.locp utrtest_2
    ->  Hash Join
-         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, utrtest.*
+         Output: 1, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, remotetableoid, utrtest.*
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Foreign Scan on public.remp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, utrtest_1.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, remotetableoid, utrtest_1.*
+                     Remote SQL: SELECT a, ctid, tableoid, b FROM public.loct FOR UPDATE
                ->  Seq Scan on public.locp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::record
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, NULL::oid, NULL::record
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -10175,15 +10175,15 @@ insert into utrtest values (3, 'xyzzy');
 -- with a direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 returning *;
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
    ->  Append
          ->  Seq Scan on public.locp utrtest_1
-               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+               Output: 3, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, NULL::record
          ->  Foreign Update on public.remp utrtest_2
                Remote SQL: UPDATE public.loct SET a = 3 RETURNING a, b
 (9 rows)
@@ -10193,22 +10193,22 @@ ERROR:  cannot route tuples into foreign table to be updated "remp"
 -- with a non-direct modification plan
 explain (verbose, costs off)
 update utrtest set a = 3 from (values (2), (3)) s(x) where a = s.x returning *;
-                                             QUERY PLAN                                              
------------------------------------------------------------------------------------------------------
+                                                    QUERY PLAN                                                    
+------------------------------------------------------------------------------------------------------------------
  Update on public.utrtest
    Output: utrtest_1.a, utrtest_1.b, "*VALUES*".column1
    Update on public.locp utrtest_1
    Foreign Update on public.remp utrtest_2
-     Remote SQL: UPDATE public.loct SET a = $2 WHERE ctid = $1 RETURNING a, b
+     Remote SQL: UPDATE public.loct SET a = $3 WHERE ctid = $1 AND tableoid = $2 RETURNING a, b
    ->  Hash Join
-         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::record)
+         Output: 3, "*VALUES*".*, "*VALUES*".column1, utrtest.tableoid, utrtest.ctid, (NULL::oid), (NULL::record)
          Hash Cond: (utrtest.a = "*VALUES*".column1)
          ->  Append
                ->  Seq Scan on public.locp utrtest_1
-                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::record
+                     Output: utrtest_1.a, utrtest_1.tableoid, utrtest_1.ctid, NULL::oid, NULL::record
                ->  Foreign Scan on public.remp utrtest_2
-                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, utrtest_2.*
-                     Remote SQL: SELECT a, b, ctid FROM public.loct FOR UPDATE
+                     Output: utrtest_2.a, utrtest_2.tableoid, utrtest_2.ctid, remotetableoid, utrtest_2.*
+                     Remote SQL: SELECT a, ctid, tableoid, b FROM public.loct FOR UPDATE
          ->  Hash
                Output: "*VALUES*".*, "*VALUES*".column1
                ->  Values Scan on "*VALUES*"
@@ -13161,8 +13161,8 @@ RESET enable_hashjoin;
 -- Test that UPDATE/DELETE with inherited target works with async_capable enabled
 EXPLAIN (VERBOSE, COSTS OFF)
 UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
-                                                QUERY PLAN                                                
-----------------------------------------------------------------------------------------------------------
+                                                     QUERY PLAN                                                      
+---------------------------------------------------------------------------------------------------------------------
  Update on public.async_pt
    Output: async_pt_1.a, async_pt_1.b, async_pt_1.c
    Foreign Update on public.async_p1 async_pt_1
@@ -13174,7 +13174,7 @@ UPDATE async_pt SET c = c || c WHERE b = 0 RETURNING *;
          ->  Foreign Update on public.async_p2 async_pt_2
                Remote SQL: UPDATE public.base_tbl2 SET c = (c || c) WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::record
+               Output: (async_pt_3.c || async_pt_3.c), async_pt_3.tableoid, async_pt_3.ctid, NULL::oid, NULL::record
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -13201,7 +13201,7 @@ DELETE FROM async_pt WHERE b = 0 RETURNING *;
          ->  Foreign Delete on public.async_p2 async_pt_2
                Remote SQL: DELETE FROM public.base_tbl2 WHERE ((b = 0)) RETURNING a, b, c
          ->  Seq Scan on public.async_p3 async_pt_3
-               Output: async_pt_3.tableoid, async_pt_3.ctid
+               Output: async_pt_3.tableoid, async_pt_3.ctid, NULL::oid
                Filter: (async_pt_3.b = 0)
 (13 rows)
 
@@ -14000,3 +14000,38 @@ RESET client_min_messages;
 DROP FUNCTION wait_for_backend_termination(int);
 DROP FOREIGN TABLE remote_backend_pid;
 DROP VIEW my_backend_pid;
+-- ===================================================================
+-- test UPDATE/DELETE on a foreign table whose remote counterpart is
+-- itself a partitioned table
+-- ===================================================================
+-- The two rows below land in separate partitions and, being the first
+-- row in each partition's heap, collide on ctid (0,1).  A row-identity
+-- scheme that relies on ctid alone can't tell them apart.
+CREATE TABLE parted_remote (a int, b int) PARTITION BY LIST (a);
+CREATE TABLE parted_remote_p1 PARTITION OF parted_remote FOR VALUES IN (1);
+CREATE TABLE parted_remote_p2 PARTITION OF parted_remote FOR VALUES IN (2);
+INSERT INTO parted_remote_p1 VALUES (1, 100);
+INSERT INTO parted_remote_p2 VALUES (2, 200);
+CREATE FOREIGN TABLE foreign_parted_remote (a int, b int)
+  SERVER loopback OPTIONS (table_name 'parted_remote');
+-- Force a non-direct-modify UPDATE (random() isn't shippable), so the
+-- ctid/tableoid row-identity path below is actually exercised.
+UPDATE foreign_parted_remote SET b = 999 WHERE a = 1 AND random() <= 1;
+-- Only the a = 1 row (in parted_remote_p1) should have changed.
+SELECT tableoid::regclass, a, b FROM parted_remote ORDER BY a;
+     tableoid     | a |  b  
+------------------+---+-----
+ parted_remote_p1 | 1 | 999
+ parted_remote_p2 | 2 | 200
+(2 rows)
+
+DELETE FROM foreign_parted_remote WHERE a = 2 AND random() <= 1;
+-- Only the a = 2 row (in parted_remote_p2) should be gone.
+SELECT tableoid::regclass, a, b FROM parted_remote ORDER BY a;
+     tableoid     | a |  b  
+------------------+---+-----
+ parted_remote_p1 | 1 | 999
+(1 row)
+
+DROP FOREIGN TABLE foreign_parted_remote;
+DROP TABLE parted_remote;
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index fbe01fdde85..818c04d9b08 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -233,6 +233,8 @@ typedef struct PgFdwModifyState
 
 	/* info about parameters for prepared statement */
 	AttrNumber	ctidAttno;		/* attnum of input resjunk ctid column */
+	AttrNumber	tableoidAttno;	/* attnum of input resjunk remote tableoid
+								 * column, or 0 if none */
 	int			p_nums;			/* number of parameters to transmit */
 	FmgrInfo   *p_flinfo;		/* output conversion functions for them */
 
@@ -558,6 +560,7 @@ static TupleTableSlot **execute_foreign_modify(EState *estate,
 static void prepare_foreign_modify(PgFdwModifyState *fmstate);
 static const char **convert_prep_stmt_params(PgFdwModifyState *fmstate,
 											 ItemPointer tupleid,
+											 Oid tableoid,
 											 TupleTableSlot **slots,
 											 int numSlots);
 static void store_returning_result(PgFdwModifyState *fmstate,
@@ -567,6 +570,7 @@ static void deallocate_query(PgFdwModifyState *fmstate);
 static List *build_remote_returning(Index rtindex, Relation rel,
 									List *returningList);
 static void rebuild_fdw_scan_tlist(ForeignScan *fscan, List *tlist);
+static void set_remote_tableoid_resnames(List *fdw_scan_tlist);
 static void execute_dml_stmt(ForeignScanState *node);
 static TupleTableSlot *get_returning_data(ForeignScanState *node);
 static void init_returning_filter(PgFdwDirectModifyState *dmstate,
@@ -1347,6 +1351,52 @@ postgresGetForeignPaths(PlannerInfo *root,
 	}
 }
 
+/*
+ * normalize_wholerow_vartype_mutator
+ *		Mutator for normalize_wholerow_vartype().
+ */
+static Node *
+normalize_wholerow_vartype_mutator(Node *node, Index *relid)
+{
+	if (node == NULL)
+		return NULL;
+	if (IsA(node, Var))
+	{
+		Var		   *var = (Var *) node;
+
+		if (var->varno == *relid &&
+			var->varattno == InvalidAttrNumber &&
+			var->vartype != RECORDOID)
+		{
+			var = copyObject(var);
+			var->vartype = RECORDOID;
+			var->vartypmod = -1;
+		}
+		return (Node *) var;
+	}
+	return expression_tree_mutator(node, normalize_wholerow_vartype_mutator,
+								   (void *) relid);
+}
+
+/*
+ * normalize_wholerow_vartype
+ *		Return a copy of exprs in which every whole-row Var on relid is
+ *		typed RECORD, regardless of what it was typed originally.
+ *
+ * Core's own row-identity whole-row Var (see add_row_identity_columns()) is
+ * always typed RECORD, while a direct whole-row reference in the query
+ * (e.g. "t.*") keeps the relation's real row type.  setrefs.c's Var-
+ * matching keys purely on varno/varattno and keeps whichever vartype the
+ * reference being fixed up already has, so mismatched types here would
+ * make two Vars that denote the same fdw_scan_tlist entry resolve
+ * inconsistently.  Normalizing to one vartype avoids that.
+ */
+static List *
+normalize_wholerow_vartype(List *exprs, Index relid)
+{
+	return (List *) normalize_wholerow_vartype_mutator((Node *) exprs, &relid);
+}
+
 /*
  * postgresGetForeignPlan
  *		Create ForeignScan plan node which implements selected best path
@@ -1434,6 +1484,115 @@ postgresGetForeignPlan(PlannerInfo *root,
 		 * should recheck all the remote quals.
 		 */
 		fdw_recheck_quals = remote_exprs;
+
+		/*
+		 * If a non-direct UPDATE/DELETE needs the remote tableoid (flagged by
+		 * a pseudo-column Var in the rel's targetlist), build an explicit
+		 * fdw_scan_tlist as for a join instead of scanning positionally.
+		 */
+		foreach(lc, foreignrel->reltarget->exprs)
+		{
+			Var		   *var = (Var *) lfirst(lc);
+
+			if (IsA(var, Var) &&
+				var->varattno == RemoteTableOidAttributeNumber)
+			{
+				List	   *epq_vars;
+				List	   *scan_vars = NIL;
+				ListCell   *lc2;
+
+				fdw_scan_tlist = build_tlist_to_deparse(foreignrel);
+
+				/*
+				 * fdw_scan_tlist above already carries core's own RECORD-
+				 * typed whole-row Var for this relation (see
+				 * add_row_identity_columns()).  If a local qual or recheck
+				 * qual also references this relation's whole row directly
+				 * (e.g. "t.* IS NOT NULL"), retype that reference to match,
+				 * so it resolves consistently and folds into the same
+				 * fdw_scan_tlist entry below (see normalize_wholerow_vartype()).
+				 */
+				local_exprs = normalize_wholerow_vartype(local_exprs,
+														 foreignrel->relid);
+				fdw_recheck_quals = normalize_wholerow_vartype(fdw_recheck_quals,
+															   foreignrel->relid);
+
+				/*
+				 * Vars in the EPQ recheck and local quals now resolve against
+				 * the scan output, so add any not already covered.  Skip any
+				 * Var that isn't a column of this relation (e.g. a join
+				 * partner's column referenced by a pushed-down join qual):
+				 * that isn't ours to fetch, and it's already handled as a
+				 * bound parameter wherever it appears inside a qual.
+				 */
+				epq_vars = pull_var_clause((Node *) list_concat_copy(fdw_recheck_quals,
+																	  local_exprs),
+										   PVC_RECURSE_PLACEHOLDERS);
+				foreach(lc2, epq_vars)
+				{
+					Var		   *epq_var = lfirst_node(Var, lc2);
+
+					if (bms_is_member(epq_var->varno, foreignrel->relids))
+						scan_vars = lappend(scan_vars, epq_var);
+				}
+				fdw_scan_tlist = add_to_flat_tlist(fdw_scan_tlist, scan_vars);
+
+				/*
+				 * If this relation's whole row is referenced anywhere in
+				 * the scan output, every one of its real columns must be
+				 * fetched too, so make_tuple_from_result_row() can
+				 * reconstruct it locally afterward instead of the remote
+				 * side building and shipping a second, redundant ROW(...)
+				 * (see deparseExplicitTargetList()).
+				 */
+				{
+					bool		has_wholerow = false;
+					ListCell   *lc3;
+
+					foreach(lc3, fdw_scan_tlist)
+					{
+						TargetEntry *tle = lfirst_node(TargetEntry, lc3);
+
+						if (IsA(tle->expr, Var) &&
+							((Var *) tle->expr)->varattno == InvalidAttrNumber &&
+							((Var *) tle->expr)->varno == foreignrel->relid)
+						{
+							has_wholerow = true;
+							break;
+						}
+					}
+
+					if (has_wholerow)
+					{
+						Relation	rel = table_open(foreigntableid, NoLock);
+						TupleDesc	tupdesc = RelationGetDescr(rel);
+						List	   *relcols = NIL;
+						int			attnum;
+
+						for (attnum = 1; attnum <= tupdesc->natts; attnum++)
+						{
+							Form_pg_attribute attr = TupleDescAttr(tupdesc, attnum - 1);
+							Var		   *cvar;
+
+							if (attr->attisdropped)
+								continue;
+
+							cvar = makeVar(foreignrel->relid, attnum,
+										   attr->atttypid, attr->atttypmod,
+										   attr->attcollation, 0);
+							relcols = lappend(relcols, cvar);
+						}
+
+						fdw_scan_tlist = add_to_flat_tlist(fdw_scan_tlist, relcols);
+
+						table_close(rel, NoLock);
+					}
+				}
+
+				set_remote_tableoid_resnames(fdw_scan_tlist);
+				break;
+			}
+		}
 	}
 	else
 	{
@@ -1469,6 +1628,7 @@ postgresGetForeignPlan(PlannerInfo *root,
 
 		/* Build the list of columns to be fetched from the foreign server. */
 		fdw_scan_tlist = build_tlist_to_deparse(foreignrel);
+		set_remote_tableoid_resnames(fdw_scan_tlist);
 
 		/*
 		 * Ensure that the outer plan produces a tuple whose descriptor
@@ -1782,8 +1942,12 @@ postgresBeginForeignScan(ForeignScanState *node, int eflags)
 	/*
 	 * Get info we'll need for converting data fetched from the foreign server
 	 * into local representation and error reporting during that process.
+	 *
+	 * Key the choice off fdw_scan_tlist, not scanrelid: an explicit
+	 * fdw_scan_tlist (built to fetch the remote tableoid) fetches into the
+	 * scan tuple slot like a join, even for a base-relation scan.
 	 */
-	if (fsplan->scan.scanrelid > 0)
+	if (fsplan->scan.scanrelid > 0 && fsplan->fdw_scan_tlist == NIL)
 	{
 		fsstate->rel = node->ss.ss_currentRelation;
 		fsstate->tupdesc = RelationGetDescr(fsstate->rel);
@@ -1990,6 +2154,21 @@ postgresAddForeignUpdateTargets(PlannerInfo *root,
 
 	/* Register it as a row-identity column needed by this target rel */
 	add_row_identity_var(root, var, rtindex, "ctid");
+
+	/*
+	 * ctid alone isn't unique if the foreign table maps to a partitioned
+	 * table remotely, so also fetch the remote tableoid, via an out-of-range
+	 * attnum rather than TableOidAttributeNumber (see deparseColumnRef).
+	 */
+	var = makeVar(rtindex,
+				  RemoteTableOidAttributeNumber,
+				  OIDOID,
+				  -1,
+				  InvalidOid,
+				  0);
+
+	/* Register it as a second row-identity column needed by this target rel */
+	add_row_identity_var(root, var, rtindex, "remotetableoid");
 }
 
 /*
@@ -2854,6 +3033,31 @@ postgresPlanDirectModify(PlannerInfo *root,
 		if (returningList)
 			rebuild_fdw_scan_tlist(fscan, returningList);
 	}
+	else
+	{
+		ListCell   *lc;
+
+		/*
+		 * A direct modification identifies rows by pushed-down qualifiers, so
+		 * discard any fdw_scan_tlist built to fetch the remote tableoid,
+		 * falling back to the positional path.
+		 */
+		fscan->fdw_scan_tlist = NIL;
+
+		/*
+		 * The tlist's remote-tableoid Var likewise isn't needed; replace it
+		 * with a NULL const rather than dropping the (positionally indexed)
+		 * entry.
+		 */
+		foreach(lc, fscan->scan.plan.targetlist)
+		{
+			TargetEntry *tle = lfirst_node(TargetEntry, lc);
+
+			if (IsA(tle->expr, Var) &&
+				((Var *) tle->expr)->varattno == RemoteTableOidAttributeNumber)
+				tle->expr = (Expr *) makeNullConst(OIDOID, -1, InvalidOid);
+		}
+	}
 
 	/*
 	 * Finally, unset the async-capable flag if it is set, as we currently
@@ -4334,8 +4538,12 @@ create_foreign_modify(EState *estate,
 	if (fmstate->has_returning)
 		fmstate->attinmeta = TupleDescGetAttInMetadata(tupdesc);
 
-	/* Prepare for output conversion of parameters used in prepared stmt. */
-	n_params = list_length(fmstate->target_attrs) + 1;
+	/*
+	 * Prepare for output conversion of parameters used in prepared stmt.
+	 * UPDATE/DELETE transmit two extra leading parameters (ctid, tableoid) to
+	 * identify the row; INSERT transmits none.
+	 */
+	n_params = list_length(fmstate->target_attrs) + 2;
 	fmstate->p_flinfo = palloc0_array(FmgrInfo, n_params);
 	fmstate->p_nums = 0;
 
@@ -4353,6 +4561,19 @@ create_foreign_modify(EState *estate,
 		getTypeOutputInfo(TIDOID, &typefnoid, &isvarlena);
 		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
 		fmstate->p_nums++;
+
+		/*
+		 * Find the remote tableoid resjunk column; it's the second
+		 * transmittable parameter, disambiguating ctid across partitions.
+		 */
+		fmstate->tableoidAttno =
+			ExecFindJunkAttributeInTlist(subplan->targetlist, "remotetableoid");
+		if (!AttributeNumberIsValid(fmstate->tableoidAttno))
+			elog(ERROR, "could not find junk remotetableoid column");
+
+		getTypeOutputInfo(OIDOID, &typefnoid, &isvarlena);
+		fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
+		fmstate->p_nums++;
 	}
 
 	if (operation == CMD_INSERT || operation == CMD_UPDATE)
@@ -4405,6 +4626,7 @@ execute_foreign_modify(EState *estate,
 {
 	PgFdwModifyState *fmstate = (PgFdwModifyState *) resultRelInfo->ri_FdwState;
 	ItemPointer ctid = NULL;
+	Oid			tableoid = InvalidOid;
 	const char **p_values;
 	PGresult   *res;
 	int			n_rows;
@@ -4445,7 +4667,8 @@ execute_foreign_modify(EState *estate,
 		prepare_foreign_modify(fmstate);
 
 	/*
-	 * For UPDATE/DELETE, get the ctid that was passed up as a resjunk column
+	 * For UPDATE/DELETE, get the ctid and remote tableoid that were passed up
+	 * as resjunk columns; together they identify the remote row to modify.
 	 */
 	if (operation == CMD_UPDATE || operation == CMD_DELETE)
 	{
@@ -4459,10 +4682,19 @@ execute_foreign_modify(EState *estate,
 		if (isNull)
 			elog(ERROR, "ctid is NULL");
 		ctid = (ItemPointer) DatumGetPointer(datum);
+
+		datum = ExecGetJunkAttribute(planSlots[0],
+									 fmstate->tableoidAttno,
+									 &isNull);
+		/* shouldn't ever get a null result... */
+		if (isNull)
+			elog(ERROR, "remote tableoid is NULL");
+		tableoid = DatumGetObjectId(datum);
 	}
 
 	/* Convert parameters needed by prepared statement to text form */
-	p_values = convert_prep_stmt_params(fmstate, ctid, slots, *numSlots);
+	p_values = convert_prep_stmt_params(fmstate, ctid, tableoid,
+										slots, *numSlots);
 
 	/*
 	 * Execute the prepared statement.
@@ -4560,6 +4792,7 @@ prepare_foreign_modify(PgFdwModifyState *fmstate)
  *		Create array of text strings representing parameter values
  *
  * tupleid is ctid to send, or NULL if none
+ * tableoid is the remote tableoid to send; used only when tupleid != NULL
  * slot is slot to get remaining parameters from, or NULL if none
  *
  * Data is constructed in temp_cxt; caller should reset that after use.
@@ -4567,6 +4800,7 @@ prepare_foreign_modify(PgFdwModifyState *fmstate)
 static const char **
 convert_prep_stmt_params(PgFdwModifyState *fmstate,
 						 ItemPointer tupleid,
+						 Oid tableoid,
 						 TupleTableSlot **slots,
 						 int numSlots)
 {
@@ -4583,7 +4817,7 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 	/* ctid is provided only for UPDATE/DELETE, which don't allow batching */
 	Assert(!(tupleid != NULL && numSlots > 1));
 
-	/* 1st parameter should be ctid, if it's in use */
+	/* 1st and 2nd parameters should be ctid and tableoid, if in use */
 	if (tupleid != NULL)
 	{
 		Assert(numSlots == 1);
@@ -4591,6 +4825,10 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 		p_values[pindex] = OutputFunctionCall(&fmstate->p_flinfo[pindex],
 											  PointerGetDatum(tupleid));
 		pindex++;
+		/* don't need set_transmission_modes for OID output */
+		p_values[pindex] = OutputFunctionCall(&fmstate->p_flinfo[pindex],
+											  ObjectIdGetDatum(tableoid));
+		pindex++;
 	}
 
 	/* get following parameters from slots */
@@ -4604,7 +4842,8 @@ convert_prep_stmt_params(PgFdwModifyState *fmstate,
 
 		for (i = 0; i < numSlots; i++)
 		{
-			j = (tupleid != NULL) ? 1 : 0;
+			/* ctid and tableoid occupy the first two parameter slots */
+			j = (tupleid != NULL) ? 2 : 0;
 			foreach(lc, fmstate->target_attrs)
 			{
 				int			attnum = lfirst_int(lc);
@@ -4830,6 +5069,26 @@ rebuild_fdw_scan_tlist(ForeignScan *fscan, List *tlist)
 	fscan->fdw_scan_tlist = new_tlist;
 }
 
+/*
+ * set_remote_tableoid_resnames
+ *		Name fdw_scan_tlist's remote-tableoid pseudo-column entries
+ *		"remotetableoid", since their out-of-range attno has no catalog name.
+ */
+static void
+set_remote_tableoid_resnames(List *fdw_scan_tlist)
+{
+	ListCell   *lc;
+
+	foreach(lc, fdw_scan_tlist)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+
+		if (IsA(tle->expr, Var) &&
+			((Var *) tle->expr)->varattno == RemoteTableOidAttributeNumber)
+			tle->resname = pstrdup("remotetableoid");
+	}
+}
+
 /*
  * Execute a direct UPDATE/DELETE statement.
  */
@@ -9028,6 +9287,95 @@ make_tuple_from_result_row(PGresult *res,
 		j++;
 	}
 
+	/*
+	 * Fill in any local-tableoid pseudo-entry and any whole-row reference
+	 * that deparseExplicitTargetList() skipped shipping to the remote
+	 * server (see there): the tableoid is simply this scan's own relation
+	 * OID, and the whole row is built here from the columns already decoded
+	 * above.
+	 */
+	if (fsstate)
+	{
+		ForeignScan *fsplan = castNode(ForeignScan, fsstate->ss.ps.plan);
+
+		if (fsplan->scan.scanrelid > 0 && fsplan->fdw_scan_tlist != NIL)
+		{
+			Oid			reloid = RelationGetRelid(fsstate->ss.ss_currentRelation);
+			TupleDesc	reltupdesc = RelationGetDescr(fsstate->ss.ss_currentRelation);
+			Datum	   *relvalues = NULL;
+			bool	   *relnulls = NULL;
+			List	   *wholerow_positions = NIL;
+			ListCell   *lc2;
+			int			pos = 0;
+
+			/*
+			 * There can be more than one whole-row Var for this relation in
+			 * the tlist (a query's own reference alongside core's row-
+			 * identity one); collect every position to fill in below.
+			 */
+			foreach(lc2, fsplan->fdw_scan_tlist)
+			{
+				TargetEntry *tle = lfirst_node(TargetEntry, lc2);
+				Var		   *tvar;
+
+				pos++;
+
+				if (!IsA(tle->expr, Var))
+					continue;
+				tvar = (Var *) tle->expr;
+
+				if (tvar->varattno == TableOidAttributeNumber)
+				{
+					values[pos - 1] = ObjectIdGetDatum(reloid);
+					nulls[pos - 1] = false;
+				}
+				else if (tvar->varno == fsplan->scan.scanrelid &&
+						 tvar->varattno == InvalidAttrNumber)
+				{
+					/* Whole-row Var: fill in after collecting its columns. */
+					wholerow_positions = lappend_int(wholerow_positions, pos);
+				}
+				else if (tvar->varno == fsplan->scan.scanrelid &&
+						 tvar->varattno > InvalidAttrNumber &&
+						 tvar->varattno <= reltupdesc->natts)
+				{
+					if (relvalues == NULL)
+					{
+						relvalues = palloc0_array(Datum, reltupdesc->natts);
+						relnulls = palloc_array(bool, reltupdesc->natts);
+						memset(relnulls, true, reltupdesc->natts * sizeof(bool));
+					}
+					relvalues[tvar->varattno - 1] = values[pos - 1];
+					relnulls[tvar->varattno - 1] = nulls[pos - 1];
+				}
+			}
+
+			/*
+			 * Only reconstruct locally if there are other columns to build
+			 * from (relvalues != NULL); otherwise the whole row was shipped
+			 * as a literal ROW(...) instead (see
+			 * tlist_has_other_column_of_rel()), and the value already
+			 * fetched above is correct as is.
+			 */
+			if (wholerow_positions != NIL && relvalues != NULL)
+			{
+				HeapTuple	rowtup;
+				Datum		rowdatum;
+
+				rowtup = heap_form_tuple(reltupdesc, relvalues, relnulls);
+				rowdatum = heap_copy_tuple_as_datum(rowtup, reltupdesc);
+
+				foreach(lc2, wholerow_positions)
+				{
+					int			wholerow_pos = lfirst_int(lc2);
+
+					values[wholerow_pos - 1] = rowdatum;
+					nulls[wholerow_pos - 1] = false;
+				}
+			}
+		}
+	}
+
 	/* Uninstall error context callback. */
 	error_context_stack = errcallback.previous;
 
@@ -9102,15 +9450,21 @@ conversion_error_callback(void *arg)
 		int			varno = 0;
 		AttrNumber	colno = 0;
 
-		if (fsplan->scan.scanrelid > 0)
+		if (fsplan->scan.scanrelid > 0 && fsplan->fdw_scan_tlist == NIL)
 		{
-			/* error occurred in a scan against a foreign table */
+			/*
+			 * Error occurred in a scan against a foreign table, fetched
+			 * positionally, so cur_attno is the table's attribute number.
+			 */
 			varno = fsplan->scan.scanrelid;
 			colno = errpos->cur_attno;
 		}
 		else
 		{
-			/* error occurred in a scan against a foreign join */
+			/*
+			 * Error occurred in a scan against a foreign join, or a base
+			 * relation with an explicit fdw_scan_tlist; cur_attno indexes it.
+			 */
 			TargetEntry *tle;
 
 			tle = list_nth_node(TargetEntry, fsplan->fdw_scan_tlist,
@@ -9143,6 +9497,8 @@ conversion_error_callback(void *arg)
 				attname = strVal(list_nth(rte->eref->colnames, colno - 1));
 			else if (colno == SelfItemPointerAttributeNumber)
 				attname = "ctid";
+			else if (colno == RemoteTableOidAttributeNumber)
+				attname = "tableoid";
 		}
 	}
 	else if (rel)
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index da7da1c2ea9..eafcf42844f 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -13,6 +13,7 @@
 #ifndef POSTGRES_FDW_H
 #define POSTGRES_FDW_H
 
+#include "access/htup_details.h"
 #include "foreign/foreign.h"
 #include "lib/stringinfo.h"
 #include "libpq/libpq-be-fe.h"
@@ -20,6 +21,14 @@
 #include "nodes/pathnodes.h"
 #include "utils/relcache.h"
 
+/*
+ * Pseudo-attribute number for the remote table OID, used to identify a row for
+ * UPDATE/DELETE when a foreign table maps to a remote partitioned table (see
+ * postgresAddForeignUpdateTargets).  Larger than any real attno, so it never
+ * collides with a genuine column; fetched as an ordinary fdw_scan_tlist column.
+ */
+#define RemoteTableOidAttributeNumber	(MaxHeapAttributeNumber + 1)
+
 /*
  * FDW-specific planner information kept in RelOptInfo.fdw_private for a
  * postgres_fdw foreign table.  For a baserel, this struct is created by
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index f1ca3204382..0b55aa17701 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -5088,3 +5088,35 @@ RESET client_min_messages;
 DROP FUNCTION wait_for_backend_termination(int);
 DROP FOREIGN TABLE remote_backend_pid;
 DROP VIEW my_backend_pid;
+
+-- ===================================================================
+-- test UPDATE/DELETE on a foreign table whose remote counterpart is
+-- itself a partitioned table
+-- ===================================================================
+
+-- The two rows below land in separate partitions and, being the first
+-- row in each partition's heap, collide on ctid (0,1).  A row-identity
+-- scheme that relies on ctid alone can't tell them apart.
+CREATE TABLE parted_remote (a int, b int) PARTITION BY LIST (a);
+CREATE TABLE parted_remote_p1 PARTITION OF parted_remote FOR VALUES IN (1);
+CREATE TABLE parted_remote_p2 PARTITION OF parted_remote FOR VALUES IN (2);
+INSERT INTO parted_remote_p1 VALUES (1, 100);
+INSERT INTO parted_remote_p2 VALUES (2, 200);
+
+CREATE FOREIGN TABLE foreign_parted_remote (a int, b int)
+  SERVER loopback OPTIONS (table_name 'parted_remote');
+
+-- Force a non-direct-modify UPDATE (random() isn't shippable), so the
+-- ctid/tableoid row-identity path below is actually exercised.
+UPDATE foreign_parted_remote SET b = 999 WHERE a = 1 AND random() <= 1;
+
+-- Only the a = 1 row (in parted_remote_p1) should have changed.
+SELECT tableoid::regclass, a, b FROM parted_remote ORDER BY a;
+
+DELETE FROM foreign_parted_remote WHERE a = 2 AND random() <= 1;
+
+-- Only the a = 2 row (in parted_remote_p2) should be gone.
+SELECT tableoid::regclass, a, b FROM parted_remote ORDER BY a;
+
+DROP FOREIGN TABLE foreign_parted_remote;
+DROP TABLE parted_remote;
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index fe4e6478e28..c13800dd67d 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -1198,6 +1198,40 @@ CREATE SUBSCRIPTION my_subscription SERVER subscription_server PUBLICATION testp
    to reduce the risk of misexecution of the query.
   </para>
 
+  <para>
+   When such optimized whole-query shipping is not possible, an
+   <command>UPDATE</command> or <command>DELETE</command> is instead executed
+   by first selecting the affected rows from the remote server and then
+   issuing a separate remote <command>UPDATE</command> or
+   <command>DELETE</command> for each row, identified by its
+   <literal>ctid</literal> and remote <literal>tableoid</literal>.  If the
+   remote table is the root of a partitioning hierarchy, each such per-row
+   command targets that root and so cannot benefit from partition pruning
+   (the remote planner has no way to prune based on the transmitted
+   <literal>tableoid</literal> parameter), which can make this considerably
+   slower than the whole-query case when many rows are affected.  For best
+   performance, prefer whole-query
+   shipping when possible, and otherwise try to limit the number of rows
+   affected by an <command>UPDATE</command> or <command>DELETE</command> on
+   a foreign table that maps to a partitioned table on the remote server.
+  </para>
+
+  <para>
+   The <literal>ctid</literal>/<literal>tableoid</literal> row identity
+   described above only disambiguates rows when the remote table named by
+   <literal>table_name</literal> is itself an ordinary table, partitioned
+   table, or inheritance parent on the remote server.  If that remote table
+   is itself a foreign table (for example, another
+   <filename>postgres_fdw</filename> table forwarding to a partitioned
+   table one hop further away), the remote <literal>tableoid</literal>
+   returned for every row is simply that foreign table's own OID, which does
+   not distinguish the rows it forwards.  In that situation, a non-direct
+   <command>UPDATE</command> or <command>DELETE</command> that affects more
+   than one row can still update or delete the wrong rows, exactly as
+   without this disambiguation; chaining foreign tables in this way is not
+   currently detected or protected against.
+  </para>
+
   <para>
    When <filename>postgres_fdw</filename> encounters a join between foreign tables on
    the same foreign server, it sends the entire join to the foreign server,
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 7bbddb8bee4..1f7e2c34ef1 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -6493,6 +6493,20 @@ set_rel_width(PlannerInfo *root, RelOptInfo *rel)
 			int			ndx;
 			int32		item_width;
 
+			/*
+			 * An out-of-range attno (> max_attr) is an FDW row-identity
+			 * pseudo-column (postgres_fdw's remote table OID for
+			 * UPDATE/DELETE; see add_vars_to_targetlist()) with no
+			 * attr_widths[] slot; estimate its width from the type.
+			 */
+			if (var->varattno > rel->max_attr)
+			{
+				/* Only an FDW can inject an out-of-range row-identity column */
+				Assert(rel->fdwroutine != NULL);
+				tuple_width += get_typavgwidth(var->vartype, var->vartypmod);
+				continue;
+			}
+
 			Assert(var->varattno >= rel->min_attr);
 			Assert(var->varattno <= rel->max_attr);
 
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index 199e5d0a580..673c14ef3eb 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -314,6 +314,25 @@ add_vars_to_targetlist(PlannerInfo *root, List *vars,
 
 			if (bms_is_subset(where_needed, rel->relids))
 				continue;
+
+			/*
+			 * An out-of-range attno (> max_attr) is an FDW row-identity
+			 * pseudo-column (e.g. postgres_fdw's remote table OID for
+			 * UPDATE/DELETE) with no attr_needed[] slot; like a ROWID_VAR
+			 * it's always needed, so just add it to the reltarget once.
+			 */
+			if (attno > rel->max_attr)
+			{
+				/* Only an FDW can inject an out-of-range row-identity column */
+				Assert(rel->fdwroutine != NULL);
+				var = copyObject(var);
+				var->varnullingrels = NULL;
+				if (!list_member(rel->reltarget->exprs, var))
+					rel->reltarget->exprs = lappend(rel->reltarget->exprs,
+													var);
+				continue;
+			}
+
 			Assert(attno >= rel->min_attr && attno <= rel->max_attr);
 			attno -= rel->min_attr;
 			if (rel->attr_needed[attno] == NULL)
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index 8a641402a96..f333ee9e1fd 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -15,6 +15,7 @@
  */
 #include "postgres.h"
 
+#include "access/htup_details.h"
 #include "access/transam.h"
 #include "catalog/pg_type.h"
 #include "nodes/makefuncs.h"
@@ -1053,6 +1054,11 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 					 * it here and keep the assertions that ROWID_VARs
 					 * shouldn't be seen by fix_scan_expr.
 					 *
+					 * The same applies to an FDW row-identity pseudo-column
+					 * (attno > MaxHeapAttributeNumber; see
+					 * add_vars_to_targetlist()): no scan node exists here to
+					 * give it a value or a name, so replace it the same way.
+					 *
 					 * We also must handle the case where set operations have
 					 * been short-circuited resulting in a dummy Result node.
 					 * prepunion.c uses varno==0 for the set op targetlist.
@@ -1069,7 +1075,8 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 
 						if (var && IsA(var, Var))
 						{
-							if (var->varno == ROWID_VAR)
+							if (var->varno == ROWID_VAR ||
+								var->varattno > MaxHeapAttributeNumber)
 								tle->expr = (Expr *) makeNullConst(var->vartype,
 																   var->vartypmod,
 																   var->varcollid);
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 8e2409ccd9d..89c697b96f4 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -1298,6 +1298,7 @@ build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 	foreach(vars, input_rel->reltarget->exprs)
 	{
 		Var		   *var = (Var *) lfirst(vars);
+		RelOptInfo *baserel = NULL;
 
 		/*
 		 * For a PlaceHolderVar, we have to look up the PlaceHolderInfo.
@@ -1369,28 +1370,45 @@ build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 		}
 		else
 		{
-			RelOptInfo *baserel;
-			int			ndx;
-
 			/* Get the Var's original base rel */
 			baserel = find_base_rel(root, var->varno);
 
-			/* Is it still needed above this joinrel? */
-			ndx = var->varattno - baserel->min_attr;
-			if (!bms_nonempty_difference(baserel->attr_needed[ndx], relids))
-				continue;		/* nope, skip it */
+			if (var->varattno > baserel->max_attr)
+			{
+				/*
+				 * An out-of-range attno (> max_attr) is an FDW row-identity
+				 * pseudo-column (postgres_fdw's remote table OID for
+				 * UPDATE/DELETE; see add_vars_to_targetlist()) with no
+				 * attr_needed[]/attr_widths[] slot; like a ROWID_VAR, always
+				 * needed above every join.
+				 */
+				Assert(baserel->fdwroutine != NULL);
+				tuple_width += get_typavgwidth(var->vartype, var->vartypmod);
+			}
+			else
+			{
+				int			ndx;
 
-			/* Update reltarget width estimate from baserel's attr_widths */
-			tuple_width += baserel->attr_widths[ndx];
+				/* Is it still needed above this joinrel? */
+				ndx = var->varattno - baserel->min_attr;
+				if (!bms_nonempty_difference(baserel->attr_needed[ndx], relids))
+					continue;	/* nope, skip it */
+
+				/* Update reltarget width estimate from baserel's attr_widths */
+				tuple_width += baserel->attr_widths[ndx];
+			}
 		}
 
 		/*
 		 * Add the Var to the output.  If this join potentially nulls this
 		 * input, we have to update the Var's varnullingrels, which means
 		 * making a copy.  But note that we don't ever add nullingrel bits to
-		 * row identity Vars (cf. comments in setrefs.c).
+		 * row identity Vars (cf. comments in setrefs.c); that includes the
+		 * FDW row-identity pseudo-columns handled just above, which have an
+		 * out-of-range attribute number.
 		 */
-		if (can_null && var->varno != ROWID_VAR)
+		if (can_null && var->varno != ROWID_VAR &&
+			var->varattno <= baserel->max_attr)
 		{
 			var = copyObject(var);
 			/* See comments above to understand this logic */
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 5e8e1683db0..2e30cde3381 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -8011,6 +8011,29 @@ resolve_special_varno(Node *node, deparse_context *context,
 		if (!tle)
 			elog(ERROR, "bogus varattno for INDEX_VAR var: %d", var->varattno);
 
+		/*
+		 * A row-identity pseudo-column in fdw_scan_tlist has an out-of-range
+		 * attno and no catalog column to name, so when deparsing it as a
+		 * variable (get_special_variable) print its scan-tlist name rather
+		 * than chasing a non-existent attribute (which fails in
+		 * get_variable).
+		 */
+		if (callback == get_special_variable && tle->resname &&
+			IsA(tle->expr, Var))
+		{
+			Var		   *itvar = (Var *) tle->expr;
+
+			if (itvar->varno >= 1 &&
+				itvar->varno <= list_length(dpns->rtable) &&
+				itvar->varattno >
+				deparse_columns_fetch(itvar->varno, dpns)->num_cols)
+			{
+				appendStringInfoString(context->buf,
+									   quote_identifier(tle->resname));
+				return;
+			}
+		}
+
 		resolve_special_varno((Node *) tle->expr, context,
 							  callback, callback_arg);
 		return;
-- 
2.52.0



^ permalink  raw  reply  [nested|flat] 30+ messages in thread

* Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
  2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-07-23 10:38 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2025-07-29 15:48   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
  2025-08-06 11:25     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-04-21 11:34       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 15:05         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-05-14 17:22           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-01 10:44             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 11:30               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-06-10 22:30                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Michael Paquier <michael@paquier.xyz>
  2026-06-13 19:43                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-06-15 14:54                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Etsuro Fujita <etsuro.fujita@gmail.com>
  2026-07-09 14:03                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-27 09:20                         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-08-27 14:36                           ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Nikita Malakhov <hukutoc@gmail.com>
  2026-08-28 05:43                             ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-09-04 13:37                               ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jakub Wartak <jakub.wartak@enterprisedb.com>
  2026-09-07 03:07                                 ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table shihao zhong <zhong950419@gmail.com>
  2026-09-10 12:21                                   ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
  2026-09-16 12:02                                     ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jakub Wartak <jakub.wartak@enterprisedb.com>
  2026-09-16 13:15                                       ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
  2026-09-22 08:44                                         ` Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Ajit Awekar <ajitpostgres@gmail.com>
@ 2026-09-22 14:45                                           ` Manu <manuelreyesbravo@gmail.com>
  0 siblings, 0 replies; 30+ messages in thread

From: Manu @ 2026-09-22 14:45 UTC (permalink / raw)
  To: Ajit Awekar <ajitpostgres@gmail.com>; +Cc: Jakub Wartak <jakub.wartak@enterprisedb.com>; Etsuro Fujita <etsuro.fujita@gmail.com>; Michael Paquier <michael@paquier.xyz>; shihao zhong <zhong950419@gmail.com>; Nikita Malakhov <hukutoc@gmail.com>; Jehan-Guillaume de Rorthais <jgdr@dalibo.com>; Robert Haas <rhaas@postgresql.org>; pgsql-hackers@lists.postgresql.org

Hi Ajit,

Ajit Awekar <ajitpostgres@gmail.com> wrote:
> @Manu: went with your option (1) rather than (2) for case_f
>
> Please find attached V3. Request a review.

Thanks, the new paragraph describes case_f as I saw it, including that
the remote tableoid is the middle foreign table's own OID.

I ran the same eight cases on V3, applied on master e8a3ee5b197, against
that master, both with --enable-cassert.  V3 builds without warnings,
and make check (239) and postgres_fdw's check pass.

- Jakub's zero-column case with a BEFORE DELETE trigger no longer fails
  the assertion; the output is the same as master's.
- Etsuro's DELETE ... USING case, the dropped and renamed columns with
  triggers, the mixed local and foreign partitions and the self-join
  give the right rows, as with v2.
- The zero-column RETURNING and the cross-partition cases are the same
  as master.
- The chained case still updates the wrong rows, as documented.

Since the commit message mentions the extra column and WHERE clause for
every non-direct UPDATE/DELETE, I measured it, on a loopback server
with 100000 rows, UPDATE ... WHERE id <= N AND random() <= 1, median of 5
runs:

                                  master      V3
    plain table, 1000 rows         26 ms     29 ms
    plain table, 10000 rows       234 ms    224 ms
    100 partitions, 1000 rows      42 ms    309 ms
    100 partitions, 10000 rows    102 ms   3606 ms

On a plain table I can't see a difference.  With partitions V3 looks
much slower, but it is not what it adds.  With log_min_duration_statement
= 0, for 1000 rows master sends 100 per-row UPDATEs and V3 sends 1000,
at the same cost each (0.20 ms in one run, 0.25 ms in another, the same
for both).  Master sends fewer because each
"WHERE ctid = $1" hits that ctid in every partition, so the rows already
changed are not fetched again.  Master is faster only because it is
doing the wrong thing.

What remains is the cost your paragraph describes: each per-row UPDATE
goes through the root and scans every partition, so with 100 partitions
a row costs about ten times what it costs on a plain table (309 ms vs
29 ms for 1000 rows).  Prepared on the remote side the way postgres_fdw
does it, "WHERE ctid = $1 AND tableoid = $2" costs about 10% more than
"WHERE ctid = $1" (190 vs 172 ms for 1000 executions), so the tableoid
condition itself is cheap.

The attached fdw_update_cost.sh does both measurements: the medians, and
the statements the remote side receives with their count and time.  It
takes an install prefix and a port, like run_cases.sh.  The numbers move
a bit between runs, but the counts don't.

I have nothing else to raise on V3.

Regards,
Manu
#!/bin/bash
# usage: fdw_update_cost.sh <install prefix> <port>
#
# Cost of a non-direct UPDATE through postgres_fdw, on a loopback server.
# Remote tables: a plain one and one with 100 list partitions, 100000 rows
# each.  "random() <= 1" keeps the UPDATE from being shipped whole.
#
# 1. Median of 5 runs, for 1000 and 10000 rows, on each table.
# 2. For 1000 rows on the partitioned table, the statements the remote
#    side received (log_min_duration_statement = 0), grouped, with their
#    total time and count.
P=$1; PORT=$2
D=/tmp/fdwcost_$PORT
rm -rf $D
$P/bin/initdb -D $D -U postgres --no-sync >/dev/null 2>&1
cat >> $D/postgresql.conf <<EOF
shared_buffers = 512MB
fsync = off
synchronous_commit = off
EOF
$P/bin/pg_ctl -D $D -o "-p $PORT -k /tmp" -l $D/log -w start >/dev/null
trap '$P/bin/pg_ctl -D $D -m immediate -w stop >/dev/null 2>&1; rm -rf $D' EXIT
PSQL="$P/bin/psql -h /tmp -p $PORT -U postgres -X -q postgres"

$PSQL <<SQL
CREATE EXTENSION postgres_fdw;
CREATE SERVER loopback FOREIGN DATA WRAPPER postgres_fdw
  OPTIONS (dbname 'postgres', port '$PORT', host '/tmp');
CREATE USER MAPPING FOR CURRENT_USER SERVER loopback OPTIONS (user 'postgres');
CREATE TABLE plain (id int PRIMARY KEY, grp int, v int);
INSERT INTO plain SELECT g, g % 100, 0 FROM generate_series(1, 100000) g;
CREATE TABLE part (id int, grp int, v int) PARTITION BY LIST (grp);
SELECT format('CREATE TABLE part_%s PARTITION OF part FOR VALUES IN (%s)', i, i)
  FROM generate_series(0, 99) i \gexec
INSERT INTO part SELECT g, g % 100, 0 FROM generate_series(1, 100000) g;
CREATE INDEX ON part (id);
VACUUM ANALYZE plain, part;
CREATE FOREIGN TABLE f_plain (id int, grp int, v int)
  SERVER loopback OPTIONS (table_name 'plain');
CREATE FOREIGN TABLE f_part (id int, grp int, v int)
  SERVER loopback OPTIONS (table_name 'part');
SQL

median() {  # $1 = foreign table, $2 = rows
  for i in 1 2 3 4 5; do
    $PSQL -At -c '\timing on' \
      -c "UPDATE $1 SET v = v + 1 WHERE id <= $2 AND random() <= 1" 2>&1 |
      grep -oE 'Time: [0-9.]+' | cut -d' ' -f2
  done | sort -n | sed -n 3p
}
echo "== median of 5 runs (ms)"
for t in f_plain f_part; do
  for n in 1000 10000; do
    printf "   %-8s %5d rows  %10s\n" $t $n "$(median $t $n)"
  done
done

echo "== statements received by the remote side, 1000 rows on f_part"
$PSQL -c "ALTER SYSTEM SET log_min_duration_statement = 0" -c "SELECT pg_reload_conf()" >/dev/null
sleep 1
OFF=$(wc -l < $D/log)
$PSQL -c "UPDATE f_part SET v = v + 1 WHERE id <= 1000 AND random() <= 1" >/dev/null
tail -n +$((OFF + 1)) $D/log | perl -ne '
  next unless /duration: ([0-9.]+) ms\s+(\w+)[^:]*: (.*)/;
  my ($ms, $kind, $sql) = ($1, $2, $3);
  $sql =~ s/\s+/ /g;
  my $k = "$kind " . substr($sql, 0, 60);
  $tot{$k} += $ms; $cnt{$k}++;
  END {
    for my $k (sort { $tot{$b} <=> $tot{$a} } keys %tot) {
      printf "   %9.1f ms  x%-5d %s\n", $tot{$k}, $cnt{$k}, $k;
    }
  }' | head -6

Attachments:

  [text/plain] fdw_update_cost.sh.txt (2.8K, ../../179008834802.2666470.8606898268993687598@gmail.com/2-fdw_update_cost.sh.txt)
  download | inline:
#!/bin/bash
# usage: fdw_update_cost.sh <install prefix> <port>
#
# Cost of a non-direct UPDATE through postgres_fdw, on a loopback server.
# Remote tables: a plain one and one with 100 list partitions, 100000 rows
# each.  "random() <= 1" keeps the UPDATE from being shipped whole.
#
# 1. Median of 5 runs, for 1000 and 10000 rows, on each table.
# 2. For 1000 rows on the partitioned table, the statements the remote
#    side received (log_min_duration_statement = 0), grouped, with their
#    total time and count.
P=$1; PORT=$2
D=/tmp/fdwcost_$PORT
rm -rf $D
$P/bin/initdb -D $D -U postgres --no-sync >/dev/null 2>&1
cat >> $D/postgresql.conf <<EOF
shared_buffers = 512MB
fsync = off
synchronous_commit = off
EOF
$P/bin/pg_ctl -D $D -o "-p $PORT -k /tmp" -l $D/log -w start >/dev/null
trap '$P/bin/pg_ctl -D $D -m immediate -w stop >/dev/null 2>&1; rm -rf $D' EXIT
PSQL="$P/bin/psql -h /tmp -p $PORT -U postgres -X -q postgres"

$PSQL <<SQL
CREATE EXTENSION postgres_fdw;
CREATE SERVER loopback FOREIGN DATA WRAPPER postgres_fdw
  OPTIONS (dbname 'postgres', port '$PORT', host '/tmp');
CREATE USER MAPPING FOR CURRENT_USER SERVER loopback OPTIONS (user 'postgres');
CREATE TABLE plain (id int PRIMARY KEY, grp int, v int);
INSERT INTO plain SELECT g, g % 100, 0 FROM generate_series(1, 100000) g;
CREATE TABLE part (id int, grp int, v int) PARTITION BY LIST (grp);
SELECT format('CREATE TABLE part_%s PARTITION OF part FOR VALUES IN (%s)', i, i)
  FROM generate_series(0, 99) i \gexec
INSERT INTO part SELECT g, g % 100, 0 FROM generate_series(1, 100000) g;
CREATE INDEX ON part (id);
VACUUM ANALYZE plain, part;
CREATE FOREIGN TABLE f_plain (id int, grp int, v int)
  SERVER loopback OPTIONS (table_name 'plain');
CREATE FOREIGN TABLE f_part (id int, grp int, v int)
  SERVER loopback OPTIONS (table_name 'part');
SQL

median() {  # $1 = foreign table, $2 = rows
  for i in 1 2 3 4 5; do
    $PSQL -At -c '\timing on' \
      -c "UPDATE $1 SET v = v + 1 WHERE id <= $2 AND random() <= 1" 2>&1 |
      grep -oE 'Time: [0-9.]+' | cut -d' ' -f2
  done | sort -n | sed -n 3p
}
echo "== median of 5 runs (ms)"
for t in f_plain f_part; do
  for n in 1000 10000; do
    printf "   %-8s %5d rows  %10s\n" $t $n "$(median $t $n)"
  done
done

echo "== statements received by the remote side, 1000 rows on f_part"
$PSQL -c "ALTER SYSTEM SET log_min_duration_statement = 0" -c "SELECT pg_reload_conf()" >/dev/null
sleep 1
OFF=$(wc -l < $D/log)
$PSQL -c "UPDATE f_part SET v = v + 1 WHERE id <= 1000 AND random() <= 1" >/dev/null
tail -n +$((OFF + 1)) $D/log | perl -ne '
  next unless /duration: ([0-9.]+) ms\s+(\w+)[^:]*: (.*)/;
  my ($ms, $kind, $sql) = ($1, $2, $3);
  $sql =~ s/\s+/ /g;
  my $k = "$kind " . substr($sql, 0, 60);
  $tot{$k} += $ms; $cnt{$k}++;
  END {
    for my $k (sort { $tot{$b} <=> $tot{$a} } keys %tot) {
      printf "   %9.1f ms  x%-5d %s\n", $tot{$k}, $cnt{$k}, $k;
    }
  }' | head -6

^ permalink  raw  reply  [nested|flat] 30+ messages in thread


end of thread, other threads:[~2026-09-22 14:45 UTC | newest]

Thread overview: 30+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2025-07-18 15:53 [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
2025-07-23 10:38 ` Etsuro Fujita <etsuro.fujita@gmail.com>
2025-07-27 10:35   ` Etsuro Fujita <etsuro.fujita@gmail.com>
2025-07-29 15:48   ` Jehan-Guillaume de Rorthais <jgdr@dalibo.com>
2025-08-06 11:25     ` Etsuro Fujita <etsuro.fujita@gmail.com>
2026-04-21 11:34       ` Nikita Malakhov <hukutoc@gmail.com>
2026-05-14 15:05         ` Nikita Malakhov <hukutoc@gmail.com>
2026-05-14 17:22           ` Nikita Malakhov <hukutoc@gmail.com>
2026-06-01 10:44             ` Etsuro Fujita <etsuro.fujita@gmail.com>
2026-06-05 11:59               ` Etsuro Fujita <etsuro.fujita@gmail.com>
2026-06-06 20:33                 ` Nikita Malakhov <hukutoc@gmail.com>
2026-06-08 12:45                   ` Etsuro Fujita <etsuro.fujita@gmail.com>
2026-06-10 05:37                 ` Michael Paquier <michael@paquier.xyz>
2026-06-10 11:22                   ` Etsuro Fujita <etsuro.fujita@gmail.com>
2026-06-10 22:25                     ` Michael Paquier <michael@paquier.xyz>
2026-06-10 11:30               ` Etsuro Fujita <etsuro.fujita@gmail.com>
2026-06-10 22:30                 ` Michael Paquier <michael@paquier.xyz>
2026-06-13 19:43                   ` Nikita Malakhov <hukutoc@gmail.com>
2026-06-15 14:54                     ` Etsuro Fujita <etsuro.fujita@gmail.com>
2026-07-09 14:03                       ` Nikita Malakhov <hukutoc@gmail.com>
2026-08-27 09:20                         ` Ajit Awekar <ajitpostgres@gmail.com>
2026-08-27 14:36                           ` Nikita Malakhov <hukutoc@gmail.com>
2026-08-28 05:43                             ` Ajit Awekar <ajitpostgres@gmail.com>
2026-09-04 13:37                               ` Jakub Wartak <jakub.wartak@enterprisedb.com>
2026-09-07 03:07                                 ` shihao zhong <zhong950419@gmail.com>
2026-09-10 12:21                                   ` Ajit Awekar <ajitpostgres@gmail.com>
2026-09-16 12:02                                     ` Jakub Wartak <jakub.wartak@enterprisedb.com>
2026-09-16 13:15                                       ` Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
2026-09-22 08:44                                         ` Ajit Awekar <ajitpostgres@gmail.com>
2026-09-22 14:45                                           ` Manu <manuelreyesbravo@gmail.com>

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox