From 52fa46bdee07c4651c1c9e826b17be3e0b1cb9bd Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
Date: Wed, 8 Jul 2026 23:20:33 +0000
Subject: [PATCH 7/8] Add json5 data type with cast paths to json/jsonb

New json5 type stores JSON5 text verbatim (validated via the json5
lexer). Casts: json5->json and json5->jsonb normalize and error on
Infinity/NaN; json->json5 implicit; jsonb->json5 and text->json5 via
I/O (text->json5 validates); json5->text verbatim.
---
 src/backend/utils/adt/json.c              | 264 +++++++++-
 src/backend/utils/adt/jsonb.c             |  47 ++
 src/backend/utils/adt/jsonfuncs.c         |   3 +-
 src/include/catalog/pg_cast.dat           |  14 +
 src/include/catalog/pg_proc.dat           |  20 +
 src/include/catalog/pg_type.dat           |   7 +
 src/include/utils/json.h                  |   1 +
 src/test/regress/expected/json5.out       | 612 ++++++++++++++++++++++
 src/test/regress/expected/opr_sanity.out  |   3 +-
 src/test/regress/expected/type_sanity.out |   1 +
 src/test/regress/parallel_schedule        |   2 +-
 src/test/regress/sql/json5.sql            | 165 ++++++
 src/test/regress/sql/type_sanity.sql      |   1 +
 src/tools/pgindent/typedefs.list          |   1 +
 14 files changed, 1130 insertions(+), 11 deletions(-)
 create mode 100644 src/test/regress/expected/json5.out
 create mode 100644 src/test/regress/sql/json5.sql

diff --git a/src/backend/utils/adt/json.c b/src/backend/utils/adt/json.c
index 94040c5e03e..6e01f094a98 100644
--- a/src/backend/utils/adt/json.c
+++ b/src/backend/utils/adt/json.c
@@ -100,17 +100,20 @@ static void add_json(Datum val, bool is_null, StringInfo result,
 static text *catenate_stringinfo_string(StringInfo buffer, const char *addon);
 
 /*
- * Input.
+ * Input.  Shared by json and json5: both store the input text verbatim and
+ * differ only in the syntax the lexer accepts.
  */
-Datum
-json_in(PG_FUNCTION_ARGS)
+static Datum
+json_in_common(FunctionCallInfo fcinfo, bool json5)
 {
 	char	   *json = PG_GETARG_CSTRING(0);
 	text	   *result = cstring_to_text(json);
 	JsonLexContext lex;
 
 	/* validate it */
-	makeJsonLexContext(&lex, result, false);
+	makeJsonLexContextCstringLen(&lex, VARDATA_ANY(result),
+								 VARSIZE_ANY_EXHDR(result),
+								 GetDatabaseEncoding(), false, json5);
 	if (!pg_parse_json_or_errsave(&lex, &nullSemAction, fcinfo->context))
 		PG_RETURN_NULL();
 
@@ -118,6 +121,12 @@ json_in(PG_FUNCTION_ARGS)
 	PG_RETURN_TEXT_P(result);
 }
 
+Datum
+json_in(PG_FUNCTION_ARGS)
+{
+	return json_in_common(fcinfo, false);
+}
+
 /*
  * Output.
  */
@@ -145,10 +154,10 @@ json_send(PG_FUNCTION_ARGS)
 }
 
 /*
- * Binary receive.
+ * Binary receive.  Shared by json and json5, like the input function.
  */
-Datum
-json_recv(PG_FUNCTION_ARGS)
+static Datum
+json_recv_common(FunctionCallInfo fcinfo, bool json5)
 {
 	StringInfo	buf = (StringInfo) PG_GETARG_POINTER(0);
 	char	   *str;
@@ -159,12 +168,18 @@ json_recv(PG_FUNCTION_ARGS)
 
 	/* Validate it. */
 	makeJsonLexContextCstringLen(&lex, str, nbytes, GetDatabaseEncoding(),
-								 false, false);
+								 false, json5);
 	pg_parse_json_or_ereport(&lex, &nullSemAction);
 
 	PG_RETURN_TEXT_P(cstring_to_text_with_len(str, nbytes));
 }
 
