From f1a29f2cbf1cc73e122c6c4c2a74262927acd4ed Mon Sep 17 00:00:00 2001
From: Florents Tselai <florents.tselai@gmail.com>
Date: Sat, 24 Jan 2026 19:02:18 +0200
Subject: [PATCH v1] Add SQL/JSON ON MISMATCH clause to JSON_VALUE

This commit implements the standard SQL/JSON ON MISMATCH clause for the JSON_VALU() function.
This feature allows users to define specific behavior when a JSON scalar value cannot be successfully coerced to the target SQL data type.

Previously, coercion failures (such as attempting to cast "not_a_number" to integer, or numeric overflows)
would strictly trigger the ON ERROR clause or raise a runtime exception.
This made it difficult to distinguish between malformed data values
and actual structural issues (such as finding an array where a scalar was expected).
---
 src/backend/executor/execExpr.c               |  62 ++++++++-
 src/backend/executor/execExprInterp.c         |  73 ++++++++++-
 src/backend/parser/gram.y                     |  45 ++++---
 src/backend/parser/parse_expr.c               |  22 +++-
 src/backend/parser/parse_jsontable.c          |   1 +
 src/include/nodes/execnodes.h                 |   3 +
 src/include/nodes/parsenodes.h                |   2 +
 src/include/nodes/primnodes.h                 |   3 +-
 src/include/parser/kwlist.h                   |   1 +
 .../regress/expected/sqljson_queryfuncs.out   | 119 ++++++++++++++++++
 src/test/regress/sql/sqljson_queryfuncs.sql   |  60 +++++++++
 11 files changed, 371 insertions(+), 20 deletions(-)

diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c
index 088eca24021..1e1ba04dfaf 100644
--- a/src/backend/executor/execExpr.c
+++ b/src/backend/executor/execExpr.c
@@ -4831,7 +4831,7 @@ ExecInitJsonExpr(JsonExpr *jsexpr, ExprState *state,
 	scratch->d.constval.isnull = true;
 	ExprEvalPushStep(state, scratch);
 
-	escontext = jsexpr->on_error->btype != JSON_BEHAVIOR_ERROR ?
+	escontext = (jsexpr->on_error->btype != JSON_BEHAVIOR_ERROR || jsexpr->on_mismatch != NULL) ?
 		&jsestate->escontext : NULL;
 
 	/*
@@ -4899,7 +4899,7 @@ ExecInitJsonExpr(JsonExpr *jsexpr, ExprState *state,
 		ExprEvalPushStep(state, scratch);
 	}
 
-	jsestate->jump_empty = jsestate->jump_error = -1;
+	jsestate->jump_empty = jsestate->jump_error = jsestate->jump_mismatch = -1;
 
 	/*
 	 * Step to check jsestate->error and return the ON ERROR expression if
@@ -4968,6 +4968,64 @@ ExecInitJsonExpr(JsonExpr *jsexpr, ExprState *state,
 		ExprEvalPushStep(state, scratch);
 	}
 
+	/*
+	 * Step to check jsestate->mismatch and return the ON MISMATCH expression
+	 * if there is one.
+	 */
+	if (jsexpr->on_mismatch != NULL &&
+		jsexpr->on_mismatch->btype != JSON_BEHAVIOR_ERROR &&
+		(!(IsA(jsexpr->on_mismatch->expr, Const) &&
+		   ((Const *) jsexpr->on_mismatch->expr)->constisnull) ||
+		 returning_domain))
+	{
+		ErrorSaveContext *saved_escontext;
+
+		jsestate->jump_mismatch = state->steps_len;
+
+		/* JUMP to end if mismatch flag is false (skip this handler) */
+		jumps_to_end = lappend_int(jumps_to_end, state->steps_len);
+		scratch->opcode = EEOP_JUMP_IF_NOT_TRUE;
+		scratch->resvalue = &jsestate->mismatch.value;
+		scratch->resnull = &jsestate->mismatch.isnull;
+		scratch->d.jump.jumpdone = -1;	/* set below */
+		ExprEvalPushStep(state, scratch);
+
+		/*
+		 * Evaluate the ON MISMATCH expression (e.g. DEFAULT -1). Use soft
+		 * error handling so we can re-throw safely if needed.
+		 */
+		saved_escontext = state->escontext;
+		state->escontext = escontext;
+		ExecInitExprRec((Expr *) jsexpr->on_mismatch->expr,
+						state, resv, resnull);
+		state->escontext = saved_escontext;
+
+		/* Coerce the result if the DEFAULT value needs casting */
+		if (jsexpr->on_mismatch->coerce)
+			ExecInitJsonCoercion(state, jsexpr->returning, escontext,
+								 jsexpr->omit_quotes, false,
+								 resv, resnull);
+
+		/* Add COERCION_FINISH step to verify the DEFAULT value's validity */
+		if (jsexpr->on_mismatch->coerce ||
+			IsA(jsexpr->on_mismatch->expr, CoerceViaIO) ||
+			IsA(jsexpr->on_mismatch->expr, CoerceToDomain))
+		{
+			scratch->opcode = EEOP_JSONEXPR_COERCION_FINISH;
+			scratch->resvalue = resv;
+			scratch->resnull = resnull;
+			scratch->d.jsonexpr.jsestate = jsestate;
+			ExprEvalPushStep(state, scratch);
+		}
+
+		/* JUMP to end (skip subsequent ON EMPTY checks if we matched here) */
+		jumps_to_end = lappend_int(jumps_to_end, state->steps_len);
+		scratch->opcode = EEOP_JUMP;
+		scratch->d.jump.jumpdone = -1;
+		ExprEvalPushStep(state, scratch);
+	}
+
+
 	/*
 	 * Step to check jsestate->empty and return the ON EMPTY expression if
 	 * there is one.
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index a7a5ac1e83b..034b314fb95 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -4848,9 +4848,10 @@ ExecEvalJsonExprPath(ExprState *state, ExprEvalStep *op,
 	item = jsestate->formatted_expr.value;
 	path = DatumGetJsonPathP(jsestate->pathspec.value);
 
-	/* Set error/empty to false. */
+	/* Set error/empty/mismatch to false. */
 	memset(&jsestate->error, 0, sizeof(NullableDatum));
 	memset(&jsestate->empty, 0, sizeof(NullableDatum));
+	memset(&jsestate->mismatch, 0, sizeof(NullableDatum));
 
 	/* Also reset ErrorSaveContext contents for the next row. */
 	if (jsestate->escontext.details_wanted)
@@ -4950,6 +4951,10 @@ ExecEvalJsonExprPath(ExprState *state, ExprEvalStep *op,
 		fcinfo->args[0].value = PointerGetDatum(val_string);
 		fcinfo->args[0].isnull = *op->resnull;
 
+		/* Request Error Details so we can see the error code */
+		if (jsexpr->on_mismatch)
+			jsestate->escontext.details_wanted = true;
+
 		/*
 		 * Second and third arguments are already set up in
 		 * ExecInitJsonExpr().
@@ -4958,7 +4963,40 @@ ExecEvalJsonExprPath(ExprState *state, ExprEvalStep *op,
 		fcinfo->isnull = false;
 		*op->resvalue = FunctionCallInvoke(fcinfo);
 		if (SOFT_ERROR_OCCURRED(&jsestate->escontext))
-			error = true;
+		{
+			/* Check for Type Mismatch codes */
+			/*
+			 * * We capture: 1. INVALID_TEXT_REPRESENTATION (e.g. "abc" ->
+			 * int) 2. NUMERIC_VALUE_OUT_OF_RANGE (e.g. 10000000000 -> int4)
+			 * 3. INVALID_DATETIME_FORMAT (if casting to date/timestamp)
+			 */
+			if (jsexpr->on_mismatch &&
+				jsestate->escontext.error_data &&
+				(jsestate->escontext.error_data->sqlerrcode == ERRCODE_INVALID_TEXT_REPRESENTATION ||
+				 jsestate->escontext.error_data->sqlerrcode == ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE ||
+				 jsestate->escontext.error_data->sqlerrcode == ERRCODE_INVALID_DATETIME_FORMAT))
+			{
+				/*
+				 * We must suppress the generic error so ON ERROR
+				 * does not catch it. We will handle the "ON MISMATCH ERROR"
+				 * case manually later.
+				 */
+				jsestate->escontext.error_occurred = false;
+
+				pfree(jsestate->escontext.error_data);
+				jsestate->escontext.error_data = NULL;
+
+				jsestate->mismatch.value = BoolGetDatum(true);
+				jsestate->mismatch.isnull = false;
+
+				*op->resvalue = (Datum) 0;
+				*op->resnull = true;
+			}
+			else
+			{
+				error = true;
+			}
+		}
 	}
 
 	/*
@@ -5025,6 +5063,37 @@ ExecEvalJsonExprPath(ExprState *state, ExprEvalStep *op,
 		return jsestate->jump_error >= 0 ? jsestate->jump_error : jsestate->jump_end;
 	}
 
+	if (DatumGetBool(jsestate->mismatch.value))
+	{
+		/* Logic for MISMATCH found */
+
+		if (jsexpr->on_mismatch->btype == JSON_BEHAVIOR_ERROR)
+		{
+			/*
+			 * If the user asked for ERROR ON MISMATCH, we explicitly
+			 * throw here. We cannot let this fall through, or it will return
+			 * NULL.
+			 */
+			ereport(ERROR,
+					(errcode(ERRCODE_SQL_JSON_ITEM_CANNOT_BE_CAST_TO_TARGET_TYPE),
+					 errmsg("JSON item could not be cast to the target type"),
+					 errhint("Use ON MISMATCH to handle this specific coercion failure.")));
+		}
+		else
+		{
+			/* Handle NULL, DEFAULT, etc. */
+
+			*op->resvalue = (Datum) 0;
+			*op->resnull = true;
+
+			/* Reset context for the DEFAULT expression evaluation */
+			jsestate->escontext.error_occurred = false;
+			jsestate->escontext.details_wanted = true;
+
+			return jsestate->jump_mismatch >= 0 ? jsestate->jump_mismatch : jsestate->jump_end;
+		}
+	}
+
 	return jump_eval_coercion >= 0 ? jump_eval_coercion : jsestate->jump_end;
 }
 
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 713ee5c10a2..7d7eafec6f9 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -754,7 +754,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	LOCALTIME LOCALTIMESTAMP LOCATION LOCK_P LOCKED LOGGED LSN_P
 
 	MAPPING MATCH MATCHED MATERIALIZED MAXVALUE MERGE MERGE_ACTION METHOD
