From 79836b40bcdc7857badaeb8f819aca723f83d779 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Wed, 5 Aug 2026 15:29:38 -0300
Subject: [PATCH v16 3/3] postgres_fdw: use COPY to bulk-load foreign tables in
 COPY FROM

Implement the ExecForeignBatchCopy callback so that COPY FROM into a
foreign table transfers rows to the remote server with the COPY protocol
(COPY ... FROM STDIN) instead of inserting them one at a time.
deparseCopySql() builds the remote command and each row is serialized in
COPY text format using CopyEscapeText().

The callback is self-contained: it opens the COPY on the remote
connection, streams the whole batch (buffering locally and flushing once
COPYBUFSIZ bytes accumulate), and closes the COPY before returning.
Because the connection is left idle between calls, this works for a
direct COPY FROM on a foreign table as well as for foreign-table
partitions that share a cached connection.

The path is taken only when batching is enabled for the table
(batch_size > 1); with batching disabled, COPY FROM keeps inserting rows
individually, since issuing one COPY command per row would be slower.

pgfdw_abort_cleanup() is taught to terminate an in-progress COPY on the
connection before it is reused, so an error raised mid-transfer does not
leave the connection stuck in COPY_IN mode.

Author: Matheus Alcantara <mths.dev@pm.me>
Discussion: https://www.postgresql.org/message-id/DDIZJ217OUDK.2R5WE4OGL5PTY%40gmail.com
---
 contrib/postgres_fdw/connection.c             |  41 +++++
 contrib/postgres_fdw/deparse.c                |  55 ++++++
 .../postgres_fdw/expected/postgres_fdw.out    |  92 ++++++++++-
 contrib/postgres_fdw/postgres_fdw.c           | 156 ++++++++++++++++++
 contrib/postgres_fdw/postgres_fdw.h           |   3 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     | 100 +++++++++++
 doc/src/sgml/postgres-fdw.sgml                |  12 +-
 7 files changed, 451 insertions(+), 8 deletions(-)

diff --git a/contrib/postgres_fdw/connection.c b/contrib/postgres_fdw/connection.c
index 094eac2f343..81cf820ca61 100644
--- a/contrib/postgres_fdw/connection.c
+++ b/contrib/postgres_fdw/connection.c
@@ -1896,6 +1896,27 @@ pgfdw_abort_cleanup(ConnCacheEntry *entry, bool toplevel)
 	/* Assume we might have lost track of prepared statements */
 	entry->have_error = true;
 
+	/*
+	 * If a COPY FROM STDIN started by ExecForeignBatchCopy is still open on
+	 * this connection, terminate it before we can send any other commands. We
+	 * send a COPY end with an error message so the remote aborts it.
+	 */
+	if (entry->state.copy_in_progress)
+	{
+		if (PQputCopyEnd(entry->conn, "COPY aborted due to local error") == 1)
+		{
+			PGresult   *res;
+
+			/*
+			 * Consume the error result from the aborted COPY.  We don't care
+			 * about the specific error since we're aborting anyway.
+			 */
+			res = PQgetResult(entry->conn);
+			PQclear(res);
+		}
+		entry->state.copy_in_progress = false;
+	}
+
 	/*
 	 * If a command has been submitted to the remote server by using an
 	 * asynchronous execution function, the command might not have yet
@@ -1969,6 +1990,26 @@ pgfdw_abort_cleanup_begin(ConnCacheEntry *entry, bool toplevel,
 	/* Assume we might have lost track of prepared statements */
 	entry->have_error = true;
 
