postgres_fdw: Use COPY to speed up batch inserts

Started by Matheus Alcantara3 months ago22 messages
#1Matheus Alcantara
matheusssilv97@gmail.com
2 attachment(s)

Hi all,

Currently on postgres_fdw we use prepared statements to insert batches
into foreign tables. Although this works fine for the most use cases the
COPY command can also be used in some scenarios to speed up large batch
inserts.

The attached patch implements this idea of using the COPY command for
batch inserts on postgres_fdw foreign tables. I've performed some
benchmarks using pgbench and the results seem good to consider this.

I've performed the benchmark using different batch_size values to see
when this optimization could be useful. The following results are the
best tps of 3 runs.

Command: pgbench -n -c 10 -j 10 -t 100 -f bench.sql postgres

batch_size: 10
    master tps: 76.360406
    patch tps: 68.917109

batch_size: 100
    master tps: 123.427731
    patch tps: 243.737055

batch_size: 1000
    master tps: 132.500506
    patch tps: 239.295132

It seems that using a batch_size greater than 100 we can have a
considerable speed up for batch inserts.

The attached patch uses the COPY command whenever we have a *numSlots >
1 but the tests show that maybe we should have a GUC to enable this?

I also think that we can have a better patch by removing the duplicated
code introduced on this first version, specially on the clean up phase,
but I tried to keep things more simple on this initial phase to keep the
review more easier and also just to test the idea.

Lastly, I don't know if we should change the EXPLAIN(ANALYZE, VERBOSE)
output for batch inserts that use the COPY to mention that we are
sending the COPY command to the remote server. I guess so?

(this proposal is based on a patch idea written by Tomas Vondra in one
of his blogs posts)

--
Matheus Alcantara

Attachments:

v1-0001-postgres_fdw-Use-COPY-to-speed-up-batch-inserts.patchtext/plain; charset=utf-8; name=v1-0001-postgres_fdw-Use-COPY-to-speed-up-batch-inserts.patchDownload
From d4041814ba377475a1fa36b6972d5cf5f989a38f Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Fri, 10 Oct 2025 16:07:08 -0300
Subject: [PATCH v1] postgres_fdw: Use COPY to speed up batch inserts

---
 contrib/postgres_fdw/deparse.c      |  32 +++++++++
 contrib/postgres_fdw/postgres_fdw.c | 108 ++++++++++++++++++++++++++++
 contrib/postgres_fdw/postgres_fdw.h |   1 +
 3 files changed, 141 insertions(+)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index e5b5e1a5f51..cd80bb7306a 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -2238,6 +2238,38 @@ rebuildInsertSql(StringInfo buf, Relation rel,
 	appendStringInfoString(buf, orig_query + values_end_len);
 }
 