-	MINUTE_P MINVALUE MODE MONTH_P MOVE
+	MINUTE_P MINVALUE MISMATCH MODE MONTH_P MOVE
 
 	NAME_P NAMES NATIONAL NATURAL NCHAR NESTED NEW NEXT NFC NFD NFKC NFKD NO
 	NONE NORMALIZE NORMALIZED
@@ -14549,7 +14549,8 @@ json_table_column_definition:
 					n->wrapper = $4;
 					n->quotes = $5;
 					n->on_empty = (JsonBehavior *) linitial($6);
-					n->on_error = (JsonBehavior *) lsecond($6);
+					n->on_mismatch = (JsonBehavior *) lsecond($6);
+					n->on_error = (JsonBehavior *) lthird($6);
 					n->location = @1;
 					$$ = (Node *) n;
 				}
@@ -14569,7 +14570,8 @@ json_table_column_definition:
 					n->wrapper = $5;
 					n->quotes = $6;
 					n->on_empty = (JsonBehavior *) linitial($7);
-					n->on_error = (JsonBehavior *) lsecond($7);
+					n->on_mismatch = (JsonBehavior *) lsecond($7);
+					n->on_error = (JsonBehavior *) lthird($7);
 					n->location = @1;
 					$$ = (Node *) n;
 				}