+	/*
+	 * If a COPY FROM STDIN started by ExecForeignBatchCopy is still open on
+	 * this connection, terminate it before we can send any other commands.
+	 */
+	if (entry->state.copy_in_progress)
+	{
+		if (PQputCopyEnd(entry->conn, "COPY aborted due to local error") == 1)
+		{
+			PGresult   *res;
+
+			/*
+			 * Consume the error result from the aborted COPY.  We don't care
+			 * about the specific error since we're aborting anyway.
+			 */
+			res = PQgetResult(entry->conn);
+			PQclear(res);
+		}
+		entry->state.copy_in_progress = false;
+	}
+
 	/*
 	 * If a command has been submitted to the remote server by using an
 	 * asynchronous execution function, the command might not have yet
diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index 9d1932230e7..bcbc531c241 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -2197,6 +2197,61 @@ deparseInsertSql(StringInfo buf, RangeTblEntry *rte,
 						 withCheckOptionList, returningList, retrieved_attrs);
 }
 
+/*
+ *  Build a COPY FROM STDIN statement using the TEXT format
+ */
+void
+deparseCopySql(StringInfo buf, Relation rel, List *target_attrs)
+{
+	Oid			relid = RelationGetRelid(rel);
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	bool		first = true;
+
+	appendStringInfo(buf, "COPY ");
+	deparseRelation(buf, rel);
+
+	/*
+	 * Emit the column list, skipping generated columns (which the remote
+	 * server fills in itself). The opening parenthesis is emitted lazily so
+	 * that a relation whose transmittable columns are all generated produces
+	 * no (empty) column list at all.
+	 */
+	foreach_int(attnum, target_attrs)
+	{
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, attnum - 1);
+		char	   *colname;
+		List	   *options;
+		ListCell   *lc;
+
+		if (attr->attgenerated)
+			continue;
+
+		appendStringInfoString(buf, first ? "(" : ", ");
+		first = false;
+
+		/* Use attribute name or column_name option. */
+		colname = NameStr(attr->attname);
+		options = GetForeignColumnOptions(relid, attnum);
+		foreach(lc, options)
+		{
+			DefElem    *def = (DefElem *) lfirst(lc);
+
+			if (strcmp(def->defname, "column_name") == 0)
+			{
+				colname = defGetString(def);
+				break;
+			}
+		}
+
+		appendStringInfoString(buf, quote_identifier(colname));
+	}
+	if (!first)
+		appendStringInfoChar(buf, ')');
+
+	appendStringInfoString(buf, " FROM STDIN (FORMAT TEXT)");
+}
+
+
 /*
  * rebuild remote INSERT statement
  *
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index d19121b05da..bcd10b6f43f 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -7829,6 +7829,32 @@ select * from grem1;
 (2 rows)
 
 delete from grem1;
+-- test that fdw also use COPY FROM as a remote sql
+alter foreign table grem1 options (add batch_size '10'); -- add batch size to enable COPY
+set client_min_messages to 'log';
+create function insert_or_copy() returns trigger as $$
+declare query text;
+begin
+    query := current_query();
+    raise notice '%', query;
+return new;
+end;
+$$ language plpgsql;
+CREATE TRIGGER trig_row_before
+BEFORE INSERT OR UPDATE OR DELETE ON gloc1
+FOR EACH ROW EXECUTE PROCEDURE insert_or_copy();
+copy grem1 from stdin;
+LOG:  received message via remote connection: NOTICE:  COPY public.gloc1(a) FROM STDIN (FORMAT TEXT)
+drop trigger trig_row_before on gloc1;
+reset client_min_messages;
+alter foreign table grem1 options (drop batch_size);
+-- test that copy does not fail with column_name alias
+create table gloc2(xxx int);
+create foreign table grem2(a int) server loopback options(table_name 'gloc2');
+alter foreign table grem2 alter column a options (column_name 'xxx');
+alter foreign table grem2 options (add batch_size '10'); -- enable COPY
+copy grem2 from stdin;
+alter foreign table grem2 options (drop batch_size);
 -- test batch insert
 alter server loopback options (add batch_size '10');
 explain (verbose, costs off)
@@ -7846,16 +7872,18 @@ insert into grem1 (a) values (1), (2);
 select * from gloc1;
  a | b | c 
 ---+---+---
+ 3 | 6 |  
  1 | 2 |  
  2 | 4 |  
-(2 rows)
+(3 rows)
 
 select * from grem1;
  a | b | c 
 ---+---+---
+ 3 | 6 | 9
  1 | 2 | 3
  2 | 4 | 6
-(2 rows)
+(3 rows)
 
 delete from grem1;
 -- batch insert with foreign partitions.
@@ -7880,6 +7908,12 @@ select count(*) from tab_batch_sharded;
 drop table tab_batch_local;
 drop table tab_batch_sharded;
 drop table tab_batch_sharded_p1_remote;
+-- test batch insert using copy
+set client_min_messages to 'debug1';
+copy grem1 from stdin;
+DEBUG:  postgres_fdw: COPY 10 rows into foreign table
+DEBUG:  postgres_fdw: COPY 2 rows into foreign table
+reset client_min_messages;
 alter server loopback options (drop batch_size);
 -- ===================================================================
 -- test local triggers
@@ -9762,6 +9796,18 @@ select * from rem2;
 (2 rows)
 
 delete from rem2;
+-- Test invalid data types to assert that COPY protocol is finished on
+-- transaction abort
+alter foreign table rem2 options (add batch_size '10'); -- enable COPY
+copy rem2 from stdin;
+ERROR:  invalid input syntax for type integer: "bar"
+CONTEXT:  COPY rem2, line 2, column f1: "bar"
+alter foreign table rem2 options (drop batch_size);
+select * from rem2;
+ f1 | f2 
+----+----
+(0 rows)
+
 -- Test check constraints
 alter table loc2 add constraint loc2_f1positive check (f1 >= 0);
 alter foreign table rem2 add constraint rem2_f1positive check (f1 >= 0);
@@ -9927,7 +9973,8 @@ copy rem2 from stdin;
 copy rem2 from stdin; -- ERROR
 ERROR:  new row for relation "loc2" violates check constraint "loc2_f1positive"
 DETAIL:  Failing row contains (-1, xyzzy).
-CONTEXT:  remote SQL command: INSERT INTO public.loc2(f1, f2) VALUES ($1, $2)
+CONTEXT:  COPY loc2, line 1: "-1	xyzzy"
+remote SQL command: COPY public.loc2(f1, f2) FROM STDIN (FORMAT TEXT)
 COPY rem2
 select * from rem2;
  f1 | f2  
@@ -9966,6 +10013,45 @@ select * from rem2;
 
 drop trigger trig_null on loc2;
 delete from rem2;
+-- Test COPY FROM with column list and special characters
+alter foreign table rem2 options (add batch_size '10'); -- add batch size to enable COPY
+copy rem2 (f1, f2) from stdin;
+select * from rem2;
+ f1 |  f2   
+----+-------
+  1 | hello+
+    | world
+(1 row)
+
+delete from rem2;
+-- Test COPY with NULL and special characters
+copy rem2 from stdin;
+select * from rem2;
+ f1 | f2  
+----+-----
+  1 | 
+    | bar
+  3 | a"b
+(3 rows)
+
+delete from rem2;
+alter foreign table rem2 options (drop batch_size);
+-- Test that float numbers do not loose precision when sending to the foreign
+-- server
+create table f(a float);
+create foreign table f_fdw(a float) server loopback options(table_name 'f');
+alter foreign table f_fdw options (add batch_size '10'); -- enable COPY
+set extra_float_digits = 0;
+copy f_fdw from stdin;
+reset extra_float_digits;
+alter foreign table f_fdw options (drop batch_size);
+select * from f;
+         a          
+--------------------
+ 1.0000000000000002
+(1 row)
+
+drop table f;
 -- Check with zero-column foreign table; batch insert will be disabled
 alter table loc2 drop column f1;
 alter table loc2 drop column f2;
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 8b660a6c02c..9b79f68f746 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -18,6 +18,7 @@
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/pg_opfamily.h"
+#include "commands/copy.h"
 #include "commands/defrem.h"
 #include "commands/explain_format.h"
 #include "commands/explain_state.h"
@@ -26,6 +27,7 @@
 #include "executor/instrument.h"
 #include "foreign/fdwapi.h"
 #include "funcapi.h"
+#include "mb/pg_wchar.h"
 #include "miscadmin.h"
 #include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
@@ -69,6 +71,9 @@ PG_MODULE_MAGIC_EXT(
 /* If no remote estimates, assume a sort costs 20% extra */
 #define DEFAULT_FDW_SORT_MULTIPLIER 1.2
 
