From 3e7173fd578f75ddd387537890c039252e34c91c Mon Sep 17 00:00:00 2001
From: Junwang Zhao <zhjwpku@gmail.com>
Date: Sun, 22 Mar 2026 00:04:22 +0800
Subject: [PATCH v4] Support COPY FROM with FORMAT JSON

Bytes are read into raw_buf and optionally transcoded into
input_buf. Instead of CopyReadLine, JSON mode uses CopyReadNextJson
to fetch the next row object via a small state machine:

  - BEFORE_ARRAY: expect '[' or '{'
  - BEFORE_OBJECT: in concat mode, expect next object or EOF
  - IN_ARRAY: expect object, comma, or ']'
  - IN_OBJECT: track brace/bracket depth to find the matching row object end
  - IN_STRING / IN_STRING_ESC: skip braces inside strings
  - ARRAY_END: after ']', no more rows

When a row object closes, CopyReadNextJson records its bounds in
row_text_start and row_text_end, leaving parse_pos just after the
object. The row is parsed in place; consumed input is discarded
only when line_buf needs refilling.

NextCopyFromJsonRawFieldsInternal parses that slice using
JsonLexContext and field callbacks, and looks up each target
column by name. Values retain their original JSON text in
separately allocated raw_fields entries.

CopyFromJsonOneRow then calls CopyConvertJsonAttribute for
per-column conversion. json/jsonb columns and their domains
receive the original JSON text. For other types, strings are
unquoted and unescaped and scalars go through the input functions,
while arrays and objects are converted using json_populate_type().
The existing COPY machinery for defaults and soft errors applies
unchanged.

The JSON input path is strict JSON input and does not treat \. as a
COPY end marker; frontend COPY termination is still handled by the
protocol CopyDone message.

Discussion: https://www.postgresql.org/message-id/flat/CAEG8a3%2BwxMXGLcnrDzSF0qTDzH%2BK7_x0FWNAcu-H_j8gKiwFVw%40mail.gmail.com
---
 doc/src/sgml/ref/copy.sgml                    |  96 ++-
 src/backend/commands/copy.c                   |  14 +-
 src/backend/commands/copyfrom.c               |  90 +++
 src/backend/commands/copyfromparse.c          | 685 +++++++++++++++++-
 src/backend/commands/tablecmds.c              |   2 +-
 src/include/commands/copyfrom_internal.h      |  52 ++
 .../expected/test_copy_callbacks.out          |  83 +++
 .../sql/test_copy_callbacks.sql               |  30 +
 .../test_copy_callbacks--1.0.sql              |   5 +
 .../test_copy_callbacks/test_copy_callbacks.c |  84 +++
 src/test/regress/expected/copy.out            | 308 +++++++-
 src/test/regress/sql/copy.sql                 | 219 +++++-
 src/tools/pgindent/typedefs.list              |   6 +
 13 files changed, 1647 insertions(+), 27 deletions(-)

diff --git a/doc/src/sgml/ref/copy.sgml b/doc/src/sgml/ref/copy.sgml
index b23433b2c41..dca499e3989 100644
--- a/doc/src/sgml/ref/copy.sgml
+++ b/doc/src/sgml/ref/copy.sgml
@@ -234,10 +234,6 @@ COPY { <replaceable class="parameter">table_name</replaceable> [ ( <replaceable
       The default is <literal>text</literal>.
       See <xref linkend="sql-copy-file-formats"/> below for details.
      </para>
-     <para>
-      The <literal>json</literal> option is allowed only in
-      <command>COPY TO</command>.
-     </para>
      <note>
       <para>
        In JSON format, SQL <literal>NULL</literal> values are output as
@@ -390,7 +386,8 @@ COPY (SELECT j FROM (VALUES ('null'::json), (NULL::json)) v(j))
       Force output of square brackets as array decorations at the beginning
       and end of output, and commas between the rows. It is allowed only in
       <command>COPY TO</command>, and only when using
-      <literal>json</literal> format. The default is
+      <literal>json</literal> format. It is not supported for
+      <command>COPY FROM</command>. The default is
       <literal>false</literal>.
      </para>
     </listitem>
@@ -1121,6 +1118,85 @@ versions of <productname>PostgreSQL</productname>.
     </para>
    </refsect3>
   </refsect2>
+
+  <refsect2 id="sql-copy-json-format" xreflabel="JSON Format">
+   <title>JSON Format</title>
+
+   <refsect3 id="sql-copy-json-format-to">
+    <title><command>COPY TO</command></title>
+
+    <para>
+     By default, <command>COPY TO</command> writes one JSON object per line
+     (record), in column order, using the column names as object keys.
+     With <literal>FORCE_ARRAY</literal>, the whole output is wrapped as a
+     single JSON array: an opening <literal>[</literal>, objects separated
+     by commas (with a line break after the opening bracket, matching
+     <command>COPY TO</command> row boundaries), and a closing
+     <literal>]</literal>.
+    </para>
+   </refsect3>
+
+   <refsect3 id="sql-copy-json-format-from">
+    <title><command>COPY FROM</command></title>
+
+    <para>
+     <command>COPY FROM</command> with <literal>FORMAT JSON</literal> accepts
+     either of these shapes.
+    </para>
+
+    <itemizedlist>
+     <listitem>
+      <para>
+       A JSON <emphasis>array</emphasis> whose elements are objects, one
+       object per table row, e.g.
+       <literal>[{<replaceable>row1</replaceable>},{<replaceable>row2</replaceable>}]</literal>.
+       Standard JSON rules apply: a comma is required between array elements,
+       the array must be closed with <literal>]</literal>, and a trailing
+       comma after the last element is not allowed.
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       A stream of JSON <emphasis>objects</emphasis> concatenated back-to-back
+       (optionally separated by white space), with <emphasis>no</emphasis>
+       comma between objects, e.g.
+       <literal>{<replaceable>row1</replaceable>}{<replaceable>row2</replaceable>}</literal>.
+       This form is recognized when the document begins with
+       <literal>{</literal>.
+      </para>
+     </listitem>
+    </itemizedlist>
+
+    <para>
+     For each row object, keys are matched to the target columns by name.
+     Keys that do not correspond to a column in the <command>COPY</command>
+     column list are ignored.  Missing keys are treated as
+     <literal>NULL</literal> for the corresponding column.  When no column
+     list is given, column names are those of the table (excluding generated
+     columns).
+    </para>
+
+    <para>
+     JSON strings are unquoted and unescaped before being passed to the
+     target type's input function, except for <type>json</type> and
+     <type>jsonb</type> columns (including domains over these types), which
+     receive JSON text.  JSON arrays and objects are recursively converted
+     to SQL arrays and composite values using the same rules as
+     <function>json_populate_record</function>.  Other values are passed to
+     the target type's input function using their JSON text representation.
+     In particular, JSON booleans use <literal>true</literal> and
+     <literal>false</literal>.
+    </para>
+
+    <para>
+     Options that only apply to text or CSV line parsing (such as
+     <literal>DELIMITER</literal>, <literal>NULL</literal>, and
+     <literal>HEADER</literal>) are not supported with JSON format.
+     <literal>ON_ERROR</literal> applies to errors converting individual
+     fields to the target types; malformed JSON input always causes an error.
+    </para>
+   </refsect3>
+  </refsect2>
  </refsect1>
 
  <refsect1 id="sql-copy-examples">
@@ -1149,6 +1225,16 @@ The output is as follows:
 </screen>
 </para>
 
+  <para>
+   To load rows from a JSON file (for example one produced by
+   <command>COPY TO</command> with <literal>FORMAT JSON</literal>):
+<programlisting>
+COPY mytable FROM '/path/to/rows.json' (FORMAT JSON);
+</programlisting>
+   The file may be either a JSON array of row objects or a concatenation
+   of row objects; see <xref linkend="sql-copy-json-format-from"/>.
+  </para>
+
 
   <para>
    To copy data from a file into the <literal>country</literal> table:
diff --git a/src/backend/commands/copy.c b/src/backend/commands/copy.c
index 003b70852bb..4a1240247b8 100644
--- a/src/backend/commands/copy.c
+++ b/src/backend/commands/copy.c
@@ -989,17 +989,19 @@ ProcessCopyOptions(ParseState *pstate,
 				 errmsg("COPY %s cannot be used with %s", "FREEZE",
 						"COPY TO")));
 
-	/* Check json format */
-	if (opts_out->format == COPY_FORMAT_JSON && is_from)
-		ereport(ERROR,
-				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-				errmsg("COPY %s is not supported for %s", "FORMAT JSON", "COPY FROM"));
-
 	if (opts_out->format != COPY_FORMAT_JSON && opts_out->force_array)
 		ereport(ERROR,
 				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
 				errmsg("COPY %s can only be used with JSON mode", "FORCE_ARRAY"));
 
+	if (is_from && opts_out->format == COPY_FORMAT_JSON && opts_out->force_array)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+		/*- translator: first %s is the name of a COPY option, e.g. ON_ERROR,
+		 second %s is a COPY with direction, e.g. COPY TO */
+				 errmsg("COPY %s cannot be used with %s", "FORCE_ARRAY",
+						"COPY FROM")));
+
 	if (opts_out->default_print)
 	{
 		if (!is_from)
diff --git a/src/backend/commands/copyfrom.c b/src/backend/commands/copyfrom.c
index 746b4fdf6c3..0e5fd6c10f7 100644
--- a/src/backend/commands/copyfrom.c
+++ b/src/backend/commands/copyfrom.c
@@ -153,6 +153,19 @@ static const CopyFromRoutine CopyFromRoutineBinary = {
 	.CopyFromEnd = CopyFromBinaryEnd,
 };
 
+/* JSON format */
+static void CopyFromJsonInFunc(CopyFromState cstate, Oid atttypid, FmgrInfo *finfo,
+							   Oid *typioparam);
+static void CopyFromJsonStart(CopyFromState cstate, TupleDesc tupDesc);
+static void CopyFromJsonEnd(CopyFromState cstate);
+
+static const CopyFromRoutine CopyFromRoutineJson = {
+	.CopyFromInFunc = CopyFromJsonInFunc,
+	.CopyFromStart = CopyFromJsonStart,
+	.CopyFromOneRow = CopyFromJsonOneRow,
+	.CopyFromEnd = CopyFromJsonEnd,
+};
+
 /* Return a COPY FROM routine for the given options */
 static const CopyFromRoutine *
 CopyFromGetRoutine(const CopyFormatOptions *opts)
@@ -161,6 +174,8 @@ CopyFromGetRoutine(const CopyFormatOptions *opts)
 		return &CopyFromRoutineCSV;
 	else if (opts->format == COPY_FORMAT_BINARY)
 		return &CopyFromRoutineBinary;
+	else if (opts->format == COPY_FORMAT_JSON)
+		return &CopyFromRoutineJson;
 
 	/* default is text */
 	return &CopyFromRoutineText;
@@ -247,6 +262,81 @@ CopyFromBinaryEnd(CopyFromState cstate)
 	/* nothing to do */
 }
 