+Datum
+json_recv(PG_FUNCTION_ARGS)
+{
+	return json_recv_common(fcinfo, false);
+}
+
 /*
  * Turn a Datum into JSON text, appending the string to "result".
  *
@@ -1881,3 +1896,236 @@ json_typeof(PG_FUNCTION_ARGS)
 
 	PG_RETURN_TEXT_P(cstring_to_text(type));
 }
+
+/*
+ * json5 I/O functions
+ *
+ * json5 stores its text verbatim, same as json, but input is validated
+ * with the json5 lexer instead of the strict json one.  Output and binary
+ * send are shared with json at the catalog level (see pg_proc.dat).
+ */
+
+Datum
+json5_in(PG_FUNCTION_ARGS)
+{
+	return json_in_common(fcinfo, true);
+}
+
+Datum
+json5_recv(PG_FUNCTION_ARGS)
+{
+	return json_recv_common(fcinfo, true);
+}
+
+/*
+ * Does a json5 number token denote one of the non-finite values (Infinity
+ * or NaN, optionally signed) that neither json nor jsonb can represent?
+ */
+bool
+json5_nonfinite_number(const char *token)
+{
+	if (token[0] == '+' || token[0] == '-')
+		token++;
+	return strcmp(token, "Infinity") == 0 || strcmp(token, "NaN") == 0;
+}
+
+/*
+ * json5_to_json: convert json5 text to strict JSON
+ *
+ * Re-lexes the json5 text and re-emits standard JSON via the semantic
+ * callbacks below.  Infinity/NaN numbers have no strict JSON
+ * representation, so they're rejected here.
+ */
+
+typedef struct Json5ToJsonState
+{
+	StringInfo	result;
+	bool	   *has_previous;
+	int			depth;
+	int			capacity;
+} Json5ToJsonState;
+
+/* grow has_previous so it can hold at least s->depth + 1 entries */
+static void
+json5_to_json_push(Json5ToJsonState *s)
+{
+	if (s->depth >= s->capacity)
+	{
+		s->capacity *= 2;
+		s->has_previous = repalloc(s->has_previous,
+								   s->capacity * sizeof(bool));
+	}
+	s->has_previous[s->depth++] = false;
+}
+
+/* emit the separating comma if a sibling value precedes this one */
+static void
+json5_to_json_value_prefix(Json5ToJsonState *s)
+{
+	if (s->depth > 0 && s->has_previous[s->depth - 1])
+		appendStringInfoChar(s->result, ',');
+}
+
+static JsonParseErrorType
+json5_to_json_object_start(void *state)
+{
+	Json5ToJsonState *s = (Json5ToJsonState *) state;
+
+	json5_to_json_value_prefix(s);
+	appendStringInfoChar(s->result, '{');
+	json5_to_json_push(s);
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+json5_to_json_object_end(void *state)
+{
+	Json5ToJsonState *s = (Json5ToJsonState *) state;
+
+	s->depth--;
+	appendStringInfoChar(s->result, '}');
+	if (s->depth > 0)
+		s->has_previous[s->depth - 1] = true;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+json5_to_json_array_start(void *state)
+{
+	Json5ToJsonState *s = (Json5ToJsonState *) state;
+
+	json5_to_json_value_prefix(s);
+	appendStringInfoChar(s->result, '[');
+	json5_to_json_push(s);
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+json5_to_json_array_end(void *state)
+{
+	Json5ToJsonState *s = (Json5ToJsonState *) state;
+
+	s->depth--;
+	appendStringInfoChar(s->result, ']');
+	if (s->depth > 0)
+		s->has_previous[s->depth - 1] = true;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+json5_to_json_object_field_start(void *state, char *fname, bool isnull)
+{
+	Json5ToJsonState *s = (Json5ToJsonState *) state;
+
+	if (s->has_previous[s->depth - 1])
+		appendStringInfoChar(s->result, ',');
+	escape_json(s->result, fname);
+	appendStringInfoChar(s->result, ':');
+	/* comma already emitted here, the upcoming value must not add one */
+	s->has_previous[s->depth - 1] = false;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+json5_to_json_scalar(void *state, char *token, JsonTokenType tokentype)
+{
+	Json5ToJsonState *s = (Json5ToJsonState *) state;
+
+	json5_to_json_value_prefix(s);
+
+	switch (tokentype)
+	{
+		case JSON_TOKEN_STRING:
+			escape_json(s->result, token);
+			break;
+		case JSON_TOKEN_NUMBER:
+			if (json5_nonfinite_number(token))
+				ereport(ERROR,
+						(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
+						 errmsg("cannot convert JSON5 value \"%s\" to JSON",
+								token),
+						 errdetail("JSON does not support Infinity or NaN numeric values.")));
+			if (IsValidJsonNumber(token, strlen(token)))
+				appendStringInfoString(s->result, token);
+			else
+			{
+				/*
+				 * A json5-only number form (hex, leading/trailing decimal
+				 * point, explicit plus sign) has to be normalized through
+				 * numeric to become a valid JSON number.
+				 */
+				Datum		num = DirectFunctionCall3(numeric_in,
+													  CStringGetDatum(token),
+													  ObjectIdGetDatum(InvalidOid),
+													  Int32GetDatum(-1));
+
+				appendStringInfoString(s->result,
+									   DatumGetCString(DirectFunctionCall1(numeric_out, num)));
+			}
+			break;
+		case JSON_TOKEN_TRUE:
+			appendStringInfoString(s->result, "true");
+			break;
+		case JSON_TOKEN_FALSE:
+			appendStringInfoString(s->result, "false");
+			break;
+		case JSON_TOKEN_NULL:
+			appendStringInfoString(s->result, "null");
+			break;
+		default:
+			elog(ERROR, "unexpected json token type: %d", tokentype);
+			break;
+	}
+
+	if (s->depth > 0)
+		s->has_previous[s->depth - 1] = true;
+
+	return JSON_SUCCESS;
+}
+
+Datum
+json5_to_json(PG_FUNCTION_ARGS)
+{
+	text	   *json5 = PG_GETARG_TEXT_PP(0);
+	JsonLexContext lex;
+	JsonSemAction sem;
+	Json5ToJsonState state;
+	StringInfoData result;
+
+	/*
+	 * Fast path: json5 is a superset of json, so stored text that is
+	 * already valid strict JSON casts verbatim.  Besides skipping the
+	 * rebuild, this keeps \u0000 escapes intact, which the de-escaping
+	 * rebuild below cannot represent.
+	 */
+	makeJsonLexContextCstringLen(&lex, VARDATA_ANY(json5),
+								 VARSIZE_ANY_EXHDR(json5),
+								 GetDatabaseEncoding(), false, false);
+	if (pg_parse_json(&lex, &nullSemAction) == JSON_SUCCESS)
+		PG_RETURN_TEXT_P(json5);
+	freeJsonLexContext(&lex);
+
+	initStringInfo(&result);
+	memset(&state, 0, sizeof(state));
+	state.result = &result;
+	state.depth = 0;
+	state.capacity = 64;
+	state.has_previous = palloc(state.capacity * sizeof(bool));
+
+	memset(&sem, 0, sizeof(sem));
+	sem.semstate = &state;
+	sem.object_start = json5_to_json_object_start;
+	sem.object_end = json5_to_json_object_end;
+	sem.array_start = json5_to_json_array_start;
+	sem.array_end = json5_to_json_array_end;
+	sem.object_field_start = json5_to_json_object_field_start;
+	sem.scalar = json5_to_json_scalar;
+
+	makeJsonLexContextCstringLen(&lex, VARDATA_ANY(json5),
+								 VARSIZE_ANY_EXHDR(json5),
+								 GetDatabaseEncoding(), true, true);
+	pg_parse_json_or_ereport(&lex, &sem);
+	freeJsonLexContext(&lex);
+
+	PG_RETURN_TEXT_P(cstring_to_text_with_len(result.data, result.len));
+}
diff --git a/src/backend/utils/adt/jsonb.c b/src/backend/utils/adt/jsonb.c
index 1c97ede567a..dabe8b2cd88 100644
--- a/src/backend/utils/adt/jsonb.c
+++ b/src/backend/utils/adt/jsonb.c
@@ -2034,3 +2034,50 @@ JsonbUnquote(Jsonb *jb)
 	else
 		return JsonbToCString(NULL, &jb->root, VARSIZE(jb));
 }
+
+/*
+ * json5_to_jsonb: convert json5 text to jsonb
+ *
+ * Same building blocks as jsonb_in, but lexes with json5 enabled and
+ * rejects Infinity/NaN, which jsonb has no representation for.
+ */
+static JsonParseErrorType
+json5_to_jsonb_scalar(void *pstate, char *token, JsonTokenType tokentype)
+{
+	if (tokentype == JSON_TOKEN_NUMBER && json5_nonfinite_number(token))
+		ereport(ERROR,
+				(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
+				 errmsg("cannot convert JSON5 value \"%s\" to jsonb", token),
+				 errdetail("The jsonb type does not support Infinity or NaN numeric values.")));
+
+	return jsonb_in_scalar(pstate, token, tokentype);
+}
+
+Datum
+json5_to_jsonb(PG_FUNCTION_ARGS)
+{
+	text	   *json5 = PG_GETARG_TEXT_PP(0);
+	char	   *str = VARDATA_ANY(json5);
+	int			len = VARSIZE_ANY_EXHDR(json5);
+	JsonLexContext lex;
+	JsonbInState state;
+	JsonSemAction sem;
+
+	memset(&state, 0, sizeof(state));
+	memset(&sem, 0, sizeof(sem));
+	makeJsonLexContextCstringLen(&lex, str, len, GetDatabaseEncoding(),
+								 true, true);
+
+	sem.semstate = &state;
+	sem.object_start = jsonb_in_object_start;
+	sem.array_start = jsonb_in_array_start;
+	sem.object_end = jsonb_in_object_end;
+	sem.array_end = jsonb_in_array_end;
+	sem.scalar = json5_to_jsonb_scalar;
+	sem.object_field_start = jsonb_in_object_field_start;
+
+	pg_parse_json_or_ereport(&lex, &sem);
+	freeJsonLexContext(&lex);
+
+	PG_RETURN_POINTER(JsonbValueToJsonb(state.result));
+}
diff --git a/src/backend/utils/adt/jsonfuncs.c b/src/backend/utils/adt/jsonfuncs.c
index 08aec32c0d6..f895d7050b1 100644
--- a/src/backend/utils/adt/jsonfuncs.c
+++ b/src/backend/utils/adt/jsonfuncs.c
@@ -662,7 +662,8 @@ json_errsave_error(JsonParseErrorType error, JsonLexContext *lex,
 	else
 		errsave(escontext,
 				(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
-				 errmsg("invalid input syntax for type %s", "json"),
+				 errmsg("invalid input syntax for type %s",
+						lex->json5 ? "json5" : "json"),
 				 errdetail_internal("%s", json_errdetail(error, lex)),
 				 report_json_context(lex)));
 }
diff --git a/src/include/catalog/pg_cast.dat b/src/include/catalog/pg_cast.dat
index a7b6d812c5a..9654ed318bd 100644
--- a/src/include/catalog/pg_cast.dat
+++ b/src/include/catalog/pg_cast.dat
@@ -594,4 +594,18 @@
 { castsource => 'tstzrange', casttarget => 'tstzmultirange',
   castfunc => 'tstzmultirange(tstzrange)', castcontext => 'e',
   castmethod => 'f' },
+
+# json5 casts
+{ castsource => 'json5', casttarget => 'json',
+  castfunc => 'json5_to_json', castcontext => 'a', castmethod => 'f' },
+{ castsource => 'json5', casttarget => 'jsonb',
+  castfunc => 'json5_to_jsonb', castcontext => 'a', castmethod => 'f' },
+{ castsource => 'json', casttarget => 'json5', castfunc => '0',
+  castcontext => 'i', castmethod => 'i' },
+{ castsource => 'json5', casttarget => 'text', castfunc => '0',
+  castcontext => 'a', castmethod => 'i' },
+{ castsource => 'text', casttarget => 'json5', castfunc => '0',
+  castcontext => 'e', castmethod => 'i' },
+{ castsource => 'jsonb', casttarget => 'json5', castfunc => '0',
+  castcontext => 'a', castmethod => 'i' },
 ]
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 3cb84359adf..98952f0bd49 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12718,4 +12718,24 @@
   proname => 'hashoid8extended', prorettype => 'int8',
   proargtypes => 'oid8 int8', prosrc => 'hashoid8extended' },
 
+# json5
+{ oid => '9962', descr => 'I/O',
+  proname => 'json5_in', prorettype => 'json5', proargtypes => 'cstring',
+  prosrc => 'json5_in' },
+{ oid => '9963', descr => 'I/O',
+  proname => 'json5_out', prorettype => 'cstring', proargtypes => 'json5',
+  prosrc => 'json_out' },
+{ oid => '9964', descr => 'I/O',
+  proname => 'json5_recv', prorettype => 'json5', proargtypes => 'internal',
+  prosrc => 'json5_recv' },
+{ oid => '9965', descr => 'I/O',
+  proname => 'json5_send', prorettype => 'bytea', proargtypes => 'json5',
+  prosrc => 'json_send' },
+{ oid => '9966', descr => 'convert json5 to json',
+  proname => 'json5_to_json', prorettype => 'json', proargtypes => 'json5',
+  prosrc => 'json5_to_json' },
+{ oid => '9967', descr => 'convert json5 to jsonb',
+  proname => 'json5_to_jsonb', prorettype => 'jsonb', proargtypes => 'json5',
+  prosrc => 'json5_to_jsonb' },
+
 ]
diff --git a/src/include/catalog/pg_type.dat b/src/include/catalog/pg_type.dat
index 42ee494601b..b0ec0808b5d 100644
--- a/src/include/catalog/pg_type.dat
+++ b/src/include/catalog/pg_type.dat
@@ -135,6 +135,13 @@
 
 # OIDS 100 - 199
 
+{ oid => '9960', array_type_oid => '9961',
+  descr => 'JSON5 stored as text',
+  typname => 'json5', typlen => '-1', typbyval => 'f', typcategory => 'U',
+  typinput => 'json5_in', typoutput => 'json5_out',
+  typreceive => 'json5_recv', typsend => 'json5_send',
+  typalign => 'i', typstorage => 'x' },
+
 { oid => '114', array_type_oid => '199', descr => 'JSON stored as text',
   typname => 'json', typlen => '-1', typbyval => 'f', typcategory => 'U',
   typinput => 'json_in', typoutput => 'json_out', typreceive => 'json_recv',
diff --git a/src/include/utils/json.h b/src/include/utils/json.h
index 2f4be40518d..5b3fab8bd5a 100644
--- a/src/include/utils/json.h
+++ b/src/include/utils/json.h
@@ -31,5 +31,6 @@ extern Datum json_build_object_worker(int nargs, const Datum *args, const bool *
 extern Datum json_build_array_worker(int nargs, const Datum *args, const bool *nulls,
 									 const Oid *types, bool absent_on_null);
 extern bool json_validate(text *json, bool check_unique_keys, bool throw_error);
+extern bool json5_nonfinite_number(const char *token);
 
 #endif							/* JSON_H */
diff --git a/src/test/regress/expected/json5.out b/src/test/regress/expected/json5.out
new file mode 100644
index 00000000000..6b349fe2799
--- /dev/null
+++ b/src/test/regress/expected/json5.out
@@ -0,0 +1,612 @@
+-- JSON5 type input validation
+--
+-- The json5 input function uses the non-incremental (recursive descent)
+-- parser, so this file is also what exercises that parser's JSON5
+-- handling; the incremental parser is covered by the test_json_parser
+-- module.
+-- valid JSON is valid JSON5
+SELECT '{"key": "value"}'::json5;
+      json5       
+------------------
+ {"key": "value"}
+(1 row)
+
+SELECT '[1, 2, 3]'::json5;
+   json5   
+-----------
+ [1, 2, 3]
+(1 row)
+
+SELECT '"hello"'::json5;
+  json5  
+---------
+ "hello"
+(1 row)
+
+SELECT '42'::json5;
+ json5 
+-------
+ 42
+(1 row)
+
+SELECT 'true'::json5;
+ json5 
+-------
+ true
+(1 row)
+
+SELECT 'null'::json5;
+ json5 
+-------
+ null
+(1 row)
+
+-- unquoted keys
+SELECT '{key: "value"}'::json5;
+     json5      
+----------------
+ {key: "value"}
+(1 row)
+
+SELECT '{_key: "value"}'::json5;
+      json5      
+-----------------
+ {_key: "value"}
+(1 row)
+
+SELECT '{$key: "value"}'::json5;
+      json5      
+-----------------
+ {$key: "value"}
+(1 row)
+
+-- reserved words as unquoted keys
+SELECT '{true: 1, false: 2, null: 3}'::json5;
+            json5             
+------------------------------
+ {true: 1, false: 2, null: 3}
+(1 row)
+
+-- trailing commas
+SELECT '{"key": "value",}'::json5;
+       json5       
+-------------------
+ {"key": "value",}
+(1 row)
+
+SELECT '[1, 2, 3,]'::json5;
+   json5    
+------------
+ [1, 2, 3,]
+(1 row)
+
+-- comments
+SELECT E'// line comment\n{"key": "value"}'::json5;
+      json5       
+------------------
+ // line comment +
+ {"key": "value"}
+(1 row)
+
+SELECT '/* block comment */ {"key": "value"}'::json5;
+                json5                 
+--------------------------------------
+ /* block comment */ {"key": "value"}
+(1 row)
+
+SELECT E'{"key": /* inline */ "value"}'::json5;
+             json5             
+-------------------------------
+ {"key": /* inline */ "value"}
+(1 row)
+
+-- single-quoted strings
+SELECT $json5${'key': 'value'}$json5$::json5;
+      json5       
+------------------
+ {'key': 'value'}
+(1 row)
+
+SELECT $json5$["single's quote"]$json5$::json5;
+       json5        
+--------------------
+ ["single's quote"]
+(1 row)
+
+-- number extensions
+SELECT '{"a": .5}'::json5;
+   json5   
+-----------
+ {"a": .5}
+(1 row)
+
+SELECT '{"a": 5.}'::json5;
+   json5   
+-----------
+ {"a": 5.}
+(1 row)
+
+SELECT '{"a": 0xFF}'::json5;
+    json5    
+-------------
+ {"a": 0xFF}
+(1 row)
+
+SELECT '{"a": +1}'::json5;
+   json5   
+-----------
+ {"a": +1}
+(1 row)
+
+SELECT '{"a": Infinity}'::json5;
+      json5      
+-----------------
+ {"a": Infinity}
+(1 row)
+
+SELECT '{"a": -Infinity}'::json5;
+      json5       
+------------------
+ {"a": -Infinity}
+(1 row)
+
+SELECT '{"a": NaN}'::json5;
+   json5    
+------------
+ {"a": NaN}
+(1 row)
+
+-- multi-line strings
+SELECT E'{"key": "line1\\\nline2"}'::json5;
+      json5      
+-----------------
+ {"key": "line1\+
+ line2"}
+(1 row)
+
+-- unquoted identifiers are only valid as keys, not values (all error)
+SELECT '{key: value}'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '{key: value}'::json5;
+               ^
+DETAIL:  Expected JSON value, but found "value".
+CONTEXT:  JSON data, line 1: {key: value...
+SELECT '{"key": undefined}'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '{"key": undefined}'::json5;
+               ^
+DETAIL:  Expected JSON value, but found "undefined".
+CONTEXT:  JSON data, line 1: {"key": undefined...
+SELECT '[a]'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '[a]'::json5;
+               ^
+DETAIL:  Expected JSON value, but found "a".
+CONTEXT:  JSON data, line 1: [a...
+SELECT 'undefined'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT 'undefined'::json5;
+               ^
+DETAIL:  Expected JSON value, but found "undefined".
+CONTEXT:  JSON data, line 1: undefined
+-- invalid JSON5 (should error)
+SELECT '{"key": }'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '{"key": }'::json5;
+               ^
+DETAIL:  Expected JSON value, but found "}".
+CONTEXT:  JSON data, line 1: {"key": }
+SELECT ''::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT ''::json5;
+               ^
+DETAIL:  The input string ended unexpectedly.
+CONTEXT:  JSON data, line 1: 
+SELECT '[1,,]'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '[1,,]'::json5;
+               ^
+DETAIL:  Expected JSON value, but found ",".
+CONTEXT:  JSON data, line 1: [1,,...
+SELECT '{1a: 1}'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '{1a: 1}'::json5;
+               ^
+DETAIL:  Token "1a" is invalid.
+CONTEXT:  JSON data, line 1: {1a...
+SELECT '0x'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '0x'::json5;
+               ^
+DETAIL:  Token "0x" is invalid.
+CONTEXT:  JSON data, line 1: 0x
+SELECT '.'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '.'::json5;
+               ^
+DETAIL:  Token "." is invalid.
+CONTEXT:  JSON data, line 1: .
+SELECT '/* unterminated'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '/* unterminated'::json5;
+               ^
+DETAIL:  Block comment is not terminated.
+CONTEXT:  JSON data, line 1: /* unterminated
+SELECT $json5$'unterminated$json5$::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT $json5$'unterminated$json5$::json5;
+               ^
+DETAIL:  Token "'unterminated" is invalid.
+CONTEXT:  JSON data, line 1: 'unterminated
+-- unterminated block comments error even after a complete value
+SELECT '1 /*'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '1 /*'::json5;
+               ^
+DETAIL:  Block comment is not terminated.
+CONTEXT:  JSON data, line 1: 1 /*
+SELECT '1 /**'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '1 /**'::json5;
+               ^
+DETAIL:  Block comment is not terminated.
+CONTEXT:  JSON data, line 1: 1 /**
+SELECT '1 /*/'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '1 /*/'::json5;
+               ^
+DETAIL:  Block comment is not terminated.
+CONTEXT:  JSON data, line 1: 1 /*/
+SELECT '[1] /* {"garbage" [[['::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '[1] /* {"garbage" [[['::json5;
+               ^
+DETAIL:  Block comment is not terminated.
+CONTEXT:  JSON data, line 1: [1] /* {"garbage" [[[
+SELECT '1 /**/ /*'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '1 /**/ /*'::json5;
+               ^
+DETAIL:  Block comment is not terminated.
+CONTEXT:  JSON data, line 1: 1 /**/ /*
+SELECT '1 /**/'::json5;
+ json5  
+--------
+ 1 /**/
+(1 row)
+
+-- line comments end at CR as well as LF
+SELECT E'[1, // c\r2\n]'::json5::jsonb;
+ jsonb  
+--------
+ [1, 2]
+(1 row)
+
+SELECT E'// c\r1'::json5::json;
+ json 
+------
+ 1
+(1 row)
+
+SELECT E'1 // c\rgarbage'::json5;
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT E'1 // c\rgarbage'::json5;
+               ^
+DETAIL:  Expected end of input, but found "garbage".
+CONTEXT:  JSON data, line 1: 1 // c
garbage
+-- signed NaN
+SELECT '-NaN'::json5;
+ json5 
+-------
+ -NaN
+(1 row)
+
+SELECT '+NaN'::json5;
+ json5 
+-------
+ +NaN
+(1 row)
+
+SELECT '{"a": -NaN}'::json5::json;   -- should ERROR
+ERROR:  cannot convert JSON5 value "-NaN" to JSON
+DETAIL:  JSON does not support Infinity or NaN numeric values.
+SELECT '{"a": +NaN}'::json5::jsonb;  -- should ERROR
+ERROR:  cannot convert JSON5 value "+NaN" to jsonb
+DETAIL:  The jsonb type does not support Infinity or NaN numeric values.
+-- Infinity and NaN are identifiers, so also valid as unquoted keys
+SELECT '{Infinity: 1, NaN: 2}'::json5;
+         json5         
+-----------------------
+ {Infinity: 1, NaN: 2}
+(1 row)
+
+SELECT '{Infinity: 1, NaN: 2}'::json5::json;
+          json          
+------------------------
+ {"Infinity":1,"NaN":2}
+(1 row)
+
+SELECT '{Infinity: 1, NaN: 2}'::json5::jsonb;
+           jsonb           
+---------------------------
+ {"NaN": 2, "Infinity": 1}
+(1 row)
+
+SELECT '{-Infinity: 1}'::json5;      -- should ERROR (not an identifier)
+ERROR:  invalid input syntax for type json5
+LINE 1: SELECT '{-Infinity: 1}'::json5;
+               ^
+DETAIL:  Expected string or "}", but found "-Infinity".
+CONTEXT:  JSON data, line 1: {-Infinity...
+-- \u0000 is valid json, so the json5 -> json cast must preserve it
+SELECT '"\u0000"'::json5::json;
+   json   
+----------
+ "\u0000"
+(1 row)
+
+SELECT '{"a": "\u0000"}'::json5::json;
+      json       
+-----------------
+ {"a": "\u0000"}
+(1 row)
+
+-- but combined with json5-only syntax the rebuild path still rejects it
+SELECT '{a: "\u0000"}'::json5::json; -- should ERROR
+ERROR:  unsupported Unicode escape sequence
+DETAIL:  \u0000 cannot be converted to text.
+CONTEXT:  JSON data, line 1: {a: "\u0000...
+-- jsonb rejects \u0000 itself, the cast stays consistent with that
+SELECT '"\u0000"'::json5::jsonb;     -- should ERROR
+ERROR:  unsupported Unicode escape sequence
+DETAIL:  \u0000 cannot be converted to text.
+CONTEXT:  JSON data, line 1: "\u0000...
+-- verbatim storage: output preserves original formatting
+SELECT $json5${key: 'value', /* comment */}$json5$::json5;
+             json5             
+-------------------------------
+ {key: 'value', /* comment */}
+(1 row)
+
+SELECT E'// comment\n[1, 2,]'::json5;
+   json5    
+------------
+ // comment+
+ [1, 2,]
+(1 row)
+
+-- cast: json5 -> json (normalizes)
+SELECT '{"key": "value"}'::json5::json;
+       json       
+------------------
+ {"key": "value"}
+(1 row)
+
+SELECT $json5${key: 'value'}$json5$::json5::json;
+      json       
+-----------------
+ {"key":"value"}
+(1 row)
+
+SELECT '{true: 1, null: 2}'::json5::json;
+        json         
+---------------------
+ {"true":1,"null":2}
+(1 row)
+
+SELECT '{"a": .5}'::json5::json;
+   json    
+-----------
+ {"a":0.5}
+(1 row)
+
+SELECT '{"a": 5.}'::json5::json;
+  json   
+---------
+ {"a":5}
+(1 row)
+
+SELECT '{"a": 0xFF}'::json5::json;
+   json    
+-----------
+ {"a":255}
+(1 row)
+
+SELECT '{"a": +1}'::json5::json;
+  json   
+---------
+ {"a":1}
+(1 row)
+
+SELECT '{"a": 6.022e23}'::json5::json;
+      json       
+-----------------
+ {"a": 6.022e23}
+(1 row)
+
+SELECT '[1, 2, 3,]'::json5::json;
+  json   
+---------
+ [1,2,3]
+(1 row)
+
+SELECT E'// c\n{"a": [1, /* c */ 2]}'::json5::json;
+    json     
+-------------
+ {"a":[1,2]}
+(1 row)
+
+SELECT E'{"key": "line1\\\nline2"}'::json5::json;
+         json         
+----------------------
+ {"key":"line1line2"}
+(1 row)
+
+-- containers as array elements need separating commas
+SELECT '[[1], [2], {"a": [3, {"b": 4}]}]'::json5::json;
+               json               
+----------------------------------
+ [[1], [2], {"a": [3, {"b": 4}]}]
+(1 row)
+
+SELECT '[{"a": 1}, {"b": 2}, 3, [4]]'::json5::json;
+             json             
+------------------------------
+ [{"a": 1}, {"b": 2}, 3, [4]]
+(1 row)
+
+SELECT '{"key": Infinity}'::json5::json;   -- should ERROR
+ERROR:  cannot convert JSON5 value "Infinity" to JSON
+DETAIL:  JSON does not support Infinity or NaN numeric values.
+SELECT '{"key": -Infinity}'::json5::json;  -- should ERROR
+ERROR:  cannot convert JSON5 value "-Infinity" to JSON
+DETAIL:  JSON does not support Infinity or NaN numeric values.
+SELECT '{"key": +Infinity}'::json5::json;  -- should ERROR
+ERROR:  cannot convert JSON5 value "+Infinity" to JSON
+DETAIL:  JSON does not support Infinity or NaN numeric values.
+SELECT '{"key": NaN}'::json5::json;        -- should ERROR
+ERROR:  cannot convert JSON5 value "NaN" to JSON
+DETAIL:  JSON does not support Infinity or NaN numeric values.
+-- cast: json5 -> jsonb
+SELECT '{"key": "value"}'::json5::jsonb;
+      jsonb       
+------------------
+ {"key": "value"}
+(1 row)
+
+SELECT $json5${key: 'value'}$json5$::json5::jsonb;
+      jsonb       
+------------------
+ {"key": "value"}
+(1 row)
+
+SELECT '{true: 1, null: 2}'::json5::jsonb;
+         jsonb          
+------------------------
+ {"null": 2, "true": 1}
+(1 row)
+
+SELECT '{"a": .5}'::json5::jsonb;
+   jsonb    
+------------
+ {"a": 0.5}
+(1 row)
+
+SELECT '{"a": 5.}'::json5::jsonb;
+  jsonb   
+----------
+ {"a": 5}
+(1 row)
+
+SELECT '{"a": 0xFF}'::json5::jsonb;
+   jsonb    
+------------
+ {"a": 255}
+(1 row)
+
+SELECT '[1, 2, 3,]'::json5::jsonb;
+   jsonb   
+-----------
+ [1, 2, 3]
+(1 row)
+
+SELECT '[[1], [2], {"a": [3, {"b": 4}]}]'::json5::jsonb;
+              jsonb               
+----------------------------------
+ [[1], [2], {"a": [3, {"b": 4}]}]
+(1 row)
+
+SELECT '{"key": Infinity}'::json5::jsonb;  -- should ERROR
+ERROR:  cannot convert JSON5 value "Infinity" to jsonb
+DETAIL:  The jsonb type does not support Infinity or NaN numeric values.
+SELECT '{"key": -Infinity}'::json5::jsonb; -- should ERROR
+ERROR:  cannot convert JSON5 value "-Infinity" to jsonb
+DETAIL:  The jsonb type does not support Infinity or NaN numeric values.
+SELECT '{"key": +Infinity}'::json5::jsonb; -- should ERROR
+ERROR:  cannot convert JSON5 value "+Infinity" to jsonb
+DETAIL:  The jsonb type does not support Infinity or NaN numeric values.
+SELECT '{"key": NaN}'::json5::jsonb;       -- should ERROR
+ERROR:  cannot convert JSON5 value "NaN" to jsonb
+DETAIL:  The jsonb type does not support Infinity or NaN numeric values.
+-- cast: json -> json5 (implicit)
+SELECT '{"key": "value"}'::json::json5;
+      json5       
+------------------
+ {"key": "value"}
+(1 row)
+
+-- cast: jsonb -> json5 (assignment; serializes then validates)
+SELECT '{"key": "value"}'::jsonb::json5;
+      json5       
+------------------
+ {"key": "value"}
+(1 row)
+
+SELECT '{"a": 0.5, "b": [1,2,3]}'::jsonb::json5;
+           json5            
+----------------------------
+ {"a": 0.5, "b": [1, 2, 3]}
+(1 row)
+
+-- cast: json5 -> text (assignment; verbatim)
+SELECT '{"key": "value"}'::json5::text;
+       text       
+------------------
+ {"key": "value"}
+(1 row)
+
+SELECT $json5${key: 'value', /* comment */}$json5$::json5::text;
+             text              
+-------------------------------
+ {key: 'value', /* comment */}
+(1 row)
+
+-- cast: text -> json5 (explicit; validates)
+SELECT CAST('{"key": "value"}' AS json5);
+      json5       
+------------------
+ {"key": "value"}
+(1 row)
+
+SELECT CAST($json5${key: 'value'}$json5$ AS json5);
+     json5      
+----------------
+ {key: 'value'}
+(1 row)
+
+SELECT 'not json5 {'::text::json5;         -- should ERROR (validated)
+ERROR:  invalid input syntax for type json5
+DETAIL:  Expected JSON value, but found "not".
+CONTEXT:  JSON data, line 1: not...
+-- deep nesting: json5 -> json must not impose an arbitrary depth cap
+-- (parity with json5 -> jsonb and plain json; 100 > old fixed limit of 64)
+SELECT (repeat('[', 100) || '1' || repeat(']', 100))::json5::json IS NOT NULL AS to_json_ok,
+       (repeat('[', 100) || '1' || repeat(']', 100))::json5::jsonb IS NOT NULL AS to_jsonb_ok,
+       (repeat('[', 100) || '1' || repeat(']', 100))::json IS NOT NULL AS json_ok;
+ to_json_ok | to_jsonb_ok | json_ok 
+------------+-------------+---------
+ t          | t           | t
+(1 row)
+
+-- table storage test
+CREATE TABLE json5_test(id serial, data json5);
+INSERT INTO json5_test(data) VALUES ($json5${key: 'value', /* comment */}$json5$);
+INSERT INTO json5_test(data) VALUES ('{"normal": "json"}');
+INSERT INTO json5_test(data) VALUES ('[1, 2, 3,]');
+SELECT * FROM json5_test;
+ id |             data              
+----+-------------------------------
+  1 | {key: 'value', /* comment */}
+  2 | {"normal": "json"}
+  3 | [1, 2, 3,]
+(3 rows)
+
+SELECT id, data::jsonb FROM json5_test;
+ id |        data        
+----+--------------------
+  1 | {"key": "value"}
+  2 | {"normal": "json"}
+  3 | [1, 2, 3]
+(3 rows)
+
+DROP TABLE json5_test;
diff --git a/src/test/regress/expected/opr_sanity.out b/src/test/regress/expected/opr_sanity.out
index 6b519a65cc9..77c890461a8 100644
--- a/src/test/regress/expected/opr_sanity.out
+++ b/src/test/regress/expected/opr_sanity.out
@@ -178,10 +178,11 @@ ORDER BY 1, 2;
  bigint                      | xid8
  text                        | character
  text                        | character varying
+ json                        | json5
  timestamp without time zone | timestamp with time zone
  bit                         | bit varying
  txid_snapshot               | pg_snapshot
-(6 rows)
+(7 rows)
 
 SELECT DISTINCT p1.proargtypes[1]::regtype, p2.proargtypes[1]::regtype
 FROM pg_proc AS p1, pg_proc AS p2
diff --git a/src/test/regress/expected/type_sanity.out b/src/test/regress/expected/type_sanity.out
index 1d21d3eb446..336a5ecb70c 100644
--- a/src/test/regress/expected/type_sanity.out
+++ b/src/test/regress/expected/type_sanity.out
@@ -738,6 +738,7 @@ CREATE TABLE tab_core_types AS SELECT
   'now'::timestamptz,
   '12 seconds'::interval,
   '{"reason":"because"}'::json,
+  '{reason:"because"}'::json5,
   '{"when":"now"}'::jsonb,
   '$.a[*] ? (@ > 2)'::jsonpath,
   '127.0.0.1'::inet,
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 8fa0a6c47fb..446fa45c5e5 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -107,7 +107,7 @@ test: select_views portals_p2 foreign_key dependency guc bitmapops combocid tsea
 # ----------
 # Another group of parallel tests (JSON related)
 # ----------
-test: json jsonb json_encoding jsonpath jsonpath_encoding jsonb_jsonpath sqljson sqljson_queryfuncs sqljson_jsontable
+test: json jsonb json5 json_encoding jsonpath jsonpath_encoding jsonb_jsonpath sqljson sqljson_queryfuncs sqljson_jsontable
 
 # ----------
 # Another group of parallel tests
diff --git a/src/test/regress/sql/json5.sql b/src/test/regress/sql/json5.sql
new file mode 100644
index 00000000000..4cf078c717d
--- /dev/null
+++ b/src/test/regress/sql/json5.sql
@@ -0,0 +1,165 @@
+-- JSON5 type input validation
+--
+-- The json5 input function uses the non-incremental (recursive descent)
+-- parser, so this file is also what exercises that parser's JSON5
+-- handling; the incremental parser is covered by the test_json_parser
+-- module.
+
+-- valid JSON is valid JSON5
+SELECT '{"key": "value"}'::json5;
+SELECT '[1, 2, 3]'::json5;
+SELECT '"hello"'::json5;
+SELECT '42'::json5;
+SELECT 'true'::json5;
+SELECT 'null'::json5;
+
+-- unquoted keys
+SELECT '{key: "value"}'::json5;
+SELECT '{_key: "value"}'::json5;
+SELECT '{$key: "value"}'::json5;
+
+-- reserved words as unquoted keys
+SELECT '{true: 1, false: 2, null: 3}'::json5;
+
+-- trailing commas
+SELECT '{"key": "value",}'::json5;
+SELECT '[1, 2, 3,]'::json5;
+
+-- comments
+SELECT E'// line comment\n{"key": "value"}'::json5;
+SELECT '/* block comment */ {"key": "value"}'::json5;
+SELECT E'{"key": /* inline */ "value"}'::json5;
+
+-- single-quoted strings
+SELECT $json5${'key': 'value'}$json5$::json5;
+SELECT $json5$["single's quote"]$json5$::json5;
+
+-- number extensions
+SELECT '{"a": .5}'::json5;
+SELECT '{"a": 5.}'::json5;
+SELECT '{"a": 0xFF}'::json5;
+SELECT '{"a": +1}'::json5;
+SELECT '{"a": Infinity}'::json5;
+SELECT '{"a": -Infinity}'::json5;
+SELECT '{"a": NaN}'::json5;
+
+-- multi-line strings
+SELECT E'{"key": "line1\\\nline2"}'::json5;
+
+-- unquoted identifiers are only valid as keys, not values (all error)
+SELECT '{key: value}'::json5;
+SELECT '{"key": undefined}'::json5;
+SELECT '[a]'::json5;
+SELECT 'undefined'::json5;
+
+-- invalid JSON5 (should error)
+SELECT '{"key": }'::json5;
+SELECT ''::json5;
+SELECT '[1,,]'::json5;
+SELECT '{1a: 1}'::json5;
+SELECT '0x'::json5;
+SELECT '.'::json5;
+SELECT '/* unterminated'::json5;
+SELECT $json5$'unterminated$json5$::json5;
+
+-- unterminated block comments error even after a complete value
+SELECT '1 /*'::json5;
+SELECT '1 /**'::json5;
+SELECT '1 /*/'::json5;
+SELECT '[1] /* {"garbage" [[['::json5;
+SELECT '1 /**/ /*'::json5;
+SELECT '1 /**/'::json5;
+
+-- line comments end at CR as well as LF
+SELECT E'[1, // c\r2\n]'::json5::jsonb;
+SELECT E'// c\r1'::json5::json;
+SELECT E'1 // c\rgarbage'::json5;
+
+-- signed NaN
+SELECT '-NaN'::json5;
+SELECT '+NaN'::json5;
+SELECT '{"a": -NaN}'::json5::json;   -- should ERROR
+SELECT '{"a": +NaN}'::json5::jsonb;  -- should ERROR
+
+-- Infinity and NaN are identifiers, so also valid as unquoted keys
+SELECT '{Infinity: 1, NaN: 2}'::json5;
+SELECT '{Infinity: 1, NaN: 2}'::json5::json;
+SELECT '{Infinity: 1, NaN: 2}'::json5::jsonb;
+SELECT '{-Infinity: 1}'::json5;      -- should ERROR (not an identifier)
+
+-- \u0000 is valid json, so the json5 -> json cast must preserve it
+SELECT '"\u0000"'::json5::json;
+SELECT '{"a": "\u0000"}'::json5::json;
+-- but combined with json5-only syntax the rebuild path still rejects it
+SELECT '{a: "\u0000"}'::json5::json; -- should ERROR
+-- jsonb rejects \u0000 itself, the cast stays consistent with that
+SELECT '"\u0000"'::json5::jsonb;     -- should ERROR
+
+-- verbatim storage: output preserves original formatting
+SELECT $json5${key: 'value', /* comment */}$json5$::json5;
+SELECT E'// comment\n[1, 2,]'::json5;
+
+-- cast: json5 -> json (normalizes)
+SELECT '{"key": "value"}'::json5::json;
+SELECT $json5${key: 'value'}$json5$::json5::json;
+SELECT '{true: 1, null: 2}'::json5::json;
+SELECT '{"a": .5}'::json5::json;
+SELECT '{"a": 5.}'::json5::json;
+SELECT '{"a": 0xFF}'::json5::json;
+SELECT '{"a": +1}'::json5::json;
+SELECT '{"a": 6.022e23}'::json5::json;
+SELECT '[1, 2, 3,]'::json5::json;
+SELECT E'// c\n{"a": [1, /* c */ 2]}'::json5::json;
+SELECT E'{"key": "line1\\\nline2"}'::json5::json;
+-- containers as array elements need separating commas
+SELECT '[[1], [2], {"a": [3, {"b": 4}]}]'::json5::json;
+SELECT '[{"a": 1}, {"b": 2}, 3, [4]]'::json5::json;
+SELECT '{"key": Infinity}'::json5::json;   -- should ERROR
+SELECT '{"key": -Infinity}'::json5::json;  -- should ERROR
+SELECT '{"key": +Infinity}'::json5::json;  -- should ERROR
+SELECT '{"key": NaN}'::json5::json;        -- should ERROR
+
+-- cast: json5 -> jsonb
+SELECT '{"key": "value"}'::json5::jsonb;
+SELECT $json5${key: 'value'}$json5$::json5::jsonb;
+SELECT '{true: 1, null: 2}'::json5::jsonb;
+SELECT '{"a": .5}'::json5::jsonb;
+SELECT '{"a": 5.}'::json5::jsonb;
+SELECT '{"a": 0xFF}'::json5::jsonb;
+SELECT '[1, 2, 3,]'::json5::jsonb;
+SELECT '[[1], [2], {"a": [3, {"b": 4}]}]'::json5::jsonb;
+SELECT '{"key": Infinity}'::json5::jsonb;  -- should ERROR
+SELECT '{"key": -Infinity}'::json5::jsonb; -- should ERROR
+SELECT '{"key": +Infinity}'::json5::jsonb; -- should ERROR
+SELECT '{"key": NaN}'::json5::jsonb;       -- should ERROR
+
+-- cast: json -> json5 (implicit)
+SELECT '{"key": "value"}'::json::json5;
+
+-- cast: jsonb -> json5 (assignment; serializes then validates)
+SELECT '{"key": "value"}'::jsonb::json5;
+SELECT '{"a": 0.5, "b": [1,2,3]}'::jsonb::json5;
+
+-- cast: json5 -> text (assignment; verbatim)
+SELECT '{"key": "value"}'::json5::text;
+SELECT $json5${key: 'value', /* comment */}$json5$::json5::text;
+
+-- cast: text -> json5 (explicit; validates)
+SELECT CAST('{"key": "value"}' AS json5);
+SELECT CAST($json5${key: 'value'}$json5$ AS json5);
+SELECT 'not json5 {'::text::json5;         -- should ERROR (validated)
+
+-- deep nesting: json5 -> json must not impose an arbitrary depth cap
+-- (parity with json5 -> jsonb and plain json; 100 > old fixed limit of 64)
+SELECT (repeat('[', 100) || '1' || repeat(']', 100))::json5::json IS NOT NULL AS to_json_ok,
+       (repeat('[', 100) || '1' || repeat(']', 100))::json5::jsonb IS NOT NULL AS to_jsonb_ok,
+       (repeat('[', 100) || '1' || repeat(']', 100))::json IS NOT NULL AS json_ok;
+
+-- table storage test
+CREATE TABLE json5_test(id serial, data json5);
+INSERT INTO json5_test(data) VALUES ($json5${key: 'value', /* comment */}$json5$);
+INSERT INTO json5_test(data) VALUES ('{"normal": "json"}');
+INSERT INTO json5_test(data) VALUES ('[1, 2, 3,]');
+SELECT * FROM json5_test;
+SELECT id, data::jsonb FROM json5_test;
+DROP TABLE json5_test;
diff --git a/src/test/regress/sql/type_sanity.sql b/src/test/regress/sql/type_sanity.sql
index 95d5b6e0915..5fba6ad4dc8 100644
--- a/src/test/regress/sql/type_sanity.sql
+++ b/src/test/regress/sql/type_sanity.sql
@@ -554,6 +554,7 @@ CREATE TABLE tab_core_types AS SELECT
   'now'::timestamptz,
   '12 seconds'::interval,
   '{"reason":"because"}'::json,
+  '{reason:"because"}'::json5,
   '{"when":"now"}'::jsonb,
   '$.a[*] ? (@ > 2)'::jsonpath,
   '127.0.0.1'::inet,
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 68a93e27ab9..d599ee80ac7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1434,6 +1434,7 @@ JoinType
 JsObject
 JsValue
 Json5CommentState
+Json5ToJsonState
 JsonAggConstructor
 JsonAggState
 JsonArgument
-- 
2.43.0