+/* Buffer size to send COPY IN data */
+#define COPYBUFSIZ 8192
+
 /*
  * Indexes of FDW-private information stored in fdw_private lists.
  *
@@ -433,6 +438,12 @@ static void postgresBeginForeignInsert(ModifyTableState *mtstate,
 									   ResultRelInfo *resultRelInfo);
 static void postgresEndForeignInsert(EState *estate,
 									 ResultRelInfo *resultRelInfo);
+
+static void postgresExecForeignBatchCopy(EState *estate,
+										 ResultRelInfo *resultRelInfo,
+										 TupleTableSlot **slots,
+										 int numSlots);
+
 static int	postgresIsForeignRelUpdatable(Relation rel);
 static bool postgresPlanDirectModify(PlannerInfo *root,
 									 ModifyTable *plan,
@@ -638,6 +649,9 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_o,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
+static void convert_slot_to_copy_text(StringInfo buf,
+									  PgFdwModifyState *fmstate,
+									  TupleTableSlot *slot);
 
 
 /*
@@ -670,6 +684,7 @@ postgres_fdw_handler(PG_FUNCTION_ARGS)
 	routine->EndForeignModify = postgresEndForeignModify;
 	routine->BeginForeignInsert = postgresBeginForeignInsert;
 	routine->EndForeignInsert = postgresEndForeignInsert;
+	routine->ExecForeignBatchCopy = postgresExecForeignBatchCopy;
 	routine->IsForeignRelUpdatable = postgresIsForeignRelUpdatable;
 	routine->PlanDirectModify = postgresPlanDirectModify;
 	routine->BeginDirectModify = postgresBeginDirectModify;
@@ -2243,6 +2258,94 @@ postgresEndForeignModify(EState *estate,
 	finish_foreign_modify(fmstate);
 }
 
+/*
+ * postgresExecForeignBatchCopy
+ *		Insert a batch of tuples into a foreign table using the COPY protocol.
+ *
+ * This is a self-contained operation: it opens a COPY ... FROM STDIN on the
+ * remote connection, streams all the given tuples, and closes the COPY, all
+ * within a single call.  The connection is therefore left idle again once we
+ * return, which lets several foreign partitions (or local code that runs
+ * between batches, such as triggers) safely share the same cached connection.
+ */
+static void
+postgresExecForeignBatchCopy(EState *estate,
+							 ResultRelInfo *resultRelInfo,
+							 TupleTableSlot **slots,
+							 int numSlots)
+{
+	PgFdwModifyState *fmstate = (PgFdwModifyState *) resultRelInfo->ri_FdwState;
+	StringInfoData sql;
+	StringInfoData buf;
+	PGresult   *res;
+	int			nestlevel;
+	int			i;
+
+	elog(DEBUG1, "postgres_fdw: COPY %d rows into foreign table", numSlots);
+
+	/* Build the COPY ... FROM STDIN command for this relation. */
+	initStringInfo(&sql);
+	deparseCopySql(&sql, fmstate->rel, fmstate->target_attrs);
+
+	/* Make sure any constants are printed portably. */
+	nestlevel = set_transmission_modes();
+
+	/* Start the COPY protocol. */
+	if (!PQsendQuery(fmstate->conn, sql.data))
+		pgfdw_report_error(NULL, fmstate->conn, sql.data);
+
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COPY_IN)
+		pgfdw_report_error(res, fmstate->conn, sql.data);
+	PQclear(res);
+
+	/*
+	 * The connection is now in COPY_IN mode.  Record that so that, if an
+	 * error is thrown before we finish, transaction abort cleanup knows it
+	 * must terminate the COPY before the connection can be reused.
+	 */
+	fmstate->conn_state->copy_in_progress = true;
+
+	/* Stream all the rows, flushing the buffer when it grows large. */
+	initStringInfo(&buf);
+	for (i = 0; i < numSlots; i++)
+	{
+		convert_slot_to_copy_text(&buf, fmstate, slots[i]);
+
+		if (buf.len >= COPYBUFSIZ)
+		{
+			if (PQputCopyData(fmstate->conn, buf.data, buf.len) <= 0)
+				pgfdw_report_error(NULL, fmstate->conn, sql.data);
+			resetStringInfo(&buf);
+		}
+	}
+	if (buf.len > 0)
+	{
+		if (PQputCopyData(fmstate->conn, buf.data, buf.len) <= 0)
+			pgfdw_report_error(NULL, fmstate->conn, sql.data);
+	}
+
+	/* Close the COPY protocol and check for success. */
+	if (PQputCopyEnd(fmstate->conn, NULL) < 0 || PQflush(fmstate->conn))
+		pgfdw_report_error(NULL, fmstate->conn, sql.data);
+
+	/*
+	 * The COPY termination has been sent, so the connection is no longer in
+	 * COPY_IN mode; abort cleanup no longer needs to terminate it.
+	 */
+	fmstate->conn_state->copy_in_progress = false;
+
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pgfdw_report_error(res, fmstate->conn, sql.data);
+	PQclear(res);
+
+	reset_transmission_modes(nestlevel);
+
+	pfree(buf.data);
+	pfree(sql.data);
+}
+
 /*
  * postgresBeginForeignInsert
  *		Begin an insert operation on a foreign table
@@ -8766,3 +8869,56 @@ get_batch_size_option(Relation rel)
 
 	return batch_size;
 }
+
+/*
+ *  Write target attribute values from fmstate into buf buffer to be sent as
+ *  COPY FROM STDIN data
+ */
+static void
+convert_slot_to_copy_text(StringInfo buf,
+						  PgFdwModifyState *fmstate,
+						  TupleTableSlot *slot)
+{
+	TupleDesc	tupdesc = RelationGetDescr(fmstate->rel);
+	bool		first = true;
+	int			i = 0;
+
+	foreach_int(attnum, fmstate->target_attrs)
+	{
+		CompactAttribute *attr = TupleDescCompactAttr(tupdesc, attnum - 1);
+		Datum		datum;
+		bool		isnull;
+
+		/* Ignore generated columns; they are set to DEFAULT */
+		if (attr->attgenerated)
+			continue;
+
+		if (!first)
+			appendStringInfoCharMacro(buf, '\t');
+		first = false;
+
+		datum = slot_getattr(slot, attnum, &isnull);
+
+		if (isnull)
+			appendStringInfoString(buf, "\\N");
+		else
+		{
+			const char *value = OutputFunctionCall(&fmstate->p_flinfo[i],
+												   datum);
+
+			/*
+			 * Append a string to buf, escaping special characters for COPY
+			 * TEXT format.
+			 */
+			CopyEscapeText(buf,
+						   value,
+						   '\t',
+						   GetDatabaseEncoding(),
+						   false,
+						   false);
+		}
+		i++;
+	}
+
+	appendStringInfoCharMacro(buf, '\n');
+}
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index a2bb1ff352c..4ded16481dc 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -137,6 +137,8 @@ typedef struct PgFdwRelationInfo
 typedef struct PgFdwConnState
 {
 	AsyncRequest *pendingAreq;	/* pending async request */
+	bool		copy_in_progress;	/* true while a COPY FROM STDIN is open on
+									 * the connection */
 } PgFdwConnState;
 
 /*
@@ -204,6 +206,7 @@ extern void rebuildInsertSql(StringInfo buf, Relation rel,
 							 char *orig_query, List *target_attrs,
 							 int values_end_len, int num_params,
 							 int num_rows);
+extern void deparseCopySql(StringInfo buf, Relation rel, List *target_attrs);
 extern void deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 							 Index rtindex, Relation rel,
 							 List *targetAttrs,
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index e7019952173..d16f0a88db9 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -2063,6 +2063,41 @@ select * from gloc1;
 select * from grem1;
 delete from grem1;
 
+-- test that fdw also use COPY FROM as a remote sql
+alter foreign table grem1 options (add batch_size '10'); -- add batch size to enable COPY
+set client_min_messages to 'log';
+
+create function insert_or_copy() returns trigger as $$
+declare query text;
+begin
+    query := current_query();
+    raise notice '%', query;
+return new;
+end;
+$$ language plpgsql;
+
+CREATE TRIGGER trig_row_before
+BEFORE INSERT OR UPDATE OR DELETE ON gloc1
+FOR EACH ROW EXECUTE PROCEDURE insert_or_copy();
+
+copy grem1 from stdin;
+3
+\.
+
+drop trigger trig_row_before on gloc1;
+reset client_min_messages;
+alter foreign table grem1 options (drop batch_size);
+
+-- test that copy does not fail with column_name alias
+create table gloc2(xxx int);
+create foreign table grem2(a int) server loopback options(table_name 'gloc2');
+alter foreign table grem2 alter column a options (column_name 'xxx');
+alter foreign table grem2 options (add batch_size '10'); -- enable COPY
+copy grem2 from stdin;
+1
+\.
+alter foreign table grem2 options (drop batch_size);
+
 -- test batch insert
 alter server loopback options (add batch_size '10');
 explain (verbose, costs off)
@@ -2089,6 +2124,24 @@ drop table tab_batch_local;
 drop table tab_batch_sharded;
 drop table tab_batch_sharded_p1_remote;
 
+-- test batch insert using copy
+set client_min_messages to 'debug1';
+copy grem1 from stdin;
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+\.
+reset client_min_messages;
+
 alter server loopback options (drop batch_size);
 
 -- ===================================================================
@@ -2956,6 +3009,16 @@ select * from rem2;
 
 delete from rem2;
 
+-- Test invalid data types to assert that COPY protocol is finished on
+-- transaction abort
+alter foreign table rem2 options (add batch_size '10'); -- enable COPY
+copy rem2 from stdin;
+1	foo
+bar	2
+\.
+alter foreign table rem2 options (drop batch_size);
+select * from rem2;
+
 -- Test check constraints
 alter table loc2 add constraint loc2_f1positive check (f1 >= 0);
 alter foreign table rem2 add constraint rem2_f1positive check (f1 >= 0);
@@ -3153,6 +3216,43 @@ drop trigger trig_null on loc2;
 
 delete from rem2;
 
+-- Test COPY FROM with column list and special characters
+alter foreign table rem2 options (add batch_size '10'); -- add batch size to enable COPY
+copy rem2 (f1, f2) from stdin;
+1	hello\nworld
+\.
+select * from rem2;
+
+delete from rem2;
+
+-- Test COPY with NULL and special characters
+copy rem2 from stdin;
+1	\N
+\N	bar
+3	a"b
+\.
+select * from rem2;
+
+delete from rem2;
+
+alter foreign table rem2 options (drop batch_size);
+
+-- Test that float numbers do not loose precision when sending to the foreign
+-- server
+create table f(a float);
+create foreign table f_fdw(a float) server loopback options(table_name 'f');
+
+alter foreign table f_fdw options (add batch_size '10'); -- enable COPY
+set extra_float_digits = 0;
+copy f_fdw from stdin;
+1.0000000000000002
+\.
+reset extra_float_digits;
+alter foreign table f_fdw options (drop batch_size);
+select * from f;
+
+drop table f;
+
 -- Check with zero-column foreign table; batch insert will be disabled
 alter table loc2 drop column f1;
 alter table loc2 drop column f2;
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index 8b0669f672d..9593882066e 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -467,11 +467,13 @@ OPTIONS (ADD password_required 'false');
       </para>
 
       <para>
-       This option also applies when copying into foreign tables.  In that case
-       the actual number of rows <filename>postgres_fdw</filename> copies at
-       once is determined in a similar way to the insert case, but it is
-       limited to at most 1000 due to implementation restrictions of the
-       <command>COPY</command> command.
+       This option also applies when copying into foreign tables.  When
+       <command>COPY FROM</command> is executed on a foreign table (or routed
+       into a foreign-table partition) that has this option set greater than
+       one and has no row-level triggers, <filename>postgres_fdw</filename> uses
+       the <command>COPY</command> protocol to transfer data to the remote
+       server, which is significantly faster than inserting rows individually.
+       Otherwise the rows are inserted individually.
       </para>
      </listitem>
     </varlistentry>
-- 
2.50.1 (Apple Git-155)