+/* Implementation of the infunc callback for JSON format */
+static void
+CopyFromJsonInFunc(CopyFromState cstate, Oid atttypid, FmgrInfo *finfo,
+				   Oid *typioparam)
+{
+	Oid			func_oid;
+
+	getTypeInputInfo(atttypid, &func_oid, typioparam);
+	fmgr_info(func_oid, finfo);
+}
+
+/* Implementation of the start callback for JSON format */
+static void
+CopyFromJsonStart(CopyFromState cstate, TupleDesc tupDesc)
+{
+	CopyFromJsonState *json_state;
+	HASHCTL		ctl = {0};
+	int			fieldno = 0;
+
+	/*
+	 * Set up input_buf for encoding conversion, same as text format.
+	 */
+	if (cstate->need_transcoding)
+	{
+		cstate->input_buf = (char *) palloc(INPUT_BUF_SIZE + 1);
+		cstate->input_buf_index = cstate->input_buf_len = 0;
+	}
+	else
+		cstate->input_buf = cstate->raw_buf;
+	cstate->input_reached_eof = false;
+
+	initStringInfo(&cstate->line_buf);
+
+	/* Store state for CopyFromJsonOneRow (JSON scan uses line_buf) */
+	json_state = palloc0_object(CopyFromJsonState);
+	/* Accept [...] or auto-detect concatenated objects {...}{...} */
+	json_state->parse_state = COPY_JSON_BEFORE_ARRAY;
+	json_state->row_text_start = -1;
+	json_state->row_text_end = -1;
+	json_state->base_types = palloc0_array(Oid, tupDesc->natts);
+	json_state->conversion_cache = palloc0_array(void *, tupDesc->natts);
+	cstate->format_private = json_state;
+
+	ctl.keysize = NAMEDATALEN;
+	ctl.entrysize = sizeof(CopyJsonAttribute);
+	ctl.hcxt = cstate->copycontext;
+	json_state->attribute_map = hash_create("COPY JSON attributes",
+											Max(1, list_length(cstate->attnumlist)),
+											&ctl, HASH_ELEM | HASH_STRINGS | HASH_CONTEXT);
+	foreach_int(attnum, cstate->attnumlist)
+	{
+		Form_pg_attribute att = TupleDescAttr(tupDesc, attnum - 1);
+		CopyJsonAttribute *entry;
+
+		entry = hash_search(json_state->attribute_map, NameStr(att->attname),
+							HASH_ENTER, NULL);
+		entry->fieldno = fieldno++;
+		json_state->base_types[attnum - 1] = getBaseType(att->atttypid);
+	}
+
+	/*
+	 * Raw JSON field strings are allocated in the per-tuple context.  Only
+	 * the array of pointers needs to survive between rows.
+	 */
+	cstate->max_fields = list_length(cstate->attnumlist);
+	cstate->raw_fields = palloc_array(char *, cstate->max_fields);
+}
+
+/* Implementation of the end callback for JSON format */
+static void
+CopyFromJsonEnd(CopyFromState cstate)
+{
+	/* format_private (CopyFromJsonState) is freed with copycontext */
+}
+
 /*
  * error context callback for COPY FROM
  *
diff --git a/src/backend/commands/copyfromparse.c b/src/backend/commands/copyfromparse.c
index 06450535779..c6b7c12548f 100644
--- a/src/backend/commands/copyfromparse.c
+++ b/src/backend/commands/copyfromparse.c
@@ -1,9 +1,9 @@
 /*-------------------------------------------------------------------------
  *
  * copyfromparse.c
- *		Parse CSV/text/binary format for COPY FROM.
+ *		Parse CSV/text/binary/JSON format for COPY FROM.
  *
- * This file contains routines to parse the text, CSV and binary input
+ * This file contains routines to parse the text, CSV, binary and JSON input
  * formats.  The main entry point is NextCopyFrom(), which parses the
  * next input line and returns it as Datums.
  *
@@ -42,6 +42,14 @@
  * but 'attribute_buf' is used as a temporary buffer to hold one attribute's
  * data when it's passed the receive function.
  *
+ * In JSON mode, steps 1--2 are the same as text mode.  CopyReadNextJson() then
+ * scans line_buf (refilled via CopyLoadInputBuf, then drained into line_buf)
+ * for the next top-level JSON object, like CopyReadLine() gathers one text
+ * line.  The row is parsed in place using row_text_start and row_text_end;
+ * parse_pos remains the scan cursor for subsequent rows.  Consumed data is
+ * discarded only when refilling the buffer.  Selected fields retain their
+ * JSON text until type conversion.
+ *
  * 'raw_buf' is always 64 kB in size (RAW_BUF_SIZE).  'input_buf' is also
  * 64 kB (INPUT_BUF_SIZE), if encoding conversion is required.  'line_buf'
  * and 'attribute_buf' are expanded on demand, to hold the longest line
@@ -62,6 +70,7 @@
 #include <unistd.h>
 #include <sys/stat.h>
 
+#include "catalog/pg_type_d.h"
 #include "commands/copyapi.h"
 #include "commands/copyfrom_internal.h"
 #include "commands/progress.h"
@@ -75,6 +84,7 @@
 #include "port/pg_bswap.h"
 #include "port/simd.h"
 #include "utils/builtins.h"
+#include "utils/jsonfuncs.h"
 #include "utils/rel.h"
 #include "utils/wait_event.h"
 
@@ -160,6 +170,9 @@ static pg_always_inline bool NextCopyFromRawFieldsInternal(CopyFromState cstate,
 														   char ***fields,
 														   int *nfields,
 														   bool is_csv);
+static bool NextCopyFromJsonRawFieldsInternal(CopyFromState cstate,
+											  char ***fields, int *nfields);
+static bool CopyReadNextJson(CopyFromState cstate);
 
 
 /* Low-level communications functions */
@@ -770,6 +783,353 @@ CopyReadBinaryData(CopyFromState cstate, char *dest, int nbytes)
 	return copied_bytes;
 }
 