+/*
+ *  Build a COPY FROM STDIN statement using the TEXT format
+ */
+void
+buildCopySql(StringInfo buf, Relation rel, List *target_attrs)
+{
+	ListCell   *lc;
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	bool		first = true;
+
+	appendStringInfo(buf, "COPY ");
+	deparseRelation(buf, rel);
+	appendStringInfo(buf, "(");
+
+	foreach(lc, target_attrs)
+	{
+		int			attnum = lfirst_int(lc);
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, attnum - 1);
+
+		if (attr->attgenerated)
+			continue;
+
+		if (!first)
+			appendStringInfoString(buf, ", ");
+
+		first = false;
+
+		appendStringInfoString(buf, quote_identifier(NameStr(attr->attname)));
+	}
+	appendStringInfoString(buf, ") FROM STDIN (FORMAT TEXT, DELIMITER ',')");
+}
+
 /*
  * deparse remote UPDATE statement
  *
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 456b267f70b..985c9bc5be7 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -4066,6 +4066,50 @@ create_foreign_modify(EState *estate,
 	return fmstate;
 }
 
+/*
+ *  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)
+{
+	ListCell   *lc;
+	TupleDesc	tupdesc = RelationGetDescr(fmstate->rel);
+	bool		first = true;
+
+	foreach(lc, fmstate->target_attrs)
+	{
+		int			attnum = lfirst_int(lc);
+		CompactAttribute *attr = TupleDescCompactAttr(tupdesc, attnum - 1);
+		Datum		datum;
+		bool		isnull;
+
+		/* Ignore generated columns; they are set to DEFAULT */
+		if (attr->attgenerated)
+			continue;
+
+		if (!first)
+			appendStringInfoString(buf, ",");
+		first = false;
+
+		datum = slot_getattr(slot, attnum, &isnull);
+
+		if (isnull)
+			appendStringInfoString(buf, "\\N");
+		else
+		{
+			const char *value = OutputFunctionCall(&fmstate->p_flinfo[attnum - 1],
+												   datum);
+
+			appendStringInfoString(buf, value);
+		}
+	}
+
+	appendStringInfoChar(buf, '\n');
+}
+
 /*
  * execute_foreign_modify
  *		Perform foreign-table modification as required, and fetch RETURNING
@@ -4097,6 +4141,70 @@ execute_foreign_modify(EState *estate,
 	if (fmstate->conn_state->pendingAreq)
 		process_pending_request(fmstate->conn_state->pendingAreq);
 
+	/*
+	 * Use COPY command for batch insert if the original query don't include a
+	 * RETURNING clause
+	 */
+	if (operation == CMD_INSERT && *numSlots > 1 && !fmstate->has_returning)
+	{
+		int			i;
+		StringInfoData copy_data;
+
+		/* Build COPY command */
+		initStringInfo(&sql);
+		buildCopySql(&sql, fmstate->rel, fmstate->target_attrs);
+
+		/* Send COPY command */
+		if (!PQsendQuery(fmstate->conn, sql.data))
+			pgfdw_report_error(NULL, fmstate->conn, sql.data);
+
+		/* get the COPY result */
+		res = pgfdw_get_result(fmstate->conn);
+		if (PQresultStatus(res) != PGRES_COPY_IN)
+			pgfdw_report_error(res, fmstate->conn, sql.data);
+
+		/* Convert the TupleTableSlot data into a TEXT-formatted line */
+		initStringInfo(&copy_data);
+		for (i = 0; i < *numSlots; i++)
+		{
+			/*
+			 * XXX(matheus): Should we have a COPYBUFSIZ limit to send large
+			 * data in batches instead of grow the buffer too much?
+			 */
+			convert_slot_to_copy_text(&copy_data, fmstate, slots[i]);
+		}
+
+		/* Send COPY data */
+		if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+			pgfdw_report_error(NULL, fmstate->conn, sql.data);
+
+		/* End the COPY operation */
+		if (PQputCopyEnd(fmstate->conn, NULL) < 0 || PQflush(fmstate->conn))
+			pgfdw_report_error(NULL, fmstate->conn, sql.data);
+
+		/*
+		 * Get the result, and check for success.
+		 */
+		res = pgfdw_get_result(fmstate->conn);
+		if (PQresultStatus(res) != PGRES_COMMAND_OK)
+			pgfdw_report_error(res, fmstate->conn, sql.data);
+
+		n_rows = atoi(PQcmdTuples(res));
+
+		/* And clean up */
+		PQclear(res);
+
+		MemoryContextReset(fmstate->temp_cxt);
+
+		*numSlots = n_rows;
+
+		/*
+		 * Return NULL if nothing was inserted/updated/deleted on the remote
+		 * end
+		 */
+		return (n_rows > 0) ? slots : NULL;
+	}
+
 	/*
 	 * If the existing query was deparsed and prepared for a different number
 	 * of rows, rebuild it for the proper number.
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index e69735298d7..c0198b865f3 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -204,6 +204,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 buildCopySql(StringInfo buf, Relation rel, List *target_attrs);
 extern void deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 							 Index rtindex, Relation rel,
 							 List *targetAttrs,
-- 
2.51.0

bench.sqlapplication/x-sql; name=bench.sqlDownload
#2Tomas Vondra
tomas@vondra.me
In reply to: Matheus Alcantara (#1)
2 attachment(s)
Re: postgres_fdw: Use COPY to speed up batch inserts

Hi Matheus,

Thanks for the patch. Please add it to the next committfest (PG19-3) at

https://commitfest.postgresql.org/

so that we don't lose track of the patch.

On 10/15/25 17:02, Matheus Alcantara wrote:

Hi all,

Currently on postgres_fdw we use prepared statements to insert batches
into foreign tables. Although this works fine for the most use cases the
COPY command can also be used in some scenarios to speed up large batch
inserts.

Makes sense.

The attached patch implements this idea of using the COPY command for
batch inserts on postgres_fdw foreign tables. I've performed some
benchmarks using pgbench and the results seem good to consider this.

Thanks. The code looks sensible in general, I think. I'll have a couple
minor comments. It'd be good to also update the documentation, and add
some tests to postgres_fdw.sql, to exercise this new code.

The sgml docs (doc/src/sgml/postgres-fdw.sgml) mention batch_size, and
explain how it's related to the number of parameters in the INSERT
state. Which does not matter when using COPY under the hood, so this
should be amended/clarified in some way. It doesn't need to be
super-detailed, though.

A couple minor comments about the code:

1) buildCopySql

- Does this really need the "(FORMAT TEXT, DELIMITER ',')" part? AFAIK
no, if we use the default copy format in convert_slot_to_copy_text.

- Shouldn't we cache the COPY SQL, similarly to how we keep insert SQL?
Instead of rebuilding it over and over for every batch.

2) convert_slot_to_copy_text

- It's probably better to not hard-code the delimiters etc.

- I wonder if the formatting needs to do something more like what
copyto.c does (through CopyToTextOneRow and CopyAttributeOutText). Maybe
not, not sure.

3) execute_foreign_modify

- I think the new block of code is a bit confusing. It essentially does
something similar to the original code, but not entirely. I suggest we
move it to a new function, and call that from execute_foreign_modify.

- I agree it's probably better to do COPYBUFSIZ, or something like that
to send the data in smaller (but not tiny) chunks.

I've performed the benchmark using different batch_size values to see
when this optimization could be useful. The following results are the
best tps of 3 runs.

Command: pgbench -n -c 10 -j 10 -t 100 -f bench.sql postgres

batch_size: 10
    master tps: 76.360406
    patch tps: 68.917109

batch_size: 100
    master tps: 123.427731
    patch tps: 243.737055

batch_size: 1000
    master tps: 132.500506
    patch tps: 239.295132

It seems that using a batch_size greater than 100 we can have a
considerable speed up for batch inserts.

I did a bunch of benchmarks too, and I see similar speedups (depending
on the batch and data size). Attached is the script I used to run this.
It runs COPY into a foreign table, pointing to a second instance on the
same machine. And it does that for a range of data sizes, batch sizes,
client counts and logged/unlogged table.

The attached PDF summarizes the results, comparing "copy" build (with
this patch) to "master". There's also two "resourceowner" builds, with
this patch [1]/messages/by-id/84f20db7-7d57-4dc0-8144-7e38e0bbe75d@vondra.me - that helps COPY in general a lot, and it improves the
case with batch_size=1000.

I think the results are good, at least with larger batch sizes. The
regressions with batch_size=10 on UNLOGGED table are not great, though.
I have results from another machine, and there it affects even LOGGED
table. I'm not sure if this inherent, or something the patch can fix.

In a way, it's not surprising - batching with tiny batches adds the
overhead without much benefit. I don't think that kills the patch.

The attached patch uses the COPY command whenever we have a *numSlots >
1 but the tests show that maybe we should have a GUC to enable this?

I can imagine having a GUC for testing, but it's not strictly necessary.

I also think that we can have a better patch by removing the duplicated
code introduced on this first version, specially on the clean up phase,
but I tried to keep things more simple on this initial phase to keep the
review more easier and also just to test the idea.

Lastly, I don't know if we should change the EXPLAIN(ANALYZE, VERBOSE)
output for batch inserts that use the COPY to mention that we are
sending the COPY command to the remote server. I guess so?

Good point. We definitely should not show SQL for INSERT, when we're
actually running a COPY.

regards

[1]: /messages/by-id/84f20db7-7d57-4dc0-8144-7e38e0bbe75d@vondra.me
/messages/by-id/84f20db7-7d57-4dc0-8144-7e38e0bbe75d@vondra.me

--
Tomas Vondra

Attachments:

fdw-copy-results.pdfapplication/pdf; name=fdw-copy-results.pdfDownload
%PDF-1.4
% ����
3
0
obj
<<
/Type
/Catalog
/Names
<<
>>
/PageLabels
<<
/Nums
[
0
<<
/S
/D
/St
1
>>
]
>>
/Outlines
2
0
R
/Pages
1
0
R
>>
endobj
4
0
obj
<<
/Creator
(��Google Sheets)
/Title
(��Untitled spreadsheet)
>>
endobj
5
0
obj
<<
/Type
/Page
/Parent
1
0
R
/MediaBox
[
0
0
792
612
]
/Contents
6
0
R
/Resources
7
0
R
/Annots
9
0
R
/Group
<<
/S
/Transparency
/CS
/DeviceRGB
>>
>>
endobj
6
0
obj
<<
/Filter
/FlateDecode
/Length
8
0
R
>>
stream
x�����$�����w���fnK>�]S(r�YTR8�����
�jDVt����/�V���0������n}���}������������_���?n_[�����_	����nG��m������w��D�}��������/�/�������"�?������n������G�|������K��
���5z����gd������m���IP?�H���O��K-�O�R������/�Z����#�g�����w7��?�?	_/r��9�������"�o�3��?�?	_/r��9�������"�p��"�g����"xl�n�P�^��]�#����g��I��S������)��8>�J�z�[xlR���Y}%|��R����Z]	_/r������VW���\���t�>��J�z��R���S����9|�Hq"�OGR��xJq:��ju%|��v)N��S������[??
���[j5�^M���?������]���}�����"xJq:�?�������5l<�)V�B�^�����Sv`n�l�K}���go��=�����������s>�]�u����+ �D+L�gS��A��M���
 ��n;�������������z�:��u��� ��	�:��Q����U���*p�TH�@[�����:�=��"*9��;���\aT������V����g�Uo�Y��NoOH�0]�=�G���u=��4V��wzs"}��0Y���������9�6������Uo�~�v��3�W���6���:����7'��~���a_qJt�~���c� ���;X�H�8%]����������:����;+?qJ������[���i��x���_��w� ����W��������oD��?�I����_�{"R�>J�V������t{
�Z����7�G���EG2^�{������;��H%]���!�o���������X%],�������Xo������Ks3�J��,oY3���?TW�T�Gp�M���K��Do^��w,V�{g���"�d��z�H���^|�Q����G:���U���c�v�~e�2���%��[��T���wE�>0�����������(�E�w
�-
��T��z���a���'�����bx�����W��,v�
���5vz��q�������X%]%��>����oT��x�U��yGD*��K-\?�����w
k-
��T��l��@��#���S��w_�J��IV���^n�{0���:!��U���*`���u�
�o~�nX�GH+R	��G\����Up\[X,�#X�&����=������i����X
�|�����Kk�����?����#c�e�X�G2��+VI���8�-�_-��6�-[�oY��"R�M^+6�6V�����Z�=�Z�4o�+�Z'�P�
��k���`���W�����2,����|D���[�D�����y��J�^���	v�:.�Z�.��7�G������[���x�$,X�J��Y���v<�����I]�~��`Q�!����_�r���N6�X%Y��f�9$�\���
n���6�7��?du���rO��-����b��$C�fh�<b�d����F����j����[�E�Cb&�"����#dG��O��;b���q���dk=[����q�i�����T"�|�w2�/����.vfa�*�j��|�W�i�-�zQ�Yw�F��n}��>����{h�G������{s��� H�H%�%�<E�[�e(�I��U����s?u��rp�|�'��LD*��E�X��������N��d����Y�)���������F9�W��.�]Cm�q�����>
v�+b�t��z��m�e����-�]]�����K�����ju���a�x��M�|D*�b�Y3k�}�.
AP_�J?�bO�r����]�.C�NF����Z���8_���~yQ�g�;d�1��Ew� �-�Z�&q���T�q-|h�U\d�f(A}E*�Z����#�m��v66b�d���?�t{_N���+!�b���V�����m<eG:]���"8'k#Ri��������D�{g��;^I{�����]�y1n������+�Z�
��<>�pRL�<�����U�e��SYbx��[�oV�����^��Y����R�:�uF�TNkf�o� ���o��	���+RI�Z�&��oX��)
%$���U��0F�3���;��]�Fwn'q9AD*u��h@g�b�]�����OS�y��x%]����z�����|�:�W���U����������lu��U�<��J��?<�����_��X�J�1
���((p'��������|��i�\��B��h���HV�GcQ,������F#Y����X�9�C^;w�F�\
�f���j���m��|�Nn�vn�h�A��h�������'��W�e��h�i��?�����R��d�3?��h����c�|c�"�<+���,��t���u)D|$��t��D��"�p�e���.�����E��gk�` V�`���3������)k-L�l�k�o�����g?x�k�y�@,����/��-8d���r������Q~��7�%�L���A��}������c�b�@,�����/���&���r�[���vem��"J�I�]M�
��6����:�_����jRpX1KD�����0�~]�V*K	KF�a����
6�%?]������` V�`C�?\��������B����6~�h/��I1b��\bnoPW���{Y]�(z�b�6�����eu�E���-��`C����������76)���4b���C��0������MJkB<�
u��5�������SX������t��S��"*.?jn^"�
�7�p9f����O��W�`C{��|Yf��&�7��G������O,dImRX/D����`dIa�[<�"��������.8���N��`C�n/�'��ei�R�Xqa.��
m��Wnfua�����9e�'�����Y}F���*������V��-���n���^T^~����
6��>\y���K!�����`��&��p��.�Y�k�`C[�CS����zj�[L��
�=����y�,��kpa� N�E��&�����^���YV�`��%"�PW����uI@����SVX�������k�vBf�YS`��"�P��PgQa�(����y�6��=|w!u������s{��~��:k���%�F�{���@Qa�[L�"�P�xUu�)�x����8���~>����N)D,�"�P�y^P�z�t�-�������%V���x=��FH��u�?���J<����"�P���&���b�^~���D��������S����M����"�,�Sj+b�6�w��+�����-v	�r����f5��o��k���uI
^��� �dG��%�R�t"4�����J���`CY���p8R�uqYMX/j-?vNY`�z���j�������y�6���|�C���J�O���`C]���YJ�����9b�6�WP�f��0�X �
e�O�=dAa�[�"�Pk�.6�	b~TP��.�7�3�A\���Cn�Y�\��g�j����R�X1KD����Z��+�eaY\X2J/?vNYc����eyu���;7/���K~��%�F�����+D��,��N�,�.�8�SG��������Y`]��b�6��o"*+�b�@,���b^�)�4`C}
)���A�x���l��X�����i���R�Xpa���
u���Aua����:e�'�PW����Q{�At�+�PW�tsRV�����WX�����;����T��O"��
6�?����5����\a��7��o�M<K�`C]��%����Q������jeE���W9���s��ruEUc.�����B����d]�|�}<
F}a����:e�l(^� ���b��n.�\1KD�����9(0���D;/�����{���n��\00W�����"*�������<ky�AY����*y����\���j�E��!��?
���X0Jb�6�����R\��W)��s�l(����\1J�������`CY�����w.�j!�\�5"��_���o�����������ny�����].�XXN���l��w�:��8_����K?r�@,�����+��~r�G��0�X����?�*V�9���\"�
e�O�������Zp!V�`CY����i>��#W�%����5��X� c�l(+��,��6���.�b,����Ru"�O��g����Q	rAF���
m��o�����Z��Z�	6�?���t� #\�+�`C[��m�iu�"�V|0�X�����{}��rEF.��V��+����~��rIF.�0WX����5�<����fa���
u�?]+����1V
�*�6l���~>����k#r�@����������\��b�6|�+��s��rYD���l�������ip�,"���i�o����S^�+ R.�
���p��,W@�z�X#�
u�l#���e�5w�ba����w=g_���\�n�H3�E�^ �`���{�p��>�j�(.�
��X����������� r�@,����pk|�\�P+-�t�����g���b`.1�7h+~�T%�?���X"�
�������j�[����^�'jP!gEPSr���*�.�;8�r��X���~>�����*��!W�lh+������e�QSr�C��0X��?���L���
E%W?���X!�
m�??� kK.~�b�6�'�!���-��!W�%��|�=� KK�}��B��+�{�A�\Q+�b�6�q�<��g���\��J�������*B�S�(.�"�
l���&z]����k!j����
6����5(.�"�
l��������%WC���B�����>�-�"���\��-���r�\0+D��-���Y[r=D�x�k"�PV<���:����%�&,���Z����O�>p�\�Pk>Xk�hg_����>�r]})����(�<��X��������lz�9n�=W��������	��_�����MN�����:���VM��i�Z�������rj��
�e"�Y�����w�F����3���xgY���`�rv�V
���[�'���zj�ux�e�u���u�����f�����*�n���:��$�!����A��Ww�oY����f����`*���x�]��������P�_�~���y�p�;��|V(o��m���E�u�����W�x�(�@�����u��x��������Lx-
�;x��l����S5�����	\w�^o����G�T)�9xZ�V3!�Y�T��}�5���O9/L��;
�|�9�:���^sZ7J%�
�;{#�Y������X�r����U�,�~�C���j�J�\������~�Q�����3��]W��[�U?<2)��UQ+X\w�^o�����'��s�[���b������YrY@0�q�������F��c�uX5�S�
�U�,�~�3.KO��E�s��w�E�y�#��\�X4+S��E�,�����M��9�Z�����d#�<�Ho���������"����e
U�KX5�#��k��w�U��K��|�UiY�GN��2+�����T�t��W�E�����x�������9��:��x��L�;�U|Z����3�����w����5��(�X4x�M�w�����yi�[m��D��O6��z��z����`*T���x�M�q�f�Z�})e��jYp�uz�e]�Z��@n���3��np��������B������6=���j~�]�����5������u��wWu&'�kAp�p��eY����2�S��&�n9������xUdr����0�w�E������������������{�����[���F�	��_/�����{������(~��h�_sN_�OH}�u~�y�W�7|�d�7�=��:�����������V�@~!������w���N��(�_6��k�������`��"���b 3����W%�`������������,�_����[~ES}qL���eff�k������RV�@~qw}Ew}aM}�
8�6���������,p�w�����'�+X`��������
O�@}Qv}%v}�D=/e
l�!|�u}3B=�}�!|�u=����������_�_��<�������S�� 0Y�<���������W��^�WG����9�+X�,�k�������,��C���z>p=�	�/y��s�G��#IW�@+��������6%�(��!|s=
���N�Q8�CW���]7�F�����nx�7���u��z��z�����L`h�!|)t��x����`��<�/x����u�`��<�<������V������b�SQ�`�>�Cc���X�z�����C����8��}��q�Cx�D�2������n��;�V��(�	�^���i}J3�Y�7x���(�E`��C�	����X�����C���/^��<t���hy}�,p�����!\�\���L�V�;<�C�R�,�����l_����d��<�����WO�M�Q���u��W�Z���{ !D��[�-�Q���G�R�mQ�s_��g����:
������W���8A�j�>�L[�����F�s��	o��%(!�?e�U/W�qp�R>�ik�?x��x�+^�������������C���D��K��Cvx�D4�[��C�?A���/�n/�B/RA��_f�^)��v���#(�g��Rv'��HP~�k{��U{�N�#A������sA�d��N��I�J���hMl�X��*eW�X�����/el��=b���c���R��aJ���5jf�@L��/vk/T�b���x�v���~F��:;��P?�[bJ��6������Rg������S���J�S���~>w�s}QM{�]��.�)u�T�+e��X9&w�s}3J{��~F������B��!��|5�s}�D{��~�v�������E���~�N��u����E���~�:����g4m���������C������C,��7�s~��~�E�����~��9������d�3:>���	k}�|�F��s�����P��������|��>V�����e?�����gY�s�S�`?��;]�`�C����)u;��R?��~�����g"�SSg���d?�s8�i.�����m��|�M���F���>����~�;����p��������� ���q��8R�f?�7�9o0��wQU?��~����wJ�}}r[y��������>�9)T���Z�R����N�3zX�����F]����)��y�]�,�zTg�d��a��mvl��F�+Xb����ibW����h�mx5���K'���
�����.b�}^'���^���
g������1�������u��W���������2���R�_���$�^�w��~9��Fx�}P�@�?A���@�����x��e���/�Q�~��u�:;g*e����:����J�/#�f������3��_F�d�����I�J�/�hMl�X��*e��X����E��J�Z�2S�6�Q���e#���^��fF��t�+S��e�����`�M��~�)u.v���~F���:�lt���/3��d
�3�e�:�����_�Q��|��gt�������/3�N����~F���:����_F,�������W�R���e?_���9t����2S���`�]��~�)u:;��T?�_fJ��l�������C�����_F,��7�3:d|5�3�e�����F���2S�v���~F���:SvJ�/#V6���y����g��L�s���6�3�e�����F���2S�v���~F���:�l4�����rG�����_F,��������5������R����N�3�e�����F]��~�	:��w����~F���:;;�����/3���6������R����N�3�e�����F]��~�)��h����~9�e����� C���6��`���wv���]M��&b�I���Lll"66��d����.byl~
���sj���_,��1����2�/�$u�I�������sU����W����B�r���*�=�������u���\����a�]�J��~���;��z{y*]����iFo�T��X�2���E�tA�C���~��m�b�4�����e�_��ha��V����!���������_�f��Hv�t�C�����.l���\W����X������$Z�0]+���yFo/���f�2���U�t�C���`�.��b�4�����u�_z��J3���i�Kl�2��L[+]m���iFd�jW���/�L3"�V�J��~��+���%�t9�C��������b�4�����V������Qr��D�s�m����7�m6��S�����X��L����/���_�gD����.��b�4#�nu�t��C���`�u���r�Jsz{�/]���iFo/����2�����qQ/l�o]iFo/��{�`�M�J3z{Up\�K���um���^ �5:�f��7!�g�:������<qD���s]���l�`��xAd���d��.�!����/<�l�Z�sAo�^��l�W�#�@o/^x������`��^1������(�AH��/�yS���>&��u���OV:�������J�I���JcS����H��;qg�)�����)S_���=a��_B����n�������9U�_'�r�3w�����NYyN5��.r�}P�@6�x�D4��,�4�[�8���2��2J�t"��Nt�Ag�L��;;X'��>8S)[O���=e�}s�Rv����me��M�T�Fkb��:Gc��A+�=��h #S�P��*S�6���>�9U����^��fF�t�+S��H������`�M��s�L�s����%E,��f�`]�g�SeJ��6�g�Se��;X�zS���h:�Nv���~�9U���r@V:��������h/#S���qN�	:�0W�R���e?_���&3t����T�R�d��g4�L����F�h"��|u�3Z��T��g��L�!���J�#"����gQ�i,���8���:;��P?��*S�v���~�9U�����P�������g��+S��W�����~F�:J���T�R�b�g�SeJ��6�.���1��hA#Si��qN�)wt;X�zY�������F[C�M��s�L����F���T�R�fu�3��2Am��T�s��e?��~Fg:JM��s�L�s���v�3��2�Ng��g�SeJ��l��2b�����3z\l��[�h�B�&{ht��2l���9U�;e���O���]M�F�l�����Dll"67�6���]�&�<;��6��9�����C��>�O�G'^��W��=o�g�_.@>6kO�@��+p'	��`��d	T���@���hO�@�?��Hd���)U���d����%�T�r�v��;�`w�2���:���R����T�r����~yvg+S�������~9�6)S���0Z�5�9��J�/G���`�f��2��p��D�3��F��8�e����^��f�~�:�!?�J�/G,�9��E;�hS?g�L�:;��P?g�L�:�lt���_&J��V�R���~�~9��`%�����~�~y��6����/�N���i��.���~�ye*u�s��D����T�~9b���//���FM���2Q���`�]���2Q�tv���~�~�(unv�QW?g�L�����O=!��#����r|�����9�e�����F��9�e���`�K���2Q�L�e(
�gL�hL�sv�O����~�~y��6����/���6:���/��`]�����Rg�����9�e����C^0!��#����r���F��9�e����`�S���2Q���`��~�~�:�!?�J�/G,�9��E;;�����_&J��l����_&J��6:���/���6������r��
rl��_F4[���@4��eX�2�e��r�){g�}���D�j"6�d^���&bc��I���Ll�"6��� �lC���^�~9�8D�0������u��W�������83�e��l�d��;ID#� v_+K ��<H"���ZY]��/@�JS�~����=W�(e����:���+2��_F�`����b��J�/#�f����b��J�/#v�Nt����MQ���~�)u1�R�����_t���t���/3��`�]�c��L�3��FC��~�	:��W�R���e?�:��F���2S�\�`�C��~�)u;��R?�_fJ��6�g��Lu�c+U����]p��6�~��gt�A]�J����;�rd*u�3�e&��C^�J�/#���.8hg5�3�e��9��F���2S�tv���~F���:7;�����/3��hc+U��X�3�`|������2S�\�`����~F4��F���2S�L�e(
�gL�hL�3:���T�2b�����v���~F���:;��P?�_fJ��6������Rg������2S��6v�R���e?����l����/3�Nge��X�3����l��������ye*U��X�3����l������R�d��g��L����F���2S���`��~F����^4���V�����F�e���l�~Y��N�;;����&b8"�&��W3�����Dln�m86���M�y6��mX�r�������3�������f�k�^��S�{=���|���~V���<�D)�x�?�R�,���O�/�$
t�+��"z<����1e�Qg�lu���2���i4X�O�X�-C[�F7�������)3�&�����]S�6�"BxtU����!�=�jFb�z<�b<F�(3�v���S-lc���i�N��S�m�_B�tb����'�)3����X����ld���R���ld���R�'��ld���R��\&d��Cz����}�v62����X���}�v62����X��7��ld���R�����T��+����X��x�	Qf����Rqw-��ld���E]�Ihu��y��6t�\'��L�s�����n��l�#���\-*�����G���*=�j�^�g���i�~��J-�g���i�~��J-��=��%0��T?�^&d:����R��3���4b?�c�_�g���i�~��J-||F;�F||��J-�||F;�F||��J�m��~F;�/#�3=V*vq||F;�M����gz����~F;�F�gz������	�N�Cz���}�642����8��'�mhd��qP�;�mhd��qP�o�!���4b���b?�d��6{oG��������Z��"�Q6��0(�S��)��W����h"6�d;n"66����M�w�z���~3���/�e��+X���������~�f	~��������D�t]�^���s�x�D���k>Y��F�I��.�N�IS����s�L)�Fs�(e����:��a{�2��aE�`�hC��=Z�J��"�f�hC���Y�J��"v�N��a4��#B���hMl�X���Q���b�������T:��87��:��kt��qn�)u&��h��qn�	:��>9Jun��g��A;�hS?��,S�\�`�C��s�L�3��F���f�Rg������f����V��G=�3����l���qn�)u���N;$w9&w�s6�+S���qn�	:��>�J��"��|�!�!4�KG)_�������
��T������Ng���lX�R�fu�3��2��(;���qN�)u.v���>�9U���<�K}�s�L�3ew�4l0e�0���h>�J��"�}86�a6�KGiS��*S�\�<�C}�s�L�3�yF���T�Rg������T�r'���T�V��������fl��6�3��2�Ng��g�SeJ��l���8���lX�L�:��X��l��lX��RS?��*S��a]�J��"��<O�s6�+S�T?��*S���`��~�9U���7��*V�����E7�e���l��s��%v���q�&v5����&��W3�����Dln�m86���M�yu�+��Z�g�g7<3���?;�o���b��C�gF�K��s���s#p'�h`��������$��]p�%��O}.$���4��sz�
����b��s�Y'����s�R�����kP���}.bo���5��9S)�\�N���~���T}.���v�u1�R�����_t���t����2��`�]�c��L�3��FC��>�	:�lW�R���e?�{
:��F��}.S�\�`��s�~F�4��F��}.S�Lv��P?��e��;X��\�����~Fg:J��}.S�t9 +�vH�rL��gt�������s����ve*U��X������lCG�����2���6����s�R������E,����gt������8������T'T�~������BM��~�)u.v���~F���:�lt���/3���]���}����d?�C^�J�/#��<6�3:��Q������R�b�g��L�3��F�/#��<�rd*
�3�e���m�`����~�7�3:dl
�6�3�e����`�S��~�)unv�QW?�_f�:���T�2b������!��RS?�_fJ��l����/3�Ng��g��L�s����_F,�y��F��-�`��-[��d�Z������K�����>M�j"v5M�
�fbc�����$�pl&6w���l�#����g�g<3�~�9�<:��W����9�	p&���c���T��w�@���iO�@��+� 	��`��d	t��_.�Dv�s�P��z�J����;[2J�/Glg����;[�J�/G�`����;[�J�/G��:��aw�2��_���:���o�2��_�5�]c�C��T�r��}�ov�+S�Pg�L�:��kt���_&J���5j���������D�r����_^t���6�s��D�s����s��D�3��F��9�e����le*U���������V�~9�z������`�]���2Q�t9 +�vH�rL�����W�RW?g�L����L%��#�������l����/���6����/�Ng������R�fu�s��D�!������~9b���/��1;�����_&J��lt���_&J��6����/���]���}����d?g��d*Q�������`�M���2Q�\�`�C���2Q�v���~�~�(u&;�h���_&�:���~9b���/���l����_&J��6:���/���6�����������D�r����_^������9�e��9��F��9�e����`�S���2Q���`��~�~�(��� �^��eD���_D�Z��/#\���/��wv���]M��&b�I���Lll"66��d����.b�}^
��6�~������C��_��k���;m�5_�~�S�;�C�w>^��W?+���f�Q
-������
��p�D]� ��eB��E2t	�b\�G��F�u���ro���4�S�@/�{��G���:u	�b�'mqdM��K��]S�6�"B�<:��T��b5#�m]���e���:u	�b\�G��F�d��a����L'�!]���`���L#�!]���b���L#�!]��x���F���.]^<��hg#��}H�.?�{v��i������'��ld������}�v62���t����}�v62���t������1���t��*]��xg?���L#�3]�w�����F���Z����V�	�WhnCG�u����:7���k]��]��6>������b���=8"?�'�j�C�b?���L#�3]�x����F���.�^<��hg#�HvS��{v����~�K��g���i�~�K�_�g���i�~�K�>>���L#>>�%��'���F���������hgc�e�~�K�c��g����	�||�K�w�3���4b?�%���]&d:�����;�mhd�����'�mhd������}�642���t����}�642���t�r���lX��b��({V�p9~���}(�e�J.�'�"vN�6���]��F��$�q�����Dln����X]��~�{v3���/�e��+X�����=��c�o���b7v?�#��qV���F)��F�Nq�vA�)K ��F�A��.���F7O��vz���t��gu�Rf�XF)[]�v��6������.b�D�g*e����u��
�o�T�V��u����&e*U��5�]c�C����.b���-jd*ja��eJ���5���8���:��k4��8����+S���"���F8�`m�g��eJ��lt��qV�)u;��R?��.S�Lv��P?��.S���J�2���~>w�3Z��Q���8���:]�J����;��nd*u�3��2A���T�����+���`�l��~�v�3Z��T���hu�R����N�3Z]�����F]���)?D;X�ZT���{c?�<.>����gu�R�b�g��eJ��6���8���:SvJ��S6���Vwe*U��X���c�C;�([f�����~F��J��gu�Rg���.�3��2��d
�3��2��nc+U��X�������bk(���qV�)u:;��T?��.S���`��~�Y]&��e^�JuV������h�CG���qV�)uNv���~�Y]����`�S����L�s�����gu�r���[x�j�3Z�����2��m�qVW��N�;;����&bW��$��j&66���M�
�fbs��>�~9�
���p�z����1�l������W��6m�����~�����]��&��_.��i��������H`�_��z�'@�\�����Y�x�H��D)����Q�~9b;��_^����T�~9b��_^����T�~9bo�A������T�r�N�A�~���D�r����b\���#V�{��C^�J�Z8�e��l\�K}��2Q�Lv��P3g�L����L%��#�������l����_&J��lt���_&J��6����/�vg+S����e?g��?v���q�c?g���d������R��Y��Cr�crg?g��2���9�e"�d��d*Q��������`��~�~�(uNv���~�~�(u:;��T?g�L�:7;�����_&�Q��������~�~9>���FM���2Q�\�`�C���2Q�v���~�~�(u��2���3�l4&�9;�'S����e?g���`m�����R�b�����Rg���.�s��D�3��FC���2Q���!�
����~�~9v��`�M���2Q�tv���~�~�(unv�QW?g�L����L%��#�������l����/���6����/�Ng������R�fu�s��D���9����/#�-]�r ��2�~����~9�����>M�j"v5M�
�fbc�����$�pl&6w���j�W�!��g�W�x"f���|v��:x�+^�Vl>=��2���R�_���$�^�w��~9��Fx�}P�@�?A���@�����_F�E*�����e��_Flg�������J�/#v�Nt�A}p�R����Y'�����L���;Y'�`�mR�R��0Z�5�9��J�/#V�{��!G���F���:��k��2b�����&��h���/3A��T�~��gt�A;�hS?�_fJ��lt���/3��`]�g��L�3��FC��~����`���q�c?�;�r�(��g��L�����t���/3��-b����~>o�3:���T�2b��Wc?�C��~F���:';�hW?�_fJ��6:�����R�fu�3�e��m�`����~������BM��~�)u.v���~F���:�lt���/3���]�R������d?�C^�J�/#��<6�3:��Q������R�b�g��L�3��F���2S�Lv��P?�_f���V�~���{c?�C��PhS?�_fJ��6:�����R�fu�3�e&��C^�J�/#��<�r�(5�3�e��9��F���2S�tv���~F���:7;�����/3��
2����/g�l�[�=4dh6�F�_,�S����4�����Dl4�6����M��&bs�l�����E,���A�l���3���-0�8D�0���m������2vc��z!I���)E�����sF��:���3��}F��:��g*u��D��:h�#�0���X

2p�D�0���"]2���m�-u��V��df_����E��o�6��:���E�����mXv���G��ju�l�3\
���[#��0���,�!Nw�����Kl����m�`����l�j�3\*-5��;nVh�K��;f��Rh��C,�xY�,�)�wV����*�7��<�R�.�Fb��U:s��z�7<���������#��[�GL1���mX}z�K\M*�9��U�u�;����UZv��by�l�3\*��R���m��;���e7,��1���Xs,���!��t�B<v�L����?+����2U���J�~�~��RV�|1��f��<I�Z���+��R/V�&<8��f�T��J�����L��8Xi�9qo4S�:��4�O�;��v�l�j����h&��*�9+6����G��TG�x5��q�4S�� ;�Z����������j��Ov&o�:�3����L��HudgVC|�3�|#���Y
���[Z�z�fwRK�������~�)r�4�s��������]��F��4��7����M����/�����r�7-X���@�5~p"w����������>��/�����/$����H���4%��W��:��?��'S�z�{�N���:r���+�d�����+����^���r����W��.b��/�m��6�^=���e��0�m+��z��E,{����Z����G��������
�WS����M��9_b���G���Ws��
3}���%6����9�zu��bw�a��^�0��9�
�W�p����by��*�zu������U@���%6����U@���%6����*�zuB:���
�W���T@���9_b���G�T@����
O?�w=�w��j�W�a�
�^�0��9�
�W�p�����by�fP�:a��by��*�zu��by��*�zu������*'��c\\����8K.Xb����0��T�`�
q��e���r���]1�a��c�b�j��l���3\L;61m��K�p3����`�]�R��L�s��%6�����'�Kl�K��g�k���q�����
�7��j�c��Y���`�e��
���p���K��:�
�U���Xu�O�a�e�p������_b��*��K��;�V8�.Xb],�xZ���`��by�n�S����4�����t���n���A��;x��W&�s���>]�j�����l�C�9������Tqn��<6W��*N�j���s
*C�#������ur~{�����/���p;��t4�D)rn��T
�����Q�9S���=X
����L�n4�D���J5���Ke��_�eX
?�����<�dV���!bh���l�j�~�����l�j�>E
?�99��~���G��ju�j���?����G��a�������8��2�g��XbS��8����3�Xv�+��~�Kd�x��7��l�K��;V��3���;^V��3���;��l���*�7���S��s�
�?�w��l�K��A����~��~�
�?�
�U@6��)���6���RW��O3���R�.�~d�V��3�X�;�V��3��-�w�VyJ��>��X��N�g�T@oR9.��@�Y�����.���a���by��* ��%6u�c8|�3���
�q��6����F�<N3�����!����6<�rz�XbC,�xY������X�qX����6��X����
�7��`;��Y������X���
��c��by�n����q��6����f�
�qA�6���0��)�w�����)����6<�rz�Xb�X��[������9-@O�L��L�V�5�rZA���@N�I�������WS����h��|5W�*�M��
�cs�������iA(8�� 3�:jz>Ut�����I�^��K�/��)J���Q���)�� bw���`Q�9S��{�����3���Y0= J��T�� �b����h���]�0=<�d���C�0=�C�
kz��[�0=�o�6�����azsN�6��L���M���aM��?��9.�l��L����t�������bw�a���c���`e���R9=<����U@NK��;V9=`,�!�w��rz�Xb��l�� \* �8���
iz���T��K�� �w���0�X����������.����6�V9=`L��lC� \*�jR9.1�f���;����U@NK���O���0��-�w�V9=`���vs�!M.��T@��)PlV9=`,�K,�xX������X���
��c�M����L��L���lC� \*`lR9.1��* ��%v�����0���;^V9=`,�)�wV9=`�
�vs�!M.poR9.�vVq�
��c�u���i��������* ��)����mH��K�&���3lV9=`,�S,��[������X���
��c��by�n�������=�2M2A�����VA�v����@��*����>]�j�x5UM����cS�����T�yl�8wU�R5-g�d�TGM�����4=x>{�X������|~%%I��AQ����IS�����Y'�����T����=X'��n��T��g��AQ����I5��AD_,U�����\Z�4=��.b9=X���mH��"������\��4=��[�rz�07�+���>E,�a����<=S����M���!M"\
��k\��
3}MK,7�+���.�����)vwf���X��lC�D�T@Mby��*���%v��������;^V5= ,�)�wV5= �#�X���q8�
����S,��[������X���
��a��Fre�� ��jz�`��lC�D�T@M�by�fP��;����U@MK���O�����-�w��
���/���u���l��Qa�	��M��P�U	+�q��l�~���V��.bt���A^�JX�������F^�J��/x��l�~
�l�K��f�����K<��]��f����El��_p��0������x��4�����5t�
��
�AVS�9�;��\������T@�V��T�l��O���������Z���(����N1m��+�QL���t1m��+�QL����b�j�W��������5q-���8��W�����*��G����t�s������*^�GS��T��7U�+�M����Uq��<�-�\
�,�����|��2)��������~���^bW�v�J����(E���������u��/�;g*U�����_t��T=�����Q6�+���?p�����_�eX=?�����<�dV���!b����l���~�z����l���>E=?�99��z~���G��ju�����?����G��a�������8��2�g��XbS��8����3�X6�+��z~�Kd�x��7����K���mX=?����by��* {~��by�a�=?#Y������p*p�R�����n�=?c�u=��~��z��R��G�a�
���1���_����#\*�jR����a�
�����N���n�=?c��.����#\*��R��G�a�
�6��>��X���t�K�&��`S�����������U@^1�XbC,�xY��%6u�c8|�3u�3���_��4@@�T�����1��* �`,�K,�xX��%6�d��RcHd����* �`�
�&�7����M* {~lg7���b����X���
�+K��;v���b�1�r���
���K�&��3lVy�c��by��* �`,�.�w<������d�4�R��> ��I�i�	�
���@N ����+�I�������WS����h��|5W�*�M��
�cs��������A(8�<"3�:j�>Ut�y�����^����_>Q%I���(Er_��)�� bw���`Qn)�L��D��:�,����������Q���`�W���/���A`n$��aM�E����H�l�� |�����\��5=@�-b��Fre���S�0=�9'g���l��&b��\b�5=@�����`ef��0���;^���0���;�NS��O�!M.����C,��Y�����.���a���by��* ��%6����* ��td����S����Fr����R9=�z�7<�������j\��
�U@NS��O�!M.����],���rz�Xb�X�q�
��c�u���i��������* ���1����4=@�T@N�'�;6���0�XnW�aM.����!�w��rz�XbS�>��w?S�?S*��O�!M.����C,��Y�����.���a���by��* ��%6����* �������4=@�T@N���;nV9=`,��l�� \* ���X��[���1�j\�d���R9=�����* ��%v��w���0�X�;�V9=`,�[,���rz�X[��DO�L��L�V ��]w�/�����0)�S������bNB��������Tqn��<6W��*N��,g�d�TGM�����4=���^b��^������$=�E)���'M��+vg��<�[�'S��+�`��<�����J��,9=(J��TC�D��R5=X���eH���"������\��4=��!b9=X���mH���E,�s#��
iz�S�rz���m���0e�[<�D�P��� ����8>��mx��kz@XbC��x��kz@XbS��8��5= L��lC�D�T@Mby��*���%v��������;^V5= ,�)�wV5= �#�X���q8�
����S,��[�������
O?�w=�w��D�a�
��a���`e�� ��jz�07�K���.P����X�q�
��a�u���iP�������*����1����<=�p�����@,���jz@Xb�X���
��a�
���eP�����1���R5=x0�+���.P����)b�4=�p���,�����U@MKl��/������;����t��� ��jz�Y���fP���by��*���%v���U@MS,�+���.P����X��Y������S�
iz�R5=X�����U@MK��;v�����=��I�yz�mjz�������5=`&�S[���+^M��������\ql�86U��*8�����S*������dHu�� �8T������=bx�/�����B���Q���)�� bw���`Q�9S��{�����3���Y0= J��T�� �b����h���]�0=<�d���C�0=�C�
kz��[�0=�o�6�����azsN�6��L���M���aM��?��9.�l��L����t�������bw�a���c���`e���R9=<����U@NK��;V9=`,�!�w��rz�Xb��l�� \* �8���
iz���T��K�� �w���0�X����������.����6�V9=`L��lC� \*�jR9.1�f���;����U@NK���O���0��-�w�g�#�^�(��aZ�is�X{�?j���z>����7����	��Od�l�]������@+��v<D�O�� �V���/x�?�p�O+`�l�K�����i����E�O�~��0V���a���0V��-b�|�O+`����T?�p�O+`���b=df�[��A�
��@d;J��	���
3����D���]L�-}d;�i������6[��v�����6[��v���n1m����(���	>?��S���b\y�`�d�Uq�U���*������n�_�j�x5WMGS��Tql�87U��*�����<���|Z�p)�������i�����*��
����>V��/��]��++Iz��E)���'M�z���N���F��T�����=�\M�T������=��r��m���	K+�K��z��"�=�����lC��#|�X��s���
����[���_�;��mH=�O��?�99��{�0e�[<�D�P�R��j�C_M��6<�����X6�+��z��W��p�����z~��&��6��?����_x��7����	K��;V�����;^V�����;����	��*�7��?�R��/<����U@���%�����U@���%�M��6��?�������'��{��
���.�wlV����)�w����',�.�w<���',�[,����M'���&�7�6�R�I�%�(6���b���.���a�W0���;^Vy�c�e���
k��p��L�����lC  \*`lR��/1��* �`,�K,�xX��%6����U@^1�XbS,�8������nbyC  \*���������f�W0�X�;�Vy�c��by�n�W0�X
�lC�b�R�I�a�6���b���N���n�W0�X�;�Vy�c��by�n�W0������(�<"�hM{����s�v �&�S[���+^M��������\ql�86U��*8�����u���K����!�Q��q��s�#�s}���^b������cz@�"��iJ5=���u0=X�w�T��A��������L�nL�R'�+�����X*���%Z�5=@x1L�.��5=@�1L��l�� �1L�[�
kz��)b�����
izS6�h�C�nX����q|�"��0�������8��2���������q��sz��b9.X��4=@�T@N���f�����d���R9=by��* ��%6����* ��td����S��s�
�qA��V9=`,��}���]��]* ��m��rz��b9.X��4=@�T���r\b��* ��%v��w���0�X��mX��K\]* ��m��rz�X�M,oH��K�&��l
�U@NK��;V9=`,�!�w��rz�XbS�>��w?S�?S* �+����
�T@�B�p�
��c�]by��* ��%6�d���RcH�� �
�U@Nk���
iz�p��{�
�q����U@NK���O���0��-�w�V9=`L��lC� \*`6����a�
��c��by��* ��%�����U@NK���mX��K�[����'Q��A&h+���9-��s�v ���xj���t�����Tq4Up��+�M���sS�����UqJe�� �iz�R5=�*:��`<��G/�%���9)I:�D)�}�JS��A������"l)W�RM"�`La7�2���Y0= J��T�� �b��b#Z�5=@x1L���l�� |����HF�aM~����HF�aM>E��sr�!M`��m"��d����j�C����6<��9=`,�!Nw���9=`,�)vwf��0�X�V�!M.����C,��Y�����.���a���by��* ��%6����* ��td����S���b#b�5=@�T@N��
O?�w=�w��D�a�
��c���`e���R9=�����* ��%v��w���0�X�;�V9=`,�[,���rz�X�M,oH��K��{��c�
��c�a���5=@�T@N�X���
��c�M����L��L���lC� \* ���X�q�
��c�]by��* ��%6����U@NKl���U@Nk���
iz�p���`7+�w��rz�XbD�aM.����[,���rz��b9.X��4=@�T@Nw��c�
��c��by��* ��%�����U@NK��;v���0��=��I�iz�	�
���������9=&�s���>]�aR��
�5=��M���sS�����UqJe�� �iz�R5=�*:����s�X������|6UJ������}���D����NN�-���D��{�NN����B���%�E�S��'���}�TM�Fri�� �����`an$W�!M"|�XN�Fre�� �o�����H�lC�D����9'g�� L���6;���4=�p5�!��q��6<��5= ,�!Nw���5= ,�)vwf���X��lC�D�T@Mby��*���%v��������;^V5= ,�)�wV5= �#�X���q8�
����S,��[�������
O?�w=�w���l�nP���q��sC�D�T@M�Fr��� ��jz���;�V5= ,�.�w<�jz@Xb�X��[����>��X���.P������U@MK��;V5= ,�!�w��jz@XbS�>��w?S*������6��A�K��`a���!M"\*��/���aP��by��*���%6����*��������<=�p����nV,��Y������X���
��a��by�nP���q��m����
����],���jz@Xb9.X��4=�p���,�by��*���%v���U@Mk�^���I�yz�mjz�������5=`&�S[���+^M��������\ql�86U��*8�����S*��K��������A�q��sN����_�7������������%����(-��?R��m��(��������,����NC��L
�����B'����l��db�+^$T����t2U�cg������R+�� 8��L��x�R��c��T����r@�Mq�O����&fl�t��u���b�
���RwV����`k����Bv7
V���]2V����VC�%~ll�C��/�6��@��6?�����5�����*_�m^����6?�Oc��l�O��1�),d��`���������:V��k|��O�I� ������6?u��l�j��o��y�4GX�6�����0~���H^m��������Gv����1Of���������k��5�����o�q��#Qb��S����b�`����C�T�:�z��
�=������x����:��x8��p���� ���p���:<�lc4?�<#��<ATt������}n�������F�������XTQ`�@.�F������,�(�~ wP�a�����,�(�>����0X��mWX?�%	�a���a�UX?�%	�a���a�����$a<?�<�����$a<?�<���M+%	�ap����6�(���������:��XTQ`��x�c|�
������@J��p�|>l����A��i��w�
6m\`��}��������6.�~�?l����j��MX?6mt�n�����K�E��$��8r���6�C	���f|Dl�G�w���������G���R="v�3�P="�#��q��8���S��8�����#��d�CI��q@x?~x<�7
%	���q��(%	����e�}�o>n?���wL����^��R�?���K3_;����������'����{��wu0����u�n��
�~n��F�������*�*�t�o�3I�W���I�s�U��n���!��u�8�%�E�x7��n��F����7����?_����������?���������o}Y#([p�q�<����^�����������?�~�������=�x���y���_�O~���o/?���}[�]���l����O��U.��k_��������Mt�������_P������uL��ca~���������^S
�H���1�z�P�����1p8s[y>9CVZ�������cH��a���@�Xg't�=G�4���	��c�g���������|?����s�����������jnp���8���<\�CAa���h�6c��n����U5��:��{����H�<���
�["t#?.�J
���)�����������B�/��$��y�%�����O�uF��<Oy��D��$�\�����|�CFj�Q�����w��{:�&J.�G�JB�B/�����B6�R���r%1O��r�d��Y'���p!���"_�SN�=o������M�>�-��0�X&8���0A�m��/C��{����s��-n�~��=a���m�[����t���b�JUm�P�>��p�I�W`���F����R�gX�l��LB_N��1�ta�V��W����r1��kO�!�\��$ga��<��h��;
�R
i������6��y
�������i������H� ��_�-w�����q�c��6�������n
�\^��5��$�g�	��*9��5H�ao�.$���N�\�����|����HG��t��f���F�
R���jF��CA�"�6I�:>�;����A��Q��z��5I>/���A9���S��i�����o���%k���7.m&"����h�$������b���X� /�y3S!��X������*�
�?�����5�c�N-dd9����K�
����q����������O�:�m	Y����%�8�%j�7�"$����xi8
�k�G�~��\���w��f��<o����u�Z2�����/�d�X*r��\�:�6V
+�t���)�\��,2�+0�3��D�t��}!�?	[��'� ���T����b��Lg��u��u��[�bWM�~B���'�^��:D�td�H3F�t�%�o�^�9���KG<��������c#��t��F�_�����L)nF	�|4��tlF	���QGv2s��i���_IL��1�)��
aBK�e���B���!������:�n�:��t���
a,�����B��n� 2��v ��7�C`�-�3���a�i{���{,4`��]*��<[Yh���i���t�����+Y:_�x,D��t�M��h�W�9���Uu`;��N�v�����c���}m=�9�j1�|�H[�p,���<t�A���*rIo��X�"y�z8�9���lt,T����4uz�-��+�(�I5�X%��K6��@���X@��[%�\�uV�����p�J�<�B�o>��l�4:��G6���`���!�U���R���L���\���XS"Tp�rOo���~2���K�DAg%���������8|�xQ��UDI'�n���]����R�'���������_]F���t��X��N=�e�X��7~�����N�>�wSrMWh��O�^���b�@�(�4�z+�~C�'���L�M7#�������,F�}��j��w(�7j�t���!�M�W`����#{{��.�C_|�Z/�t�����{Q2���N�S�����B���-E�����<�b��?<�����U������j� 2o�joL����*��/K����Cn?D�u���)�
���^�l��1����uJ ����'�>���.��>d�C�[�t�D��c��
��i"J|�L��Ty�������g����l�6��u���z!��#���Y��#�5VB�LG>�����Y.B!�
�{z�JZ�$�b��
;�t�/$��zx�A���n7��r�wu|2%|�V�J_��+�t\V��������Z��>g��R�*.�xH�����2m&������������wc��F��W��g��Y�U:��������tp���e�>^����c�a\Gkyt{���3?�%_$b����U���~s��JcY-����5�^��rC�z?1�(��W�R�����{Blo�[���y`��GCf-�t��z������.�l�P�Ck���B�\�XP0Xp�E1X�H���`��9�YlD����F������Xm����c����8��|��������;82O�k6���:vAW�
�,yqz�7>����X����o4�a���C.�	"[u`��,eI5���g�.����!��7����������-�M���6������H���j�B�K�ek8�8;�p�1O�����-�5�L�z�#�5�eR��-SA[�p����y�����w�T�E�i��#���()��G�����!��'�6V���WM��������q������HA�'��c�����-���������fDW��O9=p��I8<��'Z���*���64V_���D_�'�U���=I��5h���y9��W.J>X�;���l=5`����vt`C�g�nG����?�S�|$HX#�!Q��oVO)X��x8"��l�*����5�pB�Z�WpOXO�������F�� �pf� 8�g�������������l���^���Agj�;�w�e��.'�\'�$p���K��w�cm��������i��B[�����f�*�������Z�f�t�������oF2�8/+�[��dn]|3��5��Hf���������l��/Gg]O�7g�t�����{��$���x�H6�/�fWM���F��l�mn�Y^ ��a)V��M����{�|2Tx{}vW�������w��]��y�.�v25k�n����`�4d�m�)�B��]$p�kKOtIa@�����������s����]0q����d	S��{�#��p�$�.9G�{>�
I���Q����������>��-���%����o?_7����U:�K4u!�ln�n�>=c6�X�S�Ffu��id6|V���������42{����e�>���=����h�����W��
���KT��7Wk�XF��%�Z��ks"R����D(`�����1]�W@�g�3}��c����f��a�l�#~����F�>���y6��II�� ��"v�g��^(1{6��+��'x6h�l:�^T+Id6}7�6�*�T����%}�/8:�/�K=����a���A�������~�I��������R���0��T��"U`���\�2����o����RU�'���J0zy-%��
��Hp�t2
: %J0�����$�G:�������1�S��W�Z�4����1I_���EG��2�F^���t<gH���L(���S+�����������|�_����7
q���L�h��CP���j���NQNO�o�KI�$���Q�A�p�Q��/	A,%5
IUp�@d�*lA� k��9/�I0k���0l��
���`��2L:�������$�l}�1����P���l5�2�4%�DI��u�/�+��8<{/����e��
�-h
{��_C�%F�E��k�J�I��@K�=������8,
����jwX�����7g����l��<4��x���*��<�
�����K�A"'�d��UN���P!+���g��?���n�b�DL�V���t��m$�*,{M�K�3�Z��Hfeu�H&�r�e�yoD��� ����Ka�w�{�3">?�_�1�:���$W�N��cb�qq>����C�n��5,���F^{��^�$�T����5J��]&������9�f���O.�dT��/t�C�K*�����H����.�.���0T6*�*K�����T��S�p|���qYe�6�
|�ot�*l�g��@H5���p=lt����>�|�����v�
@�����
z��$��Lhid����s_o����I2�� ���7
�n/>�g�hT�O*x;���|�\������qDf�DF�]�c��F�^�����x:��]�� �.^F�"��	g�J��l�q��c� ���q�e�"��+/J	���x9������F�>�^d`I�4��-��Nui�d�DO�mt���b@�K8�����T�\�3�[w�awgt���v54S�_CS���6{�6�h����8��F����-92:��6���3fo�E2�c6��h�������9f�Wcz�*����p�+"!�|�;zx%0`�����4,��[�4���F��[��E'�������r���,d��h���"��,��������X���<{"K��-��f����J�x��
�F���F�=��iEGfk��f�6�i�l9�*�i�dT�M.�
j_�-��M�pb�=��c�JE�
����:_����jj����jj�d�f��NY������ZZ4�M
�h���A���%`��<�wK f�"�5�4���[x>�;��]���ZE0G�!z7��B��N�ss^���s"9'�p�
���v�B�F�>���|�	}�;�H�	��_
����\���5��B�h�BrN�f����9A2��{�s�r�#��I�	�Q����s|\��Z�s�����!��B_8���N��*�xJ��
�S�����PU
�b�Q��B�������!�%���|�=%�9-&��rN��9B�I.�b1�sR���
+�9��"����9f�!�f���s������+���!���o�qo�o��qR��w_�8��W:��3N*lA�	�qRa�:��Xf�8�����#��b�U�\��F;�~+��g�-��q�g��}����������As�cH���
<���#�y�LH@
��p��oN@i�� .J@i�GVWP�lI�LH@��6J@��6J@�����������X�_�jq�uj�����%�^����d��������@:U���4P�����4P:��	c��5��Sq���r��`��
�(l0�DH|}�|�����o��$:�[������?�h65NJ��?�fl8�~���Hn��*)@���a�8�b��+Il��w�R��NJ��^�����#R�\������;�_�"���ZNO9Y��K-�����{D���P~2�������
%w�K�����X��H���Gw6C�g7���~����Qwe����3z|�6�k���k�e��w���^f�
���0�l��
���2�����0wi�����!6h?P�5zwK�L5*]��>R2G>�H<Y2����/�Bq��G�^U7�nCR��kCL���/��>�O�����%�a]j%��dk�J����)��l��f�D�����/��-Us �%�K��xx�Tq�����HO��WZ����o�5���S�y\�%���7/��uU�-7�k���@ds�,?��� `�������p 2������R�F��
}�O�A��|��8"YV�K�M� YV����A��B_��4"YVq��\�,���e�,�]���	�e���jD$�`?��m�,�<�(�e��+�.{S���g�����K�"6�6�wsa��Z���^��VS������O�m����������4[�d�k��p
w����w�������<y�Q9���5�A�������NV��t���NV�O��3(|��]p�&�iWhH._s(h����#h�U`�t����Y�G�&��c�>�=Q���6����'�*�!��Z8��!�J~���&�&���J�R�v�D�5�R��;Y|����o��_�M2�+�	����x���t�R�9k��*(}U[��*����
�^j�:He�5h���4��Vk����n�TLk)@���M��
zc��r�u����Wge����r����eT
�pgY�w�����:���W������y��xd�W����Q��P�`��F����g��&��{��]l:c��r��n	�Z�����+)tc�z�*#��2���A�.�Ez=]r9��s���hb���Nn�==�S���h���_l��W.���(ct�M���0"���!�:��-�������c�gw�%y�D��n�����W�Y%�gm.�gm�.�a����?�[���R�7�Q8��_34����76Jgt���vFG^Y�3:�zs�Z�O�>��.���h!����1�_,
"���q,�j��44:������Fv�6z�U4:��i�I�j�g]M��x��Xe0z��r���A���S"�Y���t�N��$�U��3�>��.��mh�\m������kP�=����+����C�7���f6*��qwGG?�7:��� 7:���A����L��Ra�5����hAoP� ��sj�� �Y�P������e����u� ���ot��i���m;A2���^-�#/gGV/��pt���D:����=��0�.���$������J> u?�{������\�35_��>�=9�)�)B[���$N�6����Y�?���}�w%?u���%�%��*��q�lrLt?:.��m�a��h`�E2Bf=����i�(�p,������ YGf�%��&�!���76�5WYg�.M�	��l*j����V�:X�Z���U���5v��_Q!��:rs�c?��
��S}��<���<���C�Z����������j����3��$
�L'F�����hO��:Q�L�.
Q�8��3�J��CJ�5 �L���NHI�����}rGK��(��A_\)U�������7���|���^�!y�A#l�,�A�.��J���2�e���3��)���e�;hV���'�(u����hK"L��3�*Q�i�{N��h)���m��eoK_b��Z�-M������3H���{]Wza�<^@g-z�TS�Ot��(��"/I5�OE�� �l����.J���,R�TL�]+�q��2�J��������H�����|�_���dt�wI����A�#�\@�����wl�.J�FE��3�?���r�������3�f�I��g� ?{f�92h��92mpD���������������E��S���QZL�����������P����v��b�a��hz�X���`��D47���[[r��l��(/���L����6J��a3f�.[7U2c��;K��h�'{�.�l�5�60_h�f/;k`>����}�mfs�P���:��!��
endstream
endobj
8
0
obj
33233
endobj
9
0
obj
[
]
endobj
10
0
obj
<<
/CA
0.14901961
/ca
0.14901961
>>
endobj
7
0
obj
<<
/Font
<<
/Font1
11
0
R
/Font2
12
0
R
>>
/Pattern
<<
>>
/XObject
<<
>>
/ExtGState
<<
/Alpha0
10
0
R
>>
/ProcSet
[
/PDF
/Text
/ImageB
/ImageC
/ImageI
]
>>
endobj
11
0
obj
<<
/Type
/Font
/Subtype
/Type0
/BaseFont
/MUFUZY+Arial-BoldMT
/Encoding
/Identity-H
/DescendantFonts
[
13
0
R
]
/ToUnicode
14
0
R
>>
endobj
12
0
obj
<<
/Type
/Font
/Subtype
/Type0
/BaseFont
/MUFUZY+ArialMT
/Encoding
/Identity-H
/DescendantFonts
[
17
0
R
]
/ToUnicode
18
0
R
>>
endobj
14
0
obj
<<
/Filter
/FlateDecode
/Length
21
0
R
>>
stream
x�eR�n�0��+|L��H$d�JU�C*�{I-c����x�4K`fwfv�n�o��=M��([���F9����@;8hCXF������%I���ahL?����GHN��t���H��8mt��on�������pN���E�W1M�l����~^�?�s�@��6#G��0 u��s8��Q7�U]�0N������->l�I�Lq��8��/�M��<^��_�n�L+��<��Q�h{Y0�+�c�|��Em.0�e��>�;,eQ^eW�f���[�a!�.;\~���	��saxqa���yi����]T����)
endstream
endobj
16
0
obj
<<
/Filter
/FlateDecode
/Length
22
0
R
>>
stream
x���	xTE�7~�n��;��$����������H:"��������Fu\@P�D�1�2�����8�8�(.��2�;�����v�q�y���?�������S��S�N��!���B�I&���.�}?��7�r7�i����.2�7�o����w��sF��
�ymsg��u���/&��x�E�k�;��0���-�lx��I�&8�����`25������f\��8\��h�����Kf7���������U�Ki�(M�S�~$��<���T����G`|Z�a��g>je�Q}��YM
}��>J�t%�$��\�Cn�L���2����_����f��?�V�;����A�V��X��L����>5�w��VR
�	�M3�u�|�>�B������7h5�V��
��*�)�$���u�!�c���1M?W�G�E���������������PFQ&]@��F�,����
3��(�P�DK�i
]LKh5��?1�S������F���>���l #=���a��4���y���t(��m��p�~��4%��������um�o�����
����E;3�jz�^����2}���h�,���]J��JK�W�F���.�M����}�d�w:L�����`3�z��d�fI/�w�����<ygS.d���=�gz�^f*�/fu�|6����a�������bV�V�W:U�p�{}��%y(���+hd{?��n������9�`6���B�0�T�HY�8�Y� =(="����O)����K���u��S����-�G������@wbQ��j ��B+�'�U�����������������b��G��+�c���O�4T�F���K ��-��h�e�����!}"})�r�<H^ �'��6�����T�J?��2N������tu��]��>��*�YZ���i����;�t����p(�
�5C���$��-�����?A�A��	�B
�dy�w9�a�l;���f�l%��mdw�-�w� ����T%M�fH��k�����n���^�^�I���$9[�����Ty�|1��H^*_���w�/������0kIJ��X�B�C���V^Q�T/���I�C}E=���$-EK��������&�4�Tg�����s3Kc}�su�H�X��)AY��!!�)������X_P�����|�-QJV�9�TB�_���@�Z�I2��r�Z�[�a��4:��X��M�X���I;a��I��}l8��*�)��2���vz�~��.`i';����X[F�Iny"��*�-��,l4;N��V�E��/~X9�EG��*v�J��6��}��a�wL�?�u�a�f���}����k�:[���r��2�f���2m�r�o���5����<�^�=�L/�
�*��Xws�t����%O �cgc�[aKJ���h*���`���!�n�j�r}>���X_�����
z?7�l
����<����gQ}�<,��`=S/U��;����������5t4�]h�#8�^���kf��$S_*E���t�� ?A#X
5c����7F�������z~k�8����{:�$��������r���[1�W�V�����C�`��l���Q�X���-���E���.T�)��k:�f��AT�va�P9,k��g�;�9i8�b��	+4���\}�I�7<V,�����#}3v�T:�-@/G'%�q40<}x��J��U��i��R^��^��0'A�RS5Q�jR�r�iC��.X:��qQ����>�y�����L�7#=-5%���NL�w�9�v[��b6i�"K�����i���M!��=jT!�g�@��n	M!�j~X&�k�|?,D�9=J#%�]%��WA�}}#�}����}ml��z�o��n�����^'�v�33����[��&��P��sW�l�Fu�b�#�G����]�c
%e7�bI��HI#����lG�B)��#C����!9w��Y����#�S33
����s�g�({x�Eh�h&���D3�y|4����o����4�)`��=k���!yFo#.�v�CIW����r�����sS��#=�|<�z�J_�c|}��L����:�+��4��A�7@��}hM���>��E�>>>���fg��)M��B����sW����IY�	�g�������2��zR}vf�25�aFu��Z=�����/��9�}w9�"���06{����<�y�vB�d�Q�h(D�w�=��������i���Q���,����eD�j����Cj�3���K�d���)3�-��%� ��.UC~4
B}�p1������D|`a�K��A��N�Gu����!Ef&��5mA��Hh���H�G3S[(XhIM<�#��8��,��t�7eC�ww�Cf�?��?r��s�B��H~�����S�}#W7�����X$pW�
����S%#$��"JyvWa����\���R�
�PJ��|5!g���w�53�gy�L�nLm�q�%�)6���!���� ����V�����j'M]��������k�}5��V�h�����9�W�K��m��G6E'�M��&5TsC1�
��J4|W6[5~W���8�������I�-�F4
o�����v�*A�*u�������A�[$��Jm-��H�s��4s4���mR$�)��)��'_��
�-��fj�*���*a��&%�(���aI���d�3�!O��UEg�X���1�T���$��g�e������8��;NU��|J\�b+�y�f�U�,fAl�eh�)��bY��U'������zc�G�8?h��c����Qs��W39�!zNS��X����n	�^f]e��v�vX��>ny�b���nH��=/n�{n�y^s�T�
����Fk#-5�m���g-�������fy����<��M�������f��Q��A�[IM?4g����C1���>��xb������9F��8��Fjld%I�8�I���8g���,���9��%���9�~���e7�[r���w�P�N/7 B�������M{6��������h�����<���BNz�
���!M	Z.�~#��dIic��U��I�<n���l��!3&5�*)^���EI��e�����4V�������8�x��15ff�i���r�����w�r1���(��F�9/\�g���=����	��|�r�-��M�]`{���3Mq+�J�}�}�}��q��&����M�5�n"��noc���J�E�l�]�K��LA{��"�X>'������w�7Y���IA������I6�8*�eX5��{��l������`t|h�P���
�*w��� +�~�*���c�&��T�)��fc{���J�
|B1���@6 n@bv�c���������>~��}%�����oH��p$s��$��������,6�4���M�f~(vG|{���'�;�^��'�����W����<�`�G/�]a&����K�R�>f@S�:����L)��c��JZXt�lic�Ze����`�W)R$��Q�&��-�84��l)��C���z{��Xp�����s�p�4FT��~6�w��Wb	��%n���y`)�T���I#~��ww���7/�~����m��o���s�'�oV&fL���ye��k�g
�zv������o�b�M�.�����$Af��bf����-����/D����������,v^�p�S�S� j��3m+���������������!�C)[3��m��Pq{��IK����.ae�r;dq;VOZ��,<�+����7���o���� t��S���<M�����4W>�	�����6im���Wf����8wOI�T�!����q�H)I>$/�=�N��%��,�X��p��5.kp�7r��X�H��,����Ln|+�Yy<)��4���l�E����+��t��p������K����k�>v|S����.Y�p�l9��}M���s6����7/��]yh�sl��s�O7����E+��������>Z�H���>�����s���N9#uT����;���J�I>�?'�<�u���oI�����\���6M�'��dw�V����D�N��=��Q�=Y��SJ�)�����+�	f��+9�t~��)�&��8�QzZ:�tgz(��t%=�/@A�:���M���UfS�����f�I�SL6��/�[�'(�E��(&�d���,���m�M������Km)�JYitcm1V�����I��$6.iz��$9)y�����\p	&s���������nl��N���#.1��R��4�D�)G�x<5�tR���1���`��XgEEED��A|������L�?O����I�1�6�D�H4���}y[����8�i�G=���S���g�������qNY}��#8c�w��yk�����n�����dsMM���7������������W1tJ��,g6D��p����4���\X��c��ROO�\S�)�)�)����M�@e�}h����J��6~d���;,V[,61J�$���>�11�&e�S�3X��@���dAk��h/9�2"�c�uV|0���+��VT���U�G�z���X���x����
���:p����%�'$�Z%�'V�<w�O�t������k��}������������9��n�����7��s�=�����Wb%$�?���
��\�<�<�U���o�n����G����{�jG�G��&~����x��w���6�f�*s�y�%��J�:����]����=nK����RNs%�������RAq���L!+d���� �R�h�:��^�Y�$��,���<`��bSRM�	�)�U����1�'���h<��<���T���B�����e����Ob�7��e��Id	�/
����~_��d���;��{����?��������$�n��7���������h�F��7Ks��i�\j�X�|�\m;#�:��%A�K��`l�)����&����k��A)7q��4'��Wt�p�W��
�>4��b�����sE�Ek���1@���$���UE���j�������nY��;]E�W�Xu�y�V�=���13�e��J���;�����o�[���AW(���NN�����;,w�78�����,��m)fs%���X�el�����<g}��������v{�#-1���+u$>��r��(�!�R��$P�������m��b=.��=���l��x�t_��Y(�PO��A��f~t���].��U�qy��sbL���#JT�1=c~��%��i���a
\��\�����Z0��O��3��	�p[
h��D>��(���A!�a�9m����4 pQx~'�V�u��VeV��?�-h�h>6)��Fcy��A�D�E0��pp_g��*����u��L����5`�����g��G��\;�%�z������b���y�eS���`lB���=����@���W��.�b������+Mb-bc�3���L�\6{s�����+>�-e��l�4@��y��!�!wJ�t��7q���������[k�v��W�=n�L����c������b�I��v[�[��p
xLh����8!��[����(@vn��/�(�%1Ul|�U����<Nb��\bM�d�OA�?���%99%����?�`[�Jr2]��]���������G�����%b.���D�D�-[��>xBI�-�0��Q�@�[���y���	�+���OR�I�}o�&\#>�I3�b�l6���������?����x���������X�L��������W7��v���W��
N��t��>����� �a)��v���{��h_+]��=�����{ ��qj�]sSK0�`^V�'�9�
�{�}�,&���H�qs�T����8W�� 1j�l�X�	�V7Q��o�}9��Z�na�qug����l�H����3N�	~w�X�(�9�Od��I��/�$���X�}e����pb �$�^�+�o�R"v�Ra�5d;W=1��q��}�O��x@�Cl��o������R�mSV_]��4��F^|;��WA��2����r5�v���T��6����HP�����d?��\fN)�F�O�W�?��=������m-%�6b����T�F��ukK���R��\�)���F�Gz��m>��`���`y����m������l���b����O�S�S�J+_.2US�bJIws����O�PL������4����p�]q�����|�
����#%������i�	��������B�'������ �;E��U@��W�rZiQ��y����<o��<��|y�yz�����^E���,�@duU���!�+*NM�SLx������$�J��L�e��+��b���&��,e���9�k���xK>�!=o�����fT��[�P��h������]���A�~o��Qk6�%����}k����$�l=_�37m
zL�I�S�s�J��0[�js���S��b�3��5[L6w���$1���b���m�\�v��kM��q���	!�-.��.� ��@	KCi�3�|��@�C��y���q^)������i	{���G���7��O��'Fj�x{p�G�C���_'*�I��+YM�H
�)�S�
�����F���6�A���[��������m��?k���hS����1�qBc���SB�)�)�9UJ���8t�8��D��e�s��y�qc��K]��|�?��e�����������z5�m����n���
R�
L[~�����F�~���������wM�B�v��	���`�����%W�\n/�/M��G�G�W�~�j���������T3�O��;&������
bc~�S�v1=�c�U`"�G~t
��������F��|��1�;w�F��i~w~;��'��o�)v��3s�u���
S[7+�v�3�U�����G������n��	
��H.c���]e�X6Q��.V�"W}���f�b�8l^�t�M�I��q6��&-	�L�oY���dqZ�-�������%Mw-s=�:�R\N�3Y�_����82%�U��4��������1G��p���%Q,��P������S�wYK7�;��$�LB���f��#.�nj8����N(R��_P=��~U;������N����t�C����yIqI�]n������P� ����c��|?��WYZ�}�}�����]���m���`N����Y����	�e]�c)���jb���s�d�2e����l3f
��c��j�%�c��eeee�r��}�.K�<����}V%^����������m_�nJ��sG�����jI��`fv�;��-���;n�`����)W�
z�KsS��?��[���eE}Y���b's�A�����Hd_���/.k�"?	k+���	�kn���qu8PcLcn���Y�9�5$�b���bV�$))�YR~��&��LW�R�S��Rj�M����;<Q4.Hm�,�E��e�EhV�~�5#���zs"���"p��
����h�5�������,�]QR��i�[�
+��2�xVn)��t�}���|S��rv�����5���K��Q���R�t��"�!����= )�z���4)8��4���$s��zI^qm�$MN	�z;RX]��"�_�/H��H�GO���{�\���6��Fq��_Zb\��||A�����l�<�b+��>�S.�}~���\�pb����3������������a���b�������rG�������|-?�u������4?{����got������iE�����aS�X������{���is�����������G�z��	/|g�Z�{Fl����H��HI������G���&����AI��BIrHG�������������m1�"k�\1V%�������X��)���)ay���	�TJp&�������6G���P��P��vJ�;7T���tN4V8O$s�rL<�A�#p#�8��6�%f�%�&q��!������IWt�������y��W��X~�[����'�����g����oc/~��������2?����Ig���A�-Z�V!U��J�qJ&�+�)1n�&&$X-Z|�?1����u/��t��_�,�.��������]����;h�\�1����
�Ay��'�]��L���P9��>,y������ m{�:n������O��w�q��?��)�YI��&��Jn)C�SSL	�k���
�-;�\.�F�����FM�
��=��J1��*��bK���&X���6[6�+yj�%��g�Oe�0K
�.���2��,���%�e���Kl+i��R]eYe]i{��P�-oX�>���#����#�o�[�+��W�o�_�
#&n���=����Y*��$"��
��0�B����/_|�����E���cZ5���`�L6�dIL���5�d1kf�IUE��f�Z-��(�2V�MJJ1WY��IQ$�����d���,%�1�$��L�tv�$wz���]�A#:#a�!v�'@�P\y�A���x�� @����	��1�oZ���70,1AO9�"Gb�[bx�p��;�w�L����LYf
��{�q���"K���n��9Jj���Mig�d�Flx�2��Jw��U�-���(5���R�����F��e�MR���b�����kJ���=�V�M�����AW��^�O�����D��������+-�4�U���'�t��]���E1�I<�b�LT���Kx���1����?O�u ���1�8�k,����\��0���<���4���0;C`vZ'�����?�Kv���4#��c�q����d|�<�*����-�G�j���+���'�+cY �
�_?"w�Y����M>p�9�}���I��q�iYqo�6�xBM���75�Q��9�,�(��^�xS����{���d���9b3�!�L��ci������+�d�w}�
��$��Am����,$PcJ��9��>���w0BN�4�a����_z�T�w��`0��i�Y&U�y��:��z}��7�D��-63�[�)t�n��i&OG[7��l��@�=�Z��F�7���r�)t��"|�:E�M7�	i�p'��h�f�gP?�_�,��!�u�F�J������zD�=�#���x=C>K�����A>��|^�oD8�����X�(e*��(Z��G�&1n��kL����Oc2�_w�O|\G������7��Z �����^���K��K}_���w����,�o&}�Y�������)w�|��`7]�m���NR�+��>�-�� �z�0u>#�a���)�Wy;�Bj�G�/D��e��(�k=���+r��l.��C�E\�������C({��r�M���WZ����v"�����L<	<����3��$K;�/@���%`=�7�	(�e����B_�3\7�~p�P��:��=2��k�"�O��|�a:�@>�r���:��K�n�[\g�T��B�����:����v�x��.t+J��C��s*'�>�)c���-J�\�������A������F��?C�:t1J����/���s��z�	�U�����i�z9��_;�0� lX���;�s9�w���56�����M�N�� �#�zP�R2U��U���������hO��H�����������4������������V���-�r��9�6�/`m�����N�� 
Q�T�tP��HA�)������]���c������L��-�mD[���^?��nz����KQ��������*h2��^`����BG�����g�?�F7F�U?���/���k���CO���OSO��I������[�N��������8n#���{_�|O���6���	;�M5�uP���������G���>S��O���O�C�j�@^����qt��[��St/�r��btU�4��gw��h_��S� �r���i&��3�W�:�����<Q�
e;]��K��w��h$]�Hc�MT.E���<?F^'�'(_��J��A��8�D�jOs�%��^$���S�v�]�r==���z>W|�@�O|���S���A���;��0���9A���y[����LC)I�1>^�<8	�yl��2�M�0d���^���P~]e����<��/)�["��Eg��B��������	tl2�R�o��?���wXC�`}q0�%R��	����J�'B���#B�\G0�I���:� ]������A�1o�`,�`��+;��Qv$� �6�����SA�_/����hex���v����[hlI���_�^61�4f�#�e�R��D�3Bq�������lz���t������]T%o#�2����B*���X��1�2]��J_���Q����V�V*����?�:��4Ki�Y����
�>�������:����Bu�����y9��7��C��J_7��F����[�o��~}��E�{y_��i����'����(�w����r#4<^��v��7��w�R�A�������qe)[�����-�8�
��KH����@[���C�=l(�����uo����C�@��=��P9��6���D�{�T�v�X��!_JV-�
Lf*��E������t�2e'��k}�%�S�M���c������[���S��B�?�O����wp���f�'t�#��&����a���`�5�H�R�<����<!}�H�1��A\�=�����{����Q�����A�
r(o�<�3�� ��q���xW�?�IT
9�(���w��T�!5#��PGW|p���m6d��Cz�29�	�� ���&�z.W���
~1?Q=�9?�%�Y��#��'QJO�}��\�=�������X�?W������'�9���W���3��N�O�*��|��q�z�n$�\E��SD'��a>��&#���A�<P�F�F�y�/��T���+����j����|����~P�����]���}����_�||
��N�-E��>�|�a���C���yn��"��\����8�����9��4r@���D{�!�m��_�=����5=K��r���'�ng�_<�D)��['�����N��&�G��>7�
*�����d�����w��+����
z�8����,�3�9_�+��t��R_�
�
�����+�s�����K���8���s"�_�����}����}	�t�e�����?��?�������=�����30���s��/2���s/�O�k{�{/��=��>�?�G��(,����D��?�K{���5?�?���;��wq�B����K����Q��u/���`�E�c����:�������hz����f��eT
��R���aG
��sW6���+��f>I%�G��������7D�>���������E���$��h�5}����?�!d&��sq������\�5�E�����2v^~����_��/��9�s�%����qlq���2���8w�����s4�������2g���7i,�����+���g85.�>Gwkw�RR����3��G����b�`��&kU�!�CD��g����nJ�'��2������~��WZ��cb��#�A�(7�SM��C�[��L�{
)���,���n�<G��1����F�(��-�!������h=����*d���?~����NS���O �7v�\����(�yd3!�l��g�E�����	������_2�=�{�y]{~�{�{��R ����b�Vm��&�.d��/�� ��������qG����0���	F�$�T����q��L]Ie5M�v��v���$��"tv5��yn�
\ �j���%�;���3�k
,��n��>wa���w�id��)`muA�@���8�s?�Yw)��~&���|C��v�3.���CYp�5CGX�����{�
Y}���qb��nn'��������e��4�t=��Q��
�[C^m#p�<�@���1���]A��Q	[��+)�#2Q������_�L�x����������D���\�>��i<'���#a��'_��
(���^:�oQ��~�W�O�����i@��3@�,��������'��inO�)=�tN�����D?~��������=�t��B?~����@z�/���'�^�������H���~��	������G�'qF}�����/����i����!�s�>��?o��{
��{
T�,��X��
�-=�����v����H[�7�/����fx����A�-������F�m���?F�e�j��-��p�"�=��Q�m;�O���c�x
��a����">S�����H��8'��@�����]��qN<����j�D�)�������n{�������i��RA�|8�a�~���<	�/����g	�`y
������X��k(��!��,����[��!�F�9���!��*�XO�m�t�RP��/t�v�D�y1������?#3���{��!��T��jD4=z����u�/�D��+j0���k([M�l��i �;��h�]����\1�\wn��d8S���M��7 d���2����E>#���uu��M�{h�E�;L���}���J��/P��?i�����=�koB�a
��F���=t��<���|���q�E�u��m��]�]��~M����o�;��6��p���n�����S�Eg�gc�9��;z�h��s<�,
��#��at��
���P��,�PG�O?�F�S�gz���f�������Q�za��#0O/�.B���b����cB$]�M�u�i��\	����<}Y$��Y�~�we�|'�U����htF�m�������y���L���o5��_���_�����x������C�q���F�}9�/Q?�'�<�>,���A�a���q_�'���������~����������
]���
U��'�S������a�����wr]���{��s��?�m�����+�����_@�;\_��k_t�������e�\��w��������_�v�n����?7{�SJ�@��E����>Z������Iq�����;���^�O~���	�=h��3����^@a�;
���d�6l�)���A;����������N�/8�r��1*��C1_�>G�7����#�����������5�_�����
��Zz�8��ywu���kE�?��Y�wiv���r}�;�Q�6�Y�����@;�NO=������wj��Lg���������}����i��
t��
������}/������wlg�/�����������t�`��9?�����
����w�\Z+M��t���l���h�/X���?��H����C�GA{hE�����{I����������$<a�u	c���v����������G8h4/	>e�x��8�����F�	�LRn��N8�r��@��yOi��C�������;��]�w����)|������up��E�{�#l��g\�g�:9E�g�K����p�@z��2���W������P����T�9p�V�D�l������d�@��'���J���6
��
�2�������\�tm�IV9��@���9r~�
�y�2�O�Q�Q�u#|�x�z}�Z��eG)C�����f2��C�~V�>���������sr���S�z3�hnZ�&S-�U��p=������'G�Da~����'���#�����k���9����w��v�_7�x'��S�K����3��������~���n4�_�>�f���]W��.wB4<��~�����
]w���w��n� /�����\�e�Y�|_K��FK
��z����iG�w6��~������=/��w3~�]����g*=����w9~-��g0���LyO�������yX?�AF���#�M�R&�]-��}����5�DS�����|#�oe�>��������7w�=�5�]�p�T��?���x�����nw�U]���h������]����&l�U@�v�� v��q;#�%!��K<,]����:����)n��������"6K�Ln��:�����9�l6H:
����C��?�	��s����$l��W�C"l�>
���{�3P����%����������_�2/tG���g8��w��/<G�����.��hm�8����L�v�^j�9������`SRt.�i>����r���nI�N�@��\�
[��Q�g��&�~��g��g��/z�s����m�>����o�����p�wH^�����/���>�r�F������G�7��<���g��]Z*�� �<
�o�&t"�&�{q�{�yQ7��� \�?��S`���OA���6����0c/�"�BGbp��_����'���'��D�PM����C�����D��D��6����E/z��^����E/z��^����E/z��^����E/z��^����E/z��^����E/z��^������C`�/Z��TA7�F9���f��Bz�T�vMZ^e��Gd��ld
����%�6PW��-�@I�����"��������4� yg�d���5X]"���Z�_�s$��P��J[ ���n6O:���t@���[Zj���AT��J�����~��cy�>3R���V��7��J����N`9�(�2��||ot@Fh���$o��kqz�UV�^ZH���`��Dv�[�B6w�:�K�UN�6�$
�c��P�z��'	�k[
���ZcK�(��^���A����D<��kZ�����[q��7-���@��SR)\FL�-_L�����f���:S�Ev��`��Y��U�x��H����TZ-�P�(��%6�����>%��#�8d;���eSK���O
��j�����jq&�<!_+�(���T����l��Z�H&�Z�%��l�$s��E�|����TT'�������tJ��3�&�G5��������[���R4?,�Z�Z��%UyrC�ZL�Z���V������TH��2��	�_��j��j��j��jtj5�����s=��WP������j�����@N~I��,{ �>��!5���{�iq��b�V[lI��B��B���&yJ�������m��r����rRdj���S���Ap���-��P�q��^b���\H���A>����s��A_2�_"T��D��WNW�I�����?hB��Oz�������{!�!�S%�!�g���������MjkA��j���`�gZEF��k�R���]R�+=-=Ei��o�9�OI��$��CZD��>&
����
���������������.�ZL�<��q����������I;)Ei�� u{�?����������%����J��zv�6�!N�%mi)���k����K��uAOY07X�*�o�}��B_�o���)���$a�Jk�]F>	��u��-JY��c���h9�7�P��E�����=.B���4�P�R`��-)���
p%p�HY,���4����h��hG38�G�h}1�9����&p4	�&p4��	M����	M��u��G���G8��Q'8��Q�:�GApGAp�Ap���(G18�G18��Q�b�Q�bp8|����'8|�����������p��	'8���	'8��p
�����8���8����08��08���8����]���?��X���`9�`9���X���1�EB�f)�Xp��v�����C��b����G!�G!p�G!p��fpl�fpl������Ypl�����R��S#������J�Y����SA��!A��]�^I[�
��
*t	�E}�."���x�Un��q�t`>�	xx0����;�.
f)�8�&���'M����&����6i�jOj���aM�U�JvaGaZ�&�����D�])B�R)�-����R�4w��Y�r�d�hvSVe�Ng��t>*��qV���ye��a�Lk�|��m�����R�~
��+�2�(r�H�����,���@�	�x�v�+�l��lk��d�����o_K^1H[K�8��[�fz�,l�q��=���	�h������[��@��xKA[���Lk�{�[eg���p�I��qs:��;���x@-y~^��En��#��WN����P��o9/m�<>�L�B�=�TnE�>kg�
�x�yo�~
�O X���6���66%h��/����-UV^��.��8}��5�z�]���������][�fF��������
_��3�]�-�.*<�]�=�;�;����������������{�u�p4F���==�Mt��{�7������s���H�e����$�z_��On���em,.��t���4�4�4��m�2e��M	f��i�5��V�����d&s���7q4��q5�+"�����}�U1�DgP(^��j'g���s�v�/����6f?5�fg!W-�N�m3�Be����nZ�.��6 5$�jc4����<����k2��S����kolh ���JO�kX\yM�O|5��SO�`zhC�������P	��
���N��]�.9$���v)����v�Yr�������;"�A�cQ��8A1�p��b�'�y1�Q���(��	�Y����V�(�0^n�!���]>�(�KtH�9�K��@c�[�����}���b��>��Q���"�^Q���y�h,Tt�H�Qd`W���-��*���I���I�G����3{x���_������G6e��
4��\:�Z>����t1���d��s�r:cvhq������j�����D�3<�v�.zf���]�gW����=��������m]��V}�OTV�+��mUV�Dv���mU���x[��J���y\���w�i8�����J1V�pSjf�p��yW�������{b�)&��e��UXUX����xV,�F�g�����l���Dr\���?[N�Pmh���P����\UB�?=g�Gd{h��j�C|�~����?�Y�S���/�_��jC}&���GOL&4�T���~�4Yi�,��mz2�[����@�A+N]&i���$���������O`_�'-i)�giIkV.?�,j-�8�r���Y��^zX9���`\!�r��+����ps���=[���������2-
,�
�E
6���h����t��f��1���*B��B�����E�	��/4*�LDZ_e[l0����)RI$��u�����]p
endstream
endobj
13
0
obj
<<
/Type
/Font
/Subtype
/CIDFontType2
/BaseFont
/MUFUZY+Arial-BoldMT
/CIDSystemInfo
<<
/Registry
(Adobe)
/Ordering
(UCS)
/Supplement
0
>>
/FontDescriptor
15
0
R
/CIDToGIDMap
/Identity
/DW
556
/W
[
0
[
750
]
1
15
0
16
[
333
0
0
556
556
0
0
556
0
556
0
556
]
28
67
0
68
[
556
610
556
610
556
0
610
610
]
76
78
0
79
[
277
889
]
81
83
610
84
[
0
389
556
333
610
0
777
0
556
]
]
>>
endobj
15
0
obj
<<
/Type
/FontDescriptor
/FontName
/MUFUZY+Arial-BoldMT
/Flags
4
/FontBBox
[
-627
-376
2000
1017
]
/Ascent
728
/Descent
-210
/ItalicAngle
0
/CapHeight
715
/StemV
80
/FontFile2
16
0
R
>>
endobj
18
0
obj
<<
/Filter
/FlateDecode
/Length
23
0
R
>>
stream
x�]P�n� ��{L��RR�*�}�n?��A�a|��w�����c�����S��x����`Z��;oNaN���y&+���oTN3��8��e�86�����9���������$���v��p;���#��){2z��Y����%���@����%"T��,NQL��jAKA}��z�����7H���2��S��'!�;����WO�<�2i�u�����:�[:3�D��0K�5��x�wqU��	��
endstream
endobj
20
0
obj
<<
/Filter
/FlateDecode
/Length
24
0
R
>>
stream
x���	|T��7��s�,Y&3�u���0$,�JHd����dQA�;J\�"(nTQ���!,���B��V�����V��CZr��s��!�(m�����d��y��>�y�s�B�����4�M�vnhM���F�#D����\v�o�X	�1���.�a����oy&O�:��p�����2��#"�szs���-�_1���]O�E���k����Le�[�r����|���-�j�{|��s��9�o
��p���9vP&��x�2�
�_0��a|��L�W(]g�hm3h#����c(�,m�-���R�k�O��I���F��@��"��BE����"��t��t0����@{����S_N�i�b\C���v�NC�J�#��r�.�^�qz��k0NSe�|�8�b�G�P�z�>��m�Z�F�G�jZ�M��q��z�G��:
��b�,D�S�s������F�x�rhM���Cte�c�1�8H�h�z����6|�����Ht37�Q&��0�-���������3��,��b�����>zM���r�#���q�h�I������O��g��|�k{�F?J�����M/��"K�ab�l-g�U���F������Q���Pl���U������Y�a#	+R@�����`�!Q%no��e9Q>,?�����_wM��/�+hm��d�C����<�H�#�k��W����Qm�v��[����*�v�B���/���_��S�?�N�B~���Vad��Uz��#�	"	���c�M��"���b�xZlA+������[��8)	_���y�9�ay��N�/��������^���k�ZW��V��F�i����}�g������c��1�:�������������_�ns��z�_\����~��!�a
�0A���O�w&�{8�YzC$b��D�G��L3�U�z��b�xB��7bf���(���9���eW�O��9U^%��{����Asi	�WK��h�	�Tm�v��B�j���}���N�k��zPo����@}�~��J�\��1����Sg��
�Bg����n�>����	��]�\o�'�;_���5�����Z�������L��|�<�*���*����f�E�p\��%{����^���+�'d/m�,F�L����������@G�]�+��zg��Eu&R� Y�6_�:��������p�k�]=^d�#�)m8���zG9�i��o�����U���t/_$�C.���?5�4y����1�N��_���b����/�����G�����W:�8��~9C��)bI�i��X��#�������m��^���}���U�m�~�1RL����U��t��\]\F�K��aH�yZ'=t>��x��m��; �jC�_���X����:8h����b���hYG�9����r�Hg<I�������`�15��O�nZ'��Ds(;�}1�1@��`��5�m9J�8{}1��"@_���8vR��gE%�R���$�Ct)]H�`����A��\��d��`���)#(�i�q9
�]���A�]�X��x�������\mj������f���%��cF�����E�^=�{t���s�����k[��u���-���B��f9�Y�������d����IL��s��]�����L
E&E����A�8�����"&EC�pv�hh��:;g9�����9#
9�/��z�k*��K��:1nD9��J���������^
���KCQ1)Tp����I��nSB|�p��������x��f��l}�������$�=�T4+\Z��r�Z~������e��yy��FE�)�K����,�_5u���T3�<�3������u>�tRabe�r����6�������h����Qyr��E�S������kj���G�7N�c��u���0�f�^�I<*�������X�&C<�9���2��43��O��9	K�U��7��feE��)�,T3�<�-�WL.���J5#o��	e�����&����MI^���i�����|*;��l�Y�=
_��������0�����=�fJd��B�T�+2#�R��'�s��#��|G������3��q���#�2�4��m��0��
���?�}���]����N��s|!L
��N��Y�������.B�"�Qn�Ctiv-E�
+�r���S��pJ���P|R����\M���y}�)e�{FE��$O5��
1�<TV3������
��=�,_4���--���T*�r|Cf�'F�|�s*���s���*F�D}��nE|^��X��8��9S��f�g���^g���^b��CU=��&��4���������P�(������:cOFEv4�)���f�<+c�������]�t55��5�j&����C�p�v�{���9e�l��3v����s5]�����oSX,�)"�W��������R����Ulj����8rDT��X��@�4X`�����go�U�T]E���:A*�m�	�R'�8�'��q��1�G�7��%+���P��`�����y�|8J�TH�s*�����p�����o���A���+�<sy��������+#�@fj ��N�ffu,L�%�8(�D�X$A����Dv ������o/��O��%WR!z�Q������N�!�E�q!��.��NL8r���*9r�����w	��_��������{�c�@�"#E����A���
�+j�Gz�����?�7|m��
�Nyp��l�a8I"1(�p|	�AJu��B�#l��bBJ�n�;A;��DsgZjz�N��v)�Dg!~"��U+j�>�p�&Q��b\�R@k~�����~��EH���e��q����������>��j����~�r�}�q��w9a�xq��K�rx`����w�!5��������o�71!��g��E��aG��.����>3��#�O�19Nv����|�n��L���\�����d����W�aO$����O�C���p9��0U������!{���5GMN���-��|�LHpr�>�!_b"��P��:�8C��$l���A����8�Hw.��{�'9�\	Y�2$������S�����=�5+aJ��i�2'e� �s^�p�w��A�
���;�-�[	�z���������-<m��3����Q�w�'(�'��8�����<�W��O���#�U�	�h�x7����r���}w�F��'���	W)�5	b�U8z���**lF-��������|�� ���������cf�������f���������yO?}��'�7�.~������;���/l|�9�h�����v�73�l����Ip]<�)�7{��x5BRn�9
�j���,}��[>����E9R�s�D)�����Y$�"��8���#�_E���r{%)���G2�ylQ�����������.�9"��L�!z�(���	���R� �{���TR�Q,��<�4���ya������[������o���GEs�����f����eo�H\��\�N.��7C����
{T��T`��j_��!���RR�c<�3~��|�����Mu�2ep��\N��IBJn"�6�N��$����P���2�<,z� ���Y����;���

&&'K�`$���v;�#	�)rLn*�q�����d^�[D���Z���qk��H�^�^�������}��9�+G'�J�L�1���%���?��4�XV����Rd�/�����s��8�Y<Ln�8�pVn���t��J���r�diB��r4O��N>�y�_��D`+������L��7j��Q�y�*i_����7��)o��D�H�k��(g��P�;d���7�m7��Oao�qs��>�zh�'�d�8���&A��6����=��&�	W[;1?-��;8�k�ps��e7KC8]���Nu���^yt�C7�������?�qb�S�_;>w���������O�������W��jc��]�/���w�X�3=�W(�l�J��Y�����wBf ���!�{�0�:m����o�������q���=��LA�!����@���E,|����KJ|G|G��G���&�^,����o���{�<=z��b�������}3S+�������S��$�	O�#�)�KH�$�.�v�)[:;�zDW&M���S���C/��'�^�d{��dqr�����x���]vV������UU��v��_��H�?��
������o��&,�AF/o['����P�'�<>^8�A,������b�W Y�.��
/)��-���{���f�Cb�����?����CR������4uK�W�����i��-�����
q{��E����I]%��y�mw�������������]{���"�� �|�����vJ4~�<���q*�Q"���8S�+����S�q�hW����r]Jq��f<O�[��:��\S-$�'NO\��t��D�m��~]K���D��r�'h.������j��yH&zt��S�$7����x�ud��z�����i�o+�x��P�o�y_'�G<�H�pWu^W�r�����I�B0�CR�\�����6.#�&���j���:���q���}������}����XX��y��z��8Nm'��~mr1�������Z�v����Yo����<���HBqb����HAqb��v�JvT4X��>T��������k~!W��C>z���[����Oh�N]�D���N�b9��a��I��"))j9������2R�wF�D2�����d�j��Q�����e,&+7!���3��>[
m'�������������dVa	^]���$	r���q�<Jb�ra���
[����2,dZ'+%�U�����4[��T��o%����K	$����:8:$��B� i|)�i�))���I)�I^�M$�;IZ�$����4au�9�.�`Q��s��}�}�}w�t�y�J@	����/ �P	,%�]�+���Q���\�%x�p9K�L�MJ��y�� �?Y�n_���RAJ���&�ub������I�K�� h(-��g�1�M{����l\z��VO�%�>���;��#�s���iQ������+k�����?S����wO�a�(C�_i�C�������>���_gyB�l�mOK���
l�g�O����I�V��-�x�H�xF��"(&
�0[�F<�����h��������R�x����1Oe(����'�Y���}/��5����	�[�fe�RW$�4�44.yth�V��t�L��u_����0�-���~W���)3�<�3���'��Cy���^�H�3[���c�4���m���(��������)��	�A�a���c����-$[�HJ����J0�*�['�#�K2&f�����g��_����t.���=���-66�4L=�����JM)3L��g�X�XJ��!\-����bM���N�9�}�Y���Fl���h{���}�\*���l���^����O]������>����~|�M7��G%��0�C�o��2�����E���������NX_Q���g���N�nKw<E�Iz�#t��n=��M)��DO��I��a���d��]�5
3M�Z	�l1��$k3^�;>���C���w�O>L����b����K����$��
wKN�>Y������n���m��D�a�����O���Q|%�=��/��k2�k2(L��)�>�g)1�
X{��m��E�mg�y�dw����d�\����p�
��7^;U/��TB�r���8��$�ig�pk[�����.R	�3b~K"e��g��s���}r^���*7�����88�M�zYF����
��Do@�D����Kk��Z��D���jLS{:������|B9R\�p����������'�.�k+~�}���T�9Gr�\2Mi�45�3��C[����
+�������_P�Y��q��t����u_�pu�5�3���T_��5�����e��MIn�Q��b
1i*`���:�H�JD7V��f�#���e1��hn��8�C^F�V�B;Sa��)��FB�5^A^�Wz��=��N%`J���	TE5����"i-Tb��B%��J����H�L[�q����BS��[���\<AM"[T��Qzm��W�U�4��K�������m<bRR���H��s�g��|5��E��O��v��{.]8k��%�.[�����n]����Oi������VO�U��/.�e�{�~^��~���,���1t��'�o[����8��������[%���0]��6w��������S68���/M����
�jR�dy
�;�g_�m9�����,�E�K���0N��0MN(���Vy�EZ1������r���-Lh��M
&
K���Ri�� ����c�G�����b��NJKtR�����cM���."u���i�L>���?���m�4U���H��YC�#�_�_��]�~E�e��n�]�ug�����ve}��Y�D(����7�k=[W:eK���[���3�*wX�D6
s�I��pSmo�N�=��O���	���`%'�4sr����������-[�����>�w���������//<kAS[{��A
F���i�UbB��L�������%kiP��I�����6�y�=gc����n�Mt�y��S����#7������#_~b���O��y�����!��2'10v�p���[Y�q�����o��n����^|d)T�z2��\�]p�1���;w�[=�_�q�6L_�k�����G�D�F�P%T9W������~x��}d�7|<N+������K�*J.p5��c�\,�qr=�ch�(�?CW#�z�������?��
������d`��w;�Es�E�h�;H�c��ho�cMV��V���9��
�G��:Qw��2+���A�?��)�[Z�����\��ZF�L'�[��;������nz��!�R�:/���������X$��b��X�tP��/�x����P����\�o�?�p�z�<��|��e*�-��/6�
���<��1��V�~���m��b�S��F}���1�@�L����l`�<HW�CH`�r|J����>���.BX���[h%���
U�i�Z��H������|wNP���s��|�W)��X�:�P�PI��~{������K��Q{�xn��:m�������X�j�r��/�9�uc������@|���<�e�<����p�Jk�g��0��ql(>�����'p������Z`��B��v5����M��
�>�!��x��*����Yc����9��Y��N�/����&�n�S�36U�=K��7<N�����M�j��l��}���B����+���3�r�l������{�������#�Q����mj�E�N���I�K!SV� }.
���K�cT�������x�7*����=�k9��b���!1��*}��=�9�J?$���������Ab�c��E�Dc!��iL�������-���
�W�C�����{�����l��Z�h�.�g�:��9�����tD�����9����1�i��������E5U�C���F��
�4�%���\?��F|t����Mm~��,�-�
�:��^�����w�����L�
,��~����j������	�;m����Y1�����T��w{��K���|d�2���;,mT�F��>H��}������G����z_l��S�-�:-�X���_���}}�N-7�-}����f<%�z��������J�|K�+=:V�/��,�w���C����� ����O�����G�����x��D���0����������lg�+}QB���Ut*S�s\Lk�_S'}d�����qpx������9q�:�O#O�#�j5zJ����
s��B.��E����Qe"�l���j.Ty�"��<���F#�=�5=�Cc�qU����4Z�:�@�1���R���%��b����9���qR���\�h���
pTcg������]��G[O�#� ���x�j�B*s��e�[���D�w"�����KP>h�mB�K�eK��a���+B)�je���)h_���h�b�q_����,�Fc.���
�ba�	�3���|t3����:ZH 2X�n�o��X��u���S;�O�������&��a���rXO�VZ�����_����u��qzo�_LW��J��{���iXk�s�>i����^�c����Z���3�O�������v�\#�������Q]�5E�V���~�}<G��8�^��<�����x�7i���6��;�_J7�u����j���y@{�+=�
[����a�n]�]`�~t3��uc��U����vZcp;��oG�����V�@7v0b�c����n�/�����|Ju]K�ZK���\L������Zhd��|}�9����<F��^���5�!��n������;����J3y���[�{�c�[����C8��O{����Y?�
������c��|a��&6��
�p/�a�%���������^�qX�<Gm���'�`�����%C�@_����~2�����2x�2�������T�h4��x^��f��>������_�@@�AG�^h��{6v������\yb�F�����%`��{��nKx���`����<���t;�i��SE���C�A��8h����~?�.}���w�_��C&�g�j���D�6����o�Y���~8<k�?��	�����}�A��
�����~z"�������r������R�d�_�`{�G���:=���_��Y���A���{���������g
{��G��Pspf�v_������86�z�7�>�8
�2��h�e�~V��E��M��h�(��l;����3���k���C�g��U�,��X����
����<����+�=^��w8=�@8	k�x��]�r�;��7���i�l���=�N�o��]���N&����m��p#V��8����u�O���z������>����;�v�������g����X�����%v8?J��=��������|�������>�����f�1Ge�9����k!/`���(�^���>E����[���#�����X����i�oC��Ty�-T���c���seb��\���"��l������h�m	���\}����
c��v����{�B�:���z���A�A�!�G���3N;oTy.Tw�si����!��2^Twz��u%��(�C��{:���n����������z�b��8�hw�z&4K�{�o�~-�J�;�T�.���X_9��O�c4�G��:���(���Tc��E�T=�Y����E��z��_O����*w%
p�W��Vh����{�u=�,T�W��z�u�9���.3��N�s�M��7���}L�v�r�����{(��<�
t|
Pi>�0N������u�9����6���{��4B��>�N�I���}!`�ql_��0/���m�/Vw}����Ji�n���/�z]�k��`{y�����|��~=�K������z>�w����m�_�=z%�
xP�O=����O�r��������k���gG6h���c�FA��ke���j��z����f�Ki���<�L0��R��������j�UsA9/�u<F��kO�4����#�������5�86Sm6��=�u9X���^�]��r�4E�S%C0^_��Rg���6�=���?�%�s5�~�N*��X�rSr����VX�f�q��M��c==��g|�����~T)���j��h>�����ZY��/�;�c��L�b�x������b�x��b��~���O���~�T|A,_�_��O����?����@���?5�-b��?���b���b���sl�^�M������
��~�/�iV�/V�_8�8+�,@�|^�7�jc����1����1��c���l�N�m����f��A�N>3�Sm���VZ�[l�5�^����9�U��0������~�	���|/����s���1?�u�����2ctu�k�I��h��������(y�1�S�����M���!�R?�X�;���w:*����X�<o�~��K����&�WR��
v�@�[�����n��lshKh(��U�gB���zZ�E�/>�I�?G��8�-v��@y��=����������F���y}=D������T����g[��H�]`Q��4����)�F9q�`��F�1g������(�O��+���S���g�v���u����c��R��"���i�qF'�Q����+�W-��j�	��	;�P=��f�}~����::Q�}vw~�yM�6��q�}l�5�te/&��Z�}@����m�������kl;�����������l�E������iT�����H,������/Y��k7]��@��i��4�1��B�\/P�k �>s��]w�h���EGQ������x/
���\��x�����8^�M��^i���3�tN3����G��U�<3�i�C����Fw5�P��Pc;�z�j����g��?�K��;4���N�9�����@��a�y`����!�i���T7�O����6d��E����^,�}���g�;��g6=���^b����r�C�'s��N�W���;�,�����N�u������5!��c�~�P=��ws~
�p�8c�>����&a�.?����p��r�;����&��-|ma-C8K�=�0�G�����:E����	�~���`���NV����g+��:j�N������G{^0��0��
}���������v]�[����7����M��=�9���Q��]���b��y�	lX�������*iS�OS���
e~��p6eXa�����+`�~��U�k~\SM�s�4�I��c�^�b��i��k7��X��Y�@��>��?O�����Q�y�X=�@~�c.
�/�v��p���c>lm�aa�����g<k��T����g�\�A{U����v��&�?7�������?1�����/����p}��3(S����y�6���������wn��������AMx0/��u��7�_�����^�����*o��[���Y���RP���4�N��w��.�p�p_�@����AY�'p%�;�f�;��6
���`��!�*`6�P/P<\KU�I���h:�u�l�@�����tu��F���+Q����*c�I�����J��C>����BK��N�/@������8�J������8B���g
�����k�_Ro}����+�����v��f�X���^�������4��j�8
��=��a��8A;J��s8��3������4~�X�K|�x�����,����h ����
��
�c�w��G��M����"S�O�{M���R��x0�z�{��|6(��n���J���v���1[��Q�

w�L��6�-�DQ��:�k����B���T�_��R��������F��V0��~�%c�C�������q�w5~�7�����q�w9��y�r��e�U��@��v�7!�p����C�����-{{.��P�N��Is!�r���N�Y�@6�3���S���}*���]��� ���5p�Z���w
��]h�Z��Jg���8�A�T�l����<e� qH�X�{�~�c?E�_��dJ?���1��Lh^c��II������<��5�U3-��_�MS�����q�+~V��iu���!�V��SN*Y���������'/�A����%���Cw��|v�Uf�U����g7�%)J'����no�����z7�3u^�t�A����}�Z'���l_���y��}�7����lD'�Pz���s�e���CT�q�yO�q��'�O2��w6�����}� �������`u�w������|�8C�C���P�2�"� �
2���!/���2��N����yi8=���ow����Kq�!���a�+Z5]����]C�v��z�+��U|�>m~��:������xO��(i$���C�]���w���&4�	MhB���&4�	MhB���&4�	MhB���&4�	MhB���&4�	MhB���&4�	MhB���&�_�������(�H����}��I�9Hn��Z����k���t�Z���f��ZK�Ym�`�NoNN����N��"�����v:M�r��;��v�N"��f��9Ek�������-�L��D�Z@� �"`0�xp�|3�������Q{og�=��NE6����
N6��'����+L:t�IK/0��4�u�bF��g��mM�����i������Z:�����+������ZK�( 5���7�(���nM'�IMP%�=����;����<J����#f�<�9��������Y`7�����P~H��a�s�%�c�n�U�(������������"��<��.�W�>�s�r�_H�\�|�z�W��;�t�������+Oa��	�[��l�����N�^�}kpTV�SkN}����6�c�N�����oW�� ��( ��7������9����{�����j 
������<�x�:`8������:�jmA�`�t���G������Q�U�e����AsA����A���tB�������A��_����%�0`"p7���e���`2*�I�������O�Z7Ef#���!v
z�>8��+���!�N�]���N�K�c���[�c���k�c��r&|���;�F��N�z�E�`�a�D��W^�Y��tf�:��u���u����m�`�VF
[�	V���D�HQ�VTO����[EuoQ}��.�9�:WTGD�N�SQ-"[�
G�����(��Du����-DuHt�����:+R������@���+�0�y��<���p_� S���93�i��mJ�p���f�$_@��/���zl�*yx���=�Q�����[�^�E@	0���;GI��.>�:Vduz���6�7O�E��r|��A��9��+����;��Q�����m������'��wS3,�r��]�}�`�x��`g�o�����:QL"�U�pW�q3�B9rh����(��-h�!������9������~��3��P�.j���a[���%��Eun��*� ;B*�����T�[���6x�m��sg����f�%UE������P_i���H��,��$������lv@
Mot�u�j4��*��NL��u�p������:����\AW3W�+���������x���t�n�&w*���B���N���'��t��Iv��'!�pK���)�`9xT?18�g

�4=1*\'�G��:��D4y0
�/��pp���^88����MB�U���\\'hty�08jAv4�?��z�_�,�i��**(�~mI�$���x@�9�I���ol��7��<�<��YE�{�f����
�/�.���J���3�(�������x�OiE��:1V����;��c������9���f��f�|�G�L�/.��U���8�O�oSU���M-Z�<!�Ry�2B���G��|�'���<��9O������,�9*����%Gd�,c�d)��,i��D���3yr�<��v���s��s��+,�{UL_65\6)\6������h�����)��j�.�2�����������pihS���H������h|���M�#SKk{Ez��'�Vl8�K���Z��V����l8W�������9y ������m
�Tm�����������x�n�	���I�y��}s�(����%{��u�PXM��zNj��]_N����$D{���-���w�uV���p?*�{M�5(�Qj���Qs��	7����� �,�\Z5�hp��������7�\���C�������=fd{D��HMk��q�9..���������yT���E$W���
-�;x��(=c?�|l)VU`�(UvV����W��lc�5�����5K�H�=%
������
��z)�`
endstream
endobj
17
0
obj
<<
/Type
/Font
/Subtype
/CIDFontType2
/BaseFont
/MUFUZY+ArialMT
/CIDSystemInfo
<<
/Registry
(Adobe)
/Ordering
(UCS)
/Supplement
0
>>
/FontDescriptor
19
0
R
/CIDToGIDMap
/Identity
/DW
556
/W
[
0
[
750
]
1
7
0
8
[
889
]
9
18
0
19
28
556
]
>>
endobj
19
0
obj
<<
/Type
/FontDescriptor
/FontName
/MUFUZY+ArialMT
/Flags
4
/FontBBox
[
-664
-324
2000
1005
]
/Ascent
728
/Descent
-210
/ItalicAngle
0
/CapHeight
716
/StemV
80
/FontFile2
20
0
R
>>
endobj
21
0
obj
320
endobj
22
0
obj
17198
endobj
23
0
obj
249
endobj
24
0
obj
14003
endobj
1
0
obj
<<
/Type
/Pages
/Kids
[
5
0
R
]
/Count
1
>>
endobj
xref
0 25
0000000002 65535 f 
0000067489 00000 n 
0000000000 00000 f 
0000000016 00000 n 
0000000142 00000 n 
0000000255 00000 n 
0000000420 00000 n 
0000033819 00000 n 
0000033727 00000 n 
0000033748 00000 n 
0000033767 00000 n 
0000033990 00000 n 
0000034139 00000 n 
0000051953 00000 n 
0000034283 00000 n 
0000052346 00000 n 
0000034679 00000 n 
0000066952 00000 n 
0000052548 00000 n 
0000067208 00000 n 
0000052873 00000 n 
0000067405 00000 n 
0000067425 00000 n 
0000067447 00000 n 
0000067467 00000 n 
trailer
<<
/Size
25
/Root
3
0
R
/Info
4
0
R
>>
startxref
67548
%%EOF
scripts.tgzapplication/x-compressed-tar; name=scripts.tgzDownload
#3Jakub Wartak
jakub.wartak@enterprisedb.com
In reply to: Tomas Vondra (#2)
Re: postgres_fdw: Use COPY to speed up batch inserts

On Thu, Oct 16, 2025 at 10:42 PM Tomas Vondra <tomas@vondra.me> wrote:

Thanks for the patch. Please add it to the next committfest (PG19-3) at

Hi Matheus! same here - thanks for the patch!

The attached patch uses the COPY command whenever we have a *numSlots >
1 but the tests show that maybe we should have a GUC to enable this?

I can imagine having a GUC for testing, but it's not strictly necessary.

Just note, I've played maybe like 20mins with this patch and it works,
however if we would like to have yet another GUCs then we would need
to enable two of those? (enable batch_size and this hypothetical
`batch_use_copy`?)

Some other stuff I've tried to cover:
1. how this works with INSERT RETURNING -> as per patch it fallbacks
from COPY to INSERT as expected
2. how this works with INSERT ON CONFLICT -> well, we cannot have
constraints on postgres_fdw, so it is impossible
3. how this works with MERGE -> well, MERGE doesnt work with postgres_fdw
4. I've found that big rows don't play with COPY feature without
memory limitation, so probably some special handling should be done
here, it's nonsense , but:

postgres@postgres:1236 : 15836 # INSERT INTO local_t1 (id, t)
SELECT s, repeat(md5(s::text), 10000000) from generate_series(100,
103) s;
2025-10-17 11:17:08.742 CEST [15836] LOG: statement: INSERT INTO
local_t1 (id, t) SELECT s, repeat(md5(s::text), 10000000) from
generate_series(100, 103) s;
2025-10-17 11:17:08.743 CEST [15838] LOG: statement: START
TRANSACTION ISOLATION LEVEL REPEATABLE READ
2025-10-17 11:17:38.302 CEST [15838] LOG: statement: COPY
public.t1(id, t, counter) FROM STDIN (FORMAT TEXT, DELIMITER ',')
ERROR: string buffer exceeds maximum allowed length (1073741823 bytes)
DETAIL: Cannot enlarge string buffer containing 960000028 bytes
by 320000000 more bytes.
2025-10-17 11:17:40.213 CEST [15836] ERROR: string buffer exceeds
maximum allowed length (1073741823 bytes)
2025-10-17 11:17:40.213 CEST [15836] DETAIL: Cannot enlarge
string buffer containing 960000028 bytes by 320000000 more bytes.
2025-10-17 11:17:40.213 CEST [15836] STATEMENT: INSERT INTO
local_t1 (id, t) SELECT s, repeat(md5(s::text), 10000000) from
generate_series(100, 103) s;

but then it never wants to finish that backend (constant loop[ in
PQCleanup() or somewhere close to that), server behaves unstable.
Without batch_size set the very same INSERT behaves OK.

Regards,
-J.

#4Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Tomas Vondra (#2)
1 attachment(s)
Re: postgres_fdw: Use COPY to speed up batch inserts

Thanks for the review!

On Thu Oct 16, 2025 at 5:39 PM -03, Tomas Vondra wrote:

Thanks for the patch. Please add it to the next committfest (PG19-3) at

https://commitfest.postgresql.org/

so that we don't lose track of the patch.

Here it is: https://commitfest.postgresql.org/patch/6137/

Thanks. The code looks sensible in general, I think. I'll have a couple
minor comments. It'd be good to also update the documentation, and add
some tests to postgres_fdw.sql, to exercise this new code.

The sgml docs (doc/src/sgml/postgres-fdw.sgml) mention batch_size, and
explain how it's related to the number of parameters in the INSERT
state. Which does not matter when using COPY under the hood, so this
should be amended/clarified in some way. It doesn't need to be
super-detailed, though.

I'll work on this and intend to have something on the next version.

A couple minor comments about the code:

1) buildCopySql

- Does this really need the "(FORMAT TEXT, DELIMITER ',')" part? AFAIK
no, if we use the default copy format in convert_slot_to_copy_text.

You're right. I've removed the (FORMAT TEXT, DELIMITER ',') part.

- Shouldn't we cache the COPY SQL, similarly to how we keep insert SQL?
Instead of rebuilding it over and over for every batch.

I tried to reuse the fmstate->query field to cache the COPY sql but
running the postgres_fdw.sql regress test shows that this may not
work. When we are running a user supplied COPY command on a foreign
table the CopyMultiInsertBufferFlush() call
ri_FdwRoutine->ExecForeignBatchInsert which may pass different values
for numSlots based on the number of slots already sent to the foreign
server, and eventually it may pass numSlots as 1 which will not use the
COPY under the hood to send to the foreign server and if we cache the
COPY command into the fmstate->query this will not work because the
normal INSERT path on execute_foreign_modify uses the fmstate->query to
build a prepared statement to send to the foreign server. So basically
what I'm trying to say is that when the server is executing a COPY into
a foreign it may use the COPY command or INSERT command to send the data
to the foreign server. That being said, I decided to create a new
copy_query field on PgFdwModifyState to cache only COPY commands. Please
let me know if my understanding is wrong or if we could have a better
approach here.

2) convert_slot_to_copy_text

- It's probably better to not hard-code the delimiters etc.

Since now we are using the default copy format, it's safe to hard code
the default delimiter which is \t? Or should we still make this
parameterizable or something else?

- I wonder if the formatting needs to do something more like what
copyto.c does (through CopyToTextOneRow and CopyAttributeOutText). Maybe
not, not sure.

I'm still checking this, I think that is not needed but I want to do
some more tests to make sure.

3) execute_foreign_modify

- I think the new block of code is a bit confusing. It essentially does
something similar to the original code, but not entirely. I suggest we
move it to a new function, and call that from execute_foreign_modify.

Fixed

- I agree it's probably better to do COPYBUFSIZ, or something like that
to send the data in smaller (but not tiny) chunks.

Fixed. I've declared the default chunk size as 8192 (which is the same
used on psql/copy.c), let me know if we should use another value or
perhaps make this configurable.

Lastly, I don't know if we should change the EXPLAIN(ANALYZE, VERBOSE)
output for batch inserts that use the COPY to mention that we are
sending the COPY command to the remote server. I guess so?

Good point. We definitely should not show SQL for INSERT, when we're
actually running a COPY.

This seems a bit tricky to implement. The COPY is used based on the
number of slots into the TupleTableSlot array that is used for batch
insert. The numSlots that execute_foreign_modify() receive is coming
from ResultRelInfo->ri_NumSlots during ExecInsert(). We don't have this
information during EXPLAIN that is handled by
postgresExplainForeignModify(), we only have the
ResultRelInfo->ri_BatchSize at this stage. The current idea is to use
the COPY command if the number of slots is > 1 so I'm wondering if we
should use another mechanism to enable the COPY usage, for example, we
could just use if the batch_size is configured to a number greater than
X, but what if the INSERT statement is only inserting a single row,
should we still use the COPY command to ingest a single row into the
foreign table? Any thoughts?

--
Matheus Alcantara

Attachments:

v2-0001-postgres_fdw-Use-COPY-to-speed-up-batch-inserts.patchapplication/octet-stream; name=v2-0001-postgres_fdw-Use-COPY-to-speed-up-batch-inserts.patchDownload
From 5e8545c8641d1afbee22b446bbfcae7970480fbf Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Fri, 10 Oct 2025 16:07:08 -0300
Subject: [PATCH v2] postgres_fdw: Use COPY to speed up batch inserts

---
 contrib/postgres_fdw/deparse.c      |  32 +++++++
 contrib/postgres_fdw/postgres_fdw.c | 140 ++++++++++++++++++++++++++++
 contrib/postgres_fdw/postgres_fdw.h |   1 +
 3 files changed, 173 insertions(+)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index e5b5e1a5f51..3323a92617a 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -2238,6 +2238,38 @@ rebuildInsertSql(StringInfo buf, Relation rel,
 	appendStringInfoString(buf, orig_query + values_end_len);
 }
 
+/*
+ *  Build a COPY FROM STDIN statement using the TEXT format
+ */
+void
+buildCopySql(StringInfo buf, Relation rel, List *target_attrs)
+{
+	ListCell   *lc;
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	bool		first = true;
+
+	appendStringInfo(buf, "COPY ");
+	deparseRelation(buf, rel);
+	appendStringInfo(buf, "(");
+
+	foreach(lc, target_attrs)
+	{
+		int			attnum = lfirst_int(lc);
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, attnum - 1);
+
+		if (attr->attgenerated)
+			continue;
+
+		if (!first)
+			appendStringInfoString(buf, ", ");
+
+		first = false;
+
+		appendStringInfoString(buf, quote_identifier(NameStr(attr->attname)));
+	}
+	appendStringInfoString(buf, ") FROM STDIN");
+}
+
 /*
  * deparse remote UPDATE statement
  *
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 456b267f70b..7f345d19102 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -63,6 +63,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.
  *
@@ -192,6 +195,7 @@ typedef struct PgFdwModifyState
 	/* extracted fdw_private data */
 	char	   *query;			/* text of INSERT/UPDATE/DELETE command */
 	char	   *orig_query;		/* original text of INSERT command */