@@ -16415,7 +16417,8 @@ func_expr_common_subexpr:
 					n->wrapper = $8;
 					n->quotes = $9;
 					n->on_empty = (JsonBehavior *) linitial($10);
-					n->on_error = (JsonBehavior *) lsecond($10);
+					n->on_mismatch = (JsonBehavior *) lsecond($10);
+					n->on_error = (JsonBehavior *) lthird($10);
 					n->location = @1;
 					$$ = (Node *) n;
 				}
@@ -16449,7 +16452,8 @@ func_expr_common_subexpr:
 					n->passing = $6;
 					n->output = (JsonOutput *) $7;
 					n->on_empty = (JsonBehavior *) linitial($8);
-					n->on_error = (JsonBehavior *) lsecond($8);
+					n->on_mismatch = (JsonBehavior *) lsecond($8);
+					n->on_error = (JsonBehavior *) lthird($8);
 					n->location = @1;
 					$$ = (Node *) n;
 				}
@@ -17248,15 +17252,26 @@ json_behavior_type:
 		;
 
 json_behavior_clause_opt:
-			json_behavior ON EMPTY_P
-				{ $$ = list_make2($1, NULL); }
-			| json_behavior ON ERROR_P
-				{ $$ = list_make2(NULL, $1); }
-			| json_behavior ON EMPTY_P json_behavior ON ERROR_P
-				{ $$ = list_make2($1, $4); }
-			| /* EMPTY */
-				{ $$ = list_make2(NULL, NULL); }
-		;
+          json_behavior ON EMPTY_P json_behavior ON MISMATCH json_behavior ON ERROR_P
+             { $$ = list_make3($1, $4, $7); }
+
+          | json_behavior ON EMPTY_P json_behavior ON ERROR_P
+             { $$ = list_make3($1, NULL, $4); }
+          | json_behavior ON EMPTY_P json_behavior ON MISMATCH
+             { $$ = list_make3($1, $4, NULL); }
+          | json_behavior ON MISMATCH json_behavior ON ERROR_P
+             { $$ = list_make3(NULL, $1, $4); }
+
+          | json_behavior ON EMPTY_P
+             { $$ = list_make3($1, NULL, NULL); }
+          | json_behavior ON ERROR_P
+             { $$ = list_make3(NULL, NULL, $1); }
+          | json_behavior ON MISMATCH
+             { $$ = list_make3(NULL, $1, NULL); }
+
+          | /* EMPTY */
+             { $$ = list_make3(NULL, NULL, NULL); }
+       ;
 
 json_on_error_clause_opt:
 			json_behavior ON ERROR_P
@@ -18056,6 +18071,7 @@ unreserved_keyword:
 			| METHOD
 			| MINUTE_P
 			| MINVALUE
+			| MISMATCH
 			| MODE
 			| MONTH_P
 			| MOVE
@@ -18676,6 +18692,7 @@ bare_label_keyword:
 			| MERGE_ACTION
 			| METHOD
 			| MINVALUE
+			| MISMATCH
 			| MODE
 			| MOVE
 			| NAME_P
diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index dcfe1acc4c3..1d3d58348ee 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -4640,7 +4640,27 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 													 func->on_empty,
 													 JSON_BEHAVIOR_NULL,
 													 jsexpr->returning);