+/*
+ * COPY FROM JSON: incremental scanner over line_buf.
+ * Each completed row is identified by offsets in line_buf.  Parsing that
+ * slice in place avoids copying the unparsed suffix after every row.
+ *
+ * We support:
+ *   - A single JSON array of objects:   [ {...}, {...} ]
+ *   - Concatenated objects (auto-detect when input starts with '{'):  {...}{...}
+ *
+ * States:
+ *   BEFORE_ARRAY   — start of input; expect '[' or '{'.
+ *   BEFORE_OBJECT  — after an object in concatenated-object mode; expect '{'.
+ *   IN_ARRAY       — inside [...]; array_parse_state tracks whether to expect
+ *                    an object, a comma, or ']'.
+ *   IN_OBJECT      — brace depth count to find the matching '}' for one row;
+ *                    strings switch to IN_STRING so braces inside strings are ignored.
+ *   IN_STRING / IN_STRING_ESC — minimal string lexer for the above.
+ *   ARRAY_END      — saw closing ']'; no more rows from this document.
+ *
+ * obj_start is the byte offset of an unfinished object in line_buf, including
+ * while scanning a string within that object.  Refilling discards consumed
+ * rows, preserving any unfinished object and adjusting its offset.
+ *
+ * The state machine is implemented in CopyReadNextJson().
+ */
+
+/* JSON permits fewer whitespace characters than isspace(). */
+static inline bool
+CopyJsonIsSpace(unsigned char c)
+{
+	return c == ' ' || c == '\t' || c == '\n' || c == '\r';
+}
+
+/*
+ * If the scan cursor is past buffered text, compact prefix (when safe) and
+ * read more via CopyLoadInputBuf (then append new input_buf bytes into
+ * line_buf).  When line_buf is still empty afterward,
+ * distinguish clean EOF from truncation errors.
+ *
+ * Returns true if there is more input to scan in line_buf; false if there is
+ * no next JSON row (CopyReadNextJson should report EOF / end of array).
+ */
+static bool
+CopyJsonRefillIfExhausted(CopyFromState cstate, int *obj_start)
+{
+	CopyFromJsonState *json_state = cstate->format_private;
+	StringInfo	line_buf = &cstate->line_buf;
+
+	if (json_state->parse_pos < line_buf->len)
+		return true;
+
+	Assert(json_state->parse_pos == line_buf->len);
+
+	/*
+	 * Discard consumed rows only when we need more input.  If an object is
+	 * incomplete, keep just that object, even when its string spans buffers.
+	 * Keeping earlier rows here would let consecutive large objects grow
+	 * line_buf without bound.
+	 */
+	if (*obj_start < 0)
+	{
+		resetStringInfo(line_buf);
+		json_state->parse_pos = 0;
+	}
+	else if (*obj_start > 0)
+	{
+		line_buf->len -= *obj_start;
+		memmove(line_buf->data, line_buf->data + *obj_start, line_buf->len);
+		line_buf->data[line_buf->len] = '\0';
+		json_state->parse_pos -= *obj_start;
+		*obj_start = 0;
+	}
+
+	/*
+	 * Same refill primitive as text COPY: fill input_buf, then move decoded
+	 * bytes into line_buf.  Always drain the full input_buf chunk (unlike
+	 * CopyReadLine, which may leave a prefix in input_buf).
+	 */
+	{
+		int			nbytes = INPUT_BUF_BYTES(cstate);
+
+		CopyLoadInputBuf(cstate, false);
+
+		if (INPUT_BUF_BYTES(cstate) > nbytes)
+		{
+			appendBinaryStringInfo(line_buf,
+								   cstate->input_buf + cstate->input_buf_index,
+								   INPUT_BUF_BYTES(cstate));
+			cstate->input_buf_index = cstate->input_buf_len;
+			if (cstate->raw_buf == cstate->input_buf)
+				cstate->raw_buf_index = cstate->input_buf_index;
+		}
+	}
+
+	if (json_state->parse_state == COPY_JSON_IN_OBJECT ||
+		json_state->parse_state == COPY_JSON_IN_STRING ||
+		json_state->parse_state == COPY_JSON_IN_STRING_ESC)
+	{
+		if (cstate->input_reached_eof)
+		{
+			cstate->line_buf_valid = false;
+			ereport(ERROR,
+					errcode(ERRCODE_BAD_COPY_FILE_FORMAT),
+					errmsg("unexpected end of input in COPY JSON"));
+		}
+		else if (line_buf->len > 0)
+			return true;
+	}
+
+	if (line_buf->len > 0)
+		return true;
+
+	/*
+	 * Still inside [...] (e.g. missing closing "]", or a trailing comma after
+	 * the last element with no "]" following).
+	 */
+	if (json_state->array_mode &&
+		json_state->parse_state == COPY_JSON_IN_ARRAY)
+	{
+		if (cstate->cur_lineno == 0)
+			cstate->cur_lineno = 1;
+		cstate->line_buf_valid = false;
+		ereport(ERROR,
+				(errcode(ERRCODE_BAD_COPY_FILE_FORMAT),
+				 errmsg("invalid input format for COPY JSON"),
+				 errdetail("JSON array input was not closed with \"]\".")));
+	}
+
+	return false;
+}
+
+/*
+ * Read the next JSON row from the input pipeline, analogous to CopyReadLine()
+ * for text format.  On success, [row_text_start, row_text_end) identifies
+ * the row in line_buf, and parse_pos points just after its closing brace.
+ * The next call handles any whitespace or separator following the row.
+ * Does not validate the object's JSON syntax.
+ *
+ * Returns true if there is no next object (EOF / end of array).
+ *
+ * Each iteration consumes one byte (c) from line_buf, then runs the state
+ * machine.
+ */
+static bool
+CopyReadNextJson(CopyFromState cstate)
+{
+	CopyFromJsonState *json_state = cstate->format_private;
+	StringInfo	line_buf = &cstate->line_buf;
+	int			obj_start = -1;
+
+	json_state->row_text_start = -1;
+	json_state->row_text_end = -1;
+
+	for (;;)
+	{
+		/* Long whitespace runs and unfinished rows must be interruptible. */
+		CHECK_FOR_INTERRUPTS();
+
+		if (!CopyJsonRefillIfExhausted(cstate, &obj_start))
+		{
+			cstate->line_buf_valid = false;
+			return true;
+		}
+
+		while (json_state->parse_pos < line_buf->len)
+		{
+			const char *p = line_buf->data + json_state->parse_pos;
+			unsigned char c = (unsigned char) *p++;
+
+			json_state->parse_pos = p - line_buf->data;
+
+			switch (json_state->parse_state)
+			{
+				case COPY_JSON_BEFORE_ARRAY:
+					if (c == '[')
+					{
+						json_state->parse_state = COPY_JSON_IN_ARRAY;
+						json_state->array_parse_state = COPY_JSON_ARRAY_EXPECT_VALUE_OR_END;
+						json_state->array_mode = true;
+						continue;
+					}
+					if (c == '{')
+					{
+						/* Auto-detect concatenated objects {...}{...}. */
+						cstate->cur_lineno++;
+						json_state->parse_state = COPY_JSON_IN_OBJECT;
+						json_state->object_depth = 1;
+						obj_start = (p - 1) - line_buf->data;
+						continue;
+					}
+					if (CopyJsonIsSpace(c))
+						continue;
+					if (cstate->cur_lineno == 0)
+						cstate->cur_lineno = 1;
+					cstate->line_buf_valid = false;
+					ereport(ERROR,
+							(errcode(ERRCODE_BAD_COPY_FILE_FORMAT),
+							 errmsg("invalid input format for COPY JSON"),
+							 errdetail("Document must begin with \"[\" or \"{\".")));
+					pg_unreachable();
+
+				case COPY_JSON_BEFORE_OBJECT:
+					if (c == '{')
+					{
+						cstate->cur_lineno++;
+						json_state->parse_state = COPY_JSON_IN_OBJECT;
+						json_state->object_depth = 1;
+						obj_start = (p - 1) - line_buf->data;
+						continue;
+					}
+					if (CopyJsonIsSpace(c))
+						continue;
+					cstate->line_buf_valid = false;
+					if (c == ',')
+						ereport(ERROR,
+								errcode(ERRCODE_BAD_COPY_FILE_FORMAT),
+								errmsg("COPY JSON, line %" PRIu64 ": invalid input format",
+									   cstate->cur_lineno),
+								errdetail("Cannot use a comma between concatenated JSON objects; use a JSON array."));
+					ereport(ERROR,
+							(errcode(ERRCODE_BAD_COPY_FILE_FORMAT),
+							 errmsg("COPY JSON, line %" PRIu64 ": invalid input format",
+									cstate->cur_lineno),
+							 errdetail("Expected \"{\" to start the next row object.")));
+					pg_unreachable();
+
+				case COPY_JSON_IN_ARRAY:
+					if (CopyJsonIsSpace(c))
+						continue;
+
+					if (json_state->array_parse_state == COPY_JSON_ARRAY_EXPECT_COMMA_OR_END)
+					{
+						if (c == ',')
+						{
+							json_state->array_parse_state = COPY_JSON_ARRAY_EXPECT_VALUE;
+							continue;
+						}
+						if (c == ']')
+						{
+							json_state->parse_state = COPY_JSON_ARRAY_END;
+							continue;
+						}
+						if (c == '{')
+						{
+							cstate->line_buf_valid = false;
+							ereport(ERROR,
+									errcode(ERRCODE_BAD_COPY_FILE_FORMAT),
+									errmsg("COPY JSON, line %" PRIu64 ": invalid input format",
+										   cstate->cur_lineno),
+									errdetail("Expected \",\" between array elements."));
+							pg_unreachable();
+						}
+					}
+					else
+					{
+						if (c == '{')
+						{
+							cstate->cur_lineno++;
+							json_state->parse_state = COPY_JSON_IN_OBJECT;
+							json_state->object_depth = 1;
+							obj_start = (p - 1) - line_buf->data;
+							continue;
+						}
+						if (c == ']' &&
+							json_state->array_parse_state == COPY_JSON_ARRAY_EXPECT_VALUE_OR_END)
+						{
+							json_state->parse_state = COPY_JSON_ARRAY_END;
+							continue;
+						}
+					}
+					if (cstate->cur_lineno == 0)
+						cstate->cur_lineno = 1;
+					cstate->line_buf_valid = false;
+					ereport(ERROR,
+							(errcode(ERRCODE_BAD_COPY_FILE_FORMAT),
+							 errmsg("COPY JSON, line %" PRIu64 ": each array element must be a JSON object",
+									cstate->cur_lineno)));
+					pg_unreachable();
+
+				case COPY_JSON_IN_OBJECT:
+					switch (c)
+					{
+						case '{':
+							json_state->object_depth++;
+							break;
+						case '}':
+							json_state->object_depth--;
+							if (json_state->object_depth == 0)
+							{
+								json_state->row_text_start = obj_start;
+								json_state->row_text_end = json_state->parse_pos;
+
+								json_state->parse_state = (json_state->array_mode)
+									? COPY_JSON_IN_ARRAY : COPY_JSON_BEFORE_OBJECT;
+								if (json_state->array_mode)
+									json_state->array_parse_state = COPY_JSON_ARRAY_EXPECT_COMMA_OR_END;
+
+								return false;
+							}
+							break;
+						case '[':
+							json_state->object_depth++;
+							break;
+						case ']':
+							json_state->object_depth--;
+							break;
+						case '"':
+							json_state->parse_state = COPY_JSON_IN_STRING;
+							break;
+						case '\\':
+							cstate->line_buf_valid = false;
+							ereport(ERROR,
+									errcode(ERRCODE_BAD_COPY_FILE_FORMAT),
+									errmsg("invalid input syntax for type json"));
+							break;
+						default:
+							break;
+					}
+					break;
+
+				case COPY_JSON_IN_STRING:
+					if (c == '\\')
+						json_state->parse_state = COPY_JSON_IN_STRING_ESC;
+					else if (c == '"')
+						json_state->parse_state = COPY_JSON_IN_OBJECT;
+					break;
+
+				case COPY_JSON_IN_STRING_ESC:
+					json_state->parse_state = COPY_JSON_IN_STRING;
+					break;
+
+				case COPY_JSON_ARRAY_END:
+					if (CopyJsonIsSpace(c))
+						continue;
+					if (cstate->cur_lineno == 0)
+						cstate->cur_lineno = 1;
+					cstate->line_buf_valid = false;
+					ereport(ERROR,
+							errcode(ERRCODE_BAD_COPY_FILE_FORMAT),
+							errmsg("invalid input format for COPY JSON"),
+							errdetail("Trailing data after JSON array."));
+					pg_unreachable();
+			}
+		}
+	}
+}
+
 /*
  * This function is exposed for use by extensions that read raw fields in the
  * next line. See NextCopyFromRawFieldsInternal() for details.
@@ -811,7 +1171,6 @@ NextCopyFromRawFieldsInternal(CopyFromState cstate, char ***fields, int *nfields
 	/* on input check that the header line is correct if needed */
 	if (cstate->cur_lineno == 0 && cstate->opts.header_line != COPY_HEADER_FALSE)
 	{
-		ListCell   *cur;
 		TupleDesc	tupDesc;
 		int			lines_to_skip = cstate->opts.header_line;
 
@@ -844,9 +1203,8 @@ NextCopyFromRawFieldsInternal(CopyFromState cstate, char ***fields, int *nfields
 								fldct, list_length(cstate->attnumlist))));
 
 			fldnum = 0;
