ES|QL query builder
Warning: This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.
The ES|QL query builder allows you to construct ES|QL queries using JavaScript/TypeScript syntax. Consider the following example:
import { ESQL, E } from '@elastic/elasticsearch-esql-dsl'
const query = ESQL.from('employees')
.sort(E('emp_no'))
.keep('first_name', 'last_name', 'height')
.eval({
height_feet: E('height').mul(3.281),
height_cm: E('height').mul(100),
})
.limit(3)
You can then see the assembled ES|QL query by printing the resulting query object:
console.log(query.render())
// FROM employees
// | SORT emp_no
// | KEEP first_name, last_name, height
// | EVAL height_feet = height * 3.281, height_cm = height * 100
// | LIMIT 3
To execute this query, pass it to the client.esql.query() endpoint:
import { Client } from '@elastic/elasticsearch'
const client = new Client({ node: process.env.ELASTICSEARCH_URL })
const response = await client.esql.query({ query: query.render() })
The response body contains a columns attribute with the list of columns included in the results, and a values attribute with the list of results for the query, each given as an array of column values. Here is a possible response body returned by the example query given above:
{
"columns": [
{ "name": "first_name", "type": "text" },
{ "name": "last_name", "type": "text" },
{ "name": "height", "type": "double" },
{ "name": "height_feet", "type": "double" },
{ "name": "height_cm", "type": "double" }
],
"is_partial": false,
"took": 11,
"values": [
["Adrian", "Wells", 2.424, 7.953144, 242.4],
["Aaron", "Gonzalez", 1.584, 5.1971, 158.4],
["Miranda", "Kramer", 1.55, 5.08555, 155]
]
}
You can also use the built-in helper for typed records:
const { records } = await client.helpers
.esql({ query: query.render() })
.toRecords<{ first_name: string; last_name: string; height: number }>()
To construct an ES|QL query you start from one of the ES|QL source commands:
The FROM command selects the indices, data streams, or aliases to be queried.
Examples:
import { ESQL } from '@elastic/elasticsearch-esql-dsl'
// FROM employees
const q1 = ESQL.from('employees')
// FROM employees-00001, other-employees-*
const q2 = ESQL.from('employees-00001', 'other-employees-*')
// FROM cluster_one:employees-00001, cluster_two:other-employees-*
const q3 = ESQL.from('cluster_one:employees-00001', 'cluster_two:other-employees-*')
// FROM employees METADATA _id
const q4 = ESQL.from('employees').metadata('_id')
// FROM employees METADATA _id, _score
const q5 = ESQL.from('employees').metadata('_id', '_score')
Note how in the last examples the optional METADATA clause of the FROM command is added as a chained method.
The ROW command produces a row with one or more columns, with the values that you specify.
Examples:
import { ESQL, f } from '@elastic/elasticsearch-esql-dsl'
// ROW a = 1, b = "two", c = null
const q1 = ESQL.row({ a: 1, b: 'two', c: null })
// ROW a = [1, 2]
const q2 = ESQL.row({ a: [1, 2] })
// ROW a = ROUND(1.23, 0)
const q3 = ESQL.row({ a: f.round(1.23, 0) })
The SHOW command returns information about the deployment and its capabilities.
Example:
import { ESQL } from '@elastic/elasticsearch-esql-dsl'
// SHOW INFO
const q = ESQL.show('INFO')
The TS command is the source command for time-series indices.
Example:
import { ESQL } from '@elastic/elasticsearch-esql-dsl'
// TS metrics METADATA _tsid
const q = ESQL.ts('metrics').metadata('_tsid')
Once you have a query object, you can add one or more processing commands to it. The following
example shows how to create a query that uses the WHERE and LIMIT commands to filter the
results:
import { ESQL, E } from '@elastic/elasticsearch-esql-dsl'
// FROM employees
// | WHERE still_hired == true
// | LIMIT 10
const query = ESQL.from('employees')
.where(E('still_hired').eq(true))
.limit(10)
All queries are immutable — each method call returns a new query object. This means you can safely branch from a common base:
const base = ESQL.from('employees')
.where(E('still_hired').eq(true))
const byName = base.sort(E('last_name').asc()).limit(10)
const topEarners = base.sort(E('salary').desc()).limit(5)
// `base`, `byName`, and `topEarners` are three independent queries
| Method | ES|QL Command |
|---|---|
.where(expr) |
WHERE |
.eval(assignments) |
EVAL |
.stats(aggs).by(fields) |
STATS ... BY |
.sort(fields) |
SORT |
.limit(n) |
LIMIT |
.keep(fields) |
KEEP |
.drop(fields) |
DROP |
.rename(mapping) |
RENAME |
.mvExpand(field) |
MV_EXPAND |
.enrich(policy).on(field).with(fields) |
ENRICH ... ON ... WITH |
.dissect(field, pattern) |
DISSECT |
.grok(field, pattern) |
GROK |
.lookupJoin(index).on(field) |
LOOKUP JOIN ... ON |
.inlineStats(aggs).by(fields) |
INLINESTATS ... BY |
.changePoint(field).on(key).as_(t, p) |
CHANGE_POINT ... ON ... AS |
.sample(ratio) |
SAMPLE |
.fork(branches) |
FORK |
.fuse(method) |
FUSE |
.completion(prompt).with(opts) |
COMPLETION ... WITH |
.rerank(query).on(fields).with(opts) |
RERANK ... ON ... WITH |
The ES|QL query builder for JavaScript/TypeScript provides multiple ways to create expressions and conditions in ES|QL queries.
The simplest option is to provide all ES|QL expressions and conditionals as strings. The following example uses this approach to add two calculated columns to the results using the EVAL command:
import { ESQL } from '@elastic/elasticsearch-esql-dsl'
// FROM employees
// | SORT emp_no
// | KEEP first_name, last_name, height
// | EVAL height_feet = height * 3.281, height_cm = height * 100
const query = ESQL.from('employees')
.sort('emp_no')
.keep('first_name', 'last_name', 'height')
.eval('height_feet = height * 3.281', 'height_cm = height * 100')
A more advanced alternative is to replace the strings with the E() helper function, which creates InstrumentedExpression objects that support method chaining for comparisons, arithmetic, and more. The following example is functionally equivalent to the one above:
import { ESQL, E } from '@elastic/elasticsearch-esql-dsl'
// FROM employees
// | SORT emp_no
// | KEEP first_name, last_name, height
// | EVAL height_feet = height * 3.281, height_cm = height * 100
const query = ESQL.from('employees')
.sort(E('emp_no'))
.keep('first_name', 'last_name', 'height')
.eval({
height_feet: E('height').mul(3.281),
height_cm: E('height').mul(100),
})
Here the E() helper function is used as a wrapper around column names that initiates an ES|QL expression. The resulting object can be chained with comparison methods (.eq(), .gt(), .lt(), etc.), arithmetic methods (.add(), .mul(), etc.), and sort modifiers (.asc(), .desc(), .nullsFirst(), .nullsLast()).
Here is a second example, which uses a conditional expression in the WHERE command:
import { ESQL } from '@elastic/elasticsearch-esql-dsl'
// FROM employees
// | KEEP first_name, last_name, height
// | WHERE first_name == "Larry"
const query = ESQL.from('employees')
.keep('first_name', 'last_name', 'height')
.where('first_name == "Larry"')
Using the E() expression builder:
import { ESQL, E } from '@elastic/elasticsearch-esql-dsl'
// FROM employees
// | KEEP first_name, last_name, height
// | WHERE first_name == "Larry"
const query = ESQL.from('employees')
.keep('first_name', 'last_name', 'height')
.where(E('first_name').eq('Larry'))
For mixing literal ES|QL with dynamic values, the esql template tag provides safe interpolation:
import { esql, E } from '@elastic/elasticsearch-esql-dsl'
const minSalary = 50000
const dept = 'Engineering'
// salary > 50000 AND department == "Engineering"
const condition = esql`salary > ${minSalary} AND department == ${dept}`
Values are automatically escaped. InstrumentedExpression objects pass through without escaping, so you can freely mix them:
const expr = E('salary').gt(50000)
const condition = esql`${expr} AND department == ${dept}`
For complex conditions, you can pass a plain object using Op symbols:
import { ESQL, Op } from '@elastic/elasticsearch-esql-dsl'
const query = ESQL.from('employees')
.where({
department: 'Engineering',
salary: { [Op.gt]: 50000 },
[Op.or]: {
level: { [Op.in]: ['senior', 'staff'] },
},
})
Available operators: Op.eq, Op.ne, Op.gt, Op.gte, Op.lt, Op.lte, Op.in, Op.like, Op.rlike, Op.and, Op.or, Op.not.
Combine expressions with standalone functions:
import { and_, or_, not_, E } from '@elastic/elasticsearch-esql-dsl'
const condition = and_(
E('salary').gt(50000),
or_(E('dept').eq('Engineering'), E('dept').eq('Sales')),
not_(E('status').eq('inactive'))
)
const query = ESQL.from('employees').where(condition)
ES|QL, like most query languages, is vulnerable to code injection attacks if untrusted data provided by users is added to a query. To eliminate this risk, ES|QL allows untrusted data to be given separately from the query as parameters.
Continuing with the example above, let's assume that the application needs a findEmployeeByName() function that searches for the name given as an argument. If this argument is received by the application from users, then it is considered untrusted and should not be added to the query directly. Here is how to code the function in a secure manner:
function findEmployeeByName(name: string) {
const query = ESQL.from('employees')
.keep('first_name', 'last_name', 'height')
.where(E('first_name').eq(E('?')))
return client.esql.query({
query: query.render(),
params: [name],
})
}
Here the part of the query in which the untrusted data needs to be inserted is replaced with a parameter, which in ES|QL is defined by the question mark. When using the expression builder, the parameter must be given as E('?') so that it is treated as an expression and not as a literal string.
The list of values given in the params argument to the query endpoint are assigned in order to the parameters defined in the query.
The ES|QL language includes a rich set of functions that can be used in expressions and conditionals. These can be included in expressions given as strings, as shown in the example below:
import { ESQL } from '@elastic/elasticsearch-esql-dsl'
// FROM employees
// | KEEP first_name, last_name, height
// | WHERE LENGTH(first_name) < 4
const query = ESQL.from('employees')
.keep('first_name', 'last_name', 'height')
.where('LENGTH(first_name) < 4')
All available ES|QL functions have JavaScript/TypeScript wrappers in the f namespace, which can be used when building expressions. Below is the example above coded using the function wrappers:
import { ESQL, f } from '@elastic/elasticsearch-esql-dsl'
// FROM employees
// | KEEP first_name, last_name, height
// | WHERE LENGTH(first_name) < 4
const query = ESQL.from('employees')
.keep('first_name', 'last_name', 'height')
.where(f.length('first_name').lt(4))
Function argument handling varies by function. Functions like length(), abs(), and avg() treat string arguments as field references (identifiers). Functions like concat(), startsWith(), and replace() treat string arguments as literal values. When you need to explicitly pass a field reference to a literal-mode function, wrap it with E():
// field reference via E(): STARTS_WITH(name, "A")
f.startsWith(E('name'), 'A')
// without E(), both are literals: STARTS_WITH("name", "A")
f.startsWith('name', 'A')
The f namespace includes 150+ function wrappers organized across the following categories:
f.avg('salary')
f.count()
f.countDistinct('dept')
f.max('height')
f.min('height')
f.sum('hours')
f.median('salary')
f.medianAbsoluteDeviation('salary')
f.percentile('latency', 99)
f.top('salary', 5, 'DESC')
f.values('tags')
f.stdDev('salary')
f.variance('salary')
f.weightedAvg('val', 'wt')
f.first('ts')
f.last('ts')
- AVG(salary)
- COUNT(*)
- COUNT_DISTINCT(dept)
- MAX(height)
- MIN(height)
- SUM(hours)
- MEDIAN(salary)
- MEDIAN_ABSOLUTE_DEVIATION(salary)
- PERCENTILE(latency, 99)
- TOP(salary, 5, DESC)
- VALUES(tags)
- STD_DEV(salary)
- VARIANCE(salary)
- WEIGHTED_AVG(val, wt)
- FIRST(ts)
- LAST(ts)
Aggregation functions return AggregationExpression, which extends InstrumentedExpression and adds a .where() method for conditional aggregations:
const query = ESQL.from('employees')
.stats({
eng_avg: f.avg('salary').where(E('dept').eq('Engineering')),
total: f.count(),
})
f.concat(E('first_name'), ' ', E('last_name')) // CONCAT(first_name, " ", last_name)
f.length('name')
f.toUpper('name')
f.toLower('name')
f.substring('msg', 0, 100)
f.trim('name')
f.left('name', 5)
f.right('name', 3)
f.replace(E('msg'), 'old', 'new') // REPLACE(msg, "old", "new")
f.repeat('star', 5)
f.startsWith(E('name'), 'prefix') // STARTS_WITH(name, "prefix")
f.endsWith(E('name'), 'suffix') // ENDS_WITH(name, "suffix")
f.contains('message', 'error') // CONTAINS(message, "error")
f.split(E('tags'), ',') // SPLIT(tags, ",")
f.reverse('name')
f.locate(E('msg'), 'error') // LOCATE(msg, "error")
f.space(10)
f.byteLength('data')
f.bitLength('data')
f.lpad(E('val'), 10, '0') // LPAD(val, 10, "0")
f.rpad(E('val'), 10, ' ') // RPAD(val, 10, " ")
- LENGTH(name)
- TO_UPPER(name)
- TO_LOWER(name)
- SUBSTRING(msg, 0, 100)
- TRIM(name)
- LEFT(name, 5)
- RIGHT(name, 3)
- REPEAT(star, 5)
- REVERSE(name)
- SPACE(10)
- BYTE_LENGTH(data)
- BIT_LENGTH(data)
f.now()
f.dateDiff('day', 'hire_date', f.now()) // DATE_DIFF("day", hire_date, NOW())
f.dateTrunc('date', '1 month') // DATE_TRUNC(date, "1 month")
f.dateExtract('year', 'hire_date') // DATE_EXTRACT("year", hire_date)
f.dateParse('date_str', 'yyyy-MM-dd') // DATE_PARSE("date_str", "yyyy-MM-dd")
f.dateFormat('ts', 'yyyy-MM-dd') // DATE_FORMAT(ts, "yyyy-MM-dd")
- NOW()
f.abs('change')
f.round('salary', -3)
f.ceil('price')
f.floor('price')
f.sqrt('area')
f.pow('base', 2)
f.log('value')
f.log10('value')
f.pi()
f.e()
f.tau()
f.clamp('val', 0, 100)
f.greatest('a', 'b')
f.least('a', 'b')
f.signum('val')
f.cbrt('val')
f.hypot('a', 'b')
- ABS(change)
- ROUND(salary, -3)
- CEIL(price)
- FLOOR(price)
- SQRT(area)
- POW(base, 2)
- LOG(value)
- LOG10(value)
- PI()
- E()
- TAU()
- CLAMP(val, 0, 100)
- GREATEST(a, b)
- LEAST(a, b)
- SIGNUM(val)
- CBRT(val)
- HYPOT(a, b)
f.coalesce('nickname', 'first_name')
// CASE WHEN age < 18 THEN "minor" WHEN age < 65 THEN "adult" ELSE "senior" END
f.case_()
.when(E('age').lt(18), 'minor')
.when(E('age').lt(65), 'adult')
.else_('senior')
f.greatest('a', 'b')
f.least('a', 'b')
- COALESCE(nickname, first_name)
- GREATEST(a, b)
- LEAST(a, b)
f.match('title', 'search') // MATCH(title, "search")
f.matchPhrase('title', 'exact phrase') // MATCH_PHRASE(title, "exact phrase")
f.multiMatch('query', 'f1', 'f2') // MULTI_MATCH("query", f1, f2)
f.term('status', 'active') // TERM(status, "active")
f.kql('status: active') // KQL("status: active")
f.qstr('title:search') // QSTR("title:search")
f.knn('embedding', 10)
f.score()
- KNN(embedding, 10)
- SCORE()
f.mvAvg('values')
f.mvCount('values')
f.mvMin('values')
f.mvMax('values')
f.mvSum('values')
f.mvMedian('values')
f.mvFirst('values')
f.mvLast('values')
f.mvConcat(E('tags'), ', ') // MV_CONCAT(tags, ", ")
f.mvSort('values')
f.mvDedupe('values')
f.mvSlice('values', 0, 5)
f.mvZip('keys', 'values')
f.mvContains('tags', 'x') // MV_CONTAINS(tags, "x")
f.mvAppend('a', 'b')
- MV_AVG(values)
- MV_COUNT(values)
- MV_MIN(values)
- MV_MAX(values)
- MV_SUM(values)
- MV_MEDIAN(values)
- MV_FIRST(values)
- MV_LAST(values)
- MV_SORT(values)
- MV_DEDUPE(values)
- MV_SLICE(values, 0, 5)
- MV_ZIP(keys, values)
- MV_APPEND(a, b)
f.stX('point')
f.stY('point')
f.stDistance('a', 'b')
f.stIntersects('geo', 'shape')
f.stContains('geo', 'point')
f.stDisjoint('geo', 'shape')
f.stWithin('geo', 'boundary')
f.stCentroidAgg('location')
f.stExtentAgg('location')
f.stEnvelope('geo')
- ST_X(point)
- ST_Y(point)
- ST_DISTANCE(a, b)
- ST_INTERSECTS(geo, shape)
- ST_CONTAINS(geo, point)
- ST_DISJOINT(geo, shape)
- ST_WITHIN(geo, boundary)
- ST_CENTROID_AGG(location)
- ST_EXTENT_AGG(location)
- ST_ENVELOPE(geo)
f.toBoolean('val')
f.toInteger('val')
f.toLong('val')
f.toDouble('val')
f.toString_('val')
f.toDatetime('val')
f.toIp('val')
f.toVersion('val')
f.toGeoPoint('val')
f.toGeoShape('val')
f.toCartesianPoint('val')
f.toCartesianShape('val')
f.toUnsignedLong('val')
- TO_BOOLEAN(val)
- TO_INTEGER(val)
- TO_LONG(val)
- TO_DOUBLE(val)
- TO_STRING(val)
- TO_DATETIME(val)
- TO_IP(val)
- TO_VERSION(val)
- TO_GEOPOINT(val)
- TO_GEOSHAPE(val)
- TO_CARTESIANPOINT(val)
- TO_CARTESIANSHAPE(val)
- TO_UNSIGNED_LONG(val)
f.cidrMatch('ip', '10.0.0.0/8', '172.16.0.0/12') // CIDR_MATCH(ip, "10.0.0.0/8", "172.16.0.0/12")
f.ipPrefix('ip', 24, 0)
f.networkDirection('src', 'dst')
- IP_PREFIX(ip, 24, 0)
- NETWORK_DIRECTION(src, dst)
f.bucket('salary', 10000)
f.categorize('message')
- BUCKET(salary, 10000)
- CATEGORIZE(message)
f.rate('bytes')
f.delta('counter')
f.deriv('counter')
f.tbucket('@timestamp', '1h')
f.trange('@timestamp', '24h')
f.avgOverTime('cpu')
f.minOverTime('cpu')
f.maxOverTime('cpu')
f.sumOverTime('bytes')
f.countOverTime('events')
- RATE(bytes)
- DELTA(counter)
- DERIV(counter)
- TBUCKET(@timestamp, 1h)
- TRANGE(@timestamp, 24h)
- AVG_OVER_TIME(cpu)
- MIN_OVER_TIME(cpu)
- MAX_OVER_TIME(cpu)
- SUM_OVER_TIME(bytes)
- COUNT_OVER_TIME(events)
f.hash('algorithm', 'input') // HASH("algorithm", input)
f.md5('input')
f.sha1('input')
f.sha256('input')
f.urlDecode('url')
f.urlEncode('url')
f.urlEncodeComponent('str')
- MD5(input)
- SHA1(input)
- SHA256(input)
- URL_DECODE(url)
- URL_ENCODE(url)
- URL_ENCODE_COMPONENT(str)
f.vCosine('a', 'b')
f.vDotProduct('a', 'b')
f.vHamming('a', 'b')
f.vL1Norm('a', 'b')
f.vL2Norm('a', 'b')
f.vMagnitude('vec')
- V_COSINE(a, b)
- V_DOT_PRODUCT(a, b)
- V_HAMMING(a, b)
- V_L1_NORM(a, b)
- V_L2_NORM(a, b)
- V_MAGNITUDE(vec)
The stats() method accepts an object mapping output column names to aggregation expressions. Chain .by() to group the aggregation:
import { ESQL, E, f } from '@elastic/elasticsearch-esql-dsl'
// FROM employees
// | STATS avg_salary = AVG(salary), headcount = COUNT(*) BY department
// | SORT avg_salary DESC
const query = ESQL.from('employees')
.stats({
avg_salary: f.avg('salary'),
headcount: f.count(),
})
.by('department')
.sort(E('avg_salary').desc())
Use fork() to run multiple sub-queries in parallel, then fuse() to merge the results:
import { ESQL, E, f } from '@elastic/elasticsearch-esql-dsl'
const query = ESQL.from('articles')
.fork(
ESQL.branch()
.where(f.match('title', 'elasticsearch'))
.sort(E('_score').desc())
.limit(50),
ESQL.branch()
.where(f.knn('embedding', 10))
.sort(E('_score').desc())
.limit(50),
)
.fuse('RRF')
.limit(10)
const query = ESQL.from('logs')
.enrich('ip_lookup')
.on('client.ip')
.with('geo.city', 'geo.country')
.keep('message', 'geo.city', 'geo.country')
// Using DISSECT
const q1 = ESQL.from('logs')
.dissect('message', '%{timestamp} %{level} %{msg}')
.keep('timestamp', 'level', 'msg')
// Using GROK
const q2 = ESQL.from('logs')
.grok('message', '%{TIMESTAMP_ISO8601:timestamp} %{LOGLEVEL:level}')
.keep('timestamp', 'level')
// LLM completion
const q1 = ESQL.from('docs')
.completion('Summarize this document')
.with({ inferenceId: 'my-llm' })
// Semantic reranking
const q2 = ESQL.from('docs')
.rerank('user query')
.on('title', 'body')
.with({ inferenceId: 'my-reranker', topN: 10 })
const query = ESQL.ts('metrics')
.stats({
bytes_rate: f.rate('bytes'),
avg_cpu: f.avgOverTime('cpu'),
})
.by('host')
const query = ESQL.from('metrics')
.changePoint('cpu_usage')
.on('host')
.as_('change_type', 'change_pvalue')
Every query object supports render(), toString(), and toJSON():
const query = ESQL.from('employees')
.where(E('salary').gt(50000))
.limit(10)
query.render() // "FROM employees\n| WHERE salary > 50000\n| LIMIT 10"
query.toString()
query.toJSON() // { query: "FROM employees\n| WHERE salary > 50000\n| LIMIT 10" }
- same as render()