+	char	   *copy_query;		/* text of COPY command if it's being used */
 	List	   *target_attrs;	/* list of target attribute numbers */
 	int			values_end;		/* length up to the end of VALUES */
 	int			batch_size;		/* value of FDW option "batch_size" */
@@ -545,6 +549,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 TupleTableSlot **execute_foreign_insert_using_copy(PgFdwModifyState *fmstate,
+														  TupleTableSlot **slots,
+														  int *numSlots);
 
 
 /*
@@ -4066,6 +4073,50 @@ create_foreign_modify(EState *estate,
 	return fmstate;
 }
 
+/*
+ *  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)
+{
+	ListCell   *lc;
+	TupleDesc	tupdesc = RelationGetDescr(fmstate->rel);
+	bool		first = true;
+
+	foreach(lc, fmstate->target_attrs)
+	{
+		int			attnum = lfirst_int(lc);
+		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[attnum - 1],
+												   datum);
+
+			appendStringInfoString(buf, value);
+		}
+	}
+
+	appendStringInfoCharMacro(buf, '\n');
+}
+
 /*
  * execute_foreign_modify
  *		Perform foreign-table modification as required, and fetch RETURNING
@@ -4097,6 +4148,13 @@ execute_foreign_modify(EState *estate,
 	if (fmstate->conn_state->pendingAreq)
 		process_pending_request(fmstate->conn_state->pendingAreq);
 
+	/*
+	 * Use COPY command for batch insert if the original query don't include a
+	 * RETURNING clause
+	 */
+	if (operation == CMD_INSERT && *numSlots > 1 && !fmstate->has_returning)
+		return execute_foreign_insert_using_copy(fmstate, slots, numSlots);
+
 	/*
 	 * If the existing query was deparsed and prepared for a different number
 	 * of rows, rebuild it for the proper number.
@@ -7886,3 +7944,85 @@ get_batch_size_option(Relation rel)
 
 	return batch_size;
 }
+
+/*  Execute a batch insert into a foreign table using the COPY command */
+static TupleTableSlot **
+execute_foreign_insert_using_copy(PgFdwModifyState *fmstate,
+								  TupleTableSlot **slots,
+								  int *numSlots)
+{
+	PGresult   *res;
+	StringInfoData sql;
+	StringInfoData copy_data;
+	int			n_rows;
+	int			i;
+
+	if (fmstate->copy_query == NULL)
+	{
+		/* Build COPY command */
+		initStringInfo(&sql);
+		buildCopySql(&sql, fmstate->rel, fmstate->target_attrs);
+
+		/* Cache for reuse. */
+		fmstate->copy_query = sql.data;
+	}
+
+	/* Send COPY command */
+	if (!PQsendQuery(fmstate->conn, fmstate->copy_query))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->copy_query);
+
+	/* get the COPY result */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COPY_IN)
+		pgfdw_report_error(res, fmstate->conn, fmstate->copy_query);
+
+	/* Convert the TupleTableSlot data into a TEXT-formatted line */
+	initStringInfo(&copy_data);
+	for (i = 0; i < *numSlots; i++)
+	{
+		convert_slot_to_copy_text(&copy_data, fmstate, slots[i]);
+
+		/*
+		 * Send initial COPY data if the buffer reach the limit to avoid large
+		 * memory usage.
+		 */
+		if (copy_data.len >= COPYBUFSIZ)
+		{
+			if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+				pgfdw_report_error(NULL, fmstate->conn, fmstate->copy_query);
+			resetStringInfo(&copy_data);
+		}
+	}
+
+	/* Send the remaining COPY data */
+	if (copy_data.len > 0)
+	{
+		if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+			pgfdw_report_error(NULL, fmstate->conn, fmstate->copy_query);
+	}
+
+	/* End the COPY operation */
+	if (PQputCopyEnd(fmstate->conn, NULL) < 0 || PQflush(fmstate->conn))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->copy_query);
+
+	/*
+	 * Get the result, and check for success.
+	 */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pgfdw_report_error(res, fmstate->conn, fmstate->copy_query);
+
+	n_rows = atoi(PQcmdTuples(res));
+
+	/* And clean up */
+	PQclear(res);
+
+	MemoryContextReset(fmstate->temp_cxt);
+
+	*numSlots = n_rows;
+
+	/*
+	 * Return NULL if nothing was inserted on the remote end
+	 */
+	return (n_rows > 0) ? slots : NULL;
+}
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index e69735298d7..c0198b865f3 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -204,6 +204,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 buildCopySql(StringInfo buf, Relation rel, List *target_attrs);
 extern void deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 							 Index rtindex, Relation rel,
 							 List *targetAttrs,