-			foreach(cur, cstate->attnumlist)
+			foreach_int(attnum, cstate->attnumlist)
 			{
-				int			attnum = lfirst_int(cur);
 				char	   *colName;
 				Form_pg_attribute attr = TupleDescAttr(tupDesc, attnum - 1);
 
@@ -984,7 +1342,6 @@ CopyFromTextLikeOneRow(CopyFromState cstate, ExprContext *econtext,
 	Oid		   *typioparams = cstate->typioparams;
 	ExprState **defexprs = cstate->defexprs;
 	char	  **field_strings;
-	ListCell   *cur;
 	int			fldct;
 	int			fieldno;
 	char	   *string;
@@ -1006,9 +1363,8 @@ CopyFromTextLikeOneRow(CopyFromState cstate, ExprContext *econtext,
 	fieldno = 0;
 
 	/* Loop to read the user attributes on the line. */
-	foreach(cur, cstate->attnumlist)
+	foreach_int(attnum, cstate->attnumlist)
 	{
-		int			attnum = lfirst_int(cur);
 		int			m = attnum - 1;
 		Form_pg_attribute att = TupleDescAttr(tupDesc, m);
 
@@ -1194,7 +1550,6 @@ CopyFromBinaryOneRow(CopyFromState cstate, ExprContext *econtext, Datum *values,
 	FmgrInfo   *in_functions = cstate->in_functions;
 	Oid		   *typioparams = cstate->typioparams;
 	int16		fld_count;
-	ListCell   *cur;
 
 	tupDesc = RelationGetDescr(cstate->rel);
 	attr_count = list_length(cstate->attnumlist);
@@ -1232,9 +1587,8 @@ CopyFromBinaryOneRow(CopyFromState cstate, ExprContext *econtext, Datum *values,
 				 errmsg("row field count is %d, expected %d",
 						fld_count, attr_count)));
 
-	foreach(cur, cstate->attnumlist)
+	foreach_int(attnum, cstate->attnumlist)
 	{
-		int			attnum = lfirst_int(cur);
 		int			m = attnum - 1;
 		Form_pg_attribute att = TupleDescAttr(tupDesc, m);
 
@@ -1250,6 +1604,315 @@ CopyFromBinaryOneRow(CopyFromState cstate, ExprContext *econtext, Datum *values,
 	return true;
 }
 
+/* State for extracting the top-level fields of a JSON row object. */
+typedef struct CopyFromJsonParseState
+{
+	CopyFromState cstate;
+	JsonLexContext *lex;
+	const char *next_field;
+	const char *value_start;
+	int			fieldno;
+} CopyFromJsonParseState;
+
+/*
+ * Decode only top-level field names.  The main lexer deliberately leaves
+ * string values escaped so that json columns can retain their original text,
+ * including Unicode escapes that cannot be represented in a SQL text value.
+ */
+static JsonParseErrorType
+CopyJsonFieldStart(void *state, char *fname, bool isnull)
+{
+	CopyFromJsonParseState *st = state;
+	CopyFromJsonState *json_state = st->cstate->format_private;
+	JsonLexContext keylex;
+	JsonParseErrorType result;
+	CopyJsonAttribute *entry = NULL;
+
+	if (st->lex->lex_level != 1)
+		return JSON_SUCCESS;
+
+	makeJsonLexContextCstringLen(&keylex, st->next_field,
+								 st->lex->token_start - st->next_field,
+								 GetDatabaseEncoding(), true);
+	result = json_lex(&keylex);
+	if (result != JSON_SUCCESS)
+		json_errsave_error(result, &keylex, NULL);
+	Assert(keylex.token_type == JSON_TOKEN_STRING);
+
+	/* Longer names cannot match a column; do not let the hash truncate them. */
+	if (keylex.strval->len < NAMEDATALEN)
+		entry = hash_search(json_state->attribute_map, keylex.strval->data,
+							HASH_FIND, NULL);
+	st->fieldno = entry ? entry->fieldno : -1;
+	st->value_start = st->lex->token_start;
+	freeJsonLexContext(&keylex);
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+CopyJsonFieldEnd(void *state, char *fname, bool isnull)
+{
+	CopyFromJsonParseState *st = state;
+
+	if (st->lex->lex_level != 1)
+		return JSON_SUCCESS;
+
+	if (st->fieldno >= 0)
+	{
+		char	  **field = &st->cstate->raw_fields[st->fieldno];
+
+		/* As with json_populate_record, the last occurrence of a key wins. */
+		if (*field != NULL)
+			pfree(*field);
+		*field = isnull ? NULL :
+			pnstrdup(st->value_start,
+					 st->lex->prev_token_terminator - st->value_start);
+	}
+
+	/* The current token is the comma (or closing brace) after the value. */
+	st->next_field = st->lex->token_terminator;
+	return JSON_SUCCESS;
+}
+
+/*
+ * Read and validate one JSON row, keeping the raw JSON text of each selected
+ * field.  Missing keys and JSON null become SQL NULL.  Field strings live in
+ * the caller's per-tuple context, so growing one cannot invalidate another.
+ */
+static bool
+NextCopyFromJsonRawFieldsInternal(CopyFromState cstate, char ***fields, int *nfields)
+{
+	CopyFromJsonState *json_state = cstate->format_private;
+	CopyFromJsonParseState state;
+	JsonLexContext lex;
+	JsonSemAction sem = {0};
+	const char *row;
+	int			rowlen;
+
+	Assert(cstate->opts.format == COPY_FORMAT_JSON);
+
+	/* A previous row may have ended with a soft conversion error. */
+	cstate->cur_attname = NULL;
+	cstate->cur_attval = NULL;
+	if (CopyReadNextJson(cstate))
+		return false;
+
+	/* line_buf also contains read-ahead data, which is not error context. */
+	cstate->line_buf_valid = false;
+	MemSet(cstate->raw_fields, 0, cstate->max_fields * sizeof(char *));
+
+	Assert(json_state->row_text_start >= 0);
+	Assert(json_state->row_text_end > json_state->row_text_start);
+	Assert(json_state->row_text_end == json_state->parse_pos);
+	Assert(json_state->row_text_end <= cstate->line_buf.len);
+	row = cstate->line_buf.data + json_state->row_text_start;
+	rowlen = json_state->row_text_end - json_state->row_text_start;
+	makeJsonLexContextCstringLen(&lex, row, rowlen, GetDatabaseEncoding(), false);
+	state.cstate = cstate;
+	state.lex = &lex;
+
+	Assert(row[0] == '{');
+	state.next_field = row + 1;
+	state.value_start = NULL;
+	state.fieldno = -1;
+	sem.semstate = &state;
+	sem.object_field_start = CopyJsonFieldStart;
+	sem.object_field_end = CopyJsonFieldEnd;
+	pg_parse_json_or_ereport(&lex, &sem);
+	freeJsonLexContext(&lex);
+
+	*fields = cstate->raw_fields;
+	*nfields = cstate->max_fields;
+	return true;
+}
+
+/* Convert a field without discarding its original JSON representation. */
+static bool
+CopyConvertJsonAttribute(CopyFromState cstate, Form_pg_attribute att, int m,
+						 const char *string, Datum *value, bool *isnull)
+{
+	CopyFromJsonState *json_state = cstate->format_private;
+	Node	   *escontext = (Node *) cstate->escontext;
+	Oid			base_type = json_state->base_types[m];
+	JsonLexContext lex;
+	bool		result;
+
+	/* json/jsonb input functions, including domain inputs, need JSON text. */
+	if (string == NULL || base_type == JSONOID || base_type == JSONBOID)
+		return InputFunctionCallSafe(&cstate->in_functions[m], string,
+									 cstate->typioparams[m], att->atttypmod,
+									 escontext, value);
+
+	if (string[0] == '[' || string[0] == '{')
+	{
+		/*
+		 * Reuse recursive conversion for arrays, composites and their
+		 * domains.
+		 */
+		*value = json_populate_type(CStringGetTextDatum(string), JSONOID,
+									att->atttypid, att->atttypmod,
+									&json_state->conversion_cache[m],
+									cstate->copycontext, isnull, false, escontext);
+		return !SOFT_ERROR_OCCURRED(escontext);
+	}
+
+	if (string[0] != '"')
+		return InputFunctionCallSafe(&cstate->in_functions[m], string,
+									 cstate->typioparams[m], att->atttypmod,
+									 escontext, value);
+
+	/* Other input functions receive the unquoted, unescaped JSON string. */
+	makeJsonLexContextCstringLen(&lex, string, strlen(string),
+								 GetDatabaseEncoding(), true);
+	result = pg_parse_json_or_errsave(&lex, &nullSemAction, escontext);
+	if (result)
+		result = InputFunctionCallSafe(&cstate->in_functions[m], lex.strval->data,
+									   cstate->typioparams[m], att->atttypmod,
+									   escontext, value);
+	freeJsonLexContext(&lex);
+	return result;
+}
+
+/* Implementation of the per-row callback for JSON format */
+bool
+CopyFromJsonOneRow(CopyFromState cstate, ExprContext *econtext, Datum *values,
+				   bool *nulls)
+{
+	TupleDesc	tupDesc;
+	FmgrInfo   *in_functions = cstate->in_functions;
+	Oid		   *typioparams = cstate->typioparams;
+	ExprState **defexprs = cstate->defexprs;
+	char	  **field_strings;
+	int			fldct;
+	int			fieldno;
+	char	   *string;
+	bool		current_row_erroneous = false;
+
+	/* read raw fields from the next JSON object (by column name) */
+	if (!NextCopyFromJsonRawFieldsInternal(cstate, &field_strings, &fldct))
+		return false;
+
+	tupDesc = RelationGetDescr(cstate->rel);
+
+	fieldno = 0;
+
+	/* Loop to convert field strings to Datums for each column */
+	foreach_int(attnum, cstate->attnumlist)
+	{
+		int			m = attnum - 1;
+		Form_pg_attribute att = TupleDescAttr(tupDesc, m);
+
+		Assert(fieldno < fldct);
+		string = field_strings[fieldno++];
+
+		if (cstate->convert_select_flags &&
+			!cstate->convert_select_flags[m])
+		{
+			/* ignore input field, leaving column as NULL */
+			continue;
+		}
+
+		cstate->cur_attname = NameStr(att->attname);
+		cstate->cur_attval = string;
+
+		if (string != NULL)
+			nulls[m] = false;
+
+		if (cstate->defaults[m])
+		{
+			Assert(econtext != NULL);
+			Assert(CurrentMemoryContext == econtext->ecxt_per_tuple_memory);
+
+			values[m] = ExecEvalExpr(defexprs[m], econtext, &nulls[m]);
+		}
+		else if (!CopyConvertJsonAttribute(cstate, att, m, string,
+										   &values[m], &nulls[m]))
+		{
+			Assert(cstate->opts.on_error != COPY_ON_ERROR_STOP);
+
+			if (cstate->opts.on_error == COPY_ON_ERROR_IGNORE)
+				cstate->num_errors++;
+			else if (cstate->opts.on_error == COPY_ON_ERROR_SET_NULL)
+			{
+				cstate->escontext->error_occurred = false;
+
+				Assert(cstate->domain_with_constraint != NULL);
+
+				if (!cstate->domain_with_constraint[m] ||
+					InputFunctionCallSafe(&in_functions[m],
+										  NULL,
+										  typioparams[m],
+										  att->atttypmod,
+										  (Node *) cstate->escontext,
+										  &values[m]))
+				{
+					nulls[m] = true;
+					values[m] = (Datum) 0;
+				}
+				else
+					ereport(ERROR,
+							errcode(ERRCODE_NOT_NULL_VIOLATION),
+							errmsg("domain %s does not allow null values",
+								   format_type_be(typioparams[m])),
+							errdetail("ON_ERROR SET_NULL cannot be applied because column \"%s\" (domain %s) does not accept null values.",
+									  cstate->cur_attname,
+									  format_type_be(typioparams[m])),
+							errdatatype(typioparams[m]));
+
+				if (!current_row_erroneous)
+				{
+					current_row_erroneous = true;
+					cstate->num_errors++;
+				}
+			}
+
+			if (cstate->opts.log_verbosity == COPY_LOG_VERBOSITY_VERBOSE)
+			{
+				Assert(!cstate->relname_only);
+				cstate->relname_only = true;
+
+				if (cstate->cur_attval)
+				{
+					char	   *attval = CopyLimitPrintoutLength(cstate->cur_attval);
+
+					if (cstate->opts.on_error == COPY_ON_ERROR_IGNORE)
+						ereport(NOTICE,
+								errmsg("skipping row due to data type incompatibility at line %" PRIu64 " for column \"%s\": \"%s\"",
+									   cstate->cur_lineno,
+									   cstate->cur_attname,
+									   attval));
+					else if (cstate->opts.on_error == COPY_ON_ERROR_SET_NULL)
+						ereport(NOTICE,
+								errmsg("setting to null due to data type incompatibility at line %" PRIu64 " for column \"%s\": \"%s\"",
+									   cstate->cur_lineno,
+									   cstate->cur_attname,
+									   attval));
+					pfree(attval);
+				}
+				else if (cstate->opts.on_error == COPY_ON_ERROR_IGNORE)
+					ereport(NOTICE,
+							errmsg("skipping row due to data type incompatibility at line %" PRIu64 " for column \"%s\": null input",
+								   cstate->cur_lineno,
+								   cstate->cur_attname));
+				cstate->relname_only = false;
+			}
+
+			if (cstate->opts.on_error == COPY_ON_ERROR_IGNORE)
+				return true;
+			else if (cstate->opts.on_error == COPY_ON_ERROR_SET_NULL)
+				continue;
+		}
+
+		cstate->cur_attname = NULL;
+		cstate->cur_attval = NULL;
+	}
+
+	Assert(fieldno == fldct);
+
+	return true;
+}
+
 /*
  * Read the next input line and stash it in line_buf.
  *
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 0274d892f2e..69c2f72319b 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -20624,7 +20624,7 @@ ComputePartitionAttrs(ParseState *pstate, Relation rel, List *partParams, AttrNu
 				 * SET EXPRESSION would need to check whether the column is
 				 * used in partition keys).  Seems safer to prohibit for now.
 				 */
-				if (TupleDescAttr(RelationGetDescr(rel), attno - 1)->attgenerated)
+				if (TupleDescCompactAttr(RelationGetDescr(rel), attno - 1)->attgenerated)
 					ereport(ERROR,
 							(errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
 							 errmsg("cannot use generated column in partition key"),
diff --git a/src/include/commands/copyfrom_internal.h b/src/include/commands/copyfrom_internal.h
index 9d3e244ee55..4b9d296abd5 100644
--- a/src/include/commands/copyfrom_internal.h
+++ b/src/include/commands/copyfrom_internal.h
@@ -17,6 +17,53 @@
 #include "commands/copy.h"
 #include "commands/trigger.h"
 #include "nodes/miscnodes.h"
+#include "utils/hsearch.h"
+
+/*
+ * State for COPY FROM JSON format.  line_buf holds text in server encoding.
+ * parse_pos is the scan cursor, and [row_text_start, row_text_end) identifies
+ * the completed row to parse in place.  Consumed rows are discarded on refill.
+ */
+typedef enum CopyJsonScanState
+{
+	COPY_JSON_BEFORE_ARRAY,		/* skip whitespace, expect '[' or '{' */
+	COPY_JSON_BEFORE_OBJECT,	/* after {...} row when input is {...}{...}
+								 * form */
+	COPY_JSON_IN_ARRAY,			/* inside [...], expect value/comma/']' */
+	COPY_JSON_IN_OBJECT,		/* inside {...}, track depth to find matching
+								 * '}' */
+	COPY_JSON_IN_STRING,		/* inside "...", skip until unescaped '"' */
+	COPY_JSON_IN_STRING_ESC,	/* saw '\' in string, consume escape sequence */
+	COPY_JSON_ARRAY_END			/* saw ']', no more rows */
+} CopyJsonScanState;
+
+typedef enum CopyJsonArrayScanState
+{
+	COPY_JSON_ARRAY_EXPECT_VALUE_OR_END,	/* start of array: expect '{' or
+											 * ']' */
+	COPY_JSON_ARRAY_EXPECT_VALUE,	/* after comma: expect next object */
+	COPY_JSON_ARRAY_EXPECT_COMMA_OR_END /* after object: expect ',' or ']' */
+} CopyJsonArrayScanState;
+
+typedef struct CopyFromJsonState
+{
+	CopyJsonScanState parse_state;
+	CopyJsonArrayScanState array_parse_state;
+	int			object_depth;	/* brace/bracket depth: 1 = in target object */
+	bool		array_mode;		/* have we seen the opening '[' */
+	int			parse_pos;		/* scan cursor in line_buf */
+	int			row_text_start; /* set while completing a row; else -1 */
+	int			row_text_end;	/* byte offset just past row's closing '}' */
+	HTAB	   *attribute_map;	/* column name to raw_fields index */
+	Oid		   *base_types;		/* base type of each table attribute */
+	void	  **conversion_cache;	/* per-attribute JSON conversion metadata */
+} CopyFromJsonState;
+
+typedef struct CopyJsonAttribute
+{
+	char		name[NAMEDATALEN];
+	int			fieldno;
+} CopyJsonAttribute;
 
 /*
  * Represents the different source cases we need to worry about at
@@ -189,6 +236,9 @@ typedef struct CopyFromStateData
 #define RAW_BUF_BYTES(cstate) ((cstate)->raw_buf_len - (cstate)->raw_buf_index)
 
 	uint64		bytes_processed;	/* number of bytes processed so far */
+
+	/* Format-specific private data */
+	void	   *format_private;
 } CopyFromStateData;
 
 extern void ReceiveCopyBegin(CopyFromState cstate);
@@ -201,5 +251,7 @@ extern bool CopyFromCSVOneRow(CopyFromState cstate, ExprContext *econtext,
 							  Datum *values, bool *nulls);
 extern bool CopyFromBinaryOneRow(CopyFromState cstate, ExprContext *econtext,
 								 Datum *values, bool *nulls);
+extern bool CopyFromJsonOneRow(CopyFromState cstate, ExprContext *econtext,
+							   Datum *values, bool *nulls);
 
 #endif							/* COPYFROM_INTERNAL_H */
diff --git a/src/test/modules/test_copy_callbacks/expected/test_copy_callbacks.out b/src/test/modules/test_copy_callbacks/expected/test_copy_callbacks.out
index 93ebeef1301..117c7175211 100644
--- a/src/test/modules/test_copy_callbacks/expected/test_copy_callbacks.out
+++ b/src/test/modules/test_copy_callbacks/expected/test_copy_callbacks.out
@@ -11,3 +11,86 @@ NOTICE:  COPY TO callback has processed 3 rows
  
 (1 row)
 
+CREATE TABLE public.test_json (a text);
+-- Delimiters, nesting and string escapes can span callback reads.
+SELECT test_copy_from_json_callback('test_json',
+    '[{"a":"brace } and quote \"","ignored":[{},[]]},{"a":"backslash \\"}]', 1);
+ test_copy_from_json_callback 
+------------------------------
+                            2
+(1 row)
+
+SELECT * FROM test_json;
+          a          
+---------------------
+ brace } and quote "
+ backslash \
+(2 rows)
+
+TRUNCATE test_json;
+-- Many rows share a buffer, with a partial row at the next refill.
+SELECT test_copy_from_json_callback('test_json', repeat('{"a":"x"}', 8192), 65536);
+ test_copy_from_json_callback 
+------------------------------
+                         8192
+(1 row)
+
+SELECT count(*), bool_and(a = 'x') FROM test_json;
+ count | bool_and 
+-------+----------
+  8192 | t
+(1 row)
+
+TRUNCATE test_json;
+-- Consecutive objects span multiple buffers; consumed rows must be discarded.
+SELECT test_copy_from_json_callback('test_json',
+    repeat('{"a":"' || repeat('x', 70000) || '"}', 4), 65536);
+ test_copy_from_json_callback 
+------------------------------
+                            4
+(1 row)
+
+SELECT count(*), bool_and(a = repeat('x', 70000)) FROM test_json;
+ count | bool_and 
+-------+----------
+     4 | t
+(1 row)
+
+TRUNCATE test_json;
+-- Commas between concatenated objects are rejected identically for all chunks.
+SELECT test_copy_from_json_callback('test_json', '{"a":1},{"a":2}', 14);
+ERROR:  COPY JSON, line 1: invalid input format
+DETAIL:  Cannot use a comma between concatenated JSON objects; use a JSON array.
+CONTEXT:  COPY test_json, line 1
+SELECT test_copy_from_json_callback('test_json', '{"a":1},{"a":2}', 7);
+ERROR:  COPY JSON, line 1: invalid input format
+DETAIL:  Cannot use a comma between concatenated JSON objects; use a JSON array.
+CONTEXT:  COPY test_json, line 1
+SELECT count(*) FROM test_json;
+ count 
+-------
+     0
+(1 row)
+
+-- Cancellation must be checked before refilling, even without a complete row.
+SELECT test_copy_from_json_callback('test_json', repeat(' ', 131072), 65536, true);
+ERROR:  canceling statement due to user request
+CONTEXT:  COPY test_json, line 0
+SELECT test_copy_from_json_callback('test_json', '[]' || repeat(' ', 131072), 65536, true);
+ERROR:  canceling statement due to user request
+CONTEXT:  COPY test_json, line 0
+SELECT test_copy_from_json_callback('test_json', '{"a":"' || repeat('x', 131072), 65536, true);
+ERROR:  canceling statement due to user request
+CONTEXT:  COPY test_json, line 1
+SELECT test_copy_from_json_callback('test_json', '{"a":"ok"}', 1);
+ test_copy_from_json_callback 
+------------------------------
+                            1
+(1 row)
+
+SELECT * FROM test_json;
+ a  
+----
+ ok
+(1 row)
+
diff --git a/src/test/modules/test_copy_callbacks/sql/test_copy_callbacks.sql b/src/test/modules/test_copy_callbacks/sql/test_copy_callbacks.sql
index 2deffba635c..887dc3c5145 100644
--- a/src/test/modules/test_copy_callbacks/sql/test_copy_callbacks.sql
+++ b/src/test/modules/test_copy_callbacks/sql/test_copy_callbacks.sql
@@ -2,3 +2,33 @@ CREATE EXTENSION test_copy_callbacks;
 CREATE TABLE public.test (a INT, b INT, c INT);
 INSERT INTO public.test VALUES (1, 2, 3), (12, 34, 56), (123, 456, 789);
 SELECT test_copy_to_callback('public.test'::pg_catalog.regclass);
+
+CREATE TABLE public.test_json (a text);
+-- Delimiters, nesting and string escapes can span callback reads.
+SELECT test_copy_from_json_callback('test_json',
+    '[{"a":"brace } and quote \"","ignored":[{},[]]},{"a":"backslash \\"}]', 1);
+SELECT * FROM test_json;
+TRUNCATE test_json;
+
+-- Many rows share a buffer, with a partial row at the next refill.
+SELECT test_copy_from_json_callback('test_json', repeat('{"a":"x"}', 8192), 65536);
+SELECT count(*), bool_and(a = 'x') FROM test_json;
+TRUNCATE test_json;
+
+-- Consecutive objects span multiple buffers; consumed rows must be discarded.
+SELECT test_copy_from_json_callback('test_json',
+    repeat('{"a":"' || repeat('x', 70000) || '"}', 4), 65536);
+SELECT count(*), bool_and(a = repeat('x', 70000)) FROM test_json;
+TRUNCATE test_json;
+
+-- Commas between concatenated objects are rejected identically for all chunks.
+SELECT test_copy_from_json_callback('test_json', '{"a":1},{"a":2}', 14);
+SELECT test_copy_from_json_callback('test_json', '{"a":1},{"a":2}', 7);
+SELECT count(*) FROM test_json;
+
+-- Cancellation must be checked before refilling, even without a complete row.
+SELECT test_copy_from_json_callback('test_json', repeat(' ', 131072), 65536, true);
+SELECT test_copy_from_json_callback('test_json', '[]' || repeat(' ', 131072), 65536, true);
+SELECT test_copy_from_json_callback('test_json', '{"a":"' || repeat('x', 131072), 65536, true);
+SELECT test_copy_from_json_callback('test_json', '{"a":"ok"}', 1);
+SELECT * FROM test_json;
diff --git a/src/test/modules/test_copy_callbacks/test_copy_callbacks--1.0.sql b/src/test/modules/test_copy_callbacks/test_copy_callbacks--1.0.sql
index 215cf3fad69..fa42a51726d 100644
--- a/src/test/modules/test_copy_callbacks/test_copy_callbacks--1.0.sql
+++ b/src/test/modules/test_copy_callbacks/test_copy_callbacks--1.0.sql
@@ -6,3 +6,8 @@
 CREATE FUNCTION test_copy_to_callback(pg_catalog.regclass)
 	RETURNS pg_catalog.void
 	AS 'MODULE_PATHNAME' LANGUAGE C;
+
+CREATE FUNCTION test_copy_from_json_callback(pg_catalog.regclass, pg_catalog.text,
+	pg_catalog.int4, pg_catalog.bool DEFAULT false)
+	RETURNS pg_catalog.int8
+	AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
diff --git a/src/test/modules/test_copy_callbacks/test_copy_callbacks.c b/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
index f6b113e3e98..6f0dab8acab 100644
--- a/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
+++ b/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
@@ -17,10 +17,94 @@
 #include "access/table.h"
 #include "commands/copy.h"
 #include "fmgr.h"
+#include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/rel.h"
+#include "varatt.h"
 
 PG_MODULE_MAGIC;
 
+typedef struct TestCopyFromJsonState
+{
+	const char *data;
+	int			len;
+	int			pos;
+	int			chunk_size;
+	bool		cancel;
+} TestCopyFromJsonState;
+
+static TestCopyFromJsonState *from_json_state;
+
+static int
+from_json_cb(void *data, int minread, int maxread)
+{
+	TestCopyFromJsonState *state = from_json_state;
+	int			nbytes = Min(state->len - state->pos,
+							 Min(state->chunk_size, maxread));
+
+	if (state->cancel)
+	{
+		/* Request cancellation during the first read, without a timing race. */
+		if (state->pos == 0)
+		{
+			InterruptPending = true;
+			QueryCancelPending = true;
+		}
+		else
+			elog(ERROR, "COPY did not process cancellation before reading more data");
+	}
+
+	memcpy(data, state->data + state->pos, nbytes);
+	state->pos += nbytes;
+	return nbytes;
+}
+
+PG_FUNCTION_INFO_V1(test_copy_from_json_callback);
+Datum
+test_copy_from_json_callback(PG_FUNCTION_ARGS)
+{
+	Relation	rel = table_open(PG_GETARG_OID(0), RowExclusiveLock);
+	ParseState *pstate = make_parsestate(NULL);
+	text	   *data = PG_GETARG_TEXT_PP(1);
+	TestCopyFromJsonState state;
+	TestCopyFromJsonState *saved_state = from_json_state;
+	volatile uint64 processed = 0;
+
+	state.data = VARDATA_ANY(data);
+	state.len = VARSIZE_ANY_EXHDR(data);
+	state.pos = 0;
+	state.chunk_size = PG_GETARG_INT32(2);
+	state.cancel = PG_GETARG_BOOL(3);
+	if (state.chunk_size <= 0)
+		elog(ERROR, "chunk size must be positive");
+
+	addRangeTableEntryForRelation(pstate, rel, RowExclusiveLock,
+								  NULL, false, false);
+
+	from_json_state = &state;
+	PG_TRY();
+	{
+		CopyFromState cstate;
+		List	   *options = list_make1(makeDefElem("format",
+													 (Node *) makeString("json"), -1));
+
+		cstate = BeginCopyFrom(pstate, rel, NULL, NULL, false,
+							   from_json_cb, NIL, options);
+		processed = CopyFrom(cstate);
+		EndCopyFrom(cstate);
+	}
+	PG_FINALLY();
+	{
+		from_json_state = saved_state;
+	}
+	PG_END_TRY();
+
+	free_parsestate(pstate);
+	table_close(rel, NoLock);
+	PG_RETURN_INT64(processed);
+}
+
 static void
 to_cb(void *data, int len)
 {
diff --git a/src/test/regress/expected/copy.out b/src/test/regress/expected/copy.out
index 0af0b646921..ffb9958202e 100644
--- a/src/test/regress/expected/copy.out
+++ b/src/test/regress/expected/copy.out
@@ -144,9 +144,313 @@ LINE 1: copy copytest to stdout (format json, on_error ignore);
                                               ^
 copy copytest to stdout (format json, reject_limit 1);
 ERROR:  COPY REJECT_LIMIT requires ON_ERROR to be set to IGNORE
-copy copytest from stdin(format json);
-ERROR:  COPY FORMAT JSON is not supported for COPY FROM
 -- all of the above should yield error
+-- COPY FROM JSON: each array element is a row, object keys match column names
+create temp table copytest_from_json (like copytest);
+copy copytest_from_json (style, test) from stdin (format json);
+select * from copytest_from_json order by style collate "C";
+  style  |   test   | filler 
+---------+----------+--------
+ DOS     | abc\r   +|       
+         | def      | 
+ Mac     | abc\rdef |       
+ Unix    | abc     +|       
+         | def      | 
+ esc\ape | a\r\\r\ +|       
+         | \nb      | 
+(4 rows)
+
+-- Round trip: COPY TO JSON file, then COPY FROM JSON file
+\set copy_json_rt :abs_builddir '/results/copytest_roundtrip.json'
+truncate copytest2;
+copy copytest to :'copy_json_rt' (format json);
+copy copytest2 from :'copy_json_rt' (format json);
+select * from copytest except select * from copytest2;
+ style | test | filler 
+-------+------+--------
+(0 rows)
+
+truncate copytest2;
+copy copytest to :'copy_json_rt' (format json, force_array true);
+copy copytest2 from :'copy_json_rt' (format json);
+select * from copytest except select * from copytest2;
+ style | test | filler 
+-------+------+--------
+(0 rows)
+
+-- COPY FROM JSON edge cases: invalid input, non-array, missing required field
+copy copytest_from_json from stdin (format json);
+ERROR:  invalid input format for COPY JSON
+DETAIL:  Document must begin with "[" or "{".
+CONTEXT:  COPY copytest_from_json, line 1
+copy copytest_from_json from stdin (format json);
+ERROR:  invalid input syntax for type json
+CONTEXT:  COPY copytest_from_json, line 1
+copy copytest_from_json from stdin (format json);
+ERROR:  COPY JSON, line 1: each array element must be a JSON object
+CONTEXT:  COPY copytest_from_json, line 1
+copy copytest_from_json from stdin (format json);
+ERROR:  COPY JSON, line 1: each array element must be a JSON object
+CONTEXT:  COPY copytest_from_json, line 1
+copy copytest_from_json from stdin (format json);
+ERROR:  COPY JSON, line 1: invalid input format
+DETAIL:  Expected "," between array elements.
+CONTEXT:  COPY copytest_from_json, line 1
+copy copytest_from_json from stdin (format json);
+ERROR:  COPY JSON, line 1: invalid input format
+DETAIL:  Cannot use a comma between concatenated JSON objects; use a JSON array.
+CONTEXT:  COPY copytest_from_json, line 1
+copy copytest_from_json from stdin (format json);
+ERROR:  invalid input format for COPY JSON
+DETAIL:  JSON array input was not closed with "]".
+CONTEXT:  COPY copytest_from_json, line 2
+copy copytest_from_json from stdin (format json);
+ERROR:  invalid input format for COPY JSON
+DETAIL:  JSON array input was not closed with "]".
+CONTEXT:  COPY copytest_from_json, line 2
+copy copytest_from_json from stdin (format json);
+ERROR:  unexpected end of input in COPY JSON
+CONTEXT:  COPY copytest_from_json, line 1
+copy copytest_from_json from stdin (format json);
+ERROR:  unexpected end of input in COPY JSON
+CONTEXT:  COPY copytest_from_json, line 1
+copy copytest_from_json from stdin (format json);
+ERROR:  COPY JSON, line 1: each array element must be a JSON object
+CONTEXT:  COPY copytest_from_json, line 1
+copy copytest_from_json from stdin (format json);
+ERROR:  invalid input format for COPY JSON
+DETAIL:  Trailing data after JSON array.
+CONTEXT:  COPY copytest_from_json, line 1
+copy copytest_from_json from stdin (format json);
+ERROR:  invalid input format for COPY JSON
+DETAIL:  Trailing data after JSON array.
+CONTEXT:  COPY copytest_from_json, line 1
+create temp table copyjson_req (style text NOT NULL, test text);
+copy copyjson_req from stdin (format json);
+ERROR:  null value in column "style" of relation "copyjson_req" violates not-null constraint
+DETAIL:  Failing row contains (null, only test).
+CONTEXT:  COPY copyjson_req, line 1
+copy copyjson_req from stdin (format json);
+select * from copyjson_req;
+ style | test 
+-------+------
+ ok    | both
+(1 row)
+
+truncate copytest_from_json;
+copy copytest_from_json (style, test) from stdin (format json);
+select style, test, filler from copytest_from_json;
+ style | test | filler 
+-------+------+--------
+ a     | b    |       
+(1 row)
+
+\set copy_json_boundary :abs_builddir '/results/copytest_json_boundary.json'
+copy (select '[{"style":"' || repeat('x', 65512) || '","test":"a"},{"style":"b","test":"c"}]') to :'copy_json_boundary';
+truncate copytest_from_json;
+copy copytest_from_json (style, test) from :'copy_json_boundary' (format json);
+select length(style), test from copytest_from_json order by length(style);
+ length | test 
+--------+------
+      1 | c
+  65512 | a
+(2 rows)
+
+create temp table copyjsontest_scalars (js json, jsb jsonb);
+copy copyjsontest_scalars from stdin (format json);
+select js, jsb from copyjsontest_scalars;
+  js   |  jsb  
+-------+-------
+ "foo" | true
+ false | "bar"
+(2 rows)
+
+-- Field storage must survive large values and exact buffer-size boundaries.
+create temp table copyjson_wide (a text, b text);
+copy (select 'first' as a, repeat('x', 5000) as b) to :'copy_json_rt' (format json);
+copy copyjson_wide from :'copy_json_rt' (format json);
+select a = 'first', length(a), b = repeat('x', 5000) from copyjson_wide;
+ ?column? | length | ?column? 
+----------+--------+----------
+ t        |      5 | t
+(1 row)
+
+truncate copyjson_wide;
+copy (select repeat('x', 1023) as a) to :'copy_json_rt' (format json);
+copy copyjson_wide (a) from :'copy_json_rt' (format json);
+select a = repeat('x', 1023), length(a), b is null from copyjson_wide;
+ ?column? | length | ?column? 
+----------+--------+----------
+ t        |   1023 | t
+(1 row)
+
+-- Domains need the same JSON representation as their base types.
+create domain pg_temp.copyjson_json as json;
+create domain pg_temp.copyjson_jsonb as jsonb;
+create domain pg_temp.copyjson_string as json check (json_typeof(value) = 'string');
+create type pg_temp.copyjson_boolean_word as enum ('true', 'false');
+create temp table copyjson_domains
+    (j pg_temp.copyjson_json, jb pg_temp.copyjson_jsonb,
+     s pg_temp.copyjson_string, t text, e pg_temp.copyjson_boolean_word);
+copy copyjson_domains from stdin (format json);
+select j, json_typeof(j), jb, jsonb_typeof(jb), s, t, e from copyjson_domains;
+   j    | json_typeof |  jb   | jsonb_typeof |   s    |   t   |   e   
+--------+-------------+-------+--------------+--------+-------+-------
+ "true" | string      | "123" | string       | "null" | true  | false
+ true   | boolean     | false | boolean      | "foo"  | false | true
+(2 rows)
+
+copy copyjson_domains (s) from stdin (format json);
+ERROR:  value for domain copyjson_string violates check constraint "copyjson_string_check"
+CONTEXT:  COPY copyjson_domains, line 1, column s: "true"
+-- Preserve json text, including duplicate keys and values jsonb rejects.
+create temp table copyjson_preserve (id int, j json);
+insert into copyjson_preserve values
+    (1, '{"k":1, "k":2}'), (2, '"\u0000"'), (3, '1e1000000'),
+    (4, '{"nested":["\u0000",{"k":1,"k":2}]}'), (5, '"\u0061"');
+copy copyjson_preserve to :'copy_json_rt' (format json);
+truncate copyjson_preserve;
+copy copyjson_preserve from :'copy_json_rt' (format json);
+select * from copyjson_preserve order by id;
+ id |                  j                  
+----+-------------------------------------
+  1 | {"k":1, "k":2}
+  2 | "\u0000"
+  3 | 1e1000000
+  4 | {"nested":["\u0000",{"k":1,"k":2}]}
+  5 | "\u0061"
+(5 rows)
+
+-- Recurse into SQL arrays/composites and enforce domain constraints.
+create type pg_temp.copyjson_pair as (i int, s text);
+create domain pg_temp.copyjson_intarray as int[] check (cardinality(value) <= 2);
+create domain pg_temp.copyjson_pair_domain as pg_temp.copyjson_pair
+    check ((value).i > 0);
+create temp table copyjson_structured
+    (a int[], t text[], p pg_temp.copyjson_pair,
+     da pg_temp.copyjson_intarray, dp pg_temp.copyjson_pair_domain);
+copy copyjson_structured from stdin (format json);
+select * from copyjson_structured;
+       a       |     t      |       p        |  da   |   dp    
+---------------+------------+----------------+-------+---------
+ {{1,2},{3,4}} | {a,NULL,b} | (1,one)        | {1,2} | (2,two)
+ {}            | {}         | (,"missing i") | {}    | (3,)
+(2 rows)
+
+copy copyjson_structured to :'copy_json_rt' (format json, force_array);
+truncate copyjson_structured;
+copy copyjson_structured from :'copy_json_rt' (format json);
+select * from copyjson_structured;
+       a       |     t      |       p        |  da   |   dp    
+---------------+------------+----------------+-------+---------
+ {{1,2},{3,4}} | {a,NULL,b} | (1,one)        | {1,2} | (2,two)
+ {}            | {}         | (,"missing i") | {}    | (3,)
+(2 rows)
+
+-- JSON strings can also contain PostgreSQL array and record literals.
+copy copyjson_structured (a, p) from stdin (format json);
+select a, p from copyjson_structured where a = array[5,6];
+   a   |     p     
+-------+-----------
+ {5,6} | (7,seven)
+(1 row)
+
+copy copyjson_structured (da) from stdin (format json);
+ERROR:  value for domain copyjson_intarray violates check constraint "copyjson_intarray_check"
+CONTEXT:  COPY copyjson_structured, line 1, column da: "[1,2,3]"
+copy copyjson_structured (dp) from stdin (format json);
+ERROR:  value for domain copyjson_pair_domain violates check constraint "copyjson_pair_domain_check"
+CONTEXT:  COPY copyjson_structured, line 1, column dp: "{"i":0,"s":"invalid"}"
+-- Soft conversion errors still work for strings and nested values.
+truncate copyjson_structured;
+copy copyjson_structured (a, p) from stdin (format json, on_error ignore);
+NOTICE:  2 rows were skipped due to data type incompatibility
+copy copyjson_structured (a, p) from stdin (format json, on_error set_null);
+NOTICE:  in 1 row, columns were set to null due to data type incompatibility
+select a, p from copyjson_structured;
+   a   |      p      
+-------+-------------
+ {8,9} | (10,ten)
+       | (11,eleven)
+(2 rows)
+
+create domain pg_temp.copyjson_required_array as int[] not null;
+create temp table copyjson_required (a pg_temp.copyjson_required_array);
+copy copyjson_required from stdin (format json, on_error set_null);
+ERROR:  domain copyjson_required_array does not allow null values
+DETAIL:  ON_ERROR SET_NULL cannot be applied because column "a" (domain copyjson_required_array) does not accept null values.
+CONTEXT:  COPY copyjson_required, line 1, column a: "["bad"]"
+copy copyjson_required from stdin (format json);
+ERROR:  domain copyjson_required_array does not allow null values
+CONTEXT:  COPY copyjson_required, line 1, column a: null input
+create temp table copyjson_soft_strings (t text, j jsonb);
+copy copyjson_soft_strings from stdin (format json, on_error ignore);
+NOTICE:  2 rows were skipped due to data type incompatibility
+select * from copyjson_soft_strings;
+ t |  j   
+---+------
+ a | true
+(1 row)
+
+copy copyjson_soft_strings from stdin (format json, on_error set_null);
+NOTICE:  in 1 row, columns were set to null due to data type incompatibility
+select t is null, j is null from copyjson_soft_strings;
+ ?column? | ?column? 
+----------+----------
+ f        | f
+ t        | t
+(2 rows)
+
+-- Match escaped names, take the last duplicate, and ignore unselected values.
+create temp table copyjson_names (ab int, other text default 'default');
+copy copyjson_names (ab) from stdin (format json);
+select * from copyjson_names;
+ ab |  other  
+----+---------
+  2 | default
+    | default
+(2 rows)
+
+-- Only space, horizontal tab, CR and LF are JSON whitespace.
+-- Use psql output to write the control characters without COPY escaping them.
+select E'\v[{"ab":1}]' as bad_json \gset
+\o :copy_json_rt
+\qecho :bad_json
+\o
+copy copyjson_names from :'copy_json_rt' (format json);
+ERROR:  invalid input format for COPY JSON
+DETAIL:  Document must begin with "[" or "{".
+CONTEXT:  COPY copyjson_names, line 1
+select E'[\v{"ab":1}]' as bad_json \gset
+\o :copy_json_rt
+\qecho :bad_json
+\o
+copy copyjson_names from :'copy_json_rt' (format json);
+ERROR:  COPY JSON, line 1: each array element must be a JSON object
+CONTEXT:  COPY copyjson_names, line 1
+select E'{"ab":1}\f{"ab":2}' as bad_json \gset
+\o :copy_json_rt
+\qecho :bad_json
+\o
+copy copyjson_names from :'copy_json_rt' (format json);
+ERROR:  COPY JSON, line 1: invalid input format
+DETAIL:  Expected "{" to start the next row object.
+CONTEXT:  COPY copyjson_names, line 1
+select E'[{"ab":1}]\v' as bad_json \gset
+\o :copy_json_rt
+\qecho :bad_json
+\o
+copy copyjson_names from :'copy_json_rt' (format json);
+ERROR:  invalid input format for COPY JSON
+DETAIL:  Trailing data after JSON array.
+CONTEXT:  COPY copyjson_names, line 1
+select E'[{"ab":1}\f]' as bad_json \gset
+\o :copy_json_rt
+\qecho :bad_json
+\o
+copy copyjson_names from :'copy_json_rt' (format json);
+ERROR:  COPY JSON, line 1: each array element must be a JSON object
+CONTEXT:  COPY copyjson_names, line 1
 -- column list with json format
 copy copytest (style, test, filler) to stdout (format json);
 {"style":"DOS","test":"abc\r\ndef","filler":1}
diff --git a/src/test/regress/sql/copy.sql b/src/test/regress/sql/copy.sql
index 14da3c5ec37..1628334d818 100644
--- a/src/test/regress/sql/copy.sql
+++ b/src/test/regress/sql/copy.sql
@@ -114,10 +114,225 @@ copy copytest to stdout (format json, force_not_null *);
 copy copytest to stdout (format json, force_null *);
 copy copytest to stdout (format json, on_error ignore);
 copy copytest to stdout (format json, reject_limit 1);
-copy copytest from stdin(format json);
-\.
 -- all of the above should yield error
 
+-- COPY FROM JSON: each array element is a row, object keys match column names
+create temp table copytest_from_json (like copytest);
+copy copytest_from_json (style, test) from stdin (format json);
+[  {"style":"DOS","test":"abc\r\ndef"} ,{"style":"Unix","test":"abc\ndef"} ,{"style":"Mac","test":"abc\rdef"} ,{"style":"esc\\ape","test":"a\\r\\\r\\\n\\nb"} ]
+\.
+select * from copytest_from_json order by style collate "C";
+
+-- Round trip: COPY TO JSON file, then COPY FROM JSON file
+\set copy_json_rt :abs_builddir '/results/copytest_roundtrip.json'
+truncate copytest2;
+copy copytest to :'copy_json_rt' (format json);
+copy copytest2 from :'copy_json_rt' (format json);
+select * from copytest except select * from copytest2;
+
+truncate copytest2;
+copy copytest to :'copy_json_rt' (format json, force_array true);
+copy copytest2 from :'copy_json_rt' (format json);
+select * from copytest except select * from copytest2;
+
+-- COPY FROM JSON edge cases: invalid input, non-array, missing required field
+copy copytest_from_json from stdin (format json);
+not valid json
+\.
+copy copytest_from_json from stdin (format json);
+{\}
+\.
+copy copytest_from_json from stdin (format json);
+[1, 2, 3]
+\.
+copy copytest_from_json from stdin (format json);
+[null, true, "string"]
+\.
+copy copytest_from_json from stdin (format json);
+[{"style":"a","test":"b"} {"style":"c","test":"d"}]
+\.
+copy copytest_from_json from stdin (format json);
+{"style":"a","test":"b"}, {"style":"c","test":"d"}
+\.
+copy copytest_from_json from stdin (format json);
+[{"style":"a","test":"b"},{"style":"c","test":"d"}
+\.
+copy copytest_from_json from stdin (format json);
+[{"style":"a","test":"b"},{"style":"c","test":"d"},
+\.
+copy copytest_from_json from stdin (format json);
+[{"style":
+\.
+copy copytest_from_json from stdin (format json);
+{"style":"unterminated
+\.
+copy copytest_from_json from stdin (format json);
+[{"style":"a","test":"b"},]
+\.
+copy copytest_from_json from stdin (format json);
+[{"style":"a","test":"b"}]{"style":"c","test":"d"}
+\.
+copy copytest_from_json from stdin (format json);
+[{"style":"a","test":"b"}] garbage
+\.
+create temp table copyjson_req (style text NOT NULL, test text);
+copy copyjson_req from stdin (format json);
+[{"test":"only test"}]
+\.
+copy copyjson_req from stdin (format json);
+[{"style":"ok","test":"both"}]
+\.
+select * from copyjson_req;
+truncate copytest_from_json;
+copy copytest_from_json (style, test) from stdin (format json);
+[{"style":"a","test":"b","extra":"ignored","filler":999}]
+\.
+select style, test, filler from copytest_from_json;
+
+\set copy_json_boundary :abs_builddir '/results/copytest_json_boundary.json'
+copy (select '[{"style":"' || repeat('x', 65512) || '","test":"a"},{"style":"b","test":"c"}]') to :'copy_json_boundary';
+truncate copytest_from_json;
+copy copytest_from_json (style, test) from :'copy_json_boundary' (format json);
+select length(style), test from copytest_from_json order by length(style);
+
+create temp table copyjsontest_scalars (js json, jsb jsonb);
+copy copyjsontest_scalars from stdin (format json);
+[{"js":"foo","jsb":true},{"js":false,"jsb":"bar"}]
+\.
+select js, jsb from copyjsontest_scalars;
+
+-- Field storage must survive large values and exact buffer-size boundaries.
+create temp table copyjson_wide (a text, b text);
+copy (select 'first' as a, repeat('x', 5000) as b) to :'copy_json_rt' (format json);
+copy copyjson_wide from :'copy_json_rt' (format json);
+select a = 'first', length(a), b = repeat('x', 5000) from copyjson_wide;
+truncate copyjson_wide;
+copy (select repeat('x', 1023) as a) to :'copy_json_rt' (format json);
+copy copyjson_wide (a) from :'copy_json_rt' (format json);
+select a = repeat('x', 1023), length(a), b is null from copyjson_wide;
+
+-- Domains need the same JSON representation as their base types.
+create domain pg_temp.copyjson_json as json;
+create domain pg_temp.copyjson_jsonb as jsonb;
+create domain pg_temp.copyjson_string as json check (json_typeof(value) = 'string');
+create type pg_temp.copyjson_boolean_word as enum ('true', 'false');
+create temp table copyjson_domains
+    (j pg_temp.copyjson_json, jb pg_temp.copyjson_jsonb,
+     s pg_temp.copyjson_string, t text, e pg_temp.copyjson_boolean_word);
+copy copyjson_domains from stdin (format json);
+{"j":"true","jb":"123","s":"null","t":true,"e":false}
+{"j":true,"jb":false,"s":"foo","t":false,"e":true}
+\.
+select j, json_typeof(j), jb, jsonb_typeof(jb), s, t, e from copyjson_domains;
+copy copyjson_domains (s) from stdin (format json);
+{"s":true}
+\.
+
+-- Preserve json text, including duplicate keys and values jsonb rejects.
+create temp table copyjson_preserve (id int, j json);
+insert into copyjson_preserve values
+    (1, '{"k":1, "k":2}'), (2, '"\u0000"'), (3, '1e1000000'),
+    (4, '{"nested":["\u0000",{"k":1,"k":2}]}'), (5, '"\u0061"');
+copy copyjson_preserve to :'copy_json_rt' (format json);
+truncate copyjson_preserve;
+copy copyjson_preserve from :'copy_json_rt' (format json);
+select * from copyjson_preserve order by id;
+
+-- Recurse into SQL arrays/composites and enforce domain constraints.
+create type pg_temp.copyjson_pair as (i int, s text);
+create domain pg_temp.copyjson_intarray as int[] check (cardinality(value) <= 2);
+create domain pg_temp.copyjson_pair_domain as pg_temp.copyjson_pair
+    check ((value).i > 0);
+create temp table copyjson_structured
+    (a int[], t text[], p pg_temp.copyjson_pair,
+     da pg_temp.copyjson_intarray, dp pg_temp.copyjson_pair_domain);
+copy copyjson_structured from stdin (format json);
+{"a":[[1,2],[3,4]],"t":["a",null,"b"],"p":{"i":1,"s":"one"},"da":[1,2],"dp":{"i":2,"s":"two"}}
+{"a":[],"t":[],"p":{"s":"missing i"},"da":[],"dp":{"i":3,"s":null}}
+\.
+select * from copyjson_structured;
+copy copyjson_structured to :'copy_json_rt' (format json, force_array);
+truncate copyjson_structured;
+copy copyjson_structured from :'copy_json_rt' (format json);
+select * from copyjson_structured;
+-- JSON strings can also contain PostgreSQL array and record literals.
+copy copyjson_structured (a, p) from stdin (format json);
+{"a":"{5,6}","p":"(7,seven)"}
+\.
+select a, p from copyjson_structured where a = array[5,6];
+copy copyjson_structured (da) from stdin (format json);
+{"da":[1,2,3]}
+\.
+copy copyjson_structured (dp) from stdin (format json);
+{"dp":{"i":0,"s":"invalid"}}
+\.
+
+-- Soft conversion errors still work for strings and nested values.
+truncate copyjson_structured;
+copy copyjson_structured (a, p) from stdin (format json, on_error ignore);
+[{"a":[1,"bad"]},{"p":{"i":"bad"}},{"a":[8,9],"p":{"i":10,"s":"ten"}}]
+\.
+copy copyjson_structured (a, p) from stdin (format json, on_error set_null);
+{"a":[1,"bad"],"p":{"i":11,"s":"eleven"}}
+\.
+select a, p from copyjson_structured;
+create domain pg_temp.copyjson_required_array as int[] not null;
+create temp table copyjson_required (a pg_temp.copyjson_required_array);
+copy copyjson_required from stdin (format json, on_error set_null);
+{"a":["bad"]}
+\.
+copy copyjson_required from stdin (format json);
+{}
+\.
+
+create temp table copyjson_soft_strings (t text, j jsonb);
+copy copyjson_soft_strings from stdin (format json, on_error ignore);
+{"t":"\u0000","j":1}
+{"t":"ok","j":"\u0000"}
+{"t":"\u0061","j":true}
+\.
+select * from copyjson_soft_strings;
+copy copyjson_soft_strings from stdin (format json, on_error set_null);
+{"t":"\u0000","j":1e1000000}
+\.
+select t is null, j is null from copyjson_soft_strings;
+
+-- Match escaped names, take the last duplicate, and ignore unselected values.
+create temp table copyjson_names (ab int, other text default 'default');
+copy copyjson_names (ab) from stdin (format json);
+{"ab":1,"a\u0062":2,"ignored":"\u0000","huge":1e1000000}
+{"ab":3,"ab":null}
+\.
+select * from copyjson_names;
+
+-- Only space, horizontal tab, CR and LF are JSON whitespace.
+-- Use psql output to write the control characters without COPY escaping them.
+select E'\v[{"ab":1}]' as bad_json \gset
+\o :copy_json_rt
+\qecho :bad_json
+\o
+copy copyjson_names from :'copy_json_rt' (format json);
+select E'[\v{"ab":1}]' as bad_json \gset
+\o :copy_json_rt
+\qecho :bad_json
+\o
+copy copyjson_names from :'copy_json_rt' (format json);
+select E'{"ab":1}\f{"ab":2}' as bad_json \gset
+\o :copy_json_rt
+\qecho :bad_json
+\o
+copy copyjson_names from :'copy_json_rt' (format json);
+select E'[{"ab":1}]\v' as bad_json \gset
+\o :copy_json_rt
+\qecho :bad_json
+\o
+copy copyjson_names from :'copy_json_rt' (format json);
+select E'[{"ab":1}\f]' as bad_json \gset
+\o :copy_json_rt
+\qecho :bad_json
+\o
+copy copyjson_names from :'copy_json_rt' (format json);
+
 -- column list with json format
 copy copytest (style, test, filler) to stdout (format json);
 
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 656f1f60862..583a27d4579 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -544,10 +544,15 @@ CookedConstraint
 CopyDest
 CopyFormat
 CopyFormatOptions
+CopyFromJsonParseState
+CopyFromJsonState
 CopyFromRoutine
 CopyFromState
 CopyFromStateData
 CopyInsertMethod
+CopyJsonArrayScanState
+CopyJsonAttribute
+CopyJsonScanState
 CopyLogVerbosityChoice
 CopyMethod
 CopyMultiInsertBuffer
@@ -3161,6 +3166,7 @@ Tcl_Obj
 Tcl_Size
 Tcl_Time
 TempNamespaceStatus
+TestCopyFromJsonState
 TestCustomScanState
 TestDSMRegistryHashEntry
 TestDSMRegistryStruct
-- 
2.39.5 (Apple Git-154)

