diff --git a/docs/reference/query-languages/esql/_snippets/functions/description/json_extract.md b/docs/reference/query-languages/esql/_snippets/functions/description/json_extract.md
new file mode 100644
index 0000000000000..a802d002c5cf0
--- /dev/null
+++ b/docs/reference/query-languages/esql/_snippets/functions/description/json_extract.md
@@ -0,0 +1,6 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Description**
+
+Extracts a value from a JSON string using a dot-notation path expression. Path matching is case-sensitive (per the JSON specification). Returns the extracted value as a keyword string. String values are returned without surrounding quotes, numbers and booleans are returned as their string representation, and objects or arrays are returned as JSON strings. Returns `null` if either parameter is `null` or if the extracted JSON value is `null`. Returns `null` and emits a warning if the input is not valid JSON, the path does not exist, the array index is out of bounds, or the path attempts to traverse through a non-object/non-array value.
+
diff --git a/docs/reference/query-languages/esql/_snippets/functions/examples/json_extract.md b/docs/reference/query-languages/esql/_snippets/functions/examples/json_extract.md
new file mode 100644
index 0000000000000..b2831394db76b
--- /dev/null
+++ b/docs/reference/query-languages/esql/_snippets/functions/examples/json_extract.md
@@ -0,0 +1,25 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Examples**
+
+```esql
+ROW json = "{\\"name\\":\\"Alice\\",\\"age\\":30}"
+| EVAL name = JSON_EXTRACT(json, "name")
+```
+
+| json:keyword | name:keyword |
+| --- | --- |
+| "{""name"":""Alice"",""age"":30}" | Alice |
+
+Extract a nested value using dot-notation:
+
+```esql
+ROW json = "{\\"user\\":{\\"address\\":{\\"city\\":\\"London\\"}}}"
+| EVAL city = JSON_EXTRACT(json, "user.address.city")
+```
+
+| json:keyword | city:keyword |
+| --- | --- |
+| "{""user"":{""address"":{""city"":""London""}}}" | London |
+
+
diff --git a/docs/reference/query-languages/esql/_snippets/functions/layout/json_extract.md b/docs/reference/query-languages/esql/_snippets/functions/layout/json_extract.md
new file mode 100644
index 0000000000000..637827131c891
--- /dev/null
+++ b/docs/reference/query-languages/esql/_snippets/functions/layout/json_extract.md
@@ -0,0 +1,23 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+## `JSON_EXTRACT` [esql-json_extract]
+
+**Syntax**
+
+:::{image} ../../../images/functions/json_extract.svg
+:alt: Embedded
+:class: text-center
+:::
+
+
+:::{include} ../parameters/json_extract.md
+:::
+
+:::{include} ../description/json_extract.md
+:::
+
+:::{include} ../types/json_extract.md
+:::
+
+:::{include} ../examples/json_extract.md
+:::
diff --git a/docs/reference/query-languages/esql/_snippets/functions/parameters/json_extract.md b/docs/reference/query-languages/esql/_snippets/functions/parameters/json_extract.md
new file mode 100644
index 0000000000000..e34a4a15ecebe
--- /dev/null
+++ b/docs/reference/query-languages/esql/_snippets/functions/parameters/json_extract.md
@@ -0,0 +1,10 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Parameters**
+
+`json_input`
+: A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`.
+
+`path`
+: A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`.
+
diff --git a/docs/reference/query-languages/esql/_snippets/functions/types/json_extract.md b/docs/reference/query-languages/esql/_snippets/functions/types/json_extract.md
new file mode 100644
index 0000000000000..660271b99e470
--- /dev/null
+++ b/docs/reference/query-languages/esql/_snippets/functions/types/json_extract.md
@@ -0,0 +1,13 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Supported types**
+
+| json_input | path | result |
+| --- | --- | --- |
+| _source | keyword | keyword |
+| _source | text | keyword |
+| keyword | keyword | keyword |
+| keyword | text | keyword |
+| text | keyword | keyword |
+| text | text | keyword |
+
diff --git a/docs/reference/query-languages/esql/images/functions/json_extract.svg b/docs/reference/query-languages/esql/images/functions/json_extract.svg
new file mode 100644
index 0000000000000..6a729f26e88b5
--- /dev/null
+++ b/docs/reference/query-languages/esql/images/functions/json_extract.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/reference/query-languages/esql/kibana/definition/functions/json_extract.json b/docs/reference/query-languages/esql/kibana/definition/functions/json_extract.json
new file mode 100644
index 0000000000000..5443c57615382
--- /dev/null
+++ b/docs/reference/query-languages/esql/kibana/definition/functions/json_extract.json
@@ -0,0 +1,122 @@
+{
+ "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.",
+ "type" : "scalar",
+ "name" : "json_extract",
+ "description" : "Extracts a value from a JSON string using a dot-notation path expression.\nPath matching is case-sensitive (per the JSON specification).\nReturns the extracted value as a keyword string. String values are returned without\nsurrounding quotes, numbers and booleans are returned as their string representation,\nand objects or arrays are returned as JSON strings. Returns `null` if either parameter\nis `null` or if the extracted JSON value is `null`. Returns `null` and emits a warning\nif the input is not valid JSON, the path does not exist, the array index is out of bounds,\nor the path attempts to traverse through a non-object/non-array value.",
+ "signatures" : [
+ {
+ "params" : [
+ {
+ "name" : "json_input",
+ "type" : "_source",
+ "optional" : false,
+ "description" : "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`."
+ },
+ {
+ "name" : "path",
+ "type" : "keyword",
+ "optional" : false,
+ "description" : "A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`."
+ }
+ ],
+ "variadic" : false,
+ "returnType" : "keyword"
+ },
+ {
+ "params" : [
+ {
+ "name" : "json_input",
+ "type" : "_source",
+ "optional" : false,
+ "description" : "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`."
+ },
+ {
+ "name" : "path",
+ "type" : "text",
+ "optional" : false,
+ "description" : "A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`."
+ }
+ ],
+ "variadic" : false,
+ "returnType" : "keyword"
+ },
+ {
+ "params" : [
+ {
+ "name" : "json_input",
+ "type" : "keyword",
+ "optional" : false,
+ "description" : "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`."
+ },
+ {
+ "name" : "path",
+ "type" : "keyword",
+ "optional" : false,
+ "description" : "A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`."
+ }
+ ],
+ "variadic" : false,
+ "returnType" : "keyword"
+ },
+ {
+ "params" : [
+ {
+ "name" : "json_input",
+ "type" : "keyword",
+ "optional" : false,
+ "description" : "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`."
+ },
+ {
+ "name" : "path",
+ "type" : "text",
+ "optional" : false,
+ "description" : "A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`."
+ }
+ ],
+ "variadic" : false,
+ "returnType" : "keyword"
+ },
+ {
+ "params" : [
+ {
+ "name" : "json_input",
+ "type" : "text",
+ "optional" : false,
+ "description" : "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`."
+ },
+ {
+ "name" : "path",
+ "type" : "keyword",
+ "optional" : false,
+ "description" : "A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`."
+ }
+ ],
+ "variadic" : false,
+ "returnType" : "keyword"
+ },
+ {
+ "params" : [
+ {
+ "name" : "json_input",
+ "type" : "text",
+ "optional" : false,
+ "description" : "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`."
+ },
+ {
+ "name" : "path",
+ "type" : "text",
+ "optional" : false,
+ "description" : "A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`."
+ }
+ ],
+ "variadic" : false,
+ "returnType" : "keyword"
+ }
+ ],
+ "examples" : [
+ "ROW json = \"{\\\\\"name\\\\\":\\\\\"Alice\\\\\",\\\\\"age\\\\\":30}\"\n| EVAL name = JSON_EXTRACT(json, \"name\")",
+ "ROW json = \"{\\\\\"user\\\\\":{\\\\\"address\\\\\":{\\\\\"city\\\\\":\\\\\"London\\\\\"}}}\"\n| EVAL city = JSON_EXTRACT(json, \"user.address.city\")"
+ ],
+ "preview" : false,
+ "snapshot_only" : false
+}
diff --git a/docs/reference/query-languages/esql/kibana/docs/functions/json_extract.md b/docs/reference/query-languages/esql/kibana/docs/functions/json_extract.md
new file mode 100644
index 0000000000000..7422beba8e144
--- /dev/null
+++ b/docs/reference/query-languages/esql/kibana/docs/functions/json_extract.md
@@ -0,0 +1,16 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+### JSON EXTRACT
+Extracts a value from a JSON string using a dot-notation path expression.
+Path matching is case-sensitive (per the JSON specification).
+Returns the extracted value as a keyword string. String values are returned without
+surrounding quotes, numbers and booleans are returned as their string representation,
+and objects or arrays are returned as JSON strings. Returns `null` if either parameter
+is `null` or if the extracted JSON value is `null`. Returns `null` and emits a warning
+if the input is not valid JSON, the path does not exist, the array index is out of bounds,
+or the path attempts to traverse through a non-object/non-array value.
+
+```esql
+ROW json = "{\\"name\\":\\"Alice\\",\\"age\\":30}"
+| EVAL name = JSON_EXTRACT(json, "name")
+```
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/json_extract.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/json_extract.csv-spec
new file mode 100644
index 0000000000000..c7d8b10608dd9
--- /dev/null
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/json_extract.csv-spec
@@ -0,0 +1,155 @@
+jsonExtractString
+required_capability: fn_json_extract
+// tag::json_extract[]
+ROW json = "{\"name\":\"Alice\",\"age\":30}"
+| EVAL name = JSON_EXTRACT(json, "name")
+// end::json_extract[]
+;
+
+// tag::json_extract-result[]
+json:keyword | name:keyword
+"{""name"":""Alice"",""age"":30}" | Alice
+// end::json_extract-result[]
+;
+
+jsonExtractNumber
+required_capability: fn_json_extract
+ROW json = "{\"name\":\"Alice\",\"age\":30}"
+| EVAL age = JSON_EXTRACT(json, "age")
+;
+
+json:keyword | age:keyword
+"{""name"":""Alice"",""age"":30}" | 30
+;
+
+jsonExtractBoolean
+required_capability: fn_json_extract
+ROW json = "{\"active\":true}"
+| EVAL active = JSON_EXTRACT(json, "active")
+;
+
+json:keyword | active:keyword
+"{""active"":true}" | true
+;
+
+jsonExtractNested
+required_capability: fn_json_extract
+// tag::json_extract_nested[]
+ROW json = "{\"user\":{\"address\":{\"city\":\"London\"}}}"
+| EVAL city = JSON_EXTRACT(json, "user.address.city")
+// end::json_extract_nested[]
+;
+
+// tag::json_extract_nested-result[]
+json:keyword | city:keyword
+"{""user"":{""address"":{""city"":""London""}}}" | London
+// end::json_extract_nested-result[]
+;
+
+jsonExtractArrayIndex
+required_capability: fn_json_extract
+ROW json = "{\"tags\":[\"a\",\"b\",\"c\"]}"
+| EVAL first_tag = JSON_EXTRACT(json, "tags[0]")
+;
+
+json:keyword | first_tag:keyword
+"{""tags"":[""a"",""b"",""c""]}" | a
+;
+
+jsonExtractMixedNesting
+required_capability: fn_json_extract
+ROW json = "{\"orders\":[{\"id\":1,\"item\":\"book\"},{\"id\":2,\"item\":\"pen\"}]}"
+| EVAL second_item = JSON_EXTRACT(json, "orders[1].item")
+;
+
+json:keyword | second_item:keyword
+"{""orders"":[{""id"":1,""item"":""book""},{""id"":2,""item"":""pen""}]}" | pen
+;
+
+jsonExtractObject
+required_capability: fn_json_extract
+ROW json = "{\"user\":{\"name\":\"Alice\",\"age\":30}}"
+| EVAL user = JSON_EXTRACT(json, "user")
+;
+
+json:keyword | user:keyword
+"{""user"":{""name"":""Alice"",""age"":30}}" | "{""name"":""Alice"",""age"":30}"
+;
+
+jsonExtractMissing
+required_capability: fn_json_extract
+ROW json = "{\"name\":\"Alice\"}"
+| EVAL missing = JSON_EXTRACT(json, "nonexistent")
+;
+warning:Line 2:18: evaluation of [JSON_EXTRACT(json, \"nonexistent\")] failed, treating result as null. Only first 20 failures recorded.
+warning:Line 2:18: java.lang.IllegalArgumentException: path [nonexistent] does not exist
+
+json:keyword | missing:keyword
+"{""name"":""Alice""}" | null
+;
+
+jsonExtractJsonNull
+required_capability: fn_json_extract
+ROW json = "{\"value\":null}"
+| EVAL val = JSON_EXTRACT(json, "value")
+;
+
+json:keyword | val:keyword
+"{""value"":null}" | null
+;
+
+jsonExtractInvalidJson
+required_capability: fn_json_extract
+ROW json = "not valid json"
+| EVAL result = JSON_EXTRACT(json, "field")
+;
+warning:Line 2:17: evaluation of [JSON_EXTRACT(json, \"field\")] failed, treating result as null. Only first 20 failures recorded.
+warning:Line 2:17: java.lang.IllegalArgumentException: invalid JSON input
+
+json:keyword | result:keyword
+not valid json | null
+;
+
+jsonExtractNullInput
+required_capability: fn_json_extract
+ROW json = null
+| EVAL result = JSON_EXTRACT(json::keyword, "field")
+;
+
+json:null | result:keyword
+null | null
+;
+
+jsonExtractNullPath
+required_capability: fn_json_extract
+ROW json = "{\"name\":\"Alice\"}"
+| EVAL result = JSON_EXTRACT(json, null::keyword)
+;
+
+json:keyword | result:keyword
+"{""name"":""Alice""}" | null
+;
+
+jsonExtractArrayOutOfBounds
+required_capability: fn_json_extract
+ROW json = "{\"tags\":[\"a\",\"b\"]}"
+| EVAL result = JSON_EXTRACT(json, "tags[5]")
+;
+warning:Line 2:17: evaluation of [JSON_EXTRACT(json, \"tags[5]\")] failed, treating result as null. Only first 20 failures recorded.
+warning:Line 2:17: java.lang.IllegalArgumentException: array index out of bounds
+
+json:keyword | result:keyword
+"{""tags"":[""a"",""b""]}" | null
+;
+
+jsonExtractNonObjectTraversal
+required_capability: fn_json_extract
+ROW json = "{\"name\":\"Alice\"}"
+| EVAL result = JSON_EXTRACT(json, "name.nested")
+;
+warning:Line 2:17: evaluation of [JSON_EXTRACT(json, \"name.nested\")] failed, treating result as null. Only first 20 failures recorded.
+warning:Line 2:17: java.lang.IllegalArgumentException: path [name.nested] does not exist
+
+json:keyword | result:keyword
+"{""name"":""Alice""}" | null
+;
diff --git a/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractEvaluator.java b/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractEvaluator.java
new file mode 100644
index 0000000000000..4f23b8c716a66
--- /dev/null
+++ b/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractEvaluator.java
@@ -0,0 +1,178 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License
+// 2.0; you may not use this file except in compliance with the Elastic License
+// 2.0.
+package org.elasticsearch.xpack.esql.expression.function.scalar.string;
+
+import java.lang.IllegalArgumentException;
+import java.lang.Override;
+import java.lang.String;
+import org.apache.lucene.util.BytesRef;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BytesRefBlock;
+import org.elasticsearch.compute.data.BytesRefVector;
+import org.elasticsearch.compute.data.Page;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+import org.elasticsearch.compute.operator.Warnings;
+import org.elasticsearch.core.Releasables;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link JsonExtract}.
+ * This class is generated. Edit {@code EvaluatorImplementer} instead.
+ */
+public final class JsonExtractEvaluator implements EvalOperator.ExpressionEvaluator {
+ private static final long BASE_RAM_BYTES_USED = RamUsageEstimator.shallowSizeOfInstance(JsonExtractEvaluator.class);
+
+ private final Source source;
+
+ private final EvalOperator.ExpressionEvaluator jsonInput;
+
+ private final EvalOperator.ExpressionEvaluator path;
+
+ private final DriverContext driverContext;
+
+ private Warnings warnings;
+
+ public JsonExtractEvaluator(Source source, EvalOperator.ExpressionEvaluator jsonInput,
+ EvalOperator.ExpressionEvaluator path, DriverContext driverContext) {
+ this.source = source;
+ this.jsonInput = jsonInput;
+ this.path = path;
+ this.driverContext = driverContext;
+ }
+
+ @Override
+ public Block eval(Page page) {
+ try (BytesRefBlock jsonInputBlock = (BytesRefBlock) jsonInput.eval(page)) {
+ try (BytesRefBlock pathBlock = (BytesRefBlock) path.eval(page)) {
+ BytesRefVector jsonInputVector = jsonInputBlock.asVector();
+ if (jsonInputVector == null) {
+ return eval(page.getPositionCount(), jsonInputBlock, pathBlock);
+ }
+ BytesRefVector pathVector = pathBlock.asVector();
+ if (pathVector == null) {
+ return eval(page.getPositionCount(), jsonInputBlock, pathBlock);
+ }
+ return eval(page.getPositionCount(), jsonInputVector, pathVector);
+ }
+ }
+ }
+
+ @Override
+ public long baseRamBytesUsed() {
+ long baseRamBytesUsed = BASE_RAM_BYTES_USED;
+ baseRamBytesUsed += jsonInput.baseRamBytesUsed();
+ baseRamBytesUsed += path.baseRamBytesUsed();
+ return baseRamBytesUsed;
+ }
+
+ public BytesRefBlock eval(int positionCount, BytesRefBlock jsonInputBlock,
+ BytesRefBlock pathBlock) {
+ try(BytesRefBlock.Builder result = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) {
+ BytesRef jsonInputScratch = new BytesRef();
+ BytesRef pathScratch = new BytesRef();
+ position: for (int p = 0; p < positionCount; p++) {
+ switch (jsonInputBlock.getValueCount(p)) {
+ case 0:
+ result.appendNull();
+ continue position;
+ case 1:
+ break;
+ default:
+ warnings().registerException(new IllegalArgumentException("single-value function encountered multi-value"));
+ result.appendNull();
+ continue position;
+ }
+ switch (pathBlock.getValueCount(p)) {
+ case 0:
+ result.appendNull();
+ continue position;
+ case 1:
+ break;
+ default:
+ warnings().registerException(new IllegalArgumentException("single-value function encountered multi-value"));
+ result.appendNull();
+ continue position;
+ }
+ BytesRef jsonInput = jsonInputBlock.getBytesRef(jsonInputBlock.getFirstValueIndex(p), jsonInputScratch);
+ BytesRef path = pathBlock.getBytesRef(pathBlock.getFirstValueIndex(p), pathScratch);
+ try {
+ JsonExtract.process(result, jsonInput, path);
+ } catch (IllegalArgumentException e) {
+ warnings().registerException(e);
+ result.appendNull();
+ }
+ }
+ return result.build();
+ }
+ }
+
+ public BytesRefBlock eval(int positionCount, BytesRefVector jsonInputVector,
+ BytesRefVector pathVector) {
+ try(BytesRefBlock.Builder result = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) {
+ BytesRef jsonInputScratch = new BytesRef();
+ BytesRef pathScratch = new BytesRef();
+ position: for (int p = 0; p < positionCount; p++) {
+ BytesRef jsonInput = jsonInputVector.getBytesRef(p, jsonInputScratch);
+ BytesRef path = pathVector.getBytesRef(p, pathScratch);
+ try {
+ JsonExtract.process(result, jsonInput, path);
+ } catch (IllegalArgumentException e) {
+ warnings().registerException(e);
+ result.appendNull();
+ }
+ }
+ return result.build();
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "JsonExtractEvaluator[" + "jsonInput=" + jsonInput + ", path=" + path + "]";
+ }
+
+ @Override
+ public void close() {
+ Releasables.closeExpectNoException(jsonInput, path);
+ }
+
+ private Warnings warnings() {
+ if (warnings == null) {
+ this.warnings = Warnings.createWarnings(
+ driverContext.warningsMode(),
+ source.source().getLineNumber(),
+ source.source().getColumnNumber(),
+ source.text()
+ );
+ }
+ return warnings;
+ }
+
+ static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+ private final Source source;
+
+ private final EvalOperator.ExpressionEvaluator.Factory jsonInput;
+
+ private final EvalOperator.ExpressionEvaluator.Factory path;
+
+ public Factory(Source source, EvalOperator.ExpressionEvaluator.Factory jsonInput,
+ EvalOperator.ExpressionEvaluator.Factory path) {
+ this.source = source;
+ this.jsonInput = jsonInput;
+ this.path = path;
+ }
+
+ @Override
+ public JsonExtractEvaluator get(DriverContext context) {
+ return new JsonExtractEvaluator(source, jsonInput.get(context), path.get(context), context);
+ }
+
+ @Override
+ public String toString() {
+ return "JsonExtractEvaluator[" + "jsonInput=" + jsonInput + ", path=" + path + "]";
+ }
+ }
+}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
index 8e1598bfcfa18..0a0801c96566c 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
@@ -1998,9 +1998,9 @@ public enum Cap {
APPROXIMATION(Build.current().isSnapshot()),
/**
- * Periodically emit partial aggregation results when the number of groups exceeds the threshold.
+ * Support for function {@code JSON_EXTRACT}.
*/
- PERIODIC_EMIT_PARTIAL_AGGREGATION_RESULTS,
+ FN_JSON_EXTRACT(Build.current().isSnapshot()),
// Last capability should still have a comma for fewer merge conflicts when adding new ones :)
// This comment prevents the semicolon from being on the previous capability when Spotless formats the file.
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
index e1c0ae41c533d..8b39fd5809644 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
@@ -201,6 +201,7 @@
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Contains;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.EndsWith;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Hash;
+import org.elasticsearch.xpack.esql.expression.function.scalar.string.JsonExtract;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.LTrim;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Left;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Length;
@@ -427,6 +428,7 @@ private static FunctionDefinition[][] functions() {
def(Contains.class, Contains::new, "contains"),
def(EndsWith.class, EndsWith::new, "ends_with"),
def(Hash.class, Hash::new, "hash"),
+ def(JsonExtract.class, JsonExtract::new, "json_extract"),
def(LTrim.class, LTrim::new, "ltrim"),
def(Left.class, Left::new, "left"),
def(Length.class, Length::new, "length"),
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ScalarFunctionWritables.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ScalarFunctionWritables.java
index c7f507dd9048e..8cf34c2e1bab5 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ScalarFunctionWritables.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ScalarFunctionWritables.java
@@ -46,6 +46,7 @@
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Contains;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.EndsWith;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Hash;
+import org.elasticsearch.xpack.esql.expression.function.scalar.string.JsonExtract;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Left;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Locate;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Md5;
@@ -87,6 +88,7 @@ public static List getNamedWriteables() {
entries.add(Greatest.ENTRY);
entries.add(CopySign.ENTRY);
entries.add(Hash.ENTRY);
+ entries.add(JsonExtract.ENTRY);
entries.add(Hypot.ENTRY);
entries.add(In.ENTRY);
entries.add(InsensitiveEquals.ENTRY);
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtract.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtract.java
new file mode 100644
index 0000000000000..13c2921e35949
--- /dev/null
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtract.java
@@ -0,0 +1,414 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.expression.function.scalar.string;
+
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.compute.ann.Evaluator;
+import org.elasticsearch.compute.data.BytesRefBlock;
+import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator;
+import org.elasticsearch.xcontent.XContentParseException;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
+import org.elasticsearch.xcontent.json.JsonXContent;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.Example;
+import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.Param;
+import org.elasticsearch.xpack.esql.expression.function.scalar.EsqlScalarFunction;
+import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.FIRST;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.SECOND;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isString;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType;
+
+public class JsonExtract extends EsqlScalarFunction {
+ /**
+ * Internal exception used to distinguish application-level errors (path not found,
+ * array index out of bounds) from JSON parser errors (malformed JSON).
+ *
+ * This is necessary because {@link XContentParseException} extends {@link IllegalArgumentException}.
+ * Without a separate exception type, we couldn't differentiate between:
+ *
+ *
Our errors (should preserve the specific message like "path [x] does not exist")
+ *
Parser errors (should be converted to generic "invalid JSON input")
+ *
+ */
+ private static class JsonExtractException extends Exception {
+ JsonExtractException(String message) {
+ super(message);
+ }
+ }
+
+ public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
+ Expression.class,
+ "JsonExtract",
+ JsonExtract::new
+ );
+
+ private final Expression jsonInput;
+ private final Expression path;
+
+ @FunctionInfo(
+ returnType = "keyword",
+ description = """
+ Extracts a value from a JSON string using a dot-notation path expression.
+ Path matching is case-sensitive (per the JSON specification).
+ Returns the extracted value as a keyword string. String values are returned without
+ surrounding quotes, numbers and booleans are returned as their string representation,
+ and objects or arrays are returned as JSON strings. Returns `null` if either parameter
+ is `null` or if the extracted JSON value is `null`. Returns `null` and emits a warning
+ if the input is not valid JSON, the path does not exist, the array index is out of bounds,
+ or the path attempts to traverse through a non-object/non-array value.""",
+ examples = {
+ @Example(file = "json_extract", tag = "json_extract"),
+ @Example(file = "json_extract", tag = "json_extract_nested", description = "Extract a nested value using dot-notation:") }
+ )
+ public JsonExtract(
+ Source source,
+ @Param(
+ name = "json_input",
+ type = { "keyword", "text", "_source" },
+ description = "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`."
+ ) Expression jsonInput,
+ @Param(
+ name = "path",
+ type = { "keyword", "text" },
+ description = "A dot-notation path expression identifying the value to extract. "
+ + "Use dot notation for nested fields (e.g., `user.name`) and bracket notation "
+ + "for array indices (e.g., `items[0]`). If `null`, the function returns `null`."
+ ) Expression path
+ ) {
+ super(source, Arrays.asList(jsonInput, path));
+ this.jsonInput = jsonInput;
+ this.path = path;
+ }
+
+ private JsonExtract(StreamInput in) throws IOException {
+ this(Source.readFrom((PlanStreamInput) in), in.readNamedWriteable(Expression.class), in.readNamedWriteable(Expression.class));
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ source().writeTo(out);
+ out.writeNamedWriteable(jsonInput);
+ out.writeNamedWriteable(path);
+ }
+
+ @Override
+ public String getWriteableName() {
+ return ENTRY.name;
+ }
+
+ @Override
+ public DataType dataType() {
+ return DataType.KEYWORD;
+ }
+
+ /**
+ * Returns true if the given type is a string type or SOURCE (for _source field).
+ */
+ private static boolean isStringOrSource(DataType t) {
+ return DataType.isString(t) || t == DataType.SOURCE;
+ }
+
+ @Override
+ protected TypeResolution resolveType() {
+ if (childrenResolved() == false) {
+ return new TypeResolution("Unresolved children");
+ }
+
+ // First parameter accepts string types or SOURCE (for _source field)
+ TypeResolution resolution = isType(jsonInput, JsonExtract::isStringOrSource, sourceText(), FIRST, "keyword", "text", "_source");
+ if (resolution.unresolved()) {
+ return resolution;
+ }
+ resolution = isString(path, sourceText(), SECOND);
+ if (resolution.unresolved()) {
+ return resolution;
+ }
+
+ return TypeResolution.TYPE_RESOLVED;
+ }
+
+ @Override
+ public boolean foldable() {
+ return jsonInput.foldable() && path.foldable();
+ }
+
+ /**
+ * Parses a path string into segments, converting bracket notation to segments.
+ * E.g., "orders[1].item" -> ["orders", "1", "item"]
+ */
+ private static String[] parsePath(String path) {
+ if (path.isEmpty()) {
+ return new String[0];
+ }
+ // Convert bracket notation to dot notation: "orders[1].item" -> "orders.1.item"
+ String converted = path.replace("[", ".").replace("]", "");
+ return converted.split("\\.");
+ }
+
+ @Evaluator(warnExceptions = IllegalArgumentException.class)
+ static void process(BytesRefBlock.Builder builder, BytesRef jsonInput, BytesRef path) {
+ String jsonStr = jsonInput.utf8ToString();
+ String pathStr = path.utf8ToString();
+ String[] pathSegments = parsePath(pathStr);
+
+ try (XContentParser parser = JsonXContent.jsonXContent.createParser(XContentParserConfiguration.EMPTY, jsonStr)) {
+ XContentParser.Token token = parser.nextToken();
+ if (token == null) {
+ throw new JsonExtractException("invalid JSON input");
+ }
+ extractValue(builder, parser, pathSegments, 0, pathStr);
+ } catch (JsonExtractException e) {
+ throw new IllegalArgumentException(e.getMessage());
+ } catch (IOException | XContentParseException e) {
+ // JSON parsing errors
+ throw new IllegalArgumentException("invalid JSON input");
+ }
+ }
+
+ /**
+ * Recursively navigates the JSON stream to extract the value at the given path.
+ *
+ * This method uses streaming parsing to avoid creating intermediate objects for paths
+ * we don't need. It walks through the JSON structure one token at a time, descending
+ * only into the path segments we care about and skipping everything else.
+ *
+ * The recursion works as follows:
+ *
+ *
If we've consumed all path segments ({@code depth == pathSegments.length}),
+ * we've reached the target value - extract it.
+ *
If the current token is an object, iterate through its fields looking for
+ * a key matching the current segment. Skip non-matching fields entirely.
+ *
If the current token is an array, the segment must be a numeric index.
+ * Iterate through elements, skipping until we reach the target index.
+ *
If the current token is a scalar (string, number, boolean, null) but we
+ * still have path segments to consume, the path is invalid.
+ *
+ *
+ * @param builder the block builder to append the extracted value to
+ * @param parser the JSON parser positioned at the current token
+ * @param pathSegments the path split into segments (e.g., ["user", "address", "city"])
+ * @param depth current position in pathSegments (0-indexed)
+ * @param originalPath the original path string for error messages
+ */
+ private static void extractValue(
+ BytesRefBlock.Builder builder,
+ XContentParser parser,
+ String[] pathSegments,
+ int depth,
+ String originalPath
+ ) throws IOException, JsonExtractException {
+ XContentParser.Token token = parser.currentToken();
+
+ // Base case: we've consumed all path segments, so the parser is now positioned
+ // at the value we want to extract
+ if (depth == pathSegments.length) {
+ extractCurrentValue(builder, parser);
+ return;
+ }
+
+ String segment = pathSegments[depth];
+
+ if (token == XContentParser.Token.START_OBJECT) {
+ // Current value is an object - look for a field matching the current path segment.
+ // We iterate through all fields: FIELD_NAME token followed by the field's value.
+ while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
+ if (token == XContentParser.Token.FIELD_NAME) {
+ String fieldName = parser.currentName();
+ parser.nextToken(); // Advance from FIELD_NAME to the field's value
+ if (fieldName.equals(segment)) {
+ // Found the matching key - recurse to process the next path segment
+ extractValue(builder, parser, pathSegments, depth + 1, originalPath);
+ return;
+ } else {
+ // Not the key we want - skip this field's entire value (including nested structures)
+ parser.skipChildren();
+ }
+ }
+ }
+ // Exhausted all fields without finding a match
+ throw new JsonExtractException("path [" + originalPath + "] does not exist");
+
+ } else if (token == XContentParser.Token.START_ARRAY) {
+ // Current value is an array - the path segment must be a numeric index.
+ int targetIndex;
+ try {
+ targetIndex = Integer.parseInt(segment);
+ } catch (NumberFormatException e) {
+ // Path segment isn't a number, but we're at an array - path is invalid
+ throw new JsonExtractException("path [" + originalPath + "] does not exist");
+ }
+ if (targetIndex < 0) {
+ throw new JsonExtractException("array index out of bounds");
+ }
+
+ // Iterate through array elements until we reach the target index
+ int currentIndex = 0;
+ while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) {
+ if (currentIndex == targetIndex) {
+ // Found the target element - recurse to process the next path segment
+ extractValue(builder, parser, pathSegments, depth + 1, originalPath);
+ return;
+ }
+ // Not the index we want - skip this element entirely
+ parser.skipChildren();
+ currentIndex++;
+ }
+ // Reached end of array without finding the target index
+ throw new JsonExtractException("array index out of bounds");
+
+ } else {
+ // Current value is a scalar (string, number, boolean, null), but we still have
+ // path segments to consume. Can't traverse into a scalar value.
+ throw new JsonExtractException("path [" + originalPath + "] does not exist");
+ }
+ }
+
+ /**
+ * Extracts the current value from the parser and appends it to the builder.
+ */
+ private static void extractCurrentValue(BytesRefBlock.Builder builder, XContentParser parser) throws IOException {
+ XContentParser.Token token = parser.currentToken();
+
+ switch (token) {
+ case VALUE_STRING -> builder.appendBytesRef(new BytesRef(parser.text()));
+ case VALUE_NUMBER -> builder.appendBytesRef(new BytesRef(parser.text()));
+ case VALUE_BOOLEAN -> builder.appendBytesRef(new BytesRef(Boolean.toString(parser.booleanValue())));
+ case VALUE_NULL -> builder.appendNull();
+ case START_OBJECT, START_ARRAY -> {
+ // For objects and arrays, we need to serialize them back to JSON
+ // Use the parser's built-in copyCurrentStructure would require an XContentBuilder
+ // Instead, we'll read the raw text - but XContentParser doesn't give us that easily
+ // So we fall back to building the structure
+ StringBuilder sb = new StringBuilder();
+ copyCurrentStructure(sb, parser);
+ builder.appendBytesRef(new BytesRef(sb.toString()));
+ }
+ default -> throw new IllegalArgumentException("unexpected token: " + token);
+ }
+ }
+
+ /**
+ * Copies the current JSON structure (object or array) to a StringBuilder.
+ */
+ private static void copyCurrentStructure(StringBuilder sb, XContentParser parser) throws IOException {
+ XContentParser.Token token = parser.currentToken();
+
+ if (token == XContentParser.Token.START_OBJECT) {
+ sb.append('{');
+ boolean first = true;
+ while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
+ if (token == XContentParser.Token.FIELD_NAME) {
+ if (first == false) {
+ sb.append(',');
+ }
+ first = false;
+ sb.append('"').append(escapeJson(parser.currentName())).append("\":");
+ parser.nextToken();
+ copyValue(sb, parser);
+ }
+ }
+ sb.append('}');
+ } else if (token == XContentParser.Token.START_ARRAY) {
+ sb.append('[');
+ boolean first = true;
+ while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) {
+ if (first == false) {
+ sb.append(',');
+ }
+ first = false;
+ copyValue(sb, parser);
+ }
+ sb.append(']');
+ }
+ }
+
+ /**
+ * Copies the current value to a StringBuilder.
+ */
+ private static void copyValue(StringBuilder sb, XContentParser parser) throws IOException {
+ XContentParser.Token token = parser.currentToken();
+
+ switch (token) {
+ case VALUE_STRING -> sb.append('"').append(escapeJson(parser.text())).append('"');
+ case VALUE_NUMBER -> sb.append(parser.text());
+ case VALUE_BOOLEAN -> sb.append(parser.booleanValue());
+ case VALUE_NULL -> sb.append("null");
+ case START_OBJECT, START_ARRAY -> copyCurrentStructure(sb, parser);
+ default -> throw new IllegalArgumentException("unexpected token: " + token);
+ }
+ }
+
+ /**
+ * Escapes special characters in a JSON string.
+ */
+ private static String escapeJson(String s) {
+ StringBuilder sb = null;
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ String escape = switch (c) {
+ case '"' -> "\\\"";
+ case '\\' -> "\\\\";
+ case '\b' -> "\\b";
+ case '\f' -> "\\f";
+ case '\n' -> "\\n";
+ case '\r' -> "\\r";
+ case '\t' -> "\\t";
+ default -> null;
+ };
+ if (escape != null) {
+ if (sb == null) {
+ sb = new StringBuilder(s.length() + 16);
+ sb.append(s, 0, i);
+ }
+ sb.append(escape);
+ } else if (sb != null) {
+ sb.append(c);
+ }
+ }
+ return sb == null ? s : sb.toString();
+ }
+
+ @Override
+ public Expression replaceChildren(List newChildren) {
+ return new JsonExtract(source(), newChildren.get(0), newChildren.get(1));
+ }
+
+ @Override
+ protected NodeInfo extends Expression> info() {
+ return NodeInfo.create(this, JsonExtract::new, jsonInput, path);
+ }
+
+ @Override
+ public ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) {
+ ExpressionEvaluator.Factory jsonInputExpr = toEvaluator.apply(jsonInput);
+ ExpressionEvaluator.Factory pathExpr = toEvaluator.apply(path);
+ return new JsonExtractEvaluator.Factory(source(), jsonInputExpr, pathExpr);
+ }
+
+ Expression jsonInput() {
+ return jsonInput;
+ }
+
+ Expression path() {
+ return path;
+ }
+}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractErrorTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractErrorTests.java
new file mode 100644
index 0000000000000..edcbf192be93d
--- /dev/null
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractErrorTests.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.expression.function.scalar.string;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.ErrorsForCasesWithoutExamplesTestCase;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+import org.hamcrest.Matcher;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class JsonExtractErrorTests extends ErrorsForCasesWithoutExamplesTestCase {
+ @Override
+ protected List cases() {
+ return paramsToSuppliers(JsonExtractTests.parameters());
+ }
+
+ @Override
+ protected Expression build(Source source, List args) {
+ return new JsonExtract(source, args.get(0), args.get(1));
+ }
+
+ @Override
+ protected Matcher expectedTypeErrorMatcher(List> validPerPosition, List signature) {
+ // First parameter accepts keyword, text, or _source; second parameter accepts string
+ return equalTo(typeErrorMessage(true, validPerPosition, signature, (v, p) -> p == 0 ? "keyword, text or _source" : "string"));
+ }
+}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractSerializationTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractSerializationTests.java
new file mode 100644
index 0000000000000..dd7f8f06a70d7
--- /dev/null
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractSerializationTests.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.expression.function.scalar.string;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.expression.AbstractExpressionSerializationTests;
+
+import java.io.IOException;
+
+public class JsonExtractSerializationTests extends AbstractExpressionSerializationTests {
+ @Override
+ protected JsonExtract createTestInstance() {
+ Source source = randomSource();
+ Expression jsonInput = randomChild();
+ Expression path = randomChild();
+ return new JsonExtract(source, jsonInput, path);
+ }
+
+ @Override
+ protected JsonExtract mutateInstance(JsonExtract instance) throws IOException {
+ Source source = instance.source();
+ Expression jsonInput = instance.jsonInput();
+ Expression path = instance.path();
+ switch (between(0, 1)) {
+ case 0 -> jsonInput = randomValueOtherThan(jsonInput, AbstractExpressionSerializationTests::randomChild);
+ case 1 -> path = randomValueOtherThan(path, AbstractExpressionSerializationTests::randomChild);
+ }
+ return new JsonExtract(source, jsonInput, path);
+ }
+}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractTests.java
new file mode 100644
index 0000000000000..07f01d5d817fd
--- /dev/null
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractTests.java
@@ -0,0 +1,199 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.expression.function.scalar.string;
+
+import com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.AbstractScalarFunctionTestCase;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.nullValue;
+
+/**
+ * Tests for {@link JsonExtract} function.
+ */
+public class JsonExtractTests extends AbstractScalarFunctionTestCase {
+ public JsonExtractTests(@Name("TestCase") Supplier testCaseSupplier) {
+ this.testCase = testCaseSupplier.get();
+ }
+
+ @ParametersFactory
+ public static Iterable