diff --git a/docs/changelog/112350.yaml b/docs/changelog/112350.yaml
new file mode 100644
index 0000000000000..994cd3a65c633
--- /dev/null
+++ b/docs/changelog/112350.yaml
@@ -0,0 +1,5 @@
+pr: 112350
+summary: "[ESQL] Add `SPACE` function"
+area: ES|QL
+type: enhancement
+issues: []
diff --git a/docs/reference/esql/functions/description/space.asciidoc b/docs/reference/esql/functions/description/space.asciidoc
new file mode 100644
index 0000000000000..ee01da64f590f
--- /dev/null
+++ b/docs/reference/esql/functions/description/space.asciidoc
@@ -0,0 +1,5 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Description*
+
+Returns a string made of `number` spaces.
diff --git a/docs/reference/esql/functions/examples/space.asciidoc b/docs/reference/esql/functions/examples/space.asciidoc
new file mode 100644
index 0000000000000..cef3cd6139021
--- /dev/null
+++ b/docs/reference/esql/functions/examples/space.asciidoc
@@ -0,0 +1,13 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Example*
+
+[source.merge.styled,esql]
+----
+include::{esql-specs}/string.csv-spec[tag=space]
+----
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+include::{esql-specs}/string.csv-spec[tag=space-result]
+|===
+
diff --git a/docs/reference/esql/functions/kibana/definition/space.json b/docs/reference/esql/functions/kibana/definition/space.json
new file mode 100644
index 0000000000000..acf7466284d3b
--- /dev/null
+++ b/docs/reference/esql/functions/kibana/definition/space.json
@@ -0,0 +1,23 @@
+{
+ "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.",
+ "type" : "eval",
+ "name" : "space",
+ "description" : "Returns a string made of `number` spaces.",
+ "signatures" : [
+ {
+ "params" : [
+ {
+ "name" : "number",
+ "type" : "integer",
+ "optional" : false,
+ "description" : "Number of spaces in result."
+ }
+ ],
+ "variadic" : false,
+ "returnType" : "keyword"
+ }
+ ],
+ "examples" : [
+ "ROW message = CONCAT(\"Hello\", SPACE(1), \"World!\");"
+ ]
+}
diff --git a/docs/reference/esql/functions/kibana/docs/space.md b/docs/reference/esql/functions/kibana/docs/space.md
new file mode 100644
index 0000000000000..3112bf953dd65
--- /dev/null
+++ b/docs/reference/esql/functions/kibana/docs/space.md
@@ -0,0 +1,10 @@
+
+
+### SPACE
+Returns a string made of `number` spaces.
+
+```
+ROW message = CONCAT("Hello", SPACE(1), "World!");
+```
diff --git a/docs/reference/esql/functions/layout/space.asciidoc b/docs/reference/esql/functions/layout/space.asciidoc
new file mode 100644
index 0000000000000..22355d1e24978
--- /dev/null
+++ b/docs/reference/esql/functions/layout/space.asciidoc
@@ -0,0 +1,15 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+[discrete]
+[[esql-space]]
+=== `SPACE`
+
+*Syntax*
+
+[.text-center]
+image::esql/functions/signature/space.svg[Embedded,opts=inline]
+
+include::../parameters/space.asciidoc[]
+include::../description/space.asciidoc[]
+include::../types/space.asciidoc[]
+include::../examples/space.asciidoc[]
diff --git a/docs/reference/esql/functions/parameters/space.asciidoc b/docs/reference/esql/functions/parameters/space.asciidoc
new file mode 100644
index 0000000000000..de4efd34c0ba4
--- /dev/null
+++ b/docs/reference/esql/functions/parameters/space.asciidoc
@@ -0,0 +1,6 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Parameters*
+
+`number`::
+Number of spaces in result.
diff --git a/docs/reference/esql/functions/signature/space.svg b/docs/reference/esql/functions/signature/space.svg
new file mode 100644
index 0000000000000..c506c25dfcb16
--- /dev/null
+++ b/docs/reference/esql/functions/signature/space.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/reference/esql/functions/string-functions.asciidoc b/docs/reference/esql/functions/string-functions.asciidoc
index d4b120ad1c45b..ed97769b900e7 100644
--- a/docs/reference/esql/functions/string-functions.asciidoc
+++ b/docs/reference/esql/functions/string-functions.asciidoc
@@ -19,6 +19,7 @@
* <>
* <>
* <>
+* <>
* <>
* <>
* <>
@@ -39,6 +40,7 @@ include::layout/repeat.asciidoc[]
include::layout/replace.asciidoc[]
include::layout/right.asciidoc[]
include::layout/rtrim.asciidoc[]
+include::layout/space.asciidoc[]
include::layout/split.asciidoc[]
include::layout/starts_with.asciidoc[]
include::layout/substring.asciidoc[]
diff --git a/docs/reference/esql/functions/types/space.asciidoc b/docs/reference/esql/functions/types/space.asciidoc
new file mode 100644
index 0000000000000..3f2e89f80d3e5
--- /dev/null
+++ b/docs/reference/esql/functions/types/space.asciidoc
@@ -0,0 +1,9 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Supported types*
+
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+number | result
+integer | keyword
+|===
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/meta.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/meta.csv-spec
index 5cb174f9777f5..29f0bdf0c4805 100644
--- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/meta.csv-spec
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/meta.csv-spec
@@ -72,6 +72,7 @@ double pi()
"double signum(number:double|integer|long|unsigned_long)"
"double sin(angle:double|integer|long|unsigned_long)"
"double sinh(number:double|integer|long|unsigned_long)"
+"keyword space(number:integer)"
"keyword split(string:keyword|text, delim:keyword|text)"
"double sqrt(number:double|integer|long|unsigned_long)"
"geo_point|cartesian_point st_centroid_agg(field:geo_point|cartesian_point)"
@@ -196,6 +197,7 @@ rtrim |string |"keyword|text"
signum |number |"double|integer|long|unsigned_long" |"Numeric expression. If `null`, the function returns `null`."
sin |angle |"double|integer|long|unsigned_long" |An angle, in radians. If `null`, the function returns `null`.
sinh |number |"double|integer|long|unsigned_long" |Numeric expression. If `null`, the function returns `null`.
+space |number |"integer" |Number of spaces in result.
split |[string, delim] |["keyword|text", "keyword|text"] |[String expression. If `null`\, the function returns `null`., Delimiter. Only single byte delimiters are currently supported.]
sqrt |number |"double|integer|long|unsigned_long" |"Numeric expression. If `null`, the function returns `null`."
st_centroid_ag|field |"geo_point|cartesian_point" |[""]
@@ -320,6 +322,7 @@ rtrim |Removes trailing whitespaces from a string.
signum |Returns the sign of the given number. It returns `-1` for negative numbers, `0` for `0` and `1` for positive numbers.
sin |Returns the {wikipedia}/Sine_and_cosine[sine] of an angle.
sinh |Returns the {wikipedia}/Hyperbolic_functions[hyperbolic sine] of a number.
+space |Returns a string made of `number` spaces.
split |Split a single valued string into multiple strings.
sqrt |Returns the square root of a number. The input can be any numeric value, the return value is always a double. Square roots of negative numbers and infinities are null.
st_centroid_ag|Calculate the spatial centroid over a field with spatial point geometry type.
@@ -446,6 +449,7 @@ rtrim |"keyword|text"
signum |double |false |false |false
sin |double |false |false |false
sinh |double |false |false |false
+space |keyword |false |false |false
split |keyword |[false, false] |false |false
sqrt |double |false |false |false
st_centroid_ag|"geo_point|cartesian_point" |false |false |true
@@ -508,5 +512,5 @@ countFunctions#[skip:-8.15.99]
meta functions | stats a = count(*), b = count(*), c = count(*) | mv_expand c;
a:long | b:long | c:long
-115 | 115 | 115
+116 | 116 | 116
;
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec
index f85a3bb01ad40..ffcceab26bcaf 100644
--- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec
@@ -1595,4 +1595,100 @@ emp_no:integer | languages:integer | first_name:keyword
10004 | 5 | ChirstianChirstianChirstianChirstianChirstian
;
+space
+required_capability: space
+// tag::space[]
+ROW message = CONCAT("Hello", SPACE(1), "World!");
+// end::space[]
+// tag::space-result[]
+message:keyword
+Hello World!
+// end::space-result[]
+;
+
+spaceLength
+required_capability: space
+ROW len = LENGTH(SPACE(12));
+
+len:integer
+12
+;
+
+spaceCalculated
+required_capability: space
+ROW inner_width = 20, title = "Title"
+| EVAL lspace = SPACE((inner_width-LENGTH(title))/2),
+ rspace = SPACE(inner_width-LENGTH(lspace)-LENGTH(title)),
+ centered_title = CONCAT("<",lspace,title,rspace,">")
+| KEEP inner_width, centered_title;
+
+inner_width:integer | centered_title:keyword
+20 | < Title >
+;
+
+spaceNumberFromIndex
+required_capability: space
+FROM employees
+| EVAL s = CONCAT("<",SPACE(languages),">")
+| WHERE emp_no < 10005
+| SORT emp_no
+| KEEP emp_no, languages, s;
+
+emp_no:integer | languages:integer | s:keyword
+10001 | 2 | < >
+10002 | 5 | < >
+10003 | 4 | < >
+10004 | 5 | < >
+;
+
+spaceZero
+required_capability: space
+ROW s = SPACE(0);
+
+s:keyword
+""
+;
+
+spaceNull
+required_capability: space
+ROW s = SPACE(null);
+
+s:keyword
+null
+;
+
+spaceNegative
+required_capability: space
+FROM employees | SORT emp_no | LIMIT 1 | EVAL s = SPACE(-LENGTH(first_name)) | KEEP s;
+
+warning:Line 1:51: evaluation of [SPACE(-LENGTH(first_name))] failed, treating result as null. Only first 20 failures recorded.
+warning:Line 1:51: java.lang.IllegalArgumentException: Number parameter cannot be negative, found [-6]
+
+s:keyword
+null
+;
+
+spaceComplex
+required_capability: space
+ROW x = 1+2+3, y = null | EVAL z = x + 5 | LIMIT 1
+| EVAL xs = SPACE(x), xsc = CONCAT("<",xs,">"),
+ xn = SPACE(null),
+ xz = SPACE(z), xzc = CONCAT("<",xz,">"),
+ lxz = LENGTH(xz), lxs = LENGTH(xs), ltr = LENGTH(TRIM(xz))
+| DROP xs, xz;
+
+x:integer | y:null | z:integer | xsc:keyword | xn:keyword | xzc:keyword | lxz:integer | lxs:integer | ltr:integer
+6 | null | 11 | < > | null | < > | 11 | 6 | 0
+;
+
+spaceMV
+required_capability: space
+ROW mv = [1,2,3] | EVAL x = space(mv) | KEEP x;
+
+warning:Line 1:29: evaluation of [space(mv)] failed, treating result as null. Only first 20 failures recorded.
+warning:Line 1:29: java.lang.IllegalArgumentException: single-value function encountered multi-value
+
+x:keyword
+null
+;
diff --git a/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceEvaluator.java b/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceEvaluator.java
new file mode 100644
index 0000000000000..0252bd85f6ff8
--- /dev/null
+++ b/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceEvaluator.java
@@ -0,0 +1,128 @@
+// 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 java.util.function.Function;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BytesRefBlock;
+import org.elasticsearch.compute.data.IntBlock;
+import org.elasticsearch.compute.data.IntVector;
+import org.elasticsearch.compute.data.Page;
+import org.elasticsearch.compute.operator.BreakingBytesRefBuilder;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+import org.elasticsearch.core.Releasables;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.expression.function.Warnings;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link Space}.
+ * This class is generated. Do not edit it.
+ */
+public final class SpaceEvaluator implements EvalOperator.ExpressionEvaluator {
+ private final Warnings warnings;
+
+ private final BreakingBytesRefBuilder scratch;
+
+ private final EvalOperator.ExpressionEvaluator number;
+
+ private final DriverContext driverContext;
+
+ public SpaceEvaluator(Source source, BreakingBytesRefBuilder scratch,
+ EvalOperator.ExpressionEvaluator number, DriverContext driverContext) {
+ this.scratch = scratch;
+ this.number = number;
+ this.driverContext = driverContext;
+ this.warnings = Warnings.createWarnings(driverContext.warningsMode(), source);
+ }
+
+ @Override
+ public Block eval(Page page) {
+ try (IntBlock numberBlock = (IntBlock) number.eval(page)) {
+ IntVector numberVector = numberBlock.asVector();
+ if (numberVector == null) {
+ return eval(page.getPositionCount(), numberBlock);
+ }
+ return eval(page.getPositionCount(), numberVector);
+ }
+ }
+
+ public BytesRefBlock eval(int positionCount, IntBlock numberBlock) {
+ try(BytesRefBlock.Builder result = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) {
+ position: for (int p = 0; p < positionCount; p++) {
+ if (numberBlock.isNull(p)) {
+ result.appendNull();
+ continue position;
+ }
+ if (numberBlock.getValueCount(p) != 1) {
+ if (numberBlock.getValueCount(p) > 1) {
+ warnings.registerException(new IllegalArgumentException("single-value function encountered multi-value"));
+ }
+ result.appendNull();
+ continue position;
+ }
+ try {
+ result.appendBytesRef(Space.process(this.scratch, numberBlock.getInt(numberBlock.getFirstValueIndex(p))));
+ } catch (IllegalArgumentException e) {
+ warnings.registerException(e);
+ result.appendNull();
+ }
+ }
+ return result.build();
+ }
+ }
+
+ public BytesRefBlock eval(int positionCount, IntVector numberVector) {
+ try(BytesRefBlock.Builder result = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) {
+ position: for (int p = 0; p < positionCount; p++) {
+ try {
+ result.appendBytesRef(Space.process(this.scratch, numberVector.getInt(p)));
+ } catch (IllegalArgumentException e) {
+ warnings.registerException(e);
+ result.appendNull();
+ }
+ }
+ return result.build();
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "SpaceEvaluator[" + "number=" + number + "]";
+ }
+
+ @Override
+ public void close() {
+ Releasables.closeExpectNoException(scratch, number);
+ }
+
+ static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+ private final Source source;
+
+ private final Function scratch;
+
+ private final EvalOperator.ExpressionEvaluator.Factory number;
+
+ public Factory(Source source, Function scratch,
+ EvalOperator.ExpressionEvaluator.Factory number) {
+ this.source = source;
+ this.scratch = scratch;
+ this.number = number;
+ }
+
+ @Override
+ public SpaceEvaluator get(DriverContext context) {
+ return new SpaceEvaluator(source, scratch.apply(context), number.get(context), context);
+ }
+
+ @Override
+ public String toString() {
+ return "SpaceEvaluator[" + "number=" + number + "]";
+ }
+ }
+}
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 120323ebeb7a6..ef40371b27036 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
@@ -274,7 +274,12 @@ public enum Cap {
/**
* Allow mixed numeric types in coalesce
*/
- MIXED_NUMERIC_TYPES_IN_COALESCE;
+ MIXED_NUMERIC_TYPES_IN_COALESCE,
+
+ /**
+ * Support for requesting the "SPACE" function.
+ */
+ SPACE;
private final boolean snapshotOnly;
private final FeatureFlag featureFlag;
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 0d50623fe77eb..bb2ecefabf23e 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
@@ -119,6 +119,7 @@
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Repeat;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Replace;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Right;
+import org.elasticsearch.xpack.esql.expression.function.scalar.string.Space;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Split;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.StartsWith;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Substring;
@@ -307,7 +308,8 @@ private FunctionDefinition[][] functions() {
def(ToLower.class, ToLower::new, "to_lower"),
def(ToUpper.class, ToUpper::new, "to_upper"),
def(Locate.class, Locate::new, "locate"),
- def(Repeat.class, Repeat::new, "repeat") },
+ def(Repeat.class, Repeat::new, "repeat"),
+ def(Space.class, Space::new, "space") },
// date
new FunctionDefinition[] {
def(DateDiff.class, DateDiff::new, "date_diff"),
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/UnaryScalarFunction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/UnaryScalarFunction.java
index 980d3ab0e7552..bdbc9b649c101 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/UnaryScalarFunction.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/UnaryScalarFunction.java
@@ -58,6 +58,7 @@
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Length;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.RLike;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.RTrim;
+import org.elasticsearch.xpack.esql.expression.function.scalar.string.Space;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Trim;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.WildcardLike;
import org.elasticsearch.xpack.esql.expression.predicate.operator.arithmetic.Neg;
@@ -96,6 +97,7 @@ public static List getNamedWriteables() {
entries.add(Signum.ENTRY);
entries.add(Sin.ENTRY);
entries.add(Sinh.ENTRY);
+ entries.add(Space.ENTRY);
entries.add(Sqrt.ENTRY);
entries.add(StX.ENTRY);
entries.add(StY.ENTRY);
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/package-info.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/package-info.java
index b4781c7e41f98..f8b05aea324dc 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/package-info.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/package-info.java
@@ -120,15 +120,17 @@
* Rerun the {@code CsvTests}. They should find your function and maybe even pass. Add a
* few more tests in the csv-spec tests. They run quickly so it isn't a big deal having
* half a dozen of them per function. In fact, it's useful to add more complex combinations
- * of things here, just to catch any accidental strange interactions. For example, it is
- * probably a good idea to have your function passes as a parameter to another function
+ * of things here, just to catch any accidental strange interactions. For example, have
+ * your function take its input from an index like {@code FROM employees | EVAL foo=MY_FUNCTION(emp_no)}.
+ * It's probably a good idea to have your function passed as a parameter to another function
* like {@code EVAL foo=MOST(0, MY_FUNCTION(emp_no))}. And likely useful to try the reverse
* like {@code EVAL foo=MY_FUNCTION(MOST(languages + 10000, emp_no)}.
*
*
* Now it's time to make a unit test! The infrastructure for these is under some flux at
- * the moment, but it's good to extend from {@code AbstractScalarFunctionTestCase}. All of
+ * the moment, but it's good to extend {@code AbstractScalarFunctionTestCase}. All of
* these tests are parameterized and expect to spend some time finding good parameters.
+ * Also add serialization tests that extend {@code AbstractExpressionSerializationTests<>}.
*
*
* Once you are happy with the tests run the auto formatter:
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/Space.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/Space.java
new file mode 100644
index 0000000000000..e6225a008fceb
--- /dev/null
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/Space.java
@@ -0,0 +1,127 @@
+/*
+ * 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.ann.Fixed;
+import org.elasticsearch.compute.operator.BreakingBytesRefBuilder;
+import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.expression.Literal;
+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.UnaryScalarFunction;
+import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Function;
+
+import static org.elasticsearch.common.unit.ByteSizeUnit.MB;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.DEFAULT;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType;
+import static org.elasticsearch.xpack.esql.core.type.DataType.KEYWORD;
+
+public class Space extends UnaryScalarFunction {
+ public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "Space", Space::new);
+
+ private static final long MAX_LENGTH = MB.toBytes(1);
+
+ @FunctionInfo(
+ returnType = "keyword",
+ description = "Returns a string made of `number` spaces.",
+ examples = @Example(file = "string", tag = "space")
+ )
+ public Space(
+ Source source,
+ @Param(name = "number", type = { "integer" }, description = "Number of spaces in result.") Expression number
+ ) {
+ super(source, number);
+ }
+
+ private Space(StreamInput in) throws IOException {
+ this(Source.readFrom((PlanStreamInput) in), in.readNamedWriteable(Expression.class));
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ source().writeTo(out);
+ out.writeNamedWriteable(field);
+ }
+
+ @Override
+ public String getWriteableName() {
+ return ENTRY.name;
+ }
+
+ @Override
+ public DataType dataType() {
+ return KEYWORD;
+ }
+
+ @Override
+ protected TypeResolution resolveType() {
+ if (childrenResolved() == false) {
+ return new TypeResolution("Unresolved children");
+ }
+
+ return isType(field, dt -> dt == DataType.INTEGER, sourceText(), DEFAULT, "integer");
+ }
+
+ @Evaluator(warnExceptions = { IllegalArgumentException.class })
+ static BytesRef process(@Fixed(includeInToString = false, build = true) BreakingBytesRefBuilder scratch, int number) {
+ checkNumber(number);
+ scratch.grow(number);
+ scratch.setLength(number);
+ Arrays.fill(scratch.bytes(), 0, number, (byte) ' ');
+ return scratch.bytesRefView();
+ }
+
+ static void checkNumber(int number) {
+ if (number < 0) {
+ throw new IllegalArgumentException("Number parameter cannot be negative, found [" + number + "]");
+ }
+ if (number > MAX_LENGTH) {
+ throw new IllegalArgumentException("Creating strings longer than [" + MAX_LENGTH + "] bytes is not supported");
+ }
+ }
+
+ @Override
+ public Expression replaceChildren(List newChildren) {
+ return new Space(source(), newChildren.get(0));
+ }
+
+ @Override
+ protected NodeInfo extends Expression> info() {
+ return NodeInfo.create(this, Space::new, field);
+ }
+
+ @Override
+ public ExpressionEvaluator.Factory toEvaluator(Function toEvaluator) {
+ if (field.foldable()) {
+ Object folded = field.fold();
+ if (folded instanceof Integer num) {
+ checkNumber(num);
+ return toEvaluator.apply(new Literal(source(), " ".repeat(num), KEYWORD));
+ }
+ }
+
+ ExpressionEvaluator.Factory numberExpr = toEvaluator.apply(field);
+ return new SpaceEvaluator.Factory(source(), context -> new BreakingBytesRefBuilder(context.breaker(), "space"), numberExpr);
+ }
+
+}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceSerializationTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceSerializationTests.java
new file mode 100644
index 0000000000000..bf3b15145e0f3
--- /dev/null
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceSerializationTests.java
@@ -0,0 +1,31 @@
+/*
+ * 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 SpaceSerializationTests extends AbstractExpressionSerializationTests {
+ @Override
+ protected Space createTestInstance() {
+ Source source = randomSource();
+ Expression number = randomChild();
+ return new Space(source, number);
+ }
+
+ @Override
+ protected Space mutateInstance(Space instance) throws IOException {
+ Source source = instance.source();
+ Expression number = instance.field();
+ number = randomValueOtherThan(number, AbstractExpressionSerializationTests::randomChild);
+ return new Space(source, number);
+ }
+}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceTests.java
new file mode 100644
index 0000000000000..308ce2c9d932f
--- /dev/null
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceTests.java
@@ -0,0 +1,81 @@
+/*
+ * 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.elasticsearch.common.unit.ByteSizeUnit.MB;
+import static org.hamcrest.Matchers.nullValue;
+
+public class SpaceTests extends AbstractScalarFunctionTestCase {
+ public SpaceTests(@Name("TestCase") Supplier testCaseSupplier) {
+ this.testCase = testCaseSupplier.get();
+ }
+
+ @ParametersFactory
+ public static Iterable