-- 
2.51.0

#5Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Jakub Wartak (#3)
Re: postgres_fdw: Use COPY to speed up batch inserts

Thanks for testing and for the comments!

On Fri Oct 17, 2025 at 6:28 AM -03, Jakub Wartak wrote:

On Thu, Oct 16, 2025 at 10:42 PM Tomas Vondra <tomas@vondra.me> wrote:

Thanks for the patch. Please add it to the next committfest (PG19-3) at

Hi Matheus! same here - thanks for the patch!

The attached patch uses the COPY command whenever we have a *numSlots >
1 but the tests show that maybe we should have a GUC to enable this?

I can imagine having a GUC for testing, but it's not strictly necessary.

Just note, I've played maybe like 20mins with this patch and it works,
however if we would like to have yet another GUCs then we would need
to enable two of those? (enable batch_size and this hypothetical
`batch_use_copy`?)

I was thinking in a GUC to enable to use the COPY command if the number
of rows being ingested during the batch insert is greater than the value
configured on this GUC, something like, batch_size_for_copy. But for now
I'm starting to think that perhaps we may use the COPY if the batch_size
is configured to a number > 1(or make this configurable?). With this it
would make more easier to show on EXPLAIN that we will send a COPY
command to the remote server instead of INSERT. The currently patch
relies on the number of rows being sent to the foreign server to enable
the COPY usage or not, and IUUC we don't have this information during
EXPLAIN. I wrote more about this on my previous reply [1]/messages/by-id/CAFY6G8ePwjT8GiJX1AK5FDMhfq-sOnny6optgTPg98HQw7oJ0g@mail.gmail.com.

Some other stuff I've tried to cover:
...
4. I've found that big rows don't play with COPY feature without
memory limitation, so probably some special handling should be done
here, it's nonsense , but:

postgres@postgres:1236 : 15836 # INSERT INTO local_t1 (id, t)
SELECT s, repeat(md5(s::text), 10000000) from generate_series(100,
103) s;
2025-10-17 11:17:08.742 CEST [15836] LOG: statement: INSERT INTO
local_t1 (id, t) SELECT s, repeat(md5(s::text), 10000000) from
generate_series(100, 103) s;
2025-10-17 11:17:08.743 CEST [15838] LOG: statement: START
TRANSACTION ISOLATION LEVEL REPEATABLE READ
2025-10-17 11:17:38.302 CEST [15838] LOG: statement: COPY
public.t1(id, t, counter) FROM STDIN (FORMAT TEXT, DELIMITER ',')
ERROR: string buffer exceeds maximum allowed length (1073741823 bytes)
DETAIL: Cannot enlarge string buffer containing 960000028 bytes
by 320000000 more bytes.
2025-10-17 11:17:40.213 CEST [15836] ERROR: string buffer exceeds
maximum allowed length (1073741823 bytes)
2025-10-17 11:17:40.213 CEST [15836] DETAIL: Cannot enlarge
string buffer containing 960000028 bytes by 320000000 more bytes.
2025-10-17 11:17:40.213 CEST [15836] STATEMENT: INSERT INTO
local_t1 (id, t) SELECT s, repeat(md5(s::text), 10000000) from
generate_series(100, 103) s;

but then it never wants to finish that backend (constant loop[ in
PQCleanup() or somewhere close to that), server behaves unstable.
Without batch_size set the very same INSERT behaves OK.

On the last version that I sent on [1]/messages/by-id/CAFY6G8ePwjT8GiJX1AK5FDMhfq-sOnny6optgTPg98HQw7oJ0g@mail.gmail.com I introduce a buffer size limit,
and testing this INSERT statement with the latest version seems to fix
this issue. Could you please check this too?

[1]: /messages/by-id/CAFY6G8ePwjT8GiJX1AK5FDMhfq-sOnny6optgTPg98HQw7oJ0g@mail.gmail.com

--
Matheus Alcantara

#6Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Matheus Alcantara (#4)
1 attachment(s)
Re: postgres_fdw: Use COPY to speed up batch inserts

On Tue Oct 21, 2025 at 11:25 AM -03, Matheus Alcantara wrote:

Lastly, I don't know if we should change the EXPLAIN(ANALYZE, VERBOSE)
output for batch inserts that use the COPY to mention that we are
sending the COPY command to the remote server. I guess so?

Good point. We definitely should not show SQL for INSERT, when we're
actually running a COPY.

This seems a bit tricky to implement. The COPY is used based on the
number of slots into the TupleTableSlot array that is used for batch
insert. The numSlots that execute_foreign_modify() receive is coming
from ResultRelInfo->ri_NumSlots during ExecInsert(). We don't have this
information during EXPLAIN that is handled by
postgresExplainForeignModify(), we only have the
ResultRelInfo->ri_BatchSize at this stage. The current idea is to use
the COPY command if the number of slots is > 1 so I'm wondering if we
should use another mechanism to enable the COPY usage, for example, we
could just use if the batch_size is configured to a number greater than
X, but what if the INSERT statement is only inserting a single row,
should we still use the COPY command to ingest a single row into the
foreign table? Any thoughts?

Thinking more about this I realize that when we are deparsing the remote
SQL to be sent to the foreign server at the planner phase (via
postgresPlanForeignModify()) we don't have the batch_size and number of
rows information, so currently we can not know at the plan time if the
COPY usage for a batch insert is visible or not because IIUC these
information are only visible at query runtime.

One way to make it possible is that we could simply use the
PgFdwModifyState->copy_data during postgresExplainForeignModify() if
it's not null. Since we will only have this information during query
execution the drawback of this approach is that we would only show the
COPY as a Remote SQL on during EXPLAIN(ANALYZE).

Please see the attached v3 version that implements this idea.

I tried to reuse the fmstate->query field to cache the COPY sql but
running the postgres_fdw.sql regress test shows that this may not
work. When we are running a user supplied COPY command on a foreign
table the CopyMultiInsertBufferFlush() call
ri_FdwRoutine->ExecForeignBatchInsert which may pass different values
for numSlots based on the number of slots already sent to the foreign
server, and eventually it may pass numSlots as 1 which will not use the
COPY under the hood to send to the foreign server and if we cache the
COPY command into the fmstate->query this will not work because the
normal INSERT path on execute_foreign_modify uses the fmstate->query to
build a prepared statement to send to the foreign server. So basically
what I'm trying to say is that when the server is executing a COPY into
a foreign it may use the COPY command or INSERT command to send the data
to the foreign server. That being said, I decided to create a new
copy_query field on PgFdwModifyState to cache only COPY commands. Please
let me know if my understanding is wrong or if we could have a better
approach here.

Based on the information that I've mention above I think that we need
some way to not mix INSERT with COPY commands when executing a COPY in
a foreign table supplied by the user. Or we should disable the COPY
under the hood and always fallback to INSERT or enable the COPY to use
when the *numSlots is 1, so in case of an EXPLAIN(ANALYZE) output we can
show the Remote SQL correctly. Is that make sense?

I'm still not sure if the trigger to use the COPY command for batch
insert should be *numSlots > 1 or something else. I'm open for better
ideas.

Thoughts?

--
Matheus Alcantara

Attachments:

v3-0001-postgres_fdw-Use-COPY-to-speed-up-batch-inserts.patchtext/plain; charset=utf-8; name=v3-0001-postgres_fdw-Use-COPY-to-speed-up-batch-inserts.patchDownload
From 0175f829cc2944eb596f18d701b64c2d90d8e2bb Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Fri, 10 Oct 2025 16:07:08 -0300
Subject: [PATCH v3] postgres_fdw: Use COPY to speed up batch inserts

---
 contrib/postgres_fdw/deparse.c      |  32 ++++++
 contrib/postgres_fdw/postgres_fdw.c | 159 +++++++++++++++++++++++++++-
 contrib/postgres_fdw/postgres_fdw.h |   1 +
 3 files changed, 190 insertions(+), 2 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index f2fb0051843..afd1cc636d7 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -2236,6 +2236,38 @@ rebuildInsertSql(StringInfo buf, Relation rel,
 	appendStringInfoString(buf, orig_query + values_end_len);
 }
 
+/*
+ *  Build a COPY FROM STDIN statement using the TEXT format
+ */
+void
+buildCopySql(StringInfo buf, Relation rel, List *target_attrs)
+{
+	ListCell   *lc;
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	bool		first = true;
+
+	appendStringInfo(buf, "COPY ");
+	deparseRelation(buf, rel);
+	appendStringInfo(buf, "(");
+
+	foreach(lc, target_attrs)
+	{
+		int			attnum = lfirst_int(lc);
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, attnum - 1);
+
+		if (attr->attgenerated)
+			continue;
+
+		if (!first)
+			appendStringInfoString(buf, ", ");
+
+		first = false;
+
+		appendStringInfoString(buf, quote_identifier(NameStr(attr->attname)));
+	}
+	appendStringInfoString(buf, ") FROM STDIN");
+}
+
 /*
  * deparse remote UPDATE statement
  *
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 456b267f70b..0ccbff8e390 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -63,6 +63,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.
  *
@@ -192,6 +195,7 @@ typedef struct PgFdwModifyState
 	/* extracted fdw_private data */
 	char	   *query;			/* text of INSERT/UPDATE/DELETE command */
 	char	   *orig_query;		/* original text of INSERT command */
+	char	   *copy_query;		/* text of COPY command if it's being used */
 	List	   *target_attrs;	/* list of target attribute numbers */
 	int			values_end;		/* length up to the end of VALUES */
 	int			batch_size;		/* value of FDW option "batch_size" */
@@ -545,6 +549,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 TupleTableSlot **execute_foreign_insert_using_copy(PgFdwModifyState *fmstate,
+														  TupleTableSlot **slots,
+														  int *numSlots);
 
 
 /*
@@ -2942,8 +2949,23 @@ postgresExplainForeignModify(ModifyTableState *mtstate,
 {
 	if (es->verbose)
 	{
-		char	   *sql = strVal(list_nth(fdw_private,
-										  FdwModifyPrivateUpdateSql));
+		char	   *sql = NULL;
+
+		/*
+		 * We only have ri_FdwState during EXPLAIN(ANALYZE), so check if the
+		 * COPY was used during query execution and show it as a Remote SQL.
+		 */
+		if (rinfo->ri_FdwState != NULL)
+		{
+			PgFdwModifyState *fmstate = (PgFdwModifyState *) rinfo->ri_FdwState;
+
+			if (fmstate->copy_query != NULL)
+				sql = fmstate->copy_query;
+		}
+
+		if (sql == NULL)
+			sql = strVal(list_nth(fdw_private,
+								  FdwModifyPrivateUpdateSql));
 
 		ExplainPropertyText("Remote SQL", sql, es);
 
@@ -4066,6 +4088,50 @@ create_foreign_modify(EState *estate,
 	return fmstate;
 }
 
+/*
+ *  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)
+{
+	ListCell   *lc;
+	TupleDesc	tupdesc = RelationGetDescr(fmstate->rel);
+	bool		first = true;
+
+	foreach(lc, fmstate->target_attrs)
+	{
+		int			attnum = lfirst_int(lc);
+		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[attnum - 1],
+												   datum);
+
+			appendStringInfoString(buf, value);
+		}
+	}
+
+	appendStringInfoCharMacro(buf, '\n');
+}
+
 /*
  * execute_foreign_modify
  *		Perform foreign-table modification as required, and fetch RETURNING
@@ -4097,6 +4163,13 @@ execute_foreign_modify(EState *estate,
 	if (fmstate->conn_state->pendingAreq)
 		process_pending_request(fmstate->conn_state->pendingAreq);
 
+	/*
+	 * Use COPY command for batch insert if the original query don't include a
+	 * RETURNING clause
+	 */
+	if (operation == CMD_INSERT && *numSlots > 1 && !fmstate->has_returning)
+		return execute_foreign_insert_using_copy(fmstate, slots, numSlots);
+
 	/*
 	 * If the existing query was deparsed and prepared for a different number
 	 * of rows, rebuild it for the proper number.
@@ -7886,3 +7959,85 @@ get_batch_size_option(Relation rel)
 
 	return batch_size;
 }
+
+/*  Execute a batch insert into a foreign table using the COPY command */
+static TupleTableSlot **
+execute_foreign_insert_using_copy(PgFdwModifyState *fmstate,
+								  TupleTableSlot **slots,
+								  int *numSlots)
+{
+	PGresult   *res;
+	StringInfoData sql;
+	StringInfoData copy_data;
+	int			n_rows;
+	int			i;
+
+	if (fmstate->copy_query == NULL)
+	{
+		/* Build COPY command */
+		initStringInfo(&sql);
+		buildCopySql(&sql, fmstate->rel, fmstate->target_attrs);
+
+		/* Cache for reuse. */
+		fmstate->copy_query = sql.data;
+	}
+
+	/* Send COPY command */
+	if (!PQsendQuery(fmstate->conn, fmstate->copy_query))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->copy_query);
+
+	/* get the COPY result */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COPY_IN)
+		pgfdw_report_error(res, fmstate->conn, fmstate->copy_query);
+
+	/* Convert the TupleTableSlot data into a TEXT-formatted line */
+	initStringInfo(&copy_data);
+	for (i = 0; i < *numSlots; i++)
+	{
+		convert_slot_to_copy_text(&copy_data, fmstate, slots[i]);
+
+		/*
+		 * Send initial COPY data if the buffer reach the limit to avoid large
+		 * memory usage.
+		 */
+		if (copy_data.len >= COPYBUFSIZ)
+		{
+			if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+				pgfdw_report_error(NULL, fmstate->conn, fmstate->copy_query);
+			resetStringInfo(&copy_data);
+		}
+	}
+
+	/* Send the remaining COPY data */
+	if (copy_data.len > 0)
+	{
+		if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+			pgfdw_report_error(NULL, fmstate->conn, fmstate->copy_query);
+	}
+
+	/* End the COPY operation */
+	if (PQputCopyEnd(fmstate->conn, NULL) < 0 || PQflush(fmstate->conn))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->copy_query);
+
+	/*
+	 * Get the result, and check for success.
+	 */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pgfdw_report_error(res, fmstate->conn, fmstate->copy_query);
+
+	n_rows = atoi(PQcmdTuples(res));
+
+	/* And clean up */
+	PQclear(res);
+
+	MemoryContextReset(fmstate->temp_cxt);
+
+	*numSlots = n_rows;
+
+	/*
+	 * Return NULL if nothing was inserted on the remote end
+	 */
+	return (n_rows > 0) ? slots : NULL;
+}
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index e69735298d7..c0198b865f3 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -204,6 +204,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 buildCopySql(StringInfo buf, Relation rel, List *target_attrs);
 extern void deparseUpdateSql(StringInfo buf, RangeTblEntry *rte,
 							 Index rtindex, Relation rel,
 							 List *targetAttrs,
-- 
2.51.0

#7jian he
jian.universality@gmail.com
In reply to: Matheus Alcantara (#6)
Re: postgres_fdw: Use COPY to speed up batch inserts

On Thu, Oct 23, 2025 at 8:01 AM Matheus Alcantara
<matheusssilv97@gmail.com> wrote:

Please see the attached v3 version that implements this idea.

hi.

I am not famailith with this module.
some of the foreach can be replaced with foreach_int.

I suspect that somewhere Form_pg_attribute.attisdropped is not handled properly.
the following setup will crash.

---source database
drop table batch_table1;
create table batch_table1(x int);

---foreign table database
drop foreign table if exists ftable1;
CREATE FOREIGN TABLE ftable1 ( x int ) SERVER loopback1 OPTIONS (
table_name 'batch_table1', batch_size '10' );
ALTER FOREIGN TABLE ftable1 DROP COLUMN x;
ALTER FOREIGN TABLE ftable1 add COLUMN x int;

INSERT INTO ftable SELECT * FROM generate_series(1, 10) i; --- this
will cause server crash.

#8Matheus Alcantara
matheusssilv97@gmail.com
In reply to: jian he (#7)
1 attachment(s)
Re: postgres_fdw: Use COPY to speed up batch inserts

Hi, thanks for testing this patch!

On Thu Oct 23, 2025 at 6:49 AM -03, jian he wrote:

On Thu, Oct 23, 2025 at 8:01 AM Matheus Alcantara
<matheusssilv97@gmail.com> wrote:

Please see the attached v3 version that implements this idea.

hi.

I am not famailith with this module.
some of the foreach can be replaced with foreach_int.

Fixed.

I suspect that somewhere Form_pg_attribute.attisdropped is not handled properly.
the following setup will crash.

---source database
drop table batch_table1;
create table batch_table1(x int);

---foreign table database
drop foreign table if exists ftable1;
CREATE FOREIGN TABLE ftable1 ( x int ) SERVER loopback1 OPTIONS (
table_name 'batch_table1', batch_size '10' );
ALTER FOREIGN TABLE ftable1 DROP COLUMN x;
ALTER FOREIGN TABLE ftable1 add COLUMN x int;

INSERT INTO ftable SELECT * FROM generate_series(1, 10) i; --- this
will cause server crash.

I've tested this scenario and the field attisdropped is still being set
to false. After some debugging I realize that the problem was how I was
accessing the fmstate->p_flinfo array - I was using the attum-1 but I
don't think that it's correct.

On create_foreign_modify() we have the following code:

fmstate->p_flinfo = (FmgrInfo *) palloc0(sizeof(FmgrInfo) * n_params);
fmstate->p_nums = 0;
if (operation == CMD_INSERT || operation == CMD_UPDATE)
{
/* Set up for remaining transmittable parameters */
foreach(lc, fmstate->target_attrs)
{
int attnum = lfirst_int(lc);
Form_pg_attribute attr = TupleDescAttr(tupdesc, attnum - 1);

Assert(!attr->attisdropped);

/* Ignore generated columns; they are set to DEFAULT */
if (attr->attgenerated)
continue;
getTypeOutputInfo(attr->atttypid, &typefnoid, &isvarlena);
fmgr_info(typefnoid, &fmstate->p_flinfo[fmstate->p_nums]);
fmstate->p_nums++;
}
}

So I think that I should access fmstate->p_flinfo array when looping
through the target_attrs using an int value starting at 0 and ++ after
each iteration. Although I'm not sure if my understanding is fully
correct I've implemented this on the attached patch and it seems to fix
the error.

On this new version I also added some regress tests on postgres_fdw.sql

--
Matheus Alcantara

Attachments:

v4-0001-postgres_fdw-Use-COPY-to-speed-up-batch-inserts.patchtext/plain; charset=utf-8; name=v4-0001-postgres_fdw-Use-COPY-to-speed-up-batch-inserts.patchDownload
From 1856fba0c49d5aa7b164debb90b376c72cfa3e02 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Fri, 10 Oct 2025 16:07:08 -0300
Subject: [PATCH v4] postgres_fdw: Use COPY to speed up batch inserts

---
 contrib/postgres_fdw/deparse.c                |  30 ++++
 .../postgres_fdw/expected/postgres_fdw.out    | 120 +++++++++++--
 contrib/postgres_fdw/postgres_fdw.c           | 159 +++++++++++++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   1 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  52 ++++++
 5 files changed, 350 insertions(+), 12 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index f2fb0051843..113e6fb7d91 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -2236,6 +2236,36 @@ rebuildInsertSql(StringInfo buf, Relation rel,
 	appendStringInfoString(buf, orig_query + values_end_len);
 }
 
+/*
+ *  Build a COPY FROM STDIN statement using the TEXT format
+ */
+void
+buildCopySql(StringInfo buf, Relation rel, List *target_attrs)
+{
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	bool		first = true;
+
+	appendStringInfo(buf, "COPY ");
+	deparseRelation(buf, rel);
+	appendStringInfo(buf, "(");
+
+	foreach_int(attnum, target_attrs)
+	{
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, attnum - 1);
+
+		if (attr->attgenerated)
+			continue;
+
+		if (!first)
+			appendStringInfoString(buf, ", ");
+
+		first = false;
+
+		appendStringInfoString(buf, quote_identifier(NameStr(attr->attname)));
+	}
+	appendStringInfoString(buf, ") FROM STDIN");
+}
+
 /*
  * deparse remote UPDATE statement
  *
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index cd28126049d..dd507ad6186 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -50,6 +50,18 @@ CREATE TABLE "S 1"."T 4" (
 	c3 text,
 	CONSTRAINT t4_pkey PRIMARY KEY (c1)
 );
+CREATE TABLE "S 1"."T 5"(
+    x int
+);
+CREATE TABLE "S 1"."T 6"(
+    id int not null,
+    note text,
+    value int NOT NULL
+);
+CREATE TABLE "S 1"."T 7"(
+    id int,
+    t text
+);
 -- Disable autovacuum for these tables to avoid unexpected effects of that
 ALTER TABLE "S 1"."T 1" SET (autovacuum_enabled = 'false');
 ALTER TABLE "S 1"."T 2" SET (autovacuum_enabled = 'false');
@@ -132,6 +144,21 @@ CREATE FOREIGN TABLE ft7 (
 	c2 int NOT NULL,
 	c3 text
 ) SERVER loopback3 OPTIONS (schema_name 'S 1', table_name 'T 4');
+CREATE FOREIGN TABLE ft8 (
+    x int
+)
+SERVER loopback OPTIONS (schema_name 'S 1', table_name 'T 5', batch_size '10');
+CREATE FOREIGN TABLE ft9 (
+    id int not null,
+    note text,
+    value int NOT NULL
+)
+SERVER loopback OPTIONS (schema_name 'S 1', table_name 'T 6', batch_size '10');
+CREATE FOREIGN TABLE ft10 (
+    id int,
+    t text
+)
+SERVER loopback OPTIONS (schema_name 'S 1', table_name 'T 7', batch_size '10');
 -- ===================================================================
 -- tests for validator
 -- ===================================================================
@@ -205,16 +232,19 @@ ALTER FOREIGN TABLE ft2 OPTIONS (schema_name 'S 1', table_name 'T 1');
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c1 OPTIONS (column_name 'C 1');
 ALTER FOREIGN TABLE ft2 ALTER COLUMN c1 OPTIONS (column_name 'C 1');
 \det+
-                              List of foreign tables
- Schema | Table |  Server   |              FDW options              | Description 
---------+-------+-----------+---------------------------------------+-------------
- public | ft1   | loopback  | (schema_name 'S 1', table_name 'T 1') | 
- public | ft2   | loopback  | (schema_name 'S 1', table_name 'T 1') | 
- public | ft4   | loopback  | (schema_name 'S 1', table_name 'T 3') | 
- public | ft5   | loopback  | (schema_name 'S 1', table_name 'T 4') | 
- public | ft6   | loopback2 | (schema_name 'S 1', table_name 'T 4') | 
- public | ft7   | loopback3 | (schema_name 'S 1', table_name 'T 4') | 
-(6 rows)
+                                      List of foreign tables
+ Schema | Table |  Server   |                      FDW options                       | Description 
+--------+-------+-----------+--------------------------------------------------------+-------------
+ public | ft1   | loopback  | (schema_name 'S 1', table_name 'T 1')                  | 
+ public | ft10  | loopback  | (schema_name 'S 1', table_name 'T 7', batch_size '10') | 
+ public | ft2   | loopback  | (schema_name 'S 1', table_name 'T 1')                  | 
+ public | ft4   | loopback  | (schema_name 'S 1', table_name 'T 3')                  | 
+ public | ft5   | loopback  | (schema_name 'S 1', table_name 'T 4')                  | 
+ public | ft6   | loopback2 | (schema_name 'S 1', table_name 'T 4')                  | 
+ public | ft7   | loopback3 | (schema_name 'S 1', table_name 'T 4')                  | 
+ public | ft8   | loopback  | (schema_name 'S 1', table_name 'T 5', batch_size '10') | 
+ public | ft9   | loopback  | (schema_name 'S 1', table_name 'T 6', batch_size '10') | 
+(9 rows)
 
 -- Test that alteration of server options causes reconnection
 -- Remote's errors might be non-English, so hide them to ensure stable results
@@ -12664,6 +12694,76 @@ ANALYZE analyze_ftable;
 -- cleanup
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
+-- ===================================================================
+-- test for batch insert using COPY
+-- ===================================================================
+ALTER FOREIGN TABLE ft8 DROP COLUMN x;
+ALTER FOREIGN TABLE ft8 add COLUMN x int;
+INSERT INTO ft8 SELECT * FROM generate_series(1, 10) i;
+SELECT * FROM ft8;
+ x  
+----
+  1
+  2
+  3
+  4
+  5
+  6
+  7
+  8
+  9
+ 10
+(10 rows)
+
+EXPLAIN(ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF) INSERT INTO ft9 (id, value, note)
+SELECT g,
+       g * 2,
+       'batch insert test data' || g
+FROM generate_series(1, 20) g;
+                                   QUERY PLAN                                    
+---------------------------------------------------------------------------------
+ Insert on public.ft9 (actual rows=0.00 loops=1)
+   Remote SQL: COPY "S 1"."T 6"(id, note, value) FROM STDIN
+   Batch Size: 10
+   ->  Function Scan on pg_catalog.generate_series g (actual rows=20.00 loops=1)
+         Output: g.g, ('batch insert test data'::text || (g.g)::text), (g.g * 2)
+         Function Call: generate_series(1, 20)
+(6 rows)
+
+SELECT * FROM ft9;
+ id |           note           | value 
+----+--------------------------+-------
+  1 | batch insert test data1  |     2
+  2 | batch insert test data2  |     4
+  3 | batch insert test data3  |     6
+  4 | batch insert test data4  |     8
+  5 | batch insert test data5  |    10
+  6 | batch insert test data6  |    12
+  7 | batch insert test data7  |    14
+  8 | batch insert test data8  |    16
+  9 | batch insert test data9  |    18
+ 10 | batch insert test data10 |    20
+ 11 | batch insert test data11 |    22
+ 12 | batch insert test data12 |    24
+ 13 | batch insert test data13 |    26
+ 14 | batch insert test data14 |    28
+ 15 | batch insert test data15 |    30
+ 16 | batch insert test data16 |    32
+ 17 | batch insert test data17 |    34
+ 18 | batch insert test data18 |    36
+ 19 | batch insert test data19 |    38
+ 20 | batch insert test data20 |    40
+(20 rows)
+
+-- Test buffer limit of copy data on COPYBUFSIZ
+INSERT INTO ft10 (id, t)
+SELECT s, repeat(md5(s::text), 10000) from generate_series(100, 103) s;
+SELECT COUNT(*) FROM ft10;
+ count 
+-------
+     4
+(1 row)
+
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 456b267f70b..d8e13e78938 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -63,6 +63,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.
  *
@@ -192,6 +195,7 @@ typedef struct PgFdwModifyState
 	/* extracted fdw_private data */
 	char	   *query;			/* text of INSERT/UPDATE/DELETE command */
 	char	   *orig_query;		/* original text of INSERT command */
+	char	   *copy_query;		/* text of COPY command if it's being used */
 	List	   *target_attrs;	/* list of target attribute numbers */
 	int			values_end;		/* length up to the end of VALUES */
 	int			batch_size;		/* value of FDW option "batch_size" */
@@ -545,6 +549,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 TupleTableSlot **execute_foreign_insert_using_copy(PgFdwModifyState *fmstate,
+														  TupleTableSlot **slots,
+														  int *numSlots);
 
 
 /*
@@ -2942,8 +2949,23 @@ postgresExplainForeignModify(ModifyTableState *mtstate,
 {
 	if (es->verbose)
 	{
-		char	   *sql = strVal(list_nth(fdw_private,
-										  FdwModifyPrivateUpdateSql));
+		char	   *sql = NULL;
+
+		/*
+		 * We only have ri_FdwState during EXPLAIN(ANALYZE), so check if the
+		 * COPY was used during query execution and show it as a Remote SQL.
+		 */
+		if (rinfo->ri_FdwState != NULL)
+		{
+			PgFdwModifyState *fmstate = (PgFdwModifyState *) rinfo->ri_FdwState;
+
+			if (fmstate->copy_query != NULL)
+				sql = fmstate->copy_query;
+		}
+
+		if (sql == NULL)
+			sql = strVal(list_nth(fdw_private,
+								  FdwModifyPrivateUpdateSql));
 
 		ExplainPropertyText("Remote SQL", sql, es);
 
@@ -4066,6 +4088,50 @@ create_foreign_modify(EState *estate,
 	return fmstate;
 }
 
+/*
+ *  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);
+
+			appendStringInfoString(buf, value);
+		}
+		i++;
+	}
+
+	appendStringInfoCharMacro(buf, '\n');
+}
+
 /*
  * execute_foreign_modify
  *		Perform foreign-table modification as required, and fetch RETURNING
@@ -4097,6 +4163,13 @@ execute_foreign_modify(EState *estate,
 	if (fmstate->conn_state->pendingAreq)
 		process_pending_request(fmstate->conn_state->pendingAreq);
 
+	/*
+	 * Use COPY command for batch insert if the original query don't include a
+	 * RETURNING clause
+	 */
+	if (operation == CMD_INSERT && *numSlots > 1 && !fmstate->has_returning)
+		return execute_foreign_insert_using_copy(fmstate, slots, numSlots);
+
 	/*
 	 * If the existing query was deparsed and prepared for a different number
 	 * of rows, rebuild it for the proper number.
@@ -7886,3 +7959,85 @@ get_batch_size_option(Relation rel)
 
 	return batch_size;
 }
+
+/*  Execute a batch insert into a foreign table using the COPY command */
+static TupleTableSlot **
+execute_foreign_insert_using_copy(PgFdwModifyState *fmstate,
+								  TupleTableSlot **slots,
+								  int *numSlots)
+{
+	PGresult   *res;
+	StringInfoData sql;
+	StringInfoData copy_data;
+	int			n_rows;
+	int			i;
+
+	if (fmstate->copy_query == NULL)
+	{
+		/* Build COPY command */
+		initStringInfo(&sql);
+		buildCopySql(&sql, fmstate->rel, fmstate->target_attrs);
+
+		/* Cache for reuse. */
+		fmstate->copy_query = sql.data;
+	}
+
+	/* Send COPY command */
+	if (!PQsendQuery(fmstate->conn, fmstate->copy_query))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->copy_query);
+
+	/* get the COPY result */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COPY_IN)
+		pgfdw_report_error(res, fmstate->conn, fmstate->copy_query);
+
+	/* Convert the TupleTableSlot data into a TEXT-formatted line */
+	initStringInfo(&copy_data);
+	for (i = 0; i < *numSlots; i++)
+	{
+		convert_slot_to_copy_text(&copy_data, fmstate, slots[i]);
+
+		/*
+		 * Send initial COPY data if the buffer reach the limit to avoid large
+		 * memory usage.
+		 */
+		if (copy_data.len >= COPYBUFSIZ)
+		{
+			if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+				pgfdw_report_error(NULL, fmstate->conn, fmstate->copy_query);
+			resetStringInfo(&copy_data);
+		}
+	}
+
+	/* Send the remaining COPY data */
+	if (copy_data.len > 0)
+	{
+		if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+			pgfdw_report_error(NULL, fmstate->conn, fmstate->copy_query);
+	}
+
+	/* End the COPY operation */
+	if (PQputCopyEnd(fmstate->conn, NULL) < 0 || PQflush(fmstate->conn))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->copy_query);
+
+	/*
+	 * Get the result, and check for success.
+	 */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pgfdw_report_error(res, fmstate->conn, fmstate->copy_query);
+
+	n_rows = atoi(PQcmdTuples(res));
+
+	/* And clean up */
+	PQclear(res);
+
+	MemoryContextReset(fmstate->temp_cxt);
+
+	*numSlots = n_rows;
+
+	/*
+	 * Return NULL if nothing was inserted on the remote end
+	 */
+	return (n_rows > 0) ? slots : NULL;
+}
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index e69735298d7..c0198b865f3 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -204,6 +204,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 buildCopySql(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 9a8f9e28135..79f4f305641 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -54,6 +54,18 @@ CREATE TABLE "S 1"."T 4" (
 	c3 text,
 	CONSTRAINT t4_pkey PRIMARY KEY (c1)
 );
+CREATE TABLE "S 1"."T 5"(
+    x int
+);
+CREATE TABLE "S 1"."T 6"(
+    id int not null,
+    note text,
+    value int NOT NULL
+);
+CREATE TABLE "S 1"."T 7"(
+    id int,
+    t text
+);
 
 -- Disable autovacuum for these tables to avoid unexpected effects of that
 ALTER TABLE "S 1"."T 1" SET (autovacuum_enabled = 'false');
@@ -146,6 +158,24 @@ CREATE FOREIGN TABLE ft7 (
 	c3 text
 ) SERVER loopback3 OPTIONS (schema_name 'S 1', table_name 'T 4');
 
+CREATE FOREIGN TABLE ft8 (
+    x int
+)
+SERVER loopback OPTIONS (schema_name 'S 1', table_name 'T 5', batch_size '10');
+
+CREATE FOREIGN TABLE ft9 (
+    id int not null,
+    note text,
+    value int NOT NULL
+)
+SERVER loopback OPTIONS (schema_name 'S 1', table_name 'T 6', batch_size '10');
+
+CREATE FOREIGN TABLE ft10 (
+    id int,
+    t text
+)
+SERVER loopback OPTIONS (schema_name 'S 1', table_name 'T 7', batch_size '10');
+
 -- ===================================================================
 -- tests for validator
 -- ===================================================================
@@ -4379,6 +4409,28 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 
+-- ===================================================================
+-- test for batch insert using COPY
+-- ===================================================================
+ALTER FOREIGN TABLE ft8 DROP COLUMN x;
+ALTER FOREIGN TABLE ft8 add COLUMN x int;
+
+INSERT INTO ft8 SELECT * FROM generate_series(1, 10) i;
+SELECT * FROM ft8;
+
+EXPLAIN(ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF) INSERT INTO ft9 (id, value, note)
+SELECT g,
+       g * 2,
+       'batch insert test data' || g
+FROM generate_series(1, 20) g;
+
+SELECT * FROM ft9;
+
+-- Test buffer limit of copy data on COPYBUFSIZ
+INSERT INTO ft10 (id, t)
+SELECT s, repeat(md5(s::text), 10000) from generate_series(100, 103) s;
+SELECT COUNT(*) FROM ft10;
+
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
-- 
2.51.0

#9jian he
jian.universality@gmail.com
In reply to: Matheus Alcantara (#8)
1 attachment(s)
Re: postgres_fdw: Use COPY to speed up batch inserts

On Sat, Oct 25, 2025 at 2:27 AM Matheus Alcantara
<matheusssilv97@gmail.com> wrote:

On this new version I also added some regress tests on postgres_fdw.sql

In the CopyFrom function, we have the CopyInsertMethod, CIM_SINGLE is slower
than CIM_MULTI, I think.
We should do performance tests for the case where the COPY statement is limited
to use CIM_SINGLE.

You can use triggers to make COPY can only use the CIM_SINGLE copymethod.
for example:
create function dummy() returns trigger as $$ begin return new; end $$
language plpgsql;
create trigger dummy
before insert or update on batch_table_3
for each row execute procedure dummy();

My local tests show that when batch_size is greater than 2, COPY performs faster
than batch inserts into a foreign table, even though COPY can only use
CIM_SINGLE.
However, my tests were done with an enable-assert build, since I
encountered issues compiling the release build.

anyway, I am sharing my test script.

Attachments:

fdw_copy_test.sqlapplication/sql; name=fdw_copy_test.sqlDownload
#10Matheus Alcantara
matheusssilv97@gmail.com
In reply to: jian he (#9)
Re: postgres_fdw: Use COPY to speed up batch inserts

On Wed Oct 29, 2025 at 12:10 AM -03, jian he wrote:

On Sat, Oct 25, 2025 at 2:27 AM Matheus Alcantara
<matheusssilv97@gmail.com> wrote:

On this new version I also added some regress tests on postgres_fdw.sql

In the CopyFrom function, we have the CopyInsertMethod, CIM_SINGLE is slower
than CIM_MULTI, I think.
We should do performance tests for the case where the COPY statement is limited
to use CIM_SINGLE.

You can use triggers to make COPY can only use the CIM_SINGLE copymethod.
for example:
create function dummy() returns trigger as $$ begin return new; end $$
language plpgsql;
create trigger dummy
before insert or update on batch_table_3
for each row execute procedure dummy();

My local tests show that when batch_size is greater than 2, COPY performs faster
than batch inserts into a foreign table, even though COPY can only use
CIM_SINGLE.
However, my tests were done with an enable-assert build, since I
encountered issues compiling the release build.

anyway, I am sharing my test script.

I've benchmarked using buildtype=release with Dcassert=false and
buildtype=debug with Dcassert=true and in both cases I've got a worst
performance when using the COPY for batching insert into a a foreign
table with a trigger. See the results (best of 4 runs).

Batch using INSERT
batch_size: 100
buildtype=debug
Dcassert=true
tps = 13.596754

Batch using COPY
batch_size: 100
buildtype=debug
Dcassert=true
tps = 11.650642

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

Batch using INSERT
batch_size: 100
buildtype=release
Dcassert=false
tps = 28.333161

Batch using COPY
batch_size: 100
buildtype=release
Dcassert=false
tps = 18.499420

It seems to me that we need to disable the COPY usage when the foreign
table has triggers enabled.

--
Matheus Alcantara

#11Andrew Dunstan
andrew@dunslane.net
In reply to: Matheus Alcantara (#10)
Re: postgres_fdw: Use COPY to speed up batch inserts

On 2025-10-29 We 8:28 PM, Matheus Alcantara wrote:

On Wed Oct 29, 2025 at 12:10 AM -03, jian he wrote:

On Sat, Oct 25, 2025 at 2:27 AM Matheus Alcantara
<matheusssilv97@gmail.com> wrote:

On this new version I also added some regress tests on postgres_fdw.sql

In the CopyFrom function, we have the CopyInsertMethod, CIM_SINGLE is slower
than CIM_MULTI, I think.
We should do performance tests for the case where the COPY statement is limited
to use CIM_SINGLE.

You can use triggers to make COPY can only use the CIM_SINGLE copymethod.
for example:
create function dummy() returns trigger as $$ begin return new; end $$
language plpgsql;
create trigger dummy
before insert or update on batch_table_3
for each row execute procedure dummy();

My local tests show that when batch_size is greater than 2, COPY performs faster
than batch inserts into a foreign table, even though COPY can only use
CIM_SINGLE.
However, my tests were done with an enable-assert build, since I
encountered issues compiling the release build.

anyway, I am sharing my test script.

I've benchmarked using buildtype=release with Dcassert=false and
buildtype=debug with Dcassert=true and in both cases I've got a worst
performance when using the COPY for batching insert into a a foreign
table with a trigger. See the results (best of 4 runs).

Batch using INSERT
batch_size: 100
buildtype=debug
Dcassert=true
tps = 13.596754

Batch using COPY
batch_size: 100
buildtype=debug
Dcassert=true
tps = 11.650642

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

Batch using INSERT
batch_size: 100
buildtype=release
Dcassert=false
tps = 28.333161

Batch using COPY
batch_size: 100
buildtype=release
Dcassert=false
tps = 18.499420

It seems to me that we need to disable the COPY usage when the foreign
table has triggers enabled.

I think it's probably worth finding out why COPY is so much worse in the
presence of triggers. Is there something we can do to improve that, at
least so it's no worse?

cheers

andrew

--
Andrew Dunstan
EDB: https://www.enterprisedb.com

#12Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Andrew Dunstan (#11)
Re: postgres_fdw: Use COPY to speed up batch inserts

On Thu Oct 30, 2025 at 1:32 PM -03, Andrew Dunstan wrote:

It seems to me that we need to disable the COPY usage when the foreign
table has triggers enabled.

I think it's probably worth finding out why COPY is so much worse in the
presence of triggers. Is there something we can do to improve that, at
least so it's no worse?

I did a bit of reading on CopyFrom() and found comments that clarifies
why the use of COPY for batch inserts is slower when the target foreign
table has triggers.
/*
* It's generally more efficient to prepare a bunch of tuples for
* insertion, and insert them in one
* table_multi_insert()/ExecForeignBatchInsert() call, than call
* table_tuple_insert()/ExecForeignInsert() separately for every tuple.
* However, there are a number of reasons why we might not be able to do
* this. These are explained below.
*/
if (resultRelInfo->ri_TrigDesc != NULL &&
(resultRelInfo->ri_TrigDesc->trig_insert_before_row ||
resultRelInfo->ri_TrigDesc->trig_insert_instead_row))
{
/*
* Can't support multi-inserts when there are any BEFORE/INSTEAD OF
* triggers on the table. Such triggers might query the table we're
* inserting into and act differently if the tuples that have already
* been processed and prepared for insertion are not there.
*/
insertMethod = CIM_SINGLE;
}

It forces the use of CIM_SINGLE if a BEFORE or INSTEAD OF trigger is
present. This method then relies on table_tuple_insert() or
ExecForeignInsert() to insert one tuple at a time.

IUUC we cannot determine during execute_foreign_modify() whether a
foreign table has triggers enabled on the target remote table. This lack
of information suggests that we would need to query the foreign server
to find out. If it's the only viable path I think that we would have
issues if the the user configured to access the foreign server don't
have access on catalog tables.

It's showing a bit complicated to decide at runtime if we should use the
COPY or INSERT for batch insert into a foreign table. Perhaps we could
add a new option on CREATE FOREIGN TABLE to enable this usage or not? We
could document the performance improvements and the limitations so the
user can decide if it should enable or not.

--
Matheus Alcantara

#13Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Matheus Alcantara (#12)
2 attachment(s)
Re: postgres_fdw: Use COPY to speed up batch inserts

On Fri Oct 31, 2025 at 4:02 PM -03, I wrote:

It's showing a bit complicated to decide at runtime if we should use the
COPY or INSERT for batch insert into a foreign table. Perhaps we could
add a new option on CREATE FOREIGN TABLE to enable this usage or not? We
could document the performance improvements and the limitations so the
user can decide if it should enable or not.

Here is v5 that implement this idea.

On this version I've introduced a foreign table and foreign server
option "use_copy_for_insert" (I'm open for a better name) that enable
the use of the COPY as remote command to execute an INSERT into a
foreign table. The COPY can be used if the user enable this option on
the foreign table or the foreign server and if the original INSERT
statement don't have a RETURNING clause.

See the benchmark results:

pgbench -n -c 10 -j 10 -t 100 -f bench.sql postgres

Master (batch_size = 1 with a single row to insert):
tps = 16000.768037

Master (batch_size = 1 with 1000 rows to insert):
tps = 133.451518

Master (batch_size = 100 with 1000 rows to insert):
tps = 1274.096347

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

Patch(batch_size = 1, use_copy_for_insert = false with single row to
insert)
tps = 15734.155705

Master (batch_size = 1, use_copy_for_insert = false with 1000 rows to
insert):
tps = 132.644801

Master (batch_size = 100, use_copy_for_insert = false with 1000 rows to
insert):
tps = 1245.514591

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

Patch(batch_size = 1, use_copy_for_insert = true with single row to
insert)
tps = 17604.394057

Master (batch_size = 1, use_copy_for_insert = true with 1000 rows to
insert):
tps = 88.998804

Master (batch_size = 100, use_copy_for_insert = true with 1000 rows to
insert):
tps = 2406.009249

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

We can see that when batching inserting with the batch_size configured
properly we have a very significant performance improvement and when the
"use_copy_for_insert" option is disabled the performance are close
compared with master.

The problem is when the "batch_size" is 1 (default) and
"use_copy_for_insert" is enabled. This is because on this scenario we
are sending multiple COPY commands with a single row to the foreign
server.

One way to fix this would to decide at runtime (at
execute_foreign_modify()) if the COPY can be used based on the number of
rows being insert. I don't think that I like this option because it
would make the EXPLAIN output different when the ANALYZE option is used
since during planning time we don't have the number of rows being
inserted, so if just EXPLAIN(VERBOSE) is executed we would show the
INSERT as remote SQL, and if the ANALYZE is included and we have enough
rows to enable the COPY usage, the remote SQL would show the COPY
command.

Since the new "use_copy_for_insert" option is be disabled by default I
think that we could document this limitation and mention the performance
improvements when used correctly with the batch_size option.

Another option would be to use the COPY command only if the
"use_copy_for_insert" is true and also if the "batch_size" is > 1. We
would still have the performance issue if the user insert a single row
but we would close to less scenarios. The attached 0002 implement this
idea.

Thoughts?

--
Matheus Alcantara
EDB: http://www.enterprisedb.com

Attachments:

v5-0001-postgres_fdw-Enable-the-use-of-COPY-to-speed-up-i.patchtext/plain; charset=utf-8; name=v5-0001-postgres_fdw-Enable-the-use-of-COPY-to-speed-up-i.patchDownload
From dffe3f86da95451c85277a17de7fda204b678a21 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Fri, 10 Oct 2025 16:07:08 -0300
Subject: [PATCH v5 1/2] postgres_fdw: Enable the use of COPY to speed up
 inserts

---
 contrib/postgres_fdw/deparse.c                |  30 +++
 .../postgres_fdw/expected/postgres_fdw.out    | 168 ++++++++++++++-
 contrib/postgres_fdw/option.c                 |   3 +
 contrib/postgres_fdw/postgres_fdw.c           | 191 +++++++++++++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   1 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  74 +++++++
 6 files changed, 452 insertions(+), 15 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index f2fb0051843..1cdf1d8cc8d 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -2236,6 +2236,36 @@ rebuildInsertSql(StringInfo buf, Relation rel,
 	appendStringInfoString(buf, orig_query + values_end_len);
 }
 
+/*
+ *  Build a COPY FROM STDIN statement using the TEXT format
+ */
+void
+deparseCopySql(StringInfo buf, Relation rel, List *target_attrs)
+{
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	bool		first = true;
+
+	appendStringInfo(buf, "COPY ");
+	deparseRelation(buf, rel);
+	appendStringInfo(buf, "(");
+
+	foreach_int(attnum, target_attrs)
+	{
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, attnum - 1);
+
+		if (attr->attgenerated)
+			continue;
+
+		if (!first)
+			appendStringInfoString(buf, ", ");
+
+		first = false;
+
+		appendStringInfoString(buf, quote_identifier(NameStr(attr->attname)));
+	}
+	appendStringInfoString(buf, ") FROM STDIN");
+}
+
 /*
  * deparse remote UPDATE statement
  *
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index cd28126049d..bc99e278f00 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -50,6 +50,18 @@ CREATE TABLE "S 1"."T 4" (
 	c3 text,
 	CONSTRAINT t4_pkey PRIMARY KEY (c1)
 );
+CREATE TABLE "S 1"."T 5"(
+    x int
+);
+CREATE TABLE "S 1"."T 6"(
+    id int not null,
+    note text,
+    value int NOT NULL
+);
+CREATE TABLE "S 1"."T 7"(
+    id int,
+    t text
+);
 -- Disable autovacuum for these tables to avoid unexpected effects of that
 ALTER TABLE "S 1"."T 1" SET (autovacuum_enabled = 'false');
 ALTER TABLE "S 1"."T 2" SET (autovacuum_enabled = 'false');
@@ -132,6 +144,24 @@ CREATE FOREIGN TABLE ft7 (
 	c2 int NOT NULL,
 	c3 text
 ) SERVER loopback3 OPTIONS (schema_name 'S 1', table_name 'T 4');
+CREATE FOREIGN TABLE ft8 (
+    x int
+)
+SERVER loopback
+OPTIONS (schema_name 'S 1', table_name 'T 5', use_copy_for_insert 'true');
+CREATE FOREIGN TABLE ft9 (
+    id int not null,
+    note text,
+    value int NOT NULL
+)
+SERVER loopback
+OPTIONS (schema_name 'S 1', table_name 'T 6', use_copy_for_insert 'true');
+CREATE FOREIGN TABLE ft10 (
+    id int,
+    t text
+)
+SERVER loopback
+OPTIONS (schema_name 'S 1', table_name 'T 7', use_copy_for_insert 'true');
 -- ===================================================================
 -- tests for validator
 -- ===================================================================
@@ -205,16 +235,19 @@ ALTER FOREIGN TABLE ft2 OPTIONS (schema_name 'S 1', table_name 'T 1');
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c1 OPTIONS (column_name 'C 1');
 ALTER FOREIGN TABLE ft2 ALTER COLUMN c1 OPTIONS (column_name 'C 1');
 \det+
-                              List of foreign tables
- Schema | Table |  Server   |              FDW options              | Description 
---------+-------+-----------+---------------------------------------+-------------
- public | ft1   | loopback  | (schema_name 'S 1', table_name 'T 1') | 
- public | ft2   | loopback  | (schema_name 'S 1', table_name 'T 1') | 
- public | ft4   | loopback  | (schema_name 'S 1', table_name 'T 3') | 
- public | ft5   | loopback  | (schema_name 'S 1', table_name 'T 4') | 
- public | ft6   | loopback2 | (schema_name 'S 1', table_name 'T 4') | 
- public | ft7   | loopback3 | (schema_name 'S 1', table_name 'T 4') | 
-(6 rows)
+                                            List of foreign tables
+ Schema | Table |  Server   |                            FDW options                            | Description 
+--------+-------+-----------+-------------------------------------------------------------------+-------------
+ public | ft1   | loopback  | (schema_name 'S 1', table_name 'T 1')                             | 
+ public | ft10  | loopback  | (schema_name 'S 1', table_name 'T 7', use_copy_for_insert 'true') | 
+ public | ft2   | loopback  | (schema_name 'S 1', table_name 'T 1')                             | 
+ public | ft4   | loopback  | (schema_name 'S 1', table_name 'T 3')                             | 
+ public | ft5   | loopback  | (schema_name 'S 1', table_name 'T 4')                             | 
+ public | ft6   | loopback2 | (schema_name 'S 1', table_name 'T 4')                             | 
+ public | ft7   | loopback3 | (schema_name 'S 1', table_name 'T 4')                             | 
+ public | ft8   | loopback  | (schema_name 'S 1', table_name 'T 5', use_copy_for_insert 'true') | 
+ public | ft9   | loopback  | (schema_name 'S 1', table_name 'T 6', use_copy_for_insert 'true') | 
+(9 rows)
 
 -- Test that alteration of server options causes reconnection
 -- Remote's errors might be non-English, so hide them to ensure stable results
@@ -12665,6 +12698,121 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 -- ===================================================================
+-- test for COPY usage to perform INSERT's
+-- ===================================================================
+-- Test that target attr is correctly used to build the COPY command
+ALTER FOREIGN TABLE ft8 DROP COLUMN x;
+ALTER FOREIGN TABLE ft8 add COLUMN x int;
+EXPLAIN(ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF)
+INSERT INTO ft8 SELECT * FROM generate_series(1, 10) i;
+                                   QUERY PLAN                                    
+---------------------------------------------------------------------------------
+ Insert on public.ft8 (actual rows=0.00 loops=1)
+   Remote SQL: COPY "S 1"."T 5"(x) FROM STDIN
+   Batch Size: 1
+   ->  Function Scan on pg_catalog.generate_series i (actual rows=10.00 loops=1)
+         Output: NULL::integer, i.i
+         Function Call: generate_series(1, 10)
+(6 rows)
+
+SELECT * FROM ft8;
+ x  
+----
+  1
+  2
+  3
+  4
+  5
+  6
+  7
+  8
+  9
+ 10
+(10 rows)
+
+-- Test outer of order columns and batch_size with COPY
+ALTER FOREIGN TABLE ft9 OPTIONS(ADD batch_size '10');
+EXPLAIN(ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF) INSERT INTO ft9 (id, value, note)
+SELECT g,
+       g * 2,
+       'batch insert test data' || g
+FROM generate_series(1, 20) g;
+                                   QUERY PLAN                                    
+---------------------------------------------------------------------------------
+ Insert on public.ft9 (actual rows=0.00 loops=1)
+   Remote SQL: COPY "S 1"."T 6"(id, note, value) FROM STDIN
+   Batch Size: 10
+   ->  Function Scan on pg_catalog.generate_series g (actual rows=20.00 loops=1)
+         Output: g.g, ('batch insert test data'::text || (g.g)::text), (g.g * 2)
+         Function Call: generate_series(1, 20)
+(6 rows)
+
+SELECT * FROM ft9;
+ id |           note           | value 
+----+--------------------------+-------
+  1 | batch insert test data1  |     2
+  2 | batch insert test data2  |     4
+  3 | batch insert test data3  |     6
+  4 | batch insert test data4  |     8
+  5 | batch insert test data5  |    10
+  6 | batch insert test data6  |    12
+  7 | batch insert test data7  |    14
+  8 | batch insert test data8  |    16
+  9 | batch insert test data9  |    18
+ 10 | batch insert test data10 |    20
+ 11 | batch insert test data11 |    22
+ 12 | batch insert test data12 |    24
+ 13 | batch insert test data13 |    26
+ 14 | batch insert test data14 |    28
+ 15 | batch insert test data15 |    30
+ 16 | batch insert test data16 |    32
+ 17 | batch insert test data17 |    34
+ 18 | batch insert test data18 |    36
+ 19 | batch insert test data19 |    38
+ 20 | batch insert test data20 |    40
+(20 rows)
+
+-- Test buffer limit of copy data on COPYBUFSIZ
+INSERT INTO ft10 (id, t)
+SELECT s, repeat(md5(s::text), 10000) from generate_series(100, 103) s;
+SELECT COUNT(*) FROM ft10;
+ count 
+-------
+     4
+(1 row)
+
+-- Disable the use_copy_for_insert table option and check that the INSERT is
+-- used
+ALTER FOREIGN TABLE ft8 OPTIONS(DROP use_copy_for_insert);
+EXPLAIN(VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF)
+INSERT INTO ft8 VALUES (10);
+                      QUERY PLAN                      
+------------------------------------------------------
+ Insert on public.ft8
+   Remote SQL: INSERT INTO "S 1"."T 5"(x) VALUES ($1)
+   Batch Size: 1
+   ->  Result
+         Output: NULL::integer, 10
+(5 rows)
+
+-- Enable the use_copy_for_insert for the foreign server and check that the
+-- COPY is used
+ALTER SERVER loopback OPTIONS(ADD use_copy_for_insert 'true');
+EXPLAIN(VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF)
+INSERT INTO ft8 VALUES (20);
+                  QUERY PLAN                  
+----------------------------------------------
+ Insert on public.ft8
+   Remote SQL: COPY "S 1"."T 5"(x) FROM STDIN
+   Batch Size: 1
+   ->  Result
+         Output: NULL::integer, 20
+(5 rows)
+
+-- Reset state
+ALTER SERVER loopback OPTIONS(DROP use_copy_for_insert);
+ALTER FOREIGN TABLE ft8 OPTIONS(ADD use_copy_for_insert 'true');
+-- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
 -- Disable debug_discard_caches in order to manage remote connections
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index 04788b7e8b3..de0f59332c3 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -263,6 +263,9 @@ InitPgFdwOptions(void)
 		/* batch_size is available on both server and table */
 		{"batch_size", ForeignServerRelationId, false},
 		{"batch_size", ForeignTableRelationId, false},
+		/* use_copy_for_insert is available on both server and table */
+		{"use_copy_for_insert", ForeignServerRelationId, false},
+		{"use_copy_for_insert", ForeignTableRelationId, false},
 		/* async_capable is available on both server and table */
 		{"async_capable", ForeignServerRelationId, false},
 		{"async_capable", ForeignTableRelationId, false},
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 06b52c65300..77effdffeb2 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -63,6 +63,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.
  *
@@ -197,6 +200,7 @@ typedef struct PgFdwModifyState
 	int			batch_size;		/* value of FDW option "batch_size" */
 	bool		has_returning;	/* is there a RETURNING clause? */
 	List	   *retrieved_attrs;	/* attr numbers retrieved by RETURNING */
+	bool		use_copy_for_insert;	/* is the COPY enabled for INSERT's? */
 
 	/* info about parameters for prepared statement */
 	AttrNumber	ctidAttno;		/* attnum of input resjunk ctid column */
@@ -545,6 +549,10 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_o,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
+static bool get_use_copy_for_insert(Relation rel);
+static TupleTableSlot **execute_foreign_insert_using_copy(PgFdwModifyState *fmstate,
+														  TupleTableSlot **slots,
+														  int *numSlots);
 
 
 /*
@@ -1788,6 +1796,7 @@ postgresPlanForeignModify(PlannerInfo *root,
 	List	   *retrieved_attrs = NIL;
 	bool		doNothing = false;
 	int			values_end_len = -1;
+	bool		use_copy_for_insert = false;
 
 	initStringInfo(&sql);
 
@@ -1867,17 +1876,25 @@ postgresPlanForeignModify(PlannerInfo *root,
 		elog(ERROR, "unexpected ON CONFLICT specification: %d",
 			 (int) plan->onConflictAction);
 
+	if (operation == CMD_INSERT && plan->returningLists == NULL)
+		use_copy_for_insert = get_use_copy_for_insert(rel);
+
 	/*
 	 * Construct the SQL command string.
 	 */
 	switch (operation)
 	{
 		case CMD_INSERT:
-			deparseInsertSql(&sql, rte, resultRelation, rel,
-							 targetAttrs, doNothing,
-							 withCheckOptionList, returningList,
-							 &retrieved_attrs, &values_end_len);
-			break;
+			{
+				if (use_copy_for_insert)
+					deparseCopySql(&sql, rel, targetAttrs);
+				else
+					deparseInsertSql(&sql, rte, resultRelation, rel,
+									 targetAttrs, doNothing,
+									 withCheckOptionList, returningList,
+									 &retrieved_attrs, &values_end_len);
+				break;
+			}
 		case CMD_UPDATE:
 			deparseUpdateSql(&sql, rte, resultRelation, rel,
 							 targetAttrs,
@@ -4058,6 +4075,9 @@ create_foreign_modify(EState *estate,
 	if (operation == CMD_INSERT)
 		fmstate->batch_size = get_batch_size_option(rel);
 
+	if (operation == CMD_INSERT && !fmstate->has_returning)
+		fmstate->use_copy_for_insert = get_use_copy_for_insert(rel);
+
 	fmstate->num_slots = 1;
 
 	/* Initialize auxiliary state */
@@ -4066,6 +4086,50 @@ create_foreign_modify(EState *estate,
 	return fmstate;
 }
 
+/*
+ *  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);
+
+			appendStringInfoString(buf, value);
+		}
+		i++;
+	}
+
+	appendStringInfoCharMacro(buf, '\n');
+}
+
 /*
  * execute_foreign_modify
  *		Perform foreign-table modification as required, and fetch RETURNING
@@ -4097,6 +4161,14 @@ execute_foreign_modify(EState *estate,
 	if (fmstate->conn_state->pendingAreq)
 		process_pending_request(fmstate->conn_state->pendingAreq);
 
+	/* Check if the COPY command is enabled to use for INSERT's */
+	if (operation == CMD_INSERT && fmstate->use_copy_for_insert)
+	{
+		/* COPY should only be used with INSERT without RETURNING clause. */
+		Assert(!fmstate->has_returning);
+		return execute_foreign_insert_using_copy(fmstate, slots, numSlots);
+	}
+
 	/*
 	 * If the existing query was deparsed and prepared for a different number
 	 * of rows, rebuild it for the proper number.
@@ -7886,3 +7958,112 @@ get_batch_size_option(Relation rel)
 
 	return batch_size;
 }
+
+/*
+ * Determine if the usage of the COPY command to execute a INSERT into a foreign
+ * table is enabled. The option specified for a table has precedence.
+ */
+static bool
+get_use_copy_for_insert(Relation rel)
+{
+	Oid			foreigntableid = RelationGetRelid(rel);
+	List	   *options = NIL;
+	ListCell   *lc;
+	ForeignTable *table;
+	ForeignServer *server;
+	bool		enable_batch_with_copy = false;
+
+	/*
+	 * Load options for table and server. We append server options after table
+	 * options, because table options take precedence.
+	 */
+	table = GetForeignTable(foreigntableid);
+	server = GetForeignServer(table->serverid);
+
+	options = list_concat(options, table->options);
+	options = list_concat(options, server->options);
+
+	/* See if either table or server specifies enable_batch_with_copy. */
+	foreach(lc, options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "use_copy_for_insert") == 0)
+		{
+			(void) parse_bool(defGetString(def), &enable_batch_with_copy);
+			break;
+		}
+	}
+	return enable_batch_with_copy;
+}
+
+/* Execute an insert into a foreign table using the COPY command */
+static TupleTableSlot **
+execute_foreign_insert_using_copy(PgFdwModifyState *fmstate,
+								  TupleTableSlot **slots,
+								  int *numSlots)
+{
+	PGresult   *res;
+	StringInfoData copy_data;
+	int			n_rows;
+	int			i;
+
+	/* Send COPY command */
+	if (!PQsendQuery(fmstate->conn, fmstate->query))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->query);
+
+	/* get the COPY result */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COPY_IN)
+		pgfdw_report_error(res, fmstate->conn, fmstate->query);
+
+	/* Convert the TupleTableSlot data into a TEXT-formatted line */
+	initStringInfo(&copy_data);
+	for (i = 0; i < *numSlots; i++)
+	{
+		convert_slot_to_copy_text(&copy_data, fmstate, slots[i]);
+
+		/*
+		 * Send initial COPY data if the buffer reach the limit to avoid large
+		 * memory usage.
+		 */
+		if (copy_data.len >= COPYBUFSIZ)
+		{
+			if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+				pgfdw_report_error(NULL, fmstate->conn, fmstate->query);
+			resetStringInfo(&copy_data);
+		}
+	}
+
+	/* Send the remaining COPY data */
+	if (copy_data.len > 0)
+	{
+		if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+			pgfdw_report_error(NULL, fmstate->conn, fmstate->query);
+	}
+
+	/* End the COPY operation */
+	if (PQputCopyEnd(fmstate->conn, NULL) < 0 || PQflush(fmstate->conn))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->query);
+
+	/*
+	 * Get the result, and check for success.
+	 */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pgfdw_report_error(res, fmstate->conn, fmstate->query);
+
+	n_rows = atoi(PQcmdTuples(res));
+
+	/* And clean up */
+	PQclear(res);
+
+	MemoryContextReset(fmstate->temp_cxt);
+
+	*numSlots = n_rows;
+
+	/*
+	 * Return NULL if nothing was inserted on the remote end
+	 */
+	return (n_rows > 0) ? slots : NULL;
+}
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index e69735298d7..aa54d6bba53 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -204,6 +204,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 9a8f9e28135..0d29b9d9bae 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -54,6 +54,18 @@ CREATE TABLE "S 1"."T 4" (
 	c3 text,
 	CONSTRAINT t4_pkey PRIMARY KEY (c1)
 );
+CREATE TABLE "S 1"."T 5"(
+    x int
+);
+CREATE TABLE "S 1"."T 6"(
+    id int not null,
+    note text,
+    value int NOT NULL
+);
+CREATE TABLE "S 1"."T 7"(
+    id int,
+    t text
+);
 
 -- Disable autovacuum for these tables to avoid unexpected effects of that
 ALTER TABLE "S 1"."T 1" SET (autovacuum_enabled = 'false');
@@ -146,6 +158,27 @@ CREATE FOREIGN TABLE ft7 (
 	c3 text
 ) SERVER loopback3 OPTIONS (schema_name 'S 1', table_name 'T 4');
 
+CREATE FOREIGN TABLE ft8 (
+    x int
+)
+SERVER loopback
+OPTIONS (schema_name 'S 1', table_name 'T 5', use_copy_for_insert 'true');
+
+CREATE FOREIGN TABLE ft9 (
+    id int not null,
+    note text,
+    value int NOT NULL
+)
+SERVER loopback
+OPTIONS (schema_name 'S 1', table_name 'T 6', use_copy_for_insert 'true');
+
+CREATE FOREIGN TABLE ft10 (
+    id int,
+    t text
+)
+SERVER loopback
+OPTIONS (schema_name 'S 1', table_name 'T 7', use_copy_for_insert 'true');
+
 -- ===================================================================
 -- tests for validator
 -- ===================================================================
@@ -4379,6 +4412,47 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 
+-- ===================================================================
+-- test for COPY usage to perform INSERT's
+-- ===================================================================
+
+-- Test that target attr is correctly used to build the COPY command
+ALTER FOREIGN TABLE ft8 DROP COLUMN x;
+ALTER FOREIGN TABLE ft8 add COLUMN x int;
+EXPLAIN(ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF)
+INSERT INTO ft8 SELECT * FROM generate_series(1, 10) i;
+SELECT * FROM ft8;
+
+-- Test outer of order columns and batch_size with COPY
+ALTER FOREIGN TABLE ft9 OPTIONS(ADD batch_size '10');
+EXPLAIN(ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF) INSERT INTO ft9 (id, value, note)
+SELECT g,
+       g * 2,
+       'batch insert test data' || g
+FROM generate_series(1, 20) g;
+SELECT * FROM ft9;
+
+-- Test buffer limit of copy data on COPYBUFSIZ
+INSERT INTO ft10 (id, t)
+SELECT s, repeat(md5(s::text), 10000) from generate_series(100, 103) s;
+SELECT COUNT(*) FROM ft10;
+
+-- Disable the use_copy_for_insert table option and check that the INSERT is
+-- used
+ALTER FOREIGN TABLE ft8 OPTIONS(DROP use_copy_for_insert);
+EXPLAIN(VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF)
+INSERT INTO ft8 VALUES (10);
+
+-- Enable the use_copy_for_insert for the foreign server and check that the
+-- COPY is used
+ALTER SERVER loopback OPTIONS(ADD use_copy_for_insert 'true');
+EXPLAIN(VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF)
+INSERT INTO ft8 VALUES (20);
+
+-- Reset state
+ALTER SERVER loopback OPTIONS(DROP use_copy_for_insert);
+ALTER FOREIGN TABLE ft8 OPTIONS(ADD use_copy_for_insert 'true');
+
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
-- 
2.51.2

v5-0002-postgres_fdw-Only-use-COPY-if-batch_size-is-1.patchtext/plain; charset=utf-8; name=v5-0002-postgres_fdw-Only-use-COPY-if-batch_size-is-1.patchDownload
From 1fb93c8b1548b836c68c3e2e4c124eb57f2ee518 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Thu, 6 Nov 2025 20:17:19 -0300
Subject: [PATCH v5 2/2] postgres_fdw: Only use COPY if batch_size is > 1

---
 .../postgres_fdw/expected/postgres_fdw.out    | 39 +++++++++----------
 contrib/postgres_fdw/postgres_fdw.c           |  9 ++++-
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  9 ++---
 3 files changed, 30 insertions(+), 27 deletions(-)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index bc99e278f00..956ff12b590 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -148,20 +148,20 @@ CREATE FOREIGN TABLE ft8 (
     x int
 )
 SERVER loopback
-OPTIONS (schema_name 'S 1', table_name 'T 5', use_copy_for_insert 'true');
+OPTIONS (schema_name 'S 1', table_name 'T 5', use_copy_for_insert 'true', batch_size '10');
 CREATE FOREIGN TABLE ft9 (
     id int not null,
     note text,
     value int NOT NULL
 )
 SERVER loopback
-OPTIONS (schema_name 'S 1', table_name 'T 6', use_copy_for_insert 'true');
+OPTIONS (schema_name 'S 1', table_name 'T 6', use_copy_for_insert 'true', batch_size '10');
 CREATE FOREIGN TABLE ft10 (
     id int,
     t text
 )
 SERVER loopback
-OPTIONS (schema_name 'S 1', table_name 'T 7', use_copy_for_insert 'true');
+OPTIONS (schema_name 'S 1', table_name 'T 7', use_copy_for_insert 'true', batch_size '10');
 -- ===================================================================
 -- tests for validator
 -- ===================================================================
@@ -235,18 +235,18 @@ ALTER FOREIGN TABLE ft2 OPTIONS (schema_name 'S 1', table_name 'T 1');
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c1 OPTIONS (column_name 'C 1');
 ALTER FOREIGN TABLE ft2 ALTER COLUMN c1 OPTIONS (column_name 'C 1');
 \det+
-                                            List of foreign tables
- Schema | Table |  Server   |                            FDW options                            | Description 
---------+-------+-----------+-------------------------------------------------------------------+-------------
- public | ft1   | loopback  | (schema_name 'S 1', table_name 'T 1')                             | 
- public | ft10  | loopback  | (schema_name 'S 1', table_name 'T 7', use_copy_for_insert 'true') | 
- public | ft2   | loopback  | (schema_name 'S 1', table_name 'T 1')                             | 
- public | ft4   | loopback  | (schema_name 'S 1', table_name 'T 3')                             | 
- public | ft5   | loopback  | (schema_name 'S 1', table_name 'T 4')                             | 
- public | ft6   | loopback2 | (schema_name 'S 1', table_name 'T 4')                             | 
- public | ft7   | loopback3 | (schema_name 'S 1', table_name 'T 4')                             | 
- public | ft8   | loopback  | (schema_name 'S 1', table_name 'T 5', use_copy_for_insert 'true') | 
- public | ft9   | loopback  | (schema_name 'S 1', table_name 'T 6', use_copy_for_insert 'true') | 
+                                                    List of foreign tables
+ Schema | Table |  Server   |                                    FDW options                                     | Description 
+--------+-------+-----------+------------------------------------------------------------------------------------+-------------
+ public | ft1   | loopback  | (schema_name 'S 1', table_name 'T 1')                                              | 
+ public | ft10  | loopback  | (schema_name 'S 1', table_name 'T 7', use_copy_for_insert 'true', batch_size '10') | 
+ public | ft2   | loopback  | (schema_name 'S 1', table_name 'T 1')                                              | 
+ public | ft4   | loopback  | (schema_name 'S 1', table_name 'T 3')                                              | 
+ public | ft5   | loopback  | (schema_name 'S 1', table_name 'T 4')                                              | 
+ public | ft6   | loopback2 | (schema_name 'S 1', table_name 'T 4')                                              | 
+ public | ft7   | loopback3 | (schema_name 'S 1', table_name 'T 4')                                              | 
+ public | ft8   | loopback  | (schema_name 'S 1', table_name 'T 5', use_copy_for_insert 'true', batch_size '10') | 
+ public | ft9   | loopback  | (schema_name 'S 1', table_name 'T 6', use_copy_for_insert 'true', batch_size '10') | 
 (9 rows)
 
 -- Test that alteration of server options causes reconnection
@@ -12709,7 +12709,7 @@ INSERT INTO ft8 SELECT * FROM generate_series(1, 10) i;
 ---------------------------------------------------------------------------------
  Insert on public.ft8 (actual rows=0.00 loops=1)
    Remote SQL: COPY "S 1"."T 5"(x) FROM STDIN
-   Batch Size: 1
+   Batch Size: 10
    ->  Function Scan on pg_catalog.generate_series i (actual rows=10.00 loops=1)
          Output: NULL::integer, i.i
          Function Call: generate_series(1, 10)
@@ -12730,8 +12730,7 @@ SELECT * FROM ft8;
  10
 (10 rows)
 
--- Test outer of order columns and batch_size with COPY
-ALTER FOREIGN TABLE ft9 OPTIONS(ADD batch_size '10');
+-- Test outer of order columns
 EXPLAIN(ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF) INSERT INTO ft9 (id, value, note)
 SELECT g,
        g * 2,
@@ -12790,7 +12789,7 @@ INSERT INTO ft8 VALUES (10);
 ------------------------------------------------------
  Insert on public.ft8
    Remote SQL: INSERT INTO "S 1"."T 5"(x) VALUES ($1)
-   Batch Size: 1
+   Batch Size: 10
    ->  Result
          Output: NULL::integer, 10
 (5 rows)
@@ -12804,7 +12803,7 @@ INSERT INTO ft8 VALUES (20);
 ----------------------------------------------
  Insert on public.ft8
    Remote SQL: COPY "S 1"."T 5"(x) FROM STDIN
-   Batch Size: 1
+   Batch Size: 10
    ->  Result
          Output: NULL::integer, 20
 (5 rows)
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 77effdffeb2..4a89522a221 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -1877,7 +1877,12 @@ postgresPlanForeignModify(PlannerInfo *root,
 			 (int) plan->onConflictAction);
 
 	if (operation == CMD_INSERT && plan->returningLists == NULL)
-		use_copy_for_insert = get_use_copy_for_insert(rel);
+	{
+		int			batch_size = get_batch_size_option(rel);
+
+		if (batch_size > 1)
+			use_copy_for_insert = get_use_copy_for_insert(rel);
+	}
 
 	/*
 	 * Construct the SQL command string.
@@ -4075,7 +4080,7 @@ create_foreign_modify(EState *estate,
 	if (operation == CMD_INSERT)
 		fmstate->batch_size = get_batch_size_option(rel);
 
-	if (operation == CMD_INSERT && !fmstate->has_returning)
+	if (operation == CMD_INSERT && !fmstate->has_returning && fmstate->batch_size > 1)
 		fmstate->use_copy_for_insert = get_use_copy_for_insert(rel);
 
 	fmstate->num_slots = 1;
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 0d29b9d9bae..093f86abeb4 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -162,7 +162,7 @@ CREATE FOREIGN TABLE ft8 (
     x int
 )
 SERVER loopback
-OPTIONS (schema_name 'S 1', table_name 'T 5', use_copy_for_insert 'true');
+OPTIONS (schema_name 'S 1', table_name 'T 5', use_copy_for_insert 'true', batch_size '10');
 
 CREATE FOREIGN TABLE ft9 (
     id int not null,
@@ -170,14 +170,14 @@ CREATE FOREIGN TABLE ft9 (
     value int NOT NULL
 )
 SERVER loopback
-OPTIONS (schema_name 'S 1', table_name 'T 6', use_copy_for_insert 'true');
+OPTIONS (schema_name 'S 1', table_name 'T 6', use_copy_for_insert 'true', batch_size '10');
 
 CREATE FOREIGN TABLE ft10 (
     id int,
     t text
 )
 SERVER loopback
-OPTIONS (schema_name 'S 1', table_name 'T 7', use_copy_for_insert 'true');
+OPTIONS (schema_name 'S 1', table_name 'T 7', use_copy_for_insert 'true', batch_size '10');
 
 -- ===================================================================
 -- tests for validator
@@ -4423,8 +4423,7 @@ EXPLAIN(ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF)
 INSERT INTO ft8 SELECT * FROM generate_series(1, 10) i;
 SELECT * FROM ft8;
 
--- Test outer of order columns and batch_size with COPY
-ALTER FOREIGN TABLE ft9 OPTIONS(ADD batch_size '10');
+-- Test outer of order columns
 EXPLAIN(ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF) INSERT INTO ft9 (id, value, note)
 SELECT g,
        g * 2,
-- 
2.51.2

#14Masahiko Sawada
sawada.mshk@gmail.com
In reply to: Matheus Alcantara (#13)
Re: postgres_fdw: Use COPY to speed up batch inserts

On Thu, Nov 6, 2025 at 3:49 PM Matheus Alcantara
<matheusssilv97@gmail.com> wrote:

On Fri Oct 31, 2025 at 4:02 PM -03, I wrote:

It's showing a bit complicated to decide at runtime if we should use the
COPY or INSERT for batch insert into a foreign table. Perhaps we could
add a new option on CREATE FOREIGN TABLE to enable this usage or not? We
could document the performance improvements and the limitations so the
user can decide if it should enable or not.

Here is v5 that implement this idea.

On this version I've introduced a foreign table and foreign server
option "use_copy_for_insert" (I'm open for a better name) that enable
the use of the COPY as remote command to execute an INSERT into a
foreign table. The COPY can be used if the user enable this option on
the foreign table or the foreign server and if the original INSERT
statement don't have a RETURNING clause.

See the benchmark results:

pgbench -n -c 10 -j 10 -t 100 -f bench.sql postgres

Master (batch_size = 1 with a single row to insert):
tps = 16000.768037

Master (batch_size = 1 with 1000 rows to insert):
tps = 133.451518

Master (batch_size = 100 with 1000 rows to insert):
tps = 1274.096347

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

Patch(batch_size = 1, use_copy_for_insert = false with single row to
insert)
tps = 15734.155705

Master (batch_size = 1, use_copy_for_insert = false with 1000 rows to
insert):
tps = 132.644801

Master (batch_size = 100, use_copy_for_insert = false with 1000 rows to
insert):
tps = 1245.514591

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

Patch(batch_size = 1, use_copy_for_insert = true with single row to
insert)
tps = 17604.394057

Master (batch_size = 1, use_copy_for_insert = true with 1000 rows to
insert):
tps = 88.998804

Master (batch_size = 100, use_copy_for_insert = true with 1000 rows to
insert):
tps = 2406.009249

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

We can see that when batching inserting with the batch_size configured
properly we have a very significant performance improvement and when the
"use_copy_for_insert" option is disabled the performance are close
compared with master.

The problem is when the "batch_size" is 1 (default) and
"use_copy_for_insert" is enabled. This is because on this scenario we
are sending multiple COPY commands with a single row to the foreign
server.

One way to fix this would to decide at runtime (at
execute_foreign_modify()) if the COPY can be used based on the number of
rows being insert. I don't think that I like this option because it
would make the EXPLAIN output different when the ANALYZE option is used
since during planning time we don't have the number of rows being
inserted, so if just EXPLAIN(VERBOSE) is executed we would show the
INSERT as remote SQL, and if the ANALYZE is included and we have enough
rows to enable the COPY usage, the remote SQL would show the COPY
command.

Since the new "use_copy_for_insert" option is be disabled by default I
think that we could document this limitation and mention the performance
improvements when used correctly with the batch_size option.

Another option would be to use the COPY command only if the
"use_copy_for_insert" is true and also if the "batch_size" is > 1. We
would still have the performance issue if the user insert a single row
but we would close to less scenarios. The attached 0002 implement this
idea.

Thoughts?

IIUC the performance regression occurs when users insert many rows
into a foreign table with batch_size = 1 and use_copy_for_insert =
true (tps: 133.451518 vs. 132.644801 vs. 88.998804). Since batch_size
defaults to 1, users might experience performance issues if they
enable use_copy_for_insert without adjusting the batch_size. I'm
worried that users could easily find themselves in this situation.

One possible solution would be to introduce a threshold, like
copy_min_row, which would specify the minimum number of rows needed
before switching to the COPY command. However, this would require
coordination with batch_size since having copy_min_row lower than
batch_size wouldn't make sense. Alternatively, when users are using
batch insertion (batch_size > 0), we could use the COPY command only
for full batches and fall back to INSERT for partial ones.

BTW I noticed that use_copy_for_insert option doesn't work with COPY
FROM command. I got the following error with use_copy_for_insert=true
and batch_size=3:

postgres(1:2546195)=# copy t from '/tmp/a.csv'; -- table 't' is a foreign table.
ERROR: there is no parameter $1
CONTEXT: remote SQL command: INSERT INTO public.t(c) VALUES ($1)
COPY t

Regards,

--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com

#15Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Masahiko Sawada (#14)
1 attachment(s)
Re: postgres_fdw: Use COPY to speed up batch inserts

On Mon Nov 17, 2025 at 11:03 PM -03, Masahiko Sawada wrote:

IIUC the performance regression occurs when users insert many rows
into a foreign table with batch_size = 1 and use_copy_for_insert =
true (tps: 133.451518 vs. 132.644801 vs. 88.998804). Since batch_size
defaults to 1, users might experience performance issues if they
enable use_copy_for_insert without adjusting the batch_size. I'm
worried that users could easily find themselves in this situation.

Yes, you are correct. The 0002 patch aims to reduce this issue by using
the COPY command only if the use_copy_for_insert = true and if
batch_size > 1 which will reduce the cases but the regression can still
happen if the user send a single row to insert into a foreign table.

Inserting a single row into a foreign table using COPY is a bit slower
compared with using INSERT. See the followinw pgbench results:

(Single row using INSERT)
tps = 19814.535944

(Single row using COPY)
tps = 16562.324025

I think that the documentation should mention that just changing
use_copy_for_insert without also changing the batch_size option could
cause performance regression.

One possible solution would be to introduce a threshold, like
copy_min_row, which would specify the minimum number of rows needed
before switching to the COPY command. However, this would require
coordination with batch_size since having copy_min_row lower than
batch_size wouldn't make sense.

The only problem that I see with this approach is that it would make
EXPLAIN(VERBOSE) and EXPLAIN(ANALYZE, VERBOSE) remote SQL output
different. The user will never know with EXPLAIN (without analyze) if
the COPY will be used or not. Is this a problem or I'm being to much
conservative?

I think that we can do such coordination on postgres_fdw_validator().

Also if we decide to go with this idea it seems to me that we would have
to much table options to configure to enable the COPY opitimization, we
would need "copy_min_row", "batch_size" and "use_copy_for_insert". What
about decide to use the COPY command if use_copy_for_insert = true and
the number of rows being inserted is >= batch_size?

Alternatively, when users are using batch insertion (batch_size > 0),
we could use the COPY command only for full batches and fall back to
INSERT for partial ones.

IIUC in this case we would sent COPY and INSERT statements to the
foreign server for the same execution, for example, if batch_size = 100
and the user try insert 105 rows into the foreign table we will send a
COPY statement with 100 rows and then an INSERT with the 5 rows
remaining? If that's the case which SQL we should show on Remote SQL
from EXPLAIN(ANALYZE, VERBOSE) output? I think that this can cause some
confusion.

BTW I noticed that use_copy_for_insert option doesn't work with COPY
FROM command. I got the following error with use_copy_for_insert=true
and batch_size=3:

postgres(1:2546195)=# copy t from '/tmp/a.csv'; -- table 't' is a foreign table.
ERROR: there is no parameter $1
CONTEXT: remote SQL command: INSERT INTO public.t(c) VALUES ($1)
COPY t

Thanks for testing this case. The problem was that I as checking if the
COPY can be used inside create_foreign_modify() that is called by
BeginForeignInsert and also BeginForeignModify() and the COPY can be
used only by the foreign modify path. To fix this issue I've moved the
check to postgresBeginForeignModify().

I'm attaching v6 with the following changes:
- I've squashed 0002 into 0001, so now the COPY will only be used if
use_copy_for_insert = true and if batch_size > 1
- Fix for the bug of COPY FROM a foreign table
- New test case for the COPY bug

--
Matheus Alcantara
EDB: http://www.enterprisedb.com

Attachments:

v6-0001-postgres_fdw-Enable-the-use-of-COPY-to-speed-up-i.patchtext/plain; charset=utf-8; name=v6-0001-postgres_fdw-Enable-the-use-of-COPY-to-speed-up-i.patchDownload
From dd29e8b3b0092a4ba54cef9b00892f577d2461da Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Fri, 10 Oct 2025 16:07:08 -0300
Subject: [PATCH v6] postgres_fdw: Enable the use of COPY to speed up inserts

---
 contrib/postgres_fdw/deparse.c                |  30 +++
 .../postgres_fdw/expected/postgres_fdw.out    | 170 ++++++++++++++-
 contrib/postgres_fdw/option.c                 |   3 +
 contrib/postgres_fdw/postgres_fdw.c           | 198 +++++++++++++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   1 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  79 +++++++
 6 files changed, 466 insertions(+), 15 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index f2fb0051843..1cdf1d8cc8d 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -2236,6 +2236,36 @@ rebuildInsertSql(StringInfo buf, Relation rel,
 	appendStringInfoString(buf, orig_query + values_end_len);
 }
 
+/*
+ *  Build a COPY FROM STDIN statement using the TEXT format
+ */
+void
+deparseCopySql(StringInfo buf, Relation rel, List *target_attrs)
+{
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	bool		first = true;
+
+	appendStringInfo(buf, "COPY ");
+	deparseRelation(buf, rel);
+	appendStringInfo(buf, "(");
+
+	foreach_int(attnum, target_attrs)
+	{
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, attnum - 1);
+
+		if (attr->attgenerated)
+			continue;
+
+		if (!first)
+			appendStringInfoString(buf, ", ");
+
+		first = false;
+
+		appendStringInfoString(buf, quote_identifier(NameStr(attr->attname)));
+	}
+	appendStringInfoString(buf, ") FROM STDIN");
+}
+
 /*
  * deparse remote UPDATE statement
  *
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index cd28126049d..0648a964522 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -50,6 +50,18 @@ CREATE TABLE "S 1"."T 4" (
 	c3 text,
 	CONSTRAINT t4_pkey PRIMARY KEY (c1)
 );
+CREATE TABLE "S 1"."T 5"(
+    x int
+);
+CREATE TABLE "S 1"."T 6"(
+    id int not null,
+    note text,
+    value int NOT NULL
+);
+CREATE TABLE "S 1"."T 7"(
+    id int,
+    t text
+);
 -- Disable autovacuum for these tables to avoid unexpected effects of that
 ALTER TABLE "S 1"."T 1" SET (autovacuum_enabled = 'false');
 ALTER TABLE "S 1"."T 2" SET (autovacuum_enabled = 'false');
@@ -132,6 +144,24 @@ CREATE FOREIGN TABLE ft7 (
 	c2 int NOT NULL,
 	c3 text
 ) SERVER loopback3 OPTIONS (schema_name 'S 1', table_name 'T 4');
+CREATE FOREIGN TABLE ft8 (
+    x int
+)
+SERVER loopback
+OPTIONS (schema_name 'S 1', table_name 'T 5', use_copy_for_insert 'true', batch_size '10');
+CREATE FOREIGN TABLE ft9 (
+    id int not null,
+    note text,
+    value int NOT NULL
+)
+SERVER loopback
+OPTIONS (schema_name 'S 1', table_name 'T 6', use_copy_for_insert 'true', batch_size '10');
+CREATE FOREIGN TABLE ft10 (
+    id int,
+    t text
+)
+SERVER loopback
+OPTIONS (schema_name 'S 1', table_name 'T 7', use_copy_for_insert 'true', batch_size '10');
 -- ===================================================================
 -- tests for validator
 -- ===================================================================
@@ -205,16 +235,19 @@ ALTER FOREIGN TABLE ft2 OPTIONS (schema_name 'S 1', table_name 'T 1');
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c1 OPTIONS (column_name 'C 1');
 ALTER FOREIGN TABLE ft2 ALTER COLUMN c1 OPTIONS (column_name 'C 1');
 \det+
-                              List of foreign tables
- Schema | Table |  Server   |              FDW options              | Description 
---------+-------+-----------+---------------------------------------+-------------
- public | ft1   | loopback  | (schema_name 'S 1', table_name 'T 1') | 
- public | ft2   | loopback  | (schema_name 'S 1', table_name 'T 1') | 
- public | ft4   | loopback  | (schema_name 'S 1', table_name 'T 3') | 
- public | ft5   | loopback  | (schema_name 'S 1', table_name 'T 4') | 
- public | ft6   | loopback2 | (schema_name 'S 1', table_name 'T 4') | 
- public | ft7   | loopback3 | (schema_name 'S 1', table_name 'T 4') | 
-(6 rows)
+                                                    List of foreign tables
+ Schema | Table |  Server   |                                    FDW options                                     | Description 
+--------+-------+-----------+------------------------------------------------------------------------------------+-------------
+ public | ft1   | loopback  | (schema_name 'S 1', table_name 'T 1')                                              | 
+ public | ft10  | loopback  | (schema_name 'S 1', table_name 'T 7', use_copy_for_insert 'true', batch_size '10') | 
+ public | ft2   | loopback  | (schema_name 'S 1', table_name 'T 1')                                              | 
+ public | ft4   | loopback  | (schema_name 'S 1', table_name 'T 3')                                              | 
+ public | ft5   | loopback  | (schema_name 'S 1', table_name 'T 4')                                              | 
+ public | ft6   | loopback2 | (schema_name 'S 1', table_name 'T 4')                                              | 
+ public | ft7   | loopback3 | (schema_name 'S 1', table_name 'T 4')                                              | 
+ public | ft8   | loopback  | (schema_name 'S 1', table_name 'T 5', use_copy_for_insert 'true', batch_size '10') | 
+ public | ft9   | loopback  | (schema_name 'S 1', table_name 'T 6', use_copy_for_insert 'true', batch_size '10') | 
+(9 rows)
 
 -- Test that alteration of server options causes reconnection
 -- Remote's errors might be non-English, so hide them to ensure stable results
@@ -12665,6 +12698,123 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 -- ===================================================================
+-- test for COPY usage to perform INSERT's
+-- ===================================================================
+-- Test that target attr is correctly used to build the COPY command
+ALTER FOREIGN TABLE ft8 DROP COLUMN x;
+ALTER FOREIGN TABLE ft8 add COLUMN x int;
+EXPLAIN(ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF)
+INSERT INTO ft8 SELECT * FROM generate_series(1, 10) i;
+                                   QUERY PLAN                                    
+---------------------------------------------------------------------------------
+ Insert on public.ft8 (actual rows=0.00 loops=1)
+   Remote SQL: COPY "S 1"."T 5"(x) FROM STDIN
+   Batch Size: 10
+   ->  Function Scan on pg_catalog.generate_series i (actual rows=10.00 loops=1)
+         Output: NULL::integer, i.i
+         Function Call: generate_series(1, 10)
+(6 rows)
+
+SELECT * FROM ft8;
+ x  
+----
+  1
+  2
+  3
+  4
+  5
+  6
+  7
+  8
+  9
+ 10
+(10 rows)
+
+-- Test outer of order columns
+EXPLAIN(ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF) INSERT INTO ft9 (id, value, note)
+SELECT g,
+       g * 2,
+       'batch insert test data' || g
+FROM generate_series(1, 20) g;
+                                   QUERY PLAN                                    
+---------------------------------------------------------------------------------
+ Insert on public.ft9 (actual rows=0.00 loops=1)
+   Remote SQL: COPY "S 1"."T 6"(id, note, value) FROM STDIN
+   Batch Size: 10
+   ->  Function Scan on pg_catalog.generate_series g (actual rows=20.00 loops=1)
+         Output: g.g, ('batch insert test data'::text || (g.g)::text), (g.g * 2)
+         Function Call: generate_series(1, 20)
+(6 rows)
+
+SELECT * FROM ft9;
+ id |           note           | value 
+----+--------------------------+-------
+  1 | batch insert test data1  |     2
+  2 | batch insert test data2  |     4
+  3 | batch insert test data3  |     6
+  4 | batch insert test data4  |     8
+  5 | batch insert test data5  |    10
+  6 | batch insert test data6  |    12
+  7 | batch insert test data7  |    14
+  8 | batch insert test data8  |    16
+  9 | batch insert test data9  |    18
+ 10 | batch insert test data10 |    20
+ 11 | batch insert test data11 |    22
+ 12 | batch insert test data12 |    24
+ 13 | batch insert test data13 |    26
+ 14 | batch insert test data14 |    28
+ 15 | batch insert test data15 |    30
+ 16 | batch insert test data16 |    32
+ 17 | batch insert test data17 |    34
+ 18 | batch insert test data18 |    36
+ 19 | batch insert test data19 |    38
+ 20 | batch insert test data20 |    40
+(20 rows)
+
+-- Test buffer limit of copy data on COPYBUFSIZ
+INSERT INTO ft10 (id, t)
+SELECT s, repeat(md5(s::text), 10000) from generate_series(100, 103) s;
+SELECT COUNT(*) FROM ft10;
+ count 
+-------
+     4
+(1 row)
+
+-- Disable the use_copy_for_insert table option and check that the INSERT is
+-- used
+ALTER FOREIGN TABLE ft8 OPTIONS(DROP use_copy_for_insert);
+EXPLAIN(VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF)
+INSERT INTO ft8 VALUES (10);
+                      QUERY PLAN                      
+------------------------------------------------------
+ Insert on public.ft8
+   Remote SQL: INSERT INTO "S 1"."T 5"(x) VALUES ($1)
+   Batch Size: 10
+   ->  Result
+         Output: NULL::integer, 10
+(5 rows)
+
+-- Enable the use_copy_for_insert for the foreign server and check that the
+-- COPY is used
+ALTER SERVER loopback OPTIONS(ADD use_copy_for_insert 'true');
+EXPLAIN(VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF)
+INSERT INTO ft8 VALUES (20);
+                  QUERY PLAN                  
+----------------------------------------------
+ Insert on public.ft8
+   Remote SQL: COPY "S 1"."T 5"(x) FROM STDIN
+   Batch Size: 10
+   ->  Result
+         Output: NULL::integer, 20
+(5 rows)
+
+-- Check that COPY work correctly for a foreign table that has
+-- use_copy_for_insert enabled
+COPY ft8(x) FROM stdin;
+-- Reset state
+ALTER SERVER loopback OPTIONS(DROP use_copy_for_insert);
+ALTER FOREIGN TABLE ft8 OPTIONS(ADD use_copy_for_insert 'true');
+-- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
 -- Disable debug_discard_caches in order to manage remote connections
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index 04788b7e8b3..de0f59332c3 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -263,6 +263,9 @@ InitPgFdwOptions(void)
 		/* batch_size is available on both server and table */
 		{"batch_size", ForeignServerRelationId, false},
 		{"batch_size", ForeignTableRelationId, false},
+		/* use_copy_for_insert is available on both server and table */
+		{"use_copy_for_insert", ForeignServerRelationId, false},
+		{"use_copy_for_insert", ForeignTableRelationId, false},
 		/* async_capable is available on both server and table */
 		{"async_capable", ForeignServerRelationId, false},
 		{"async_capable", ForeignTableRelationId, false},
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 06b52c65300..64174727d09 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -63,6 +63,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.
  *
@@ -197,6 +200,7 @@ typedef struct PgFdwModifyState
 	int			batch_size;		/* value of FDW option "batch_size" */
 	bool		has_returning;	/* is there a RETURNING clause? */
 	List	   *retrieved_attrs;	/* attr numbers retrieved by RETURNING */
+	bool		use_copy_for_insert;	/* is the COPY enabled for INSERT's? */
 
 	/* info about parameters for prepared statement */
 	AttrNumber	ctidAttno;		/* attnum of input resjunk ctid column */
@@ -545,6 +549,10 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_o,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
+static bool get_use_copy_for_insert(Relation rel);
+static TupleTableSlot **execute_foreign_insert_using_copy(PgFdwModifyState *fmstate,
+														  TupleTableSlot **slots,
+														  int *numSlots);
 
 
 /*
@@ -1788,6 +1796,7 @@ postgresPlanForeignModify(PlannerInfo *root,
 	List	   *retrieved_attrs = NIL;
 	bool		doNothing = false;
 	int			values_end_len = -1;
+	bool		use_copy_for_insert = false;
 
 	initStringInfo(&sql);
 
@@ -1867,17 +1876,30 @@ postgresPlanForeignModify(PlannerInfo *root,
 		elog(ERROR, "unexpected ON CONFLICT specification: %d",
 			 (int) plan->onConflictAction);
 
+	if (operation == CMD_INSERT && plan->returningLists == NULL)
+	{
+		int			batch_size = get_batch_size_option(rel);
+
+		if (batch_size > 1)
+			use_copy_for_insert = get_use_copy_for_insert(rel);
+	}
+
 	/*
 	 * Construct the SQL command string.
 	 */
 	switch (operation)
 	{
 		case CMD_INSERT:
-			deparseInsertSql(&sql, rte, resultRelation, rel,
-							 targetAttrs, doNothing,
-							 withCheckOptionList, returningList,
-							 &retrieved_attrs, &values_end_len);
-			break;
+			{
+				if (use_copy_for_insert)
+					deparseCopySql(&sql, rel, targetAttrs);
+				else
+					deparseInsertSql(&sql, rte, resultRelation, rel,
+									 targetAttrs, doNothing,
+									 withCheckOptionList, returningList,
+									 &retrieved_attrs, &values_end_len);
+				break;
+			}
 		case CMD_UPDATE:
 			deparseUpdateSql(&sql, rte, resultRelation, rel,
 							 targetAttrs,
@@ -1925,6 +1947,7 @@ postgresBeginForeignModify(ModifyTableState *mtstate,
 	int			values_end_len;
 	List	   *retrieved_attrs;
 	RangeTblEntry *rte;
+	Relation	rel = resultRelInfo->ri_RelationDesc;
 
 	/*
 	 * Do nothing in EXPLAIN (no ANALYZE) case.  resultRelInfo->ri_FdwState
@@ -1961,6 +1984,10 @@ postgresBeginForeignModify(ModifyTableState *mtstate,
 									has_returning,
 									retrieved_attrs);
 
+	/* Can COPY be used for batch insert? */
+	if (mtstate->operation == CMD_INSERT && !fmstate->has_returning && fmstate->batch_size > 1)
+		fmstate->use_copy_for_insert = get_use_copy_for_insert(rel);
+
 	resultRelInfo->ri_FdwState = fmstate;
 }
 
@@ -4066,6 +4093,50 @@ create_foreign_modify(EState *estate,
 	return fmstate;
 }
 
+/*
+ *  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);
+
+			appendStringInfoString(buf, value);
+		}
+		i++;
+	}
+
+	appendStringInfoCharMacro(buf, '\n');
+}
+
 /*
  * execute_foreign_modify
  *		Perform foreign-table modification as required, and fetch RETURNING
@@ -4097,6 +4168,14 @@ execute_foreign_modify(EState *estate,
 	if (fmstate->conn_state->pendingAreq)
 		process_pending_request(fmstate->conn_state->pendingAreq);
 
+	/* Check if the COPY command is enabled to use for INSERT's */
+	if (operation == CMD_INSERT && fmstate->use_copy_for_insert)
+	{
+		/* COPY should only be used with INSERT without RETURNING clause. */
+		Assert(!fmstate->has_returning);
+		return execute_foreign_insert_using_copy(fmstate, slots, numSlots);
+	}
+
 	/*
 	 * If the existing query was deparsed and prepared for a different number
 	 * of rows, rebuild it for the proper number.
@@ -7886,3 +7965,112 @@ get_batch_size_option(Relation rel)
 
 	return batch_size;
 }
+
+/*
+ * Determine if the usage of the COPY command to execute a INSERT into a foreign
+ * table is enabled. The option specified for a table has precedence.
+ */
+static bool
+get_use_copy_for_insert(Relation rel)
+{
+	Oid			foreigntableid = RelationGetRelid(rel);
+	List	   *options = NIL;
+	ListCell   *lc;
+	ForeignTable *table;
+	ForeignServer *server;
+	bool		enable_batch_with_copy = false;
+
+	/*
+	 * Load options for table and server. We append server options after table
+	 * options, because table options take precedence.
+	 */
+	table = GetForeignTable(foreigntableid);
+	server = GetForeignServer(table->serverid);
+
+	options = list_concat(options, table->options);
+	options = list_concat(options, server->options);
+
+	/* See if either table or server specifies enable_batch_with_copy. */
+	foreach(lc, options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "use_copy_for_insert") == 0)
+		{
+			(void) parse_bool(defGetString(def), &enable_batch_with_copy);
+			break;
+		}
+	}
+	return enable_batch_with_copy;
+}
+
+/* Execute an insert into a foreign table using the COPY command */
+static TupleTableSlot **
+execute_foreign_insert_using_copy(PgFdwModifyState *fmstate,
+								  TupleTableSlot **slots,
+								  int *numSlots)
+{
+	PGresult   *res;
+	StringInfoData copy_data;
+	int			n_rows;
+	int			i;
+
+	/* Send COPY command */
+	if (!PQsendQuery(fmstate->conn, fmstate->query))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->query);
+
+	/* get the COPY result */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COPY_IN)
+		pgfdw_report_error(res, fmstate->conn, fmstate->query);
+
+	/* Convert the TupleTableSlot data into a TEXT-formatted line */
+	initStringInfo(&copy_data);
+	for (i = 0; i < *numSlots; i++)
+	{
+		convert_slot_to_copy_text(&copy_data, fmstate, slots[i]);
+
+		/*
+		 * Send initial COPY data if the buffer reach the limit to avoid large
+		 * memory usage.
+		 */
+		if (copy_data.len >= COPYBUFSIZ)
+		{
+			if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+				pgfdw_report_error(NULL, fmstate->conn, fmstate->query);
+			resetStringInfo(&copy_data);
+		}
+	}
+
+	/* Send the remaining COPY data */
+	if (copy_data.len > 0)
+	{
+		if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+			pgfdw_report_error(NULL, fmstate->conn, fmstate->query);
+	}
+
+	/* End the COPY operation */
+	if (PQputCopyEnd(fmstate->conn, NULL) < 0 || PQflush(fmstate->conn))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->query);
+
+	/*
+	 * Get the result, and check for success.
+	 */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pgfdw_report_error(res, fmstate->conn, fmstate->query);
+
+	n_rows = atoi(PQcmdTuples(res));
+
+	/* And clean up */
+	PQclear(res);
+
+	MemoryContextReset(fmstate->temp_cxt);
+
+	*numSlots = n_rows;
+
+	/*
+	 * Return NULL if nothing was inserted on the remote end
+	 */
+	return (n_rows > 0) ? slots : NULL;
+}
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index e69735298d7..aa54d6bba53 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -204,6 +204,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 9a8f9e28135..0c483cd49a5 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -54,6 +54,18 @@ CREATE TABLE "S 1"."T 4" (
 	c3 text,
 	CONSTRAINT t4_pkey PRIMARY KEY (c1)
 );
+CREATE TABLE "S 1"."T 5"(
+    x int
+);
+CREATE TABLE "S 1"."T 6"(
+    id int not null,
+    note text,
+    value int NOT NULL
+);
+CREATE TABLE "S 1"."T 7"(
+    id int,
+    t text
+);
 
 -- Disable autovacuum for these tables to avoid unexpected effects of that
 ALTER TABLE "S 1"."T 1" SET (autovacuum_enabled = 'false');
@@ -146,6 +158,27 @@ CREATE FOREIGN TABLE ft7 (
 	c3 text
 ) SERVER loopback3 OPTIONS (schema_name 'S 1', table_name 'T 4');
 
+CREATE FOREIGN TABLE ft8 (
+    x int
+)
+SERVER loopback
+OPTIONS (schema_name 'S 1', table_name 'T 5', use_copy_for_insert 'true', batch_size '10');
+
+CREATE FOREIGN TABLE ft9 (
+    id int not null,
+    note text,
+    value int NOT NULL
+)
+SERVER loopback
+OPTIONS (schema_name 'S 1', table_name 'T 6', use_copy_for_insert 'true', batch_size '10');
+
+CREATE FOREIGN TABLE ft10 (
+    id int,
+    t text
+)
+SERVER loopback
+OPTIONS (schema_name 'S 1', table_name 'T 7', use_copy_for_insert 'true', batch_size '10');
+
 -- ===================================================================
 -- tests for validator
 -- ===================================================================