-			/* Assume NULL ON ERROR when ON ERROR is not specified. */
+			if (func->on_mismatch)
+			{
+				jsexpr->on_mismatch = transformJsonBehavior(pstate,
+															jsexpr,
+															func->on_mismatch,
+															JSON_BEHAVIOR_NULL,
+															jsexpr->returning);
+
+				if (func->on_mismatch != NULL &&
+					func->on_mismatch->btype != JSON_BEHAVIOR_ERROR &&
+					func->on_mismatch->btype != JSON_BEHAVIOR_NULL &&
+					func->on_mismatch->btype != JSON_BEHAVIOR_EMPTY_ARRAY &&
+					func->on_mismatch->btype != JSON_BEHAVIOR_EMPTY_OBJECT &&
+					func->on_mismatch->btype != JSON_BEHAVIOR_DEFAULT)
+				{
+					ereport(ERROR,
+							(errcode(ERRCODE_SYNTAX_ERROR),
+							 errmsg("invalid %s behavior for %s()", "ON MISMATCH", "JSON_VALUE"),
+							 errdetail("Only ERROR, NULL, EMPTY ARRAY, EMPTY OBJECT, or DEFAULT is allowed.")));
+				}
+			}
 			jsexpr->on_error = transformJsonBehavior(pstate,
 													 jsexpr,
 													 func->on_error,
diff --git a/src/backend/parser/parse_jsontable.c b/src/backend/parser/parse_jsontable.c
index c28ae99dee8..54758ba5b01 100644
--- a/src/backend/parser/parse_jsontable.c
+++ b/src/backend/parser/parse_jsontable.c
@@ -436,6 +436,7 @@ transformJsonTableColumn(JsonTableColumn *jtc, Node *contextItemExpr,
 	jfexpr->output->returning = makeNode(JsonReturning);
 	jfexpr->output->returning->format = jtc->format;
 	jfexpr->on_empty = jtc->on_empty;
+	jfexpr->on_mismatch = jtc->on_mismatch;
 	jfexpr->on_error = jtc->on_error;
 	jfexpr->quotes = jtc->quotes;
 	jfexpr->wrapper = jtc->wrapper;
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index f8053d9e572..4ff27970bd9 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -1094,12 +1094,15 @@ typedef struct JsonExprState
 	/* Set to true if the jsonpath evaluation returned 0 items. */
 	NullableDatum empty;
 
+	NullableDatum mismatch;
+
 	/*
 	 * Addresses of steps that implement the non-ERROR variant of ON EMPTY and
 	 * ON ERROR behaviors, respectively.
 	 */
 	int			jump_empty;
 	int			jump_error;
+	int			jump_mismatch;
 
 	/*
 	 * Address of the step to coerce the result value of jsonpath evaluation
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 646d6ced763..cfbc223fdd5 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -1886,6 +1886,7 @@ typedef struct JsonFuncExpr
 	List	   *passing;		/* list of PASSING clause arguments, if any */
 	JsonOutput *output;			/* output clause, if specified */
 	JsonBehavior *on_empty;		/* ON EMPTY behavior */
+	JsonBehavior *on_mismatch;	/* ON MISMATCH behavior */
 	JsonBehavior *on_error;		/* ON ERROR behavior */
 	JsonWrapper wrapper;		/* array wrapper behavior (JSON_QUERY only) */
 	JsonQuotes	quotes;			/* omit or keep quotes? (JSON_QUERY only) */
@@ -1953,6 +1954,7 @@ typedef struct JsonTableColumn
 	JsonQuotes	quotes;			/* omit or keep quotes on scalar strings? */
 	List	   *columns;		/* nested columns */
 	JsonBehavior *on_empty;		/* ON EMPTY behavior */
+	JsonBehavior *on_mismatch;	/* ON MISMATCH behavior */
 	JsonBehavior *on_error;		/* ON ERROR behavior */
 	ParseLoc	location;		/* token location, or -1 if unknown */
 } JsonTableColumn;
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index 5211cadc258..d4ae3921620 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -1860,8 +1860,9 @@ typedef struct JsonExpr
 	List	   *passing_names;
 	List	   *passing_values;
 
-	/* User-specified or default ON EMPTY and ON ERROR behaviors */
+	/* User-specified or default ON EMPTY, ON_MISMATCH and ON ERROR behaviors */
 	JsonBehavior *on_empty;
+	JsonBehavior *on_mismatch;
 	JsonBehavior *on_error;
 
 	/*
diff --git a/src/include/parser/kwlist.h b/src/include/parser/kwlist.h
index f7753c5c8a8..b16520f9c3e 100644
--- a/src/include/parser/kwlist.h
+++ b/src/include/parser/kwlist.h
@@ -281,6 +281,7 @@ PG_KEYWORD("merge_action", MERGE_ACTION, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("method", METHOD, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("minute", MINUTE_P, UNRESERVED_KEYWORD, AS_LABEL)
 PG_KEYWORD("minvalue", MINVALUE, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("mismatch", MISMATCH, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("mode", MODE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("month", MONTH_P, UNRESERVED_KEYWORD, AS_LABEL)
 PG_KEYWORD("move", MOVE, UNRESERVED_KEYWORD, BARE_LABEL)
diff --git a/src/test/regress/expected/sqljson_queryfuncs.out b/src/test/regress/expected/sqljson_queryfuncs.out
index d1b4b8d99f4..a7835cfb578 100644
--- a/src/test/regress/expected/sqljson_queryfuncs.out
+++ b/src/test/regress/expected/sqljson_queryfuncs.out
@@ -1528,3 +1528,122 @@ SELECT JSON_VALUE(jsonb '1234', '$' RETURNING bit(3)  DEFAULT 1::bit(3) ON ERROR
 SELECT JSON_VALUE(jsonb '"111"', '$.a'  RETURNING bit(3) DEFAULT '1111' ON EMPTY);
 ERROR:  bit string length 4 does not match type bit(3)
 DROP DOMAIN queryfuncs_d_varbit3;
+--
+-- JSON_VALUE: ON MISMATCH
+--
+-- Setup test data
+SELECT '{"str": "not_a_number", "num": 123, "overflow": 9999999999, "arr": [1,2], "obj": {"k": "v"}, "date": "bad_date"}'::jsonb AS js \gset
+SELECT JSON_VALUE(:'js', '$.str' RETURNING int ERROR ON MISMATCH); -- Expected: ERROR: JSON item could not be cast to the target type
+ERROR:  JSON item could not be cast to the target type
+HINT:  Use ON MISMATCH to handle this specific coercion failure.
+SELECT JSON_VALUE(:'js', '$.str' RETURNING int DEFAULT -100 ON MISMATCH); -- Expected: -100
+ json_value 
+------------
+       -100
+(1 row)
+
+SELECT JSON_VALUE(:'js', '$.str' RETURNING int NULL ON MISMATCH); -- Expected: NULL (empty row)
+ json_value 
+------------
+           
+(1 row)
+
+SELECT JSON_VALUE(:'js', '$.str' RETURNING date DEFAULT '1970-01-01'::date ON MISMATCH); -- Expected: 01-01-1970
+ json_value 
+------------
+ 01-01-1970
+(1 row)
+
+SELECT JSON_VALUE(:'js', '$.overflow' RETURNING int DEFAULT -200 ON MISMATCH); -- Expected: -200
+ json_value 
+------------
+       -200
+(1 row)
+
+SELECT JSON_VALUE(:'js', '$.overflow' RETURNING int NULL ON MISMATCH); -- Expected: NULL (empty row)
+ json_value 
+------------
+           
+(1 row)
+
+-- Array -> Scalar (Should hit ON ERROR default, which is NULL)
+SELECT JSON_VALUE(:'js', '$.arr' RETURNING int DEFAULT -300 ON MISMATCH); -- Expected: NULL (empty row)
+ json_value 
+------------
+           
+(1 row)
+
+-- 2. Object -> Scalar (Should hit ON ERROR default, which is NULL)
+SELECT JSON_VALUE(:'js', '$.obj' RETURNING int DEFAULT -300 ON MISMATCH); -- Expected: NULL (empty row)
+ json_value 
+------------
+           
+(1 row)
+
+-- Verify it hits explicit ON ERROR
+SELECT JSON_VALUE(:'js', '$.arr' RETURNING int
+    DEFAULT -300 ON MISMATCH
+    DEFAULT -400 ON ERROR); -- Expected: -400
+ json_value 
+------------
+       -400
+(1 row)
+
+-- Should trigger ON EMPTY, ignoring Mismatch/Error
+SELECT JSON_VALUE(:'js', '$.missing' RETURNING int
+    DEFAULT -1 ON EMPTY
+    DEFAULT -2 ON MISMATCH
+    DEFAULT -3 ON ERROR); -- Expected: -1
+ json_value 
+------------
+         -1
+(1 row)
+
+-- Should trigger ON MISMATCH, ignoring Error
+SELECT JSON_VALUE(:'js', '$.str' RETURNING int
+    DEFAULT -1 ON EMPTY
+    DEFAULT -2 ON MISMATCH
+    DEFAULT -3 ON ERROR); -- Expected: -2
+ json_value 
+------------
+         -2
+(1 row)
+
+-- Should trigger ON ERROR
+SELECT JSON_VALUE(:'js', '$.arr' RETURNING int
+    DEFAULT -1 ON EMPTY
+    DEFAULT -2 ON MISMATCH
+    DEFAULT -3 ON ERROR); -- Expected: -3
+ json_value 
+------------
+         -3
+(1 row)
+
+-- If ON MISMATCH is missing, coercion errors fall through to ON ERROR
+SELECT JSON_VALUE(:'js', '$.str' RETURNING int
+    DEFAULT -1 ON EMPTY
+    DEFAULT -500 ON ERROR); -- Expected: -500
+ json_value 
+------------
+       -500
+(1 row)
+
+SELECT JSON_VALUE(:'js', '$.str' RETURNING int ERROR ON MISMATCH ERROR ON ERROR); -- Expected: ERROR: JSON item could not be cast to the target type (with Hint)
+ERROR:  JSON item could not be cast to the target type
+HINT:  Use ON MISMATCH to handle this specific coercion failure.
+SELECT JSON_VALUE(:'js', '$.arr' RETURNING int ERROR ON MISMATCH ERROR ON ERROR); -- Expected: ERROR: JSON path expression in JSON_VALUE must return single scalar item
+ERROR:  JSON path expression in JSON_VALUE must return single scalar item
+-- 1. Valid Integer
+SELECT JSON_VALUE(:'js', '$.num' RETURNING int DEFAULT -100 ON MISMATCH); -- Expected: 123
+ json_value 
+------------
+        123
+(1 row)
+
+-- 2. Valid String-to-Integer Cast
+SELECT JSON_VALUE('{"num": "123"}'::jsonb, '$.num' RETURNING int DEFAULT -100 ON MISMATCH); -- Expected: 123
+ json_value 
+------------
+        123
+(1 row)
+
diff --git a/src/test/regress/sql/sqljson_queryfuncs.sql b/src/test/regress/sql/sqljson_queryfuncs.sql
index a5d5e256d7f..3b984955d5d 100644
--- a/src/test/regress/sql/sqljson_queryfuncs.sql
+++ b/src/test/regress/sql/sqljson_queryfuncs.sql
@@ -502,3 +502,63 @@ SELECT JSON_VALUE(jsonb '1234', '$' RETURNING bit(3)  DEFAULT 1 ON ERROR);
 SELECT JSON_VALUE(jsonb '1234', '$' RETURNING bit(3)  DEFAULT 1::bit(3) ON ERROR);
 SELECT JSON_VALUE(jsonb '"111"', '$.a'  RETURNING bit(3) DEFAULT '1111' ON EMPTY);
 DROP DOMAIN queryfuncs_d_varbit3;
+
+--
+-- JSON_VALUE: ON MISMATCH
+--
+
+-- Setup test data
+SELECT '{"str": "not_a_number", "num": 123, "overflow": 9999999999, "arr": [1,2], "obj": {"k": "v"}, "date": "bad_date"}'::jsonb AS js \gset
+
+SELECT JSON_VALUE(:'js', '$.str' RETURNING int ERROR ON MISMATCH); -- Expected: ERROR: JSON item could not be cast to the target type
+SELECT JSON_VALUE(:'js', '$.str' RETURNING int DEFAULT -100 ON MISMATCH); -- Expected: -100
+SELECT JSON_VALUE(:'js', '$.str' RETURNING int NULL ON MISMATCH); -- Expected: NULL (empty row)
+SELECT JSON_VALUE(:'js', '$.str' RETURNING date DEFAULT '1970-01-01'::date ON MISMATCH); -- Expected: 01-01-1970
+SELECT JSON_VALUE(:'js', '$.overflow' RETURNING int DEFAULT -200 ON MISMATCH); -- Expected: -200
+
+SELECT JSON_VALUE(:'js', '$.overflow' RETURNING int NULL ON MISMATCH); -- Expected: NULL (empty row)
+
+-- Array -> Scalar (Should hit ON ERROR default, which is NULL)
+SELECT JSON_VALUE(:'js', '$.arr' RETURNING int DEFAULT -300 ON MISMATCH); -- Expected: NULL (empty row)
+
+-- 2. Object -> Scalar (Should hit ON ERROR default, which is NULL)
+SELECT JSON_VALUE(:'js', '$.obj' RETURNING int DEFAULT -300 ON MISMATCH); -- Expected: NULL (empty row)
+
+-- Verify it hits explicit ON ERROR
+SELECT JSON_VALUE(:'js', '$.arr' RETURNING int
+    DEFAULT -300 ON MISMATCH
+    DEFAULT -400 ON ERROR); -- Expected: -400
+
+-- Should trigger ON EMPTY, ignoring Mismatch/Error
+SELECT JSON_VALUE(:'js', '$.missing' RETURNING int
+    DEFAULT -1 ON EMPTY
+    DEFAULT -2 ON MISMATCH
+    DEFAULT -3 ON ERROR); -- Expected: -1
+
+-- Should trigger ON MISMATCH, ignoring Error
+SELECT JSON_VALUE(:'js', '$.str' RETURNING int
+    DEFAULT -1 ON EMPTY
+    DEFAULT -2 ON MISMATCH
+    DEFAULT -3 ON ERROR); -- Expected: -2
+
+-- Should trigger ON ERROR
+SELECT JSON_VALUE(:'js', '$.arr' RETURNING int
+    DEFAULT -1 ON EMPTY
+    DEFAULT -2 ON MISMATCH
+    DEFAULT -3 ON ERROR); -- Expected: -3
+
+-- If ON MISMATCH is missing, coercion errors fall through to ON ERROR
+SELECT JSON_VALUE(:'js', '$.str' RETURNING int
+    DEFAULT -1 ON EMPTY
+    DEFAULT -500 ON ERROR); -- Expected: -500
+
+SELECT JSON_VALUE(:'js', '$.str' RETURNING int ERROR ON MISMATCH ERROR ON ERROR); -- Expected: ERROR: JSON item could not be cast to the target type (with Hint)
+
+
+SELECT JSON_VALUE(:'js', '$.arr' RETURNING int ERROR ON MISMATCH ERROR ON ERROR); -- Expected: ERROR: JSON path expression in JSON_VALUE must return single scalar item
+
+-- 1. Valid Integer
+SELECT JSON_VALUE(:'js', '$.num' RETURNING int DEFAULT -100 ON MISMATCH); -- Expected: 123
+
+-- 2. Valid String-to-Integer Cast
+SELECT JSON_VALUE('{"num": "123"}'::jsonb, '$.num' RETURNING int DEFAULT -100 ON MISMATCH); -- Expected: 123
-- 
2.52.0

