From 0c228a3fd688829d6ea5addaeae3bb991e6265b9 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Wed, 5 Aug 2026 20:59:44 +0200
Subject: [PATCH v2] JSON_TABLE: propagate table-level ON ERROR to columns per
 SQL standard

Per ISO/IEC 9075-2:2023, 7.11 <JSON table>, Syntax Rules 1)e)iv) and
1)f)xi), a regular or formatted JSON_TABLE column that does not specify
its own ON ERROR clause inherits its default error behavior from the
table-level ON ERROR clause: when the table specifies ERROR ON ERROR, the
column's implicit behavior is ERROR ON ERROR; otherwise (table-level
EMPTY, which is also the default) the column defaults to NULL ON ERROR.

PostgreSQL instead always defaulted such columns to NULL ON ERROR,
ignoring the table-level ERROR ON ERROR.  For example

  SELECT * FROM JSON_TABLE(jsonb '"err"', '$'
                           COLUMNS (a int PATH '$') ERROR ON ERROR) jt;

returned a NULL row where the standard requires an error to be raised.
The documentation even stated that the table-level clause "does not
affect the errors that occur when evaluating columns".

Implement the standard behavior at the JSON_TABLE syntactic
transformation: when a column lacks its own ON ERROR clause and the
table-level behavior is ERROR ON ERROR, synthesize an implicit ERROR ON
ERROR for the column before it is transformed into a JsonExpr.  A column
with its own ON ERROR clause is unaffected.  EXISTS columns are not part
of the standard, so they keep their FALSE ON ERROR default.

Update the documentation and regression tests accordingly; existing
tests that asserted the non-conforming behavior now assert the
propagation.  Note that a JSON_TABLE stored in a view is now deparsed
with the (previously implicit) ERROR ON ERROR shown explicitly on the
affected columns, which is a semantically equivalent, round-trip-stable
form.

Discussion: https://postgr.es/m/CAPpHfdt%3DLncQH9PAq9O8qO7KZcTT9rOsxLLanscRF7xDFvK8mA%40mail.gmail.com
---
 doc/src/sgml/func/func-json.sgml              | 16 +++++++---
 src/backend/parser/parse_jsontable.c          | 29 +++++++++++++++----
 .../regress/expected/sqljson_jsontable.out    | 28 +++++++++++++-----
 src/test/regress/sql/sqljson_jsontable.sql    | 17 +++++++----
 4 files changed, 68 insertions(+), 22 deletions(-)

diff --git a/doc/src/sgml/func/func-json.sgml b/doc/src/sgml/func/func-json.sgml
index 0763ec81ed8..f8ff4355a31 100644
--- a/doc/src/sgml/func/func-json.sgml
+++ b/doc/src/sgml/func/func-json.sgml
@@ -4021,10 +4021,18 @@ where <replaceable class="parameter">json_table_column</replaceable> is:
      handle errors when evaluating the top-level
      <replaceable>path_expression</replaceable>.  Use <literal>ERROR</literal>
      if you want the errors to be thrown and <literal>EMPTY</literal> to
-     return an empty table, that is, a table containing 0 rows.  Note that
-     this clause does not affect the errors that occur when evaluating
-     columns, for which the behavior depends on whether the
-     <literal>ON ERROR</literal> clause is specified against a given column.
+     return an empty table, that is, a table containing 0 rows.
+    </para>
+    <para>
+     This clause also provides the default error behavior for the individual
+     columns: a regular or formatted column that does not specify its own
+     <literal>ON ERROR</literal> clause uses <literal>ERROR ON ERROR</literal>
+     when <literal>ERROR</literal> is specified here, and
+     <literal>NULL ON ERROR</literal> otherwise (including when this clause is
+     omitted, in which case <literal>EMPTY</literal> is the default).  A column
+     that specifies its own <literal>ON ERROR</literal> clause is unaffected.
+     <literal>EXISTS</literal> columns are an exception: they always default to
+     <literal>FALSE ON ERROR</literal> regardless of this clause.
     </para>
     </listitem>
    </varlistentry>