@@ -4379,6 +4412,52 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 
+-- ===================================================================
+-- test for COPY usage to perform INSERT's
+-- ===================================================================
+
+-- Test that target attr is correctly used to build the COPY command
+ALTER FOREIGN TABLE ft8 DROP COLUMN x;
+ALTER FOREIGN TABLE ft8 add COLUMN x int;
+EXPLAIN(ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF)
+INSERT INTO ft8 SELECT * FROM generate_series(1, 10) i;
+SELECT * FROM ft8;
+
+-- Test outer of order columns
+EXPLAIN(ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF) INSERT INTO ft9 (id, value, note)
+SELECT g,
+       g * 2,
+       'batch insert test data' || g
+FROM generate_series(1, 20) g;
+SELECT * FROM ft9;
+
+-- Test buffer limit of copy data on COPYBUFSIZ
+INSERT INTO ft10 (id, t)
+SELECT s, repeat(md5(s::text), 10000) from generate_series(100, 103) s;
+SELECT COUNT(*) FROM ft10;
+
+-- Disable the use_copy_for_insert table option and check that the INSERT is
+-- used
+ALTER FOREIGN TABLE ft8 OPTIONS(DROP use_copy_for_insert);
+EXPLAIN(VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF)
+INSERT INTO ft8 VALUES (10);
+
+-- Enable the use_copy_for_insert for the foreign server and check that the
+-- COPY is used
+ALTER SERVER loopback OPTIONS(ADD use_copy_for_insert 'true');
+EXPLAIN(VERBOSE, COSTS OFF, SUMMARY OFF, BUFFERS OFF, TIMING OFF)
+INSERT INTO ft8 VALUES (20);
+
+-- Check that COPY work correctly for a foreign table that has
+-- use_copy_for_insert enabled
+COPY ft8(x) FROM stdin;
+30
+\.
+
+-- Reset state
+ALTER SERVER loopback OPTIONS(DROP use_copy_for_insert);
+ALTER FOREIGN TABLE ft8 OPTIONS(ADD use_copy_for_insert 'true');
+
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
-- 
2.51.2

#16Masahiko Sawada
sawada.mshk@gmail.com
In reply to: Matheus Alcantara (#15)
Re: postgres_fdw: Use COPY to speed up batch inserts

On Tue, Nov 18, 2025 at 2:13 PM Matheus Alcantara
<matheusssilv97@gmail.com> wrote:

On Mon Nov 17, 2025 at 11:03 PM -03, Masahiko Sawada wrote:

IIUC the performance regression occurs when users insert many rows
into a foreign table with batch_size = 1 and use_copy_for_insert =
true (tps: 133.451518 vs. 132.644801 vs. 88.998804). Since batch_size
defaults to 1, users might experience performance issues if they
enable use_copy_for_insert without adjusting the batch_size. I'm
worried that users could easily find themselves in this situation.

Yes, you are correct. The 0002 patch aims to reduce this issue by using
the COPY command only if the use_copy_for_insert = true and if
batch_size > 1 which will reduce the cases but the regression can still
happen if the user send a single row to insert into a foreign table.

Inserting a single row into a foreign table using COPY is a bit slower
compared with using INSERT. See the followinw pgbench results:

(Single row using INSERT)
tps = 19814.535944

(Single row using COPY)
tps = 16562.324025

I think that the documentation should mention that just changing
use_copy_for_insert without also changing the batch_size option could
cause performance regression.

One possible solution would be to introduce a threshold, like
copy_min_row, which would specify the minimum number of rows needed
before switching to the COPY command. However, this would require
coordination with batch_size since having copy_min_row lower than
batch_size wouldn't make sense.

The only problem that I see with this approach is that it would make
EXPLAIN(VERBOSE) and EXPLAIN(ANALYZE, VERBOSE) remote SQL output
different. The user will never know with EXPLAIN (without analyze) if
the COPY will be used or not. Is this a problem or I'm being to much
conservative?

I think that's a valid concern. Is it a good idea to show both queries
with some additional information (e.g., threshold to switch using COPY
command)?

I think that we can do such coordination on postgres_fdw_validator().

Also if we decide to go with this idea it seems to me that we would have
to much table options to configure to enable the COPY opitimization, we
would need "copy_min_row", "batch_size" and "use_copy_for_insert". What
about decide to use the COPY command if use_copy_for_insert = true and
the number of rows being inserted is >= batch_size?

Sounds like a reasonable idea.

Alternatively, when users are using batch insertion (batch_size > 0),
we could use the COPY command only for full batches and fall back to
INSERT for partial ones.

IIUC in this case we would sent COPY and INSERT statements to the
foreign server for the same execution, for example, if batch_size = 100
and the user try insert 105 rows into the foreign table we will send a
COPY statement with 100 rows and then an INSERT with the 5 rows
remaining? If that's the case which SQL we should show on Remote SQL
from EXPLAIN(ANALYZE, VERBOSE) output? I think that this can cause some
confusion.

BTW I noticed that use_copy_for_insert option doesn't work with COPY
FROM command. I got the following error with use_copy_for_insert=true
and batch_size=3:

postgres(1:2546195)=# copy t from '/tmp/a.csv'; -- table 't' is a foreign table.
ERROR: there is no parameter $1
CONTEXT: remote SQL command: INSERT INTO public.t(c) VALUES ($1)
COPY t

Thanks for testing this case. The problem was that I as checking if the
COPY can be used inside create_foreign_modify() that is called by
BeginForeignInsert and also BeginForeignModify() and the COPY can be
used only by the foreign modify path. To fix this issue I've moved the
check to postgresBeginForeignModify().

I'm attaching v6 with the following changes:
- I've squashed 0002 into 0001, so now the COPY will only be used if
use_copy_for_insert = true and if batch_size > 1
- Fix for the bug of COPY FROM a foreign table
- New test case for the COPY bug

Thank you for updating the patch!

I think one key point in the patch is whether or not it's okay to
switch using COPY based on the actual number of tuples inserted. While
it should be okay from the performance perspective, it might be an
issue that the remote query shown in EXPLAIN (without ANALYZE) might
be different from the actual query sent. If there is a way to
distinguish the batch insertion between INSERT and COPY in
postgres_fdw, it might be a good idea to use COPY command for the
remote query only when the COPY FROM comes.

Regards,

--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com

#17Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Masahiko Sawada (#16)
1 attachment(s)
Re: postgres_fdw: Use COPY to speed up batch inserts

On Wed Nov 19, 2025 at 8:32 PM -03, Masahiko Sawada wrote:

I think one key point in the patch is whether or not it's okay to
switch using COPY based on the actual number of tuples inserted. While
it should be okay from the performance perspective, it might be an
issue that the remote query shown in EXPLAIN (without ANALYZE) might
be different from the actual query sent. If there is a way to
distinguish the batch insertion between INSERT and COPY in
postgres_fdw, it might be a good idea to use COPY command for the
remote query only when the COPY FROM comes.

Yeah, I agree that this EXPLAIN inconsistency is an issue that for now
it doesn't seems easy to fix. That being said I've take a step back and
tried to reduce the scope of the patch to implement this idea of using
COPY as a remote sql when the user is executing a COPY FROM on a foreign
table.

My initial idea was to use the COPY as a remote SQL whenever an user
execute a COPY FROM on a foreign table but this can cause breaking
changes because the table on the foreign server may have triggers for
INSERT's and changing to use only the COPY FROM as remote sql would
break these cases. We have some test cases for this scenario.

So on this new version I introduced two new foreign table and server
options:
- use_copy_for_batch_insert: Enable the usage of COPY when
appropriate
- copy_for_batch_insert_threshold: The number of rows necessary to
switch to use the COPY command instead of an INSERT.

I think that the threshold option is necessary because it can be
configured for a different value than batch_size option based on the
user needs. The default value is 1, so once "use_copy_for_batch_insert"
is set to true, the COPY will start to be used. Note that this option is
set to false by default.

Speeking about the implementation, the CopyFrom() calls
BeginForeignInsert() fdw routine. The postgres_fdw implementation of
this routine create the PgFdwModifyState that is used by
execute_foreign_modify() so I thought that it could be a good idea to
get the table options of COPY usage on this function and save it on
PgFdwModifyState struct and when execute_foreign_modify() is executed we
can just access the table options previously stored and check if the
COPY can be actually be used based on the number of tuples being batch
inserted.

The BeginForeignInsert() routine is also called when inserting tuples
into table partitions, so saving the COPY usage options on this stage
can make it possible to use the COPY command to speed up batch inserts
into partition tables that are postgres_fdw tables. I'm not sure if we
should keep the patch scope only for COPY FROM on foreign table but I
don't see any issue of using the COPY to speed up batch inserts of
postgres_fdw table partitions too since we don't expose the remote sql
being used on this case, and benchmarks shows that we can have a good
performance improvement.

I've implemented this idea on the attached v7 and here it is some
benchmarks that I've run.

Scenario: COPY FROM <fdw_table>
use_copy_for_batch_insert = false
rows being inserted = 100
batch_size = 100
copy_for_batch_insert_threshold = 50
tps = 6500.133253

Scenario: COPY FROM <fdw_table>
use_copy_for_batch_insert = true
rows being inserted = 100
batch_size = 100
copy_for_batch_insert_threshold = 50
tps = 13116.474292

Scenario: COPY FROM <fdw_table>
use_copy_for_batch_insert = false
rows being inserted = 140
batch_size = 100
copy_for_batch_insert_threshold = 50
tps = 4654.865032

Scenario: COPY FROM <fdw_table>
use_copy_for_batch_insert = true
rows being inserted = 140
batch_size = 100
copy_for_batch_insert_threshold = 50
tps = 7441.694325

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

Scenario: INSERT INTO <partitioned_table>
use_copy_for_batch_insert = false
rows being inserted per partition = 100
number of partitions: 3
tps = 3176.872369

Scenario: INSERT INTO <partitioned_table>
use_copy_for_batch_insert = true
rows being inserted per partition = 100
number of partitions: 3
tps = 6993.544958

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

Note that for the "copy_for_batch_insert_threshold = 50" and "rows being
inserted=140" the behaviour is to use the COPY for the first batch
iteration of 100 rows and then fallback to use INSERT for the 40 rows
remaining.

Summary of v7 changes:
- Introduce "use_copy_for_batch_insert" foreign server/table option
to enable the usage of COPY command
- Introduce "copy_for_batch_insert_threshold" option to use the COPY
command if the number of rows being inserted is >= of the
configured value. Default is 1.
- COPY command can only be used if the user is executing a COPY FROM
on a postgres_fdw table or an INSERT into a partitioned table that
has postgres_fdw as table partitions.
- COPY and INSERT can be used for the same execution if there is no
sufficient rows remaining (based on copy_usage_threshold) after
the first batch execution.

--
Matheus Alcantara
EDB: http://www.enterprisedb.com

Attachments:

v7-0001-postgres_fdw-speed-up-batch-inserts-using-COPY.patchtext/plain; charset=utf-8; name=v7-0001-postgres_fdw-speed-up-batch-inserts-using-COPY.patchDownload
From 17bbb0b129adc96f595e2eb4641b4b601f9fbf6b Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Wed, 26 Nov 2025 16:34:46 -0300
Subject: [PATCH v7] postgres_fdw: speed up batch inserts using COPY

Previously when the user execute a COPY into a foreign table the
statement was translated into a INSERT statement to be executed on the
foreign server. This commit introduce a new foreign table/server option
"use_copy_for_batch_insert" to enable the usage of the COPY command
instead of an INSERT. Another option "copy_for_batch_insert_threshold"
was also added to switch to use the COPY command when the number of rows
being inserted is >= than the configured value.

This logic was implement on postgresBeginForeignInsert() that is the
implementation of BeginForeignInsert() fdw routine. As this function is
also called when inserting tuples into partitions the COPY can also be
used to speed up batch inserts for table partitions that are
postgres_fdw tables.
---
 contrib/postgres_fdw/deparse.c                |  30 +++
 .../postgres_fdw/expected/postgres_fdw.out    |  13 +
 contrib/postgres_fdw/option.c                 |  13 +-
 contrib/postgres_fdw/postgres_fdw.c           | 237 +++++++++++++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   1 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  13 +
 6 files changed, 304 insertions(+), 3 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index f2fb0051843..1cdf1d8cc8d 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -2236,6 +2236,36 @@ rebuildInsertSql(StringInfo buf, Relation rel,
 	appendStringInfoString(buf, orig_query + values_end_len);
 }
 
+/*
+ *  Build a COPY FROM STDIN statement using the TEXT format
+ */
+void
+deparseCopySql(StringInfo buf, Relation rel, List *target_attrs)
+{
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	bool		first = true;
+
+	appendStringInfo(buf, "COPY ");
+	deparseRelation(buf, rel);
+	appendStringInfo(buf, "(");
+
+	foreach_int(attnum, target_attrs)
+	{
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, attnum - 1);
+
+		if (attr->attgenerated)
+			continue;
+
+		if (!first)
+			appendStringInfoString(buf, ", ");
+
+		first = false;
+
+		appendStringInfoString(buf, quote_identifier(NameStr(attr->attname)));
+	}
+	appendStringInfoString(buf, ") FROM STDIN");
+}
+
 /*
  * deparse remote UPDATE statement
  *
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index cd28126049d..9d06d9b6eb0 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -9524,6 +9524,19 @@ select * from rem2;
   2 | bar
 (2 rows)
 
+delete from rem2;
+-- Test COPY with can_use_copy = true
+alter foreign table rem2 options (add use_copy_for_batch_insert 'true', copy_for_batch_insert_threshold '2');
+-- Insert 3 rows so that the third row fallback to normal INSERT statement path
+copy rem2 from stdin;
+select * from rem2;
+ f1 | f2  
+----+-----
+  1 | foo
+  2 | bar
+  3 | baz
+(3 rows)
+
 delete from rem2;
 -- Test check constraints
 alter table loc2 add constraint loc2_f1positive check (f1 >= 0);
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index 04788b7e8b3..d56b6cc142d 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -157,7 +157,8 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 			(void) ExtractExtensionList(defGetString(def), true);
 		}
 		else if (strcmp(def->defname, "fetch_size") == 0 ||
-				 strcmp(def->defname, "batch_size") == 0)
+				 strcmp(def->defname, "batch_size") == 0 ||
+				 strcmp(def->defname, "copy_for_batch_insert_threshold") == 0)
 		{
 			char	   *value;
 			int			int_val;
@@ -263,6 +264,16 @@ InitPgFdwOptions(void)
 		/* batch_size is available on both server and table */
 		{"batch_size", ForeignServerRelationId, false},
 		{"batch_size", ForeignTableRelationId, false},
+		/* use_copy_for_batch_insert is available on both server and table */
+		{"use_copy_for_batch_insert", ForeignServerRelationId, false},
+		{"use_copy_for_batch_insert", ForeignTableRelationId, false},
+
+		/*
+		 * copy_for_batch_insert_threshold is available on both server and
+		 * table
+		 */
+		{"copy_for_batch_insert_threshold", ForeignServerRelationId, false},
+		{"copy_for_batch_insert_threshold", ForeignTableRelationId, false},
 		/* async_capable is available on both server and table */
 		{"async_capable", ForeignServerRelationId, false},
 		{"async_capable", ForeignTableRelationId, false},
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 06b52c65300..02c6312a655 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -63,6 +63,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.
  *
@@ -198,6 +201,12 @@ typedef struct PgFdwModifyState
 	bool		has_returning;	/* is there a RETURNING clause? */
 	List	   *retrieved_attrs;	/* attr numbers retrieved by RETURNING */
 
+	/* COPY usage stuff */
+	bool		use_copy_for_batch_insert;	/* COPY command is enabled to use? */
+	int			copy_for_batch_insert_threshold;	/* # of rows to switch to
+													 * use COPY */
+	bool		usingcopy;		/* is COPY being used ? */
+
 	/* info about parameters for prepared statement */
 	AttrNumber	ctidAttno;		/* attnum of input resjunk ctid column */
 	int			p_nums;			/* number of parameters to transmit */
@@ -545,6 +554,11 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_o,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
+static bool get_use_copy_for_batch_insert(Relation rel);
+static int	get_copy_for_batch_insert_threshold(Relation rel);
+static TupleTableSlot **execute_foreign_insert_using_copy(PgFdwModifyState *fmstate,
+														  TupleTableSlot **slots,
+														  int *numSlots);
 
 
 /*
@@ -2265,6 +2279,10 @@ postgresBeginForeignInsert(ModifyTableState *mtstate,
 									retrieved_attrs != NIL,
 									retrieved_attrs);
 
+	fmstate->use_copy_for_batch_insert = get_use_copy_for_batch_insert(rel);
+	if (fmstate->use_copy_for_batch_insert)
+		fmstate->copy_for_batch_insert_threshold = get_copy_for_batch_insert_threshold(rel);
+
 	/*
 	 * If the given resultRelInfo already has PgFdwModifyState set, it means
 	 * the foreign table is an UPDATE subplan result rel; in which case, store
@@ -4066,6 +4084,50 @@ create_foreign_modify(EState *estate,
 	return fmstate;
 }
 
+/*
+ *  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);
+
+			appendStringInfoString(buf, value);
+		}
+		i++;
+	}
+
+	appendStringInfoCharMacro(buf, '\n');
+}
+
 /*
  * execute_foreign_modify
  *		Perform foreign-table modification as required, and fetch RETURNING
@@ -4097,11 +4159,34 @@ execute_foreign_modify(EState *estate,
 	if (fmstate->conn_state->pendingAreq)
 		process_pending_request(fmstate->conn_state->pendingAreq);
 
+	/*
+	 * Check if the COPY command can be used to speed up inserts. The COPY
+	 * command can not be used if the original query has a RETURNING clause.
+	 */
+	if (operation == CMD_INSERT &&
+		fmstate->use_copy_for_batch_insert &&
+		!fmstate->has_returning &&
+		*numSlots >= fmstate->copy_for_batch_insert_threshold)
+	{
+
+		/* Build the COPY command if it's not already built */
+		if (!fmstate->usingcopy)
+		{
+			pfree(fmstate->query);
+			initStringInfo(&sql);
+			deparseCopySql(&sql, fmstate->rel, fmstate->target_attrs);
+			fmstate->query = sql.data;
+			fmstate->usingcopy = true;
+		}
+		return execute_foreign_insert_using_copy(fmstate, slots, numSlots);
+	}
+
 	/*
 	 * If the existing query was deparsed and prepared for a different number
-	 * of rows, rebuild it for the proper number.
+	 * of rows or if COPY was being used on the previous execution, rebuild
+	 * the INSERT statement and use the proper number.
 	 */
-	if (operation == CMD_INSERT && fmstate->num_slots != *numSlots)
+	if ((operation == CMD_INSERT && fmstate->num_slots != *numSlots) || fmstate->usingcopy)
 	{
 		/* Destroy the prepared statement created previously */
 		if (fmstate->p_name)
@@ -7886,3 +7971,151 @@ get_batch_size_option(Relation rel)
 
 	return batch_size;
 }
+
+/*
+ * Determine if the usage of the COPY command to execute a INSERT into a foreign
+ * table is enabled. The option specified for a table has precedence.
+ */
+static bool
+get_use_copy_for_batch_insert(Relation rel)
+{
+	Oid			foreigntableid = RelationGetRelid(rel);
+	List	   *options = NIL;
+	ListCell   *lc;
+	ForeignTable *table;
+	ForeignServer *server;
+	bool		can_use_copy = false;
+
+	/*
+	 * Load options for table and server. We append server options after table
+	 * options, because table options take precedence.
+	 */
+	table = GetForeignTable(foreigntableid);
+	server = GetForeignServer(table->serverid);
+
+	options = list_concat(options, table->options);
+	options = list_concat(options, server->options);
+
+	/* See if either table or server specifies enable_batch_with_copy. */
+	foreach(lc, options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "use_copy_for_batch_insert") == 0)
+		{
+			(void) parse_bool(defGetString(def), &can_use_copy);
+			break;
+		}
+	}
+	return can_use_copy;
+}
+
+static int
+get_copy_for_batch_insert_threshold(Relation rel)
+{
+	Oid			foreigntableid = RelationGetRelid(rel);
+	List	   *options = NIL;
+	ListCell   *lc;
+	ForeignTable *table;
+	ForeignServer *server;
+
+	/*
+	 * We use 1 as default, which means that COPY will be used once
+	 * "can_use_copy" is set to true.
+	 */
+	int			copy_for_batch_insert_threshold = 1;
+
+	/*
+	 * Load options for table and server. We append server options after table
+	 * options, because table options take precedence.
+	 */
+	table = GetForeignTable(foreigntableid);
+	server = GetForeignServer(table->serverid);
+
+	options = list_concat(options, table->options);
+	options = list_concat(options, server->options);
+
+	/* See if either table or server specifies enable_batch_with_copy. */
+	foreach(lc, options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "copy_for_batch_insert_threshold") == 0)
+		{
+			(void) parse_int(defGetString(def), &copy_for_batch_insert_threshold, 0, NULL);
+			break;
+		}
+	}
+	return copy_for_batch_insert_threshold;
+}
+
+/* Execute an insert into a foreign table using the COPY command */
+static TupleTableSlot **
+execute_foreign_insert_using_copy(PgFdwModifyState *fmstate,
+								  TupleTableSlot **slots,
+								  int *numSlots)
+{
+	PGresult   *res;
+	StringInfoData copy_data;
+	int			n_rows;
+	int			i;
+
+	/* Send COPY command */
+	if (!PQsendQuery(fmstate->conn, fmstate->query))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->query);
+
+	/* get the COPY result */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COPY_IN)
+		pgfdw_report_error(res, fmstate->conn, fmstate->query);
+
+	/* Convert the TupleTableSlot data into a TEXT-formatted line */
+	initStringInfo(&copy_data);
+	for (i = 0; i < *numSlots; i++)
+	{
+		convert_slot_to_copy_text(&copy_data, fmstate, slots[i]);
+
+		/*
+		 * Send initial COPY data if the buffer reach the limit to avoid large
+		 * memory usage.
+		 */
+		if (copy_data.len >= COPYBUFSIZ)
+		{
+			if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+				pgfdw_report_error(NULL, fmstate->conn, fmstate->query);
+			resetStringInfo(&copy_data);
+		}
+	}
+
+	/* Send the remaining COPY data */
+	if (copy_data.len > 0)
+	{
+		if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+			pgfdw_report_error(NULL, fmstate->conn, fmstate->query);
+	}
+
+	/* End the COPY operation */
+	if (PQputCopyEnd(fmstate->conn, NULL) < 0 || PQflush(fmstate->conn))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->query);
+
+	/*
+	 * Get the result, and check for success.
+	 */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pgfdw_report_error(res, fmstate->conn, fmstate->query);
+
+	n_rows = atoi(PQcmdTuples(res));
+
+	/* And clean up */
+	PQclear(res);
+
+	MemoryContextReset(fmstate->temp_cxt);
+
+	*numSlots = n_rows;
+
+	/*
+	 * Return NULL if nothing was inserted on the remote end
+	 */
+	return (n_rows > 0) ? slots : NULL;
+}
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index e69735298d7..aa54d6bba53 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -204,6 +204,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 9a8f9e28135..fac00c55553 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -2807,6 +2807,19 @@ select * from rem2;
 
 delete from rem2;
 
+-- Test COPY with can_use_copy = true
+alter foreign table rem2 options (add use_copy_for_batch_insert 'true', copy_for_batch_insert_threshold '2');
+
+-- Insert 3 rows so that the third row fallback to normal INSERT statement path
+copy rem2 from stdin;
+1	foo
+2	bar
+3	baz
+\.
+select * from rem2;
+
+delete 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);
-- 
2.51.2

#18Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Matheus Alcantara (#17)
1 attachment(s)
Re: postgres_fdw: Use COPY to speed up batch inserts

I've spent some more time on this patch cleaning up some things and
trying to simplify some things.

I've renamed "copy_for_batch_insert_threshold" to
"batch_with_copy_threshold" and removed the boolean option
"use_copy_for_batch_insert", so now to enable the COPY usage for batch
inserts it only need to set batch_with_copy_threshold to a number
greater than 0.

Also the COPY can only be used if batching is also enabled (batch_size >
1) and it will only be used for the COPY FROM on a foreign table and for
inserts into table partitions that are also foreign tables.

--
Matheus Alcantara
EDB: http://www.enterprisedb.com

Attachments:

v8-0001-postgres_fdw-speed-up-batch-inserts-using-COPY.patchtext/plain; charset=utf-8; name=v8-0001-postgres_fdw-speed-up-batch-inserts-using-COPY.patchDownload
From dead99e8a2db663df8676f1caca1d834e19ca076 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Wed, 26 Nov 2025 16:34:46 -0300
Subject: [PATCH v8] postgres_fdw: speed up batch inserts using COPY

This commit include a new foreign table/server option
"batch_with_copy_threshold" that enable the usage of the COPY command to
speed up batch inserts when a COPY FROM or an insert into a table
partition that is a foreign table is executed. In both cases the
BeginForeignInsert fdw routine is called, so this new option is
retrieved only on this routine. For the other cases that use the
ForeignModify routines still use the INSERT as a remote SQL.

Note that the COPY will only be used for batch inserts and only if the
current number of rows being inserted on the batch operation is >=
batch_with_copy_threshold. If batch_size=100, batch_with_copy_threshold=50
and number of rows being inserted is 120 the first 100 rows will be
inserted using the COPY command and the remaining 20 rows will be
inserted using INSERT statement because it did not reach the copy
threshold.
---
 contrib/postgres_fdw/deparse.c                |  35 +++
 .../postgres_fdw/expected/postgres_fdw.out    |  26 +++
 contrib/postgres_fdw/option.c                 |   6 +-
 contrib/postgres_fdw/postgres_fdw.c           | 210 +++++++++++++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   1 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  23 ++
 6 files changed, 298 insertions(+), 3 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index f2fb0051843..54e821f6bf5 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -2236,6 +2236,41 @@ rebuildInsertSql(StringInfo buf, Relation rel,
 	appendStringInfoString(buf, orig_query + values_end_len);
 }
 
+/*
+ *  Build a COPY FROM STDIN statement using the TEXT format
+ */
+void
+deparseCopySql(StringInfo buf, Relation rel, List *target_attrs)
+{
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	bool		first = true;
+	int			nattrs = list_length(target_attrs);
+
+	appendStringInfo(buf, "COPY ");
+	deparseRelation(buf, rel);
+	if (nattrs > 0)
+		appendStringInfo(buf, "(");
+
+	foreach_int(attnum, target_attrs)
+	{
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, attnum - 1);
+
+		if (attr->attgenerated)
+			continue;
+
+		if (!first)
+			appendStringInfoString(buf, ", ");
+
+		first = false;
+
+		appendStringInfoString(buf, quote_identifier(NameStr(attr->attname)));
+	}
+	if (nattrs > 0)
+		appendStringInfoString(buf, ") FROM STDIN");
+	else
+		appendStringInfoString(buf, " FROM STDIN");
+}
+
 /*
  * deparse remote UPDATE statement
  *
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 48e3185b227..ffba243dece 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -9215,6 +9215,19 @@ with result as (insert into itrtest values (1, 'test1'), (2, 'test2') returning
 
 drop trigger loct1_br_insert_trigger on loct1;
 drop trigger loct2_br_insert_trigger on loct2;
+-- Test batch insert using COPY with batch_with_copy_threshold
+delete from itrtest;
+alter server loopback options (add batch_with_copy_threshold '2', batch_size '3');
+insert into itrtest values (1, 'test1'), (2, 'test2'), (2, 'test3');
+select * from itrtest;
+ a |   b   
+---+-------
+ 1 | test1
+ 2 | test2
+ 2 | test3
+(3 rows)
+
+alter server loopback options (drop batch_with_copy_threshold, drop batch_size);
 drop table itrtest;
 drop table loct1;
 drop table loct2;
@@ -9524,6 +9537,19 @@ select * from rem2;
   2 | bar
 (2 rows)
 
+delete from rem2;
+-- Test COPY with batch_with_copy_threshold
+alter foreign table rem2 options (add batch_with_copy_threshold '2');
+-- Insert 3 rows so that the third row fallback to normal INSERT statement path
+copy rem2 from stdin;
+select * from rem2;
+ f1 | f2  
+----+-----
+  1 | foo
+  2 | bar
+  3 | baz
+(3 rows)
+
 delete from rem2;
 -- Test check constraints
 alter table loc2 add constraint loc2_f1positive check (f1 >= 0);
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index 04788b7e8b3..d2696206e75 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -157,7 +157,8 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 			(void) ExtractExtensionList(defGetString(def), true);
 		}
 		else if (strcmp(def->defname, "fetch_size") == 0 ||
-				 strcmp(def->defname, "batch_size") == 0)
+				 strcmp(def->defname, "batch_size") == 0 ||
+				 strcmp(def->defname, "batch_with_copy_threshold") == 0)
 		{
 			char	   *value;
 			int			int_val;
@@ -263,6 +264,9 @@ InitPgFdwOptions(void)
 		/* batch_size is available on both server and table */
 		{"batch_size", ForeignServerRelationId, false},
 		{"batch_size", ForeignTableRelationId, false},
+		/* batch_with_copy_threshold is available on both server and table */
+		{"batch_with_copy_threshold", ForeignServerRelationId, false},
+		{"batch_with_copy_threshold", ForeignTableRelationId, false},
 		/* async_capable is available on both server and table */
 		{"async_capable", ForeignServerRelationId, false},
 		{"async_capable", ForeignTableRelationId, false},
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 06b52c65300..7896760d51a 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -63,6 +63,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.
  *
@@ -198,6 +201,10 @@ typedef struct PgFdwModifyState
 	bool		has_returning;	/* is there a RETURNING clause? */
 	List	   *retrieved_attrs;	/* attr numbers retrieved by RETURNING */
 
+	/* COPY usage stuff */
+	int			batch_with_copy_threshold;	/* value of FDW option */
+	char	   *cmd_copy;		/* COPY statement */
+
 	/* info about parameters for prepared statement */
 	AttrNumber	ctidAttno;		/* attnum of input resjunk ctid column */
 	int			p_nums;			/* number of parameters to transmit */
@@ -545,6 +552,10 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_o,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
+static int	get_batch_with_copy_threshold(Relation rel);
+static TupleTableSlot **execute_foreign_modify_using_copy(PgFdwModifyState *fmstate,
+														  TupleTableSlot **slots,
+														  int *numSlots);
 
 
 /*
@@ -2013,8 +2024,30 @@ postgresExecForeignBatchInsert(EState *estate,
 	 */
 	if (fmstate->aux_fmstate)
 		resultRelInfo->ri_FdwState = fmstate->aux_fmstate;
-	rslot = execute_foreign_modify(estate, resultRelInfo, CMD_INSERT,
-								   slots, planSlots, numSlots);
+
+	/*
+	 * Check if "batch_with_copy_threshold" is enable (> 0) and if the COPY
+	 * can be used based on the number of rows being inserted on this batch.
+	 * The original query also should not have a RETURNING clause.
+	 */
+	if (fmstate->batch_with_copy_threshold > 0 &&
+		fmstate->batch_with_copy_threshold <= *numSlots &&
+		!fmstate->has_returning)
+	{
+		if (fmstate->cmd_copy == NULL)
+		{
+			StringInfoData sql;
+
+			initStringInfo(&sql);
+			deparseCopySql(&sql, fmstate->rel, fmstate->target_attrs);
+			fmstate->cmd_copy = sql.data;
+		}
+
+		rslot = execute_foreign_modify_using_copy(fmstate, slots, numSlots);
+	}
+	else
+		rslot = execute_foreign_modify(estate, resultRelInfo, CMD_INSERT,
+									   slots, planSlots, numSlots);
 	/* Revert that change */
 	if (fmstate->aux_fmstate)
 		resultRelInfo->ri_FdwState = fmstate;
@@ -2265,6 +2298,16 @@ postgresBeginForeignInsert(ModifyTableState *mtstate,
 									retrieved_attrs != NIL,
 									retrieved_attrs);
 
+
+	/*
+	 * Set batch_with_copy_threshold from foreign server/table options. We do
+	 * this outside of create_foreign_modify() because we only want to use
+	 * COPY as a remote SQL when a COPY FROM on a foreign table is executed or
+	 * an insert is being performed on a table partition. In both cases the
+	 * BeginForeignInsert fdw routine is called.
+	 */
+	fmstate->batch_with_copy_threshold = get_batch_with_copy_threshold(rel);
+
 	/*
 	 * If the given resultRelInfo already has PgFdwModifyState set, it means
 	 * the foreign table is an UPDATE subplan result rel; in which case, store
@@ -4066,6 +4109,50 @@ create_foreign_modify(EState *estate,
 	return fmstate;
 }
 
+/*
+ *  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);
+
+			appendStringInfoString(buf, value);
+		}
+		i++;
+	}
+
+	appendStringInfoCharMacro(buf, '\n');
+}
+
 /*
  * execute_foreign_modify
  *		Perform foreign-table modification as required, and fetch RETURNING
@@ -7886,3 +7973,122 @@ get_batch_size_option(Relation rel)
 
 	return batch_size;
 }
+
+/*
+ * Determine COPY usage threshold for batching inserts for a given foreign
+ * table. The option specified for a table has precedence.
+ */
+static int
+get_batch_with_copy_threshold(Relation rel)
+{
+	Oid			foreigntableid = RelationGetRelid(rel);
+	List	   *options = NIL;
+	ListCell   *lc;
+	ForeignTable *table;
+	ForeignServer *server;
+
+	/*
+	 * We use 0 as default, which means that COPY will not be used by default
+	 * for batching insert.
+	 */
+	int			copy_for_batch_insert_threshold = 0;
+
+	/*
+	 * Load options for table and server. We append server options after table
+	 * options, because table options take precedence.
+	 */
+	table = GetForeignTable(foreigntableid);
+	server = GetForeignServer(table->serverid);
+
+	options = list_concat(options, table->options);
+	options = list_concat(options, server->options);
+
+	/* See if either table or server specifies enable_batch_with_copy. */
+	foreach(lc, options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "batch_with_copy_threshold") == 0)
+		{
+			(void) parse_int(defGetString(def), &copy_for_batch_insert_threshold, 0, NULL);
+			break;
+		}
+	}
+	return copy_for_batch_insert_threshold;
+}
+
+/*
+ * execute_foreign_modify_using_copy
+ *		Perform foreign-table modification using the COPY command.
+ */
+static TupleTableSlot **
+execute_foreign_modify_using_copy(PgFdwModifyState *fmstate,
+								  TupleTableSlot **slots,
+								  int *numSlots)
+{
+	PGresult   *res;
+	StringInfoData copy_data;
+	int			n_rows;
+	int			i;
+
+	Assert(fmstate->cmd_copy != NULL);
+
+	/* Send COPY command */
+	if (!PQsendQuery(fmstate->conn, fmstate->cmd_copy))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->cmd_copy);
+
+	/* get the COPY result */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COPY_IN)
+		pgfdw_report_error(res, fmstate->conn, fmstate->cmd_copy);
+
+	/* Convert the TupleTableSlot data into a TEXT-formatted line */
+	initStringInfo(&copy_data);
+	for (i = 0; i < *numSlots; i++)
+	{
+		convert_slot_to_copy_text(&copy_data, fmstate, slots[i]);
+
+		/*
+		 * Send initial COPY data if the buffer reach the limit to avoid large
+		 * memory usage.
+		 */
+		if (copy_data.len >= COPYBUFSIZ)
+		{
+			if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+				pgfdw_report_error(NULL, fmstate->conn, fmstate->cmd_copy);
+			resetStringInfo(&copy_data);
+		}
+	}
+
+	/* Send the remaining COPY data */
+	if (copy_data.len > 0)
+	{
+		if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+			pgfdw_report_error(NULL, fmstate->conn, fmstate->cmd_copy);
+	}
+
+	/* End the COPY operation */
+	if (PQputCopyEnd(fmstate->conn, NULL) < 0 || PQflush(fmstate->conn))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->cmd_copy);
+
+	/*
+	 * Get the result, and check for success.
+	 */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pgfdw_report_error(res, fmstate->conn, fmstate->cmd_copy);
+
+	n_rows = atoi(PQcmdTuples(res));
+
+	/* And clean up */
+	PQclear(res);
+
+	MemoryContextReset(fmstate->temp_cxt);
+
+	*numSlots = n_rows;
+
+	/*
+	 * Return NULL if nothing was inserted on the remote end
+	 */
+	return (n_rows > 0) ? slots : NULL;
+}
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index e69735298d7..aa54d6bba53 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -204,6 +204,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 9a8f9e28135..f973ef07d80 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -2635,6 +2635,16 @@ with result as (insert into itrtest values (1, 'test1'), (2, 'test2') returning
 drop trigger loct1_br_insert_trigger on loct1;
 drop trigger loct2_br_insert_trigger on loct2;
 
+-- Test batch insert using COPY with batch_with_copy_threshold
+delete from itrtest;
+alter server loopback options (add batch_with_copy_threshold '2', batch_size '3');
+
+insert into itrtest values (1, 'test1'), (2, 'test2'), (2, 'test3');
+
+select * from itrtest;
+
+alter server loopback options (drop batch_with_copy_threshold, drop batch_size);
+
 drop table itrtest;
 drop table loct1;
 drop table loct2;
@@ -2807,6 +2817,19 @@ select * from rem2;
 
 delete from rem2;
 
+-- Test COPY with batch_with_copy_threshold
+alter foreign table rem2 options (add batch_with_copy_threshold '2');
+
+-- Insert 3 rows so that the third row fallback to normal INSERT statement path
+copy rem2 from stdin;
+1	foo
+2	bar
+3	baz
+\.
+select * from rem2;
+
+delete 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);
-- 
2.51.2

#19Masahiko Sawada
sawada.mshk@gmail.com
In reply to: Matheus Alcantara (#18)
Re: postgres_fdw: Use COPY to speed up batch inserts

Sorry for the late reply.

On Thu, Dec 11, 2025 at 4:03 AM Matheus Alcantara
<matheusssilv97@gmail.com> wrote:

I've spent some more time on this patch cleaning up some things and
trying to simplify some things.

I've renamed "copy_for_batch_insert_threshold" to
"batch_with_copy_threshold" and removed the boolean option
"use_copy_for_batch_insert", so now to enable the COPY usage for batch
inserts it only need to set batch_with_copy_threshold to a number
greater than 0.

Also the COPY can only be used if batching is also enabled (batch_size >
1) and it will only be used for the COPY FROM on a foreign table and for
inserts into table partitions that are also foreign tables.

Thank you for updating the patch!

+
+   /*
+    * Set batch_with_copy_threshold from foreign server/table options. We do
+    * this outside of create_foreign_modify() because we only want to use
+    * COPY as a remote SQL when a COPY FROM on a foreign table is executed or
+    * an insert is being performed on a table partition. In both cases the
+    * BeginForeignInsert fdw routine is called.
+    */
+   fmstate->batch_with_copy_threshold = get_batch_with_copy_threshold(rel);

Does it mean that we could end up using the COPY method not only when
executing COPY FROM but also when executing INSERT with tuple
routings? If so, how does the EXPLAIN command show the remote SQL?

Regards,

--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com

#20Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Masahiko Sawada (#19)
Re: postgres_fdw: Use COPY to speed up batch inserts

On Fri Jan 2, 2026 at 5:15 PM -03, Masahiko Sawada wrote:

+
+   /*
+    * Set batch_with_copy_threshold from foreign server/table options. We do
+    * this outside of create_foreign_modify() because we only want to use
+    * COPY as a remote SQL when a COPY FROM on a foreign table is executed or
+    * an insert is being performed on a table partition. In both cases the
+    * BeginForeignInsert fdw routine is called.
+    */
+   fmstate->batch_with_copy_threshold = get_batch_with_copy_threshold(rel);

Does it mean that we could end up using the COPY method not only when
executing COPY FROM but also when executing INSERT with tuple
routings? If so, how does the EXPLAIN command show the remote SQL?

It meas that we could also use the COPY method to insert rows into a
specific table partition that is a foreign table.

Let's say that an user execute an INSERT INTO on a partitioned table
that has partitions that are postgres_fdw tables, with this patch we
could use the COPY method to insert the rows on these partitions. On
this scenario we would not have issue with EXPLAIN output because
currently we do not show the remote SQL being executed on each partition
that is involved on the INSERT statement.

If an user execute an INSERT directly into a postgres_fdw table we will
use the normal INSERT statement as we use today.

Thanks for taking a look at this.

--
Matheus Alcantara
EDB: https://www.enterprisedb.com

#21Dewei Dai
daidewei1970@163.com
In reply to: Matheus Alcantara (#1)
Re: Re: postgres_fdw: Use COPY to speed up batch inserts

Hi Matheus,
I just reviewed the v8 patch and got a few comments:

1 - in function `execute_foreign_modify_using_copy`
The `res` object obtained from the first call to `pgfdw_get_result`
is not freed, maybe you can use `PQclear` to release it

2 - in function `execute_foreign_modify_using_copy`
After using `copy_data`,it appears it can be released by
calling `destroyStringInfo`.

3 - in function `convert_slot_to_copy_text`
The value returned by the OutputFunctionCall function can be
freed by calling pfree

4 - in function `execute_foreign_modify_using_copy`
```Send initial COPY data if the buffer reach the limit to avoid large
```
Typo: reach -> reaches

Best regards,
Dewei Dai

daidewei1970@163.com

From: Matheus Alcantara
Date: 2026-01-03 04:33
To: Masahiko Sawada; Matheus Alcantara
CC: Andrew Dunstan; jian he; Tomas Vondra; pgsql-hackers@postgresql.org
Subject: Re: postgres_fdw: Use COPY to speed up batch inserts
On Fri Jan 2, 2026 at 5:15 PM -03, Masahiko Sawada wrote:

+
+   /*
+    * Set batch_with_copy_threshold from foreign server/table options. We do
+    * this outside of create_foreign_modify() because we only want to use
+    * COPY as a remote SQL when a COPY FROM on a foreign table is executed or
+    * an insert is being performed on a table partition. In both cases the
+    * BeginForeignInsert fdw routine is called.
+    */
+   fmstate->batch_with_copy_threshold = get_batch_with_copy_threshold(rel);

Does it mean that we could end up using the COPY method not only when
executing COPY FROM but also when executing INSERT with tuple
routings? If so, how does the EXPLAIN command show the remote SQL?

It meas that we could also use the COPY method to insert rows into a
specific table partition that is a foreign table.

Let's say that an user execute an INSERT INTO on a partitioned table
that has partitions that are postgres_fdw tables, with this patch we
could use the COPY method to insert the rows on these partitions. On
this scenario we would not have issue with EXPLAIN output because
currently we do not show the remote SQL being executed on each partition
that is involved on the INSERT statement.

If an user execute an INSERT directly into a postgres_fdw table we will
use the normal INSERT statement as we use today.

Thanks for taking a look at this.

--
Matheus Alcantara
EDB: https://www.enterprisedb.com

#22Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Dewei Dai (#21)
1 attachment(s)
Re: postgres_fdw: Use COPY to speed up batch inserts

Hi, thank you for reviewing this patch!

On Sat Jan 3, 2026 at 9:45 AM -03, Dewei Dai wrote:

1 - in function `execute_foreign_modify_using_copy`
The `res` object obtained from the first call to `pgfdw_get_result`
is not freed, maybe you can use `PQclear` to release it

Fixed.

2 - in function `execute_foreign_modify_using_copy`
After using `copy_data`,it appears it can be released by
calling `destroyStringInfo`.

I'm wondering if we should call destroyStringInfo(&copy_data) or
pfree(copy_data.data). Other functions use the pfree version, so I
decided to use the same.

(I actually tried to use destroyStringInfo() but the postgres_fdw tests
kept running for longer than usual, so I think that using the pfree is
correct)

3 - in function `convert_slot_to_copy_text`
The value returned by the OutputFunctionCall function can be
freed by calling pfree

We have other calls to OutputFunctionCall() on postgres_fdw.c and I'm
not seeing a subsequent call to pfree. IIUC the returned valued will be
allocated on the current memory context which will be free at the end of
query execution, so I don't think that a pfree here is necessary, or I'm
missing something?

4 - in function `execute_foreign_modify_using_copy`
```Send initial COPY data if the buffer reach the limit to avoid large
```
Typo: reach -> reaches

Fixed

--
Matheus Alcantara
EDB: https://www.enterprisedb.com

Attachments:

v9-0001-postgres_fdw-speed-up-batch-inserts-using-COPY.patchtext/plain; charset=utf-8; name=v9-0001-postgres_fdw-speed-up-batch-inserts-using-COPY.patchDownload
From f4dcd9d836137589c8345b74cb24ab3e7dc18eeb Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Wed, 26 Nov 2025 16:34:46 -0300
Subject: [PATCH v9] postgres_fdw: speed up batch inserts using COPY

This commit include a new foreign table/server option
"batch_with_copy_threshold" that enable the usage of the COPY command to
speed up batch inserts when a COPY FROM or an insert into a table
partition that is a foreign table is executed. In both cases the
BeginForeignInsert fdw routine is called, so this new option is
retrieved only on this routine. For the other cases that use the
ForeignModify routines still use the INSERT as a remote SQL.

Note that the COPY will only be used for batch inserts and only if the
current number of rows being inserted on the batch operation is >=
batch_with_copy_threshold. If batch_size=100, batch_with_copy_threshold=50
and number of rows being inserted is 120 the first 100 rows will be
inserted using the COPY command and the remaining 20 rows will be
inserted using INSERT statement because it did not reach the copy
threshold.
---
 contrib/postgres_fdw/deparse.c                |  35 +++
 .../postgres_fdw/expected/postgres_fdw.out    |  26 +++
 contrib/postgres_fdw/option.c                 |   6 +-
 contrib/postgres_fdw/postgres_fdw.c           | 215 +++++++++++++++++-
 contrib/postgres_fdw/postgres_fdw.h           |   1 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  23 ++
 6 files changed, 303 insertions(+), 3 deletions(-)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index ebe2c3a596a..78335db1889 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -2236,6 +2236,41 @@ rebuildInsertSql(StringInfo buf, Relation rel,
 	appendStringInfoString(buf, orig_query + values_end_len);
 }
 
+/*
+ *  Build a COPY FROM STDIN statement using the TEXT format
+ */
+void
+deparseCopySql(StringInfo buf, Relation rel, List *target_attrs)
+{
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	bool		first = true;
+	int			nattrs = list_length(target_attrs);
+
+	appendStringInfo(buf, "COPY ");
+	deparseRelation(buf, rel);
+	if (nattrs > 0)
+		appendStringInfo(buf, "(");
+
+	foreach_int(attnum, target_attrs)
+	{
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, attnum - 1);
+
+		if (attr->attgenerated)
+			continue;
+
+		if (!first)
+			appendStringInfoString(buf, ", ");
+
+		first = false;
+
+		appendStringInfoString(buf, quote_identifier(NameStr(attr->attname)));
+	}
+	if (nattrs > 0)
+		appendStringInfoString(buf, ") FROM STDIN");
+	else
+		appendStringInfoString(buf, " FROM STDIN");
+}
+
 /*
  * deparse remote UPDATE statement
  *
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 6066510c7c0..8bbc27b7b3b 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -9215,6 +9215,19 @@ with result as (insert into itrtest values (1, 'test1'), (2, 'test2') returning
 
 drop trigger loct1_br_insert_trigger on loct1;
 drop trigger loct2_br_insert_trigger on loct2;
+-- Test batch insert using COPY with batch_with_copy_threshold
+delete from itrtest;
+alter server loopback options (add batch_with_copy_threshold '2', batch_size '3');
+insert into itrtest values (1, 'test1'), (2, 'test2'), (2, 'test3');
+select * from itrtest;
+ a |   b   
+---+-------
+ 1 | test1
+ 2 | test2
+ 2 | test3
+(3 rows)
+
+alter server loopback options (drop batch_with_copy_threshold, drop batch_size);
 drop table itrtest;
 drop table loct1;
 drop table loct2;
@@ -9524,6 +9537,19 @@ select * from rem2;
   2 | bar
 (2 rows)
 
+delete from rem2;
+-- Test COPY with batch_with_copy_threshold
+alter foreign table rem2 options (add batch_with_copy_threshold '2');
+-- Insert 3 rows so that the third row fallback to normal INSERT statement path
+copy rem2 from stdin;
+select * from rem2;
+ f1 | f2  
+----+-----
+  1 | foo
+  2 | bar
+  3 | baz
+(3 rows)
+
 delete from rem2;
 -- Test check constraints
 alter table loc2 add constraint loc2_f1positive check (f1 >= 0);
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index b0bd72d1e58..4545c2f9ba1 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -157,7 +157,8 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 			(void) ExtractExtensionList(defGetString(def), true);
 		}
 		else if (strcmp(def->defname, "fetch_size") == 0 ||
-				 strcmp(def->defname, "batch_size") == 0)
+				 strcmp(def->defname, "batch_size") == 0 ||
+				 strcmp(def->defname, "batch_with_copy_threshold") == 0)
 		{
 			char	   *value;
 			int			int_val;
@@ -263,6 +264,9 @@ InitPgFdwOptions(void)
 		/* batch_size is available on both server and table */
 		{"batch_size", ForeignServerRelationId, false},
 		{"batch_size", ForeignTableRelationId, false},
+		/* batch_with_copy_threshold is available on both server and table */
+		{"batch_with_copy_threshold", ForeignServerRelationId, false},
+		{"batch_with_copy_threshold", ForeignTableRelationId, false},
 		/* async_capable is available on both server and table */
 		{"async_capable", ForeignServerRelationId, false},
 		{"async_capable", ForeignTableRelationId, false},
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 3572689e33b..2fb95167a1c 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -63,6 +63,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.
  *
@@ -198,6 +201,10 @@ typedef struct PgFdwModifyState
 	bool		has_returning;	/* is there a RETURNING clause? */
 	List	   *retrieved_attrs;	/* attr numbers retrieved by RETURNING */
 
+	/* COPY usage stuff */
+	int			batch_with_copy_threshold;	/* value of FDW option */
+	char	   *cmd_copy;		/* COPY statement */
+
 	/* info about parameters for prepared statement */
 	AttrNumber	ctidAttno;		/* attnum of input resjunk ctid column */
 	int			p_nums;			/* number of parameters to transmit */
@@ -545,6 +552,10 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_o,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
+static int	get_batch_with_copy_threshold(Relation rel);
+static TupleTableSlot **execute_foreign_modify_using_copy(PgFdwModifyState *fmstate,
+														  TupleTableSlot **slots,
+														  int *numSlots);
 
 
 /*
@@ -2013,8 +2024,30 @@ postgresExecForeignBatchInsert(EState *estate,
 	 */
 	if (fmstate->aux_fmstate)
 		resultRelInfo->ri_FdwState = fmstate->aux_fmstate;
-	rslot = execute_foreign_modify(estate, resultRelInfo, CMD_INSERT,
-								   slots, planSlots, numSlots);
+
+	/*
+	 * Check if "batch_with_copy_threshold" is enable (> 0) and if the COPY
+	 * can be used based on the number of rows being inserted on this batch.
+	 * The original query also should not have a RETURNING clause.
+	 */
+	if (fmstate->batch_with_copy_threshold > 0 &&
+		fmstate->batch_with_copy_threshold <= *numSlots &&
+		!fmstate->has_returning)
+	{
+		if (fmstate->cmd_copy == NULL)
+		{
+			StringInfoData sql;
+
+			initStringInfo(&sql);
+			deparseCopySql(&sql, fmstate->rel, fmstate->target_attrs);
+			fmstate->cmd_copy = sql.data;
+		}
+
+		rslot = execute_foreign_modify_using_copy(fmstate, slots, numSlots);
+	}
+	else
+		rslot = execute_foreign_modify(estate, resultRelInfo, CMD_INSERT,
+									   slots, planSlots, numSlots);
 	/* Revert that change */
 	if (fmstate->aux_fmstate)
 		resultRelInfo->ri_FdwState = fmstate;
@@ -2265,6 +2298,16 @@ postgresBeginForeignInsert(ModifyTableState *mtstate,
 									retrieved_attrs != NIL,
 									retrieved_attrs);
 
+
+	/*
+	 * Set batch_with_copy_threshold from foreign server/table options. We do
+	 * this outside of create_foreign_modify() because we only want to use
+	 * COPY as a remote SQL when a COPY FROM on a foreign table is executed or
+	 * an insert is being performed on a table partition. In both cases the
+	 * BeginForeignInsert fdw routine is called.
+	 */
+	fmstate->batch_with_copy_threshold = get_batch_with_copy_threshold(rel);
+
 	/*
 	 * If the given resultRelInfo already has PgFdwModifyState set, it means
 	 * the foreign table is an UPDATE subplan result rel; in which case, store
@@ -4066,6 +4109,50 @@ create_foreign_modify(EState *estate,
 	return fmstate;
 }
 
+/*
+ *  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);
+
+			appendStringInfoString(buf, value);
+		}
+		i++;
+	}
+
+	appendStringInfoCharMacro(buf, '\n');
+}
+
 /*
  * execute_foreign_modify
  *		Perform foreign-table modification as required, and fetch RETURNING
@@ -7886,3 +7973,127 @@ get_batch_size_option(Relation rel)
 
 	return batch_size;
 }
+
+/*
+ * Determine COPY usage threshold for batching inserts for a given foreign
+ * table. The option specified for a table has precedence.
+ */
+static int
+get_batch_with_copy_threshold(Relation rel)
+{
+	Oid			foreigntableid = RelationGetRelid(rel);
+	List	   *options = NIL;
+	ListCell   *lc;
+	ForeignTable *table;
+	ForeignServer *server;
+
+	/*
+	 * We use 0 as default, which means that COPY will not be used by default
+	 * for batching insert.
+	 */
+	int			copy_for_batch_insert_threshold = 0;
+
+	/*
+	 * Load options for table and server. We append server options after table
+	 * options, because table options take precedence.
+	 */
+	table = GetForeignTable(foreigntableid);
+	server = GetForeignServer(table->serverid);
+
+	options = list_concat(options, table->options);
+	options = list_concat(options, server->options);
+
+	/* See if either table or server specifies enable_batch_with_copy. */
+	foreach(lc, options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "batch_with_copy_threshold") == 0)
+		{
+			(void) parse_int(defGetString(def), &copy_for_batch_insert_threshold, 0, NULL);
+			break;
+		}
+	}
+	return copy_for_batch_insert_threshold;
+}
+
+/*
+ * execute_foreign_modify_using_copy
+ *		Perform foreign-table modification using the COPY command.
+ */
+static TupleTableSlot **
+execute_foreign_modify_using_copy(PgFdwModifyState *fmstate,
+								  TupleTableSlot **slots,
+								  int *numSlots)
+{
+	PGresult   *res;
+	StringInfoData copy_data;
+	int			n_rows;
+	int			i;
+
+	Assert(fmstate->cmd_copy != NULL);
+
+	/* Send COPY command */
+	if (!PQsendQuery(fmstate->conn, fmstate->cmd_copy))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->cmd_copy);
+
+	/* get the COPY result */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COPY_IN)
+		pgfdw_report_error(res, fmstate->conn, fmstate->cmd_copy);
+
+	/* Clean up the COPY command result */
+	PQclear(res);
+
+	/* Convert the TupleTableSlot data into a TEXT-formatted line */
+	initStringInfo(&copy_data);
+	for (i = 0; i < *numSlots; i++)
+	{
+		convert_slot_to_copy_text(&copy_data, fmstate, slots[i]);
+
+		/*
+		 * Send initial COPY data if the buffer reaches the limit to avoid
+		 * large memory usage.
+		 */
+		if (copy_data.len >= COPYBUFSIZ)
+		{
+			if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+				pgfdw_report_error(NULL, fmstate->conn, fmstate->cmd_copy);
+			resetStringInfo(&copy_data);
+		}
+	}
+
+	/* Send the remaining COPY data */
+	if (copy_data.len > 0)
+	{
+		if (PQputCopyData(fmstate->conn, copy_data.data, copy_data.len) <= 0)
+			pgfdw_report_error(NULL, fmstate->conn, fmstate->cmd_copy);
+	}
+
+	pfree(copy_data.data);
+
+	/* End the COPY operation */
+	if (PQputCopyEnd(fmstate->conn, NULL) < 0 || PQflush(fmstate->conn))
+		pgfdw_report_error(NULL, fmstate->conn, fmstate->cmd_copy);
+
+	/*
+	 * Get the result, and check for success.
+	 */
+	res = pgfdw_get_result(fmstate->conn);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pgfdw_report_error(res, fmstate->conn, fmstate->cmd_copy);
+
+	n_rows = atoi(PQcmdTuples(res));
+
+	/* And clean up */
+	PQclear(res);
+
+	MemoryContextReset(fmstate->temp_cxt);
+
+	*numSlots = n_rows;
+
+	/*
+	 * Return NULL if nothing was inserted on the remote end
+	 */
+	return (n_rows > 0) ? slots : NULL;
+}
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index a2bb1ff352c..fc6922ddd4f 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -204,6 +204,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 4f7ab2ed0ac..840e97fed2f 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -2643,6 +2643,16 @@ with result as (insert into itrtest values (1, 'test1'), (2, 'test2') returning
 drop trigger loct1_br_insert_trigger on loct1;
 drop trigger loct2_br_insert_trigger on loct2;
 
+-- Test batch insert using COPY with batch_with_copy_threshold
+delete from itrtest;
+alter server loopback options (add batch_with_copy_threshold '2', batch_size '3');
+
+insert into itrtest values (1, 'test1'), (2, 'test2'), (2, 'test3');
+
+select * from itrtest;
+
+alter server loopback options (drop batch_with_copy_threshold, drop batch_size);
+
 drop table itrtest;
 drop table loct1;
 drop table loct2;
@@ -2815,6 +2825,19 @@ select * from rem2;
 
 delete from rem2;
 
+-- Test COPY with batch_with_copy_threshold
+alter foreign table rem2 options (add batch_with_copy_threshold '2');
+
+-- Insert 3 rows so that the third row fallback to normal INSERT statement path
+copy rem2 from stdin;
+1	foo
+2	bar
+3	baz
+\.
+select * from rem2;
+
+delete 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);
-- 
2.51.2