diff --git a/src/backend/parser/parse_jsontable.c b/src/backend/parser/parse_jsontable.c
index d86c6946ee9..0c6c0a6dbf9 100644
--- a/src/backend/parser/parse_jsontable.c
+++ b/src/backend/parser/parse_jsontable.c
@@ -49,7 +49,8 @@ static JsonTablePlan *transformJsonTableNestedColumns(JsonTableParseContext *cxt
 													  List *columns);
 static JsonFuncExpr *transformJsonTableColumn(JsonTableColumn *jtc,
 											  Node *contextItemExpr,
-											  List *passingArgs);
+											  List *passingArgs,
+											  bool errorOnError);
 static bool isCompositeType(Oid typid);
 static JsonTablePlan *makeJsonTablePathScan(JsonTableParseContext *cxt,
 											JsonTablePathSpec *pathspec,
@@ -68,7 +69,8 @@ static JsonTablePlan *makeJsonTableSiblingJoin(bool cross,
 											   JsonTablePlan *lplan,
 											   JsonTablePlan *rplan);
 static void
-			appendJsonTableColumns(JsonTableParseContext *cxt, List *columns, List *passingArgs);
+			appendJsonTableColumns(JsonTableParseContext *cxt, List *columns,
+								   List *passingArgs, bool errorOnError);
 
 /*
  * transformJsonTable -
@@ -344,7 +346,7 @@ transformJsonTableColumns(JsonTableParseContext *cxt,
 		validateJsonTableChildPlan(cxt, childPlanSpec, columns);
 	}
 
-	appendJsonTableColumns(cxt, columns, passingArgs);
+	appendJsonTableColumns(cxt, columns, passingArgs, errorOnError);
 
 	/* End of column range. */
 	if (list_length(tf->colvalexprs) == colMin)
@@ -375,7 +377,8 @@ transformJsonTableColumns(JsonTableParseContext *cxt,
 
 /* Append transformed non-nested JSON_TABLE columns to the TableFunc node */
 static void
-appendJsonTableColumns(JsonTableParseContext *cxt, List *columns, List *passingArgs)
+appendJsonTableColumns(JsonTableParseContext *cxt, List *columns,
+					   List *passingArgs, bool errorOnError)
 {
 	ListCell   *col;
 	ParseState *pstate = cxt->pstate;
@@ -439,7 +442,7 @@ appendJsonTableColumns(JsonTableParseContext *cxt, List *columns, List *passingA
 					param->typeMod = -1;
 
 					jfe = transformJsonTableColumn(rawc, (Node *) param,
-												   passingArgs);
+												   passingArgs, errorOnError);
 
 					colexpr = transformExpr(pstate, (Node *) jfe,
 											EXPR_KIND_FROM_FUNCTION);
@@ -494,7 +497,7 @@ isCompositeType(Oid typid)
  */
 static JsonFuncExpr *
 transformJsonTableColumn(JsonTableColumn *jtc, Node *contextItemExpr,
-						 List *passingArgs)
+						 List *passingArgs, bool errorOnError)
 {
 	Node	   *pathspec;
 	JsonFuncExpr *jfexpr = makeNode(JsonFuncExpr);
@@ -536,6 +539,20 @@ transformJsonTableColumn(JsonTableColumn *jtc, Node *contextItemExpr,
 	jfexpr->output->returning->format = jtc->format;
 	jfexpr->on_empty = jtc->on_empty;
 	jfexpr->on_error = jtc->on_error;
+
+	/*
+	 * Per the SQL/JSON standard, a regular or formatted column that does not
+	 * specify its own ON ERROR clause inherits ERROR ON ERROR from a
+	 * table-level ERROR ON ERROR clause; otherwise it defaults to NULL ON
+	 * ERROR (applied downstream in transformJsonExprCommon()).  See ISO/IEC
+	 * 9075-2:2023, 7.11 <JSON table>, Syntax Rules 1)e)iv) (regular columns)
+	 * and 1)f)xi) (formatted columns).  EXISTS columns are not covered by the
+	 * standard, so they keep their own default (FALSE ON ERROR).
+	 */
+	if (jfexpr->on_error == NULL && errorOnError &&
+		jfexpr->op != JSON_EXISTS_OP)
+		jfexpr->on_error = makeJsonBehavior(JSON_BEHAVIOR_ERROR, NULL, -1);
+
 	jfexpr->quotes = jtc->quotes;
 	jfexpr->wrapper = jtc->wrapper;
 	jfexpr->location = jtc->location;
diff --git a/src/test/regress/expected/sqljson_jsontable.out b/src/test/regress/expected/sqljson_jsontable.out
index ae64dbed303..4129a731faa 100644
--- a/src/test/regress/expected/sqljson_jsontable.out
+++ b/src/test/regress/expected/sqljson_jsontable.out
@@ -562,12 +562,13 @@ FROM
 	JSON_TABLE(vals.js::jsonb, '$' COLUMNS (a int PATH '$' ERROR ON ERROR)) jt
 		ON true;
 ERROR:  invalid input syntax for type integer: "err"
--- TABLE-level ERROR ON ERROR is not propagated to columns
+-- TABLE-level ERROR ON ERROR is propagated to a column without its own
+-- ON ERROR clause (per SQL standard), so "err" raises instead of yielding NULL.
 SELECT *
 FROM
 	(VALUES ('1'), ('"err"')) vals(js)
 		LEFT OUTER JOIN
-	JSON_TABLE(vals.js::jsonb, '$' COLUMNS (a int PATH '$' ERROR ON ERROR)) jt
+	JSON_TABLE(vals.js::jsonb, '$' COLUMNS (a int PATH '$') ERROR ON ERROR) jt
 		ON true;
 ERROR:  invalid input syntax for type integer: "err"
 SELECT * FROM JSON_TABLE(jsonb '1', '$' COLUMNS (a int PATH '$.a' ERROR ON EMPTY)) jt;
@@ -576,15 +577,28 @@ SELECT * FROM JSON_TABLE(jsonb '1', '$' COLUMNS (a int PATH 'strict $.a' ERROR O
 ERROR:  jsonpath member accessor can only be applied to an object
 SELECT * FROM JSON_TABLE(jsonb '1', '$' COLUMNS (a int PATH 'lax $.a' ERROR ON EMPTY) ERROR ON ERROR) jt;
 ERROR:  no SQL/JSON item found for specified path of column "a"
--- Table-level ERROR ON ERROR is not propagated to a column lacking its own
--- ON ERROR clause: the column keeps the default NULL ON ERROR behavior, so a
--- conversion failure yields NULL rather than raising an error.
+-- Table-level ERROR ON ERROR is propagated to a column lacking its own ON ERROR
+-- clause, so a conversion failure raises an error rather than yielding NULL
+-- (ISO/IEC 9075-2:2023, 7.11 <JSON table>, SR 1)e)iv)).
 SELECT * FROM JSON_TABLE(jsonb '"err"', '$' COLUMNS (a int PATH '$') ERROR ON ERROR) jt;
+ERROR:  invalid input syntax for type integer: "err"
+-- ... but an explicit column-level ON ERROR still wins over the table-level one.
+SELECT * FROM JSON_TABLE(jsonb '"err"', '$' COLUMNS (a int PATH '$' NULL ON ERROR) ERROR ON ERROR) jt;
  a 
 ---
   
 (1 row)
 
+-- Propagation likewise applies to formatted columns (SR 1)f)xi)), but not to
+-- EXISTS columns, which are not part of the standard and keep FALSE ON ERROR.
+SELECT * FROM JSON_TABLE(jsonb '{"a":1}', '$' COLUMNS (a int[] PATH '$.a') ERROR ON ERROR) jt;
+ERROR:  expected JSON array
+SELECT * FROM JSON_TABLE(jsonb '{}', 'strict $' COLUMNS (a int EXISTS PATH 'strict $.x') ERROR ON ERROR) jt;
+ a 
+---
+ 0
+(1 row)
+
 SELECT * FROM JSON_TABLE(jsonb '"a"', '$' COLUMNS (a int PATH '$'   DEFAULT 1 ON EMPTY DEFAULT 2 ON ERROR)) jt;
  a 
 ---
@@ -1884,7 +1898,7 @@ CREATE OR REPLACE VIEW public.json_table_view9 AS
    FROM JSON_TABLE(
             '"a"'::text, '$' AS json_table_path_0
             COLUMNS (
-                a text PATH '$'
+                a text PATH '$' ERROR ON ERROR
             ) ERROR ON ERROR
         )
 DROP VIEW json_table_view8, json_table_view9;
@@ -1922,7 +1936,7 @@ CREATE OR REPLACE VIEW public.json_table_view_on_empty AS
    FROM JSON_TABLE(
             '{}'::jsonb, '$' AS p0
             COLUMNS (
-                a integer PATH '$."nosuch"' ERROR ON EMPTY
+                a integer PATH '$."nosuch"' ERROR ON EMPTY ERROR ON ERROR
             ) ERROR ON ERROR
         )
 DROP VIEW json_table_view_on_empty;
diff --git a/src/test/regress/sql/sqljson_jsontable.sql b/src/test/regress/sql/sqljson_jsontable.sql
index 2a33aaec57f..b93b43899dd 100644
--- a/src/test/regress/sql/sqljson_jsontable.sql
+++ b/src/test/regress/sql/sqljson_jsontable.sql
@@ -269,22 +269,29 @@ FROM
 	JSON_TABLE(vals.js::jsonb, '$' COLUMNS (a int PATH '$' ERROR ON ERROR)) jt
 		ON true;
 
--- TABLE-level ERROR ON ERROR is not propagated to columns
+-- TABLE-level ERROR ON ERROR is propagated to a column without its own
+-- ON ERROR clause (per SQL standard), so "err" raises instead of yielding NULL.
 SELECT *
 FROM
 	(VALUES ('1'), ('"err"')) vals(js)
 		LEFT OUTER JOIN
-	JSON_TABLE(vals.js::jsonb, '$' COLUMNS (a int PATH '$' ERROR ON ERROR)) jt
+	JSON_TABLE(vals.js::jsonb, '$' COLUMNS (a int PATH '$') ERROR ON ERROR) jt
 		ON true;
 
 SELECT * FROM JSON_TABLE(jsonb '1', '$' COLUMNS (a int PATH '$.a' ERROR ON EMPTY)) jt;
 SELECT * FROM JSON_TABLE(jsonb '1', '$' COLUMNS (a int PATH 'strict $.a' ERROR ON ERROR) ERROR ON ERROR) jt;
 SELECT * FROM JSON_TABLE(jsonb '1', '$' COLUMNS (a int PATH 'lax $.a' ERROR ON EMPTY) ERROR ON ERROR) jt;
 
--- Table-level ERROR ON ERROR is not propagated to a column lacking its own
--- ON ERROR clause: the column keeps the default NULL ON ERROR behavior, so a
--- conversion failure yields NULL rather than raising an error.
+-- Table-level ERROR ON ERROR is propagated to a column lacking its own ON ERROR
+-- clause, so a conversion failure raises an error rather than yielding NULL
+-- (ISO/IEC 9075-2:2023, 7.11 <JSON table>, SR 1)e)iv)).
 SELECT * FROM JSON_TABLE(jsonb '"err"', '$' COLUMNS (a int PATH '$') ERROR ON ERROR) jt;
+-- ... but an explicit column-level ON ERROR still wins over the table-level one.
+SELECT * FROM JSON_TABLE(jsonb '"err"', '$' COLUMNS (a int PATH '$' NULL ON ERROR) ERROR ON ERROR) jt;
+-- Propagation likewise applies to formatted columns (SR 1)f)xi)), but not to
+-- EXISTS columns, which are not part of the standard and keep FALSE ON ERROR.
+SELECT * FROM JSON_TABLE(jsonb '{"a":1}', '$' COLUMNS (a int[] PATH '$.a') ERROR ON ERROR) jt;
+SELECT * FROM JSON_TABLE(jsonb '{}', 'strict $' COLUMNS (a int EXISTS PATH 'strict $.x') ERROR ON ERROR) jt;
 
 SELECT * FROM JSON_TABLE(jsonb '"a"', '$' COLUMNS (a int PATH '$'   DEFAULT 1 ON EMPTY DEFAULT 2 ON ERROR)) jt;
 SELECT * FROM JSON_TABLE(jsonb '"a"', '$' COLUMNS (a int PATH 'strict $.a' DEFAULT 1 ON EMPTY DEFAULT 2 ON ERROR)) jt;
-- 
2.55.0

