The SQL Gateway provides a Data Query Language (DQL) on top of Elasticsearch, centered around the SELECT statement.
It offers a familiar SQL experience while translating queries into Elasticsearch search, aggregations, and scroll APIs.
DQL supports:
SELECTwith expressions, aliases, nested fields, STRUCT and ARRAYWHERE,GROUP BY,HAVING,ORDER BY,LIMIT,OFFSETUNION ALL- cross-index JOINs (
INNER/LEFT/RIGHT/FULL OUTER) across indices and clusters — see Cross-Index JOIN JOIN UNNESTonARRAY<STRUCT>(the single-index nested form, handled natively inside one index)- aggregations, parent-level aggregations on nested arrays
- window functions with
OVER - rich function support (numeric, string, date/time, geo, conditional, type conversion)
- SELECT
- Quoted identifiers
- Qualified and quoted table names
- FROM-less SELECT (connection handshake)
- WHERE
- ORDER BY
- LIMIT / OFFSET
- UNION ALL
- JOIN UNNEST
- Aggregations
- Parent-Level Aggregations on Nested Arrays
- Window Functions
- Functions
- Scroll & Pagination
- Version Compatibility
- Limitations
- SHOW TABLES
- SHOW TABLE
- SHOW CREATE TABLE
- DESCRIBE TABLE
- SHOW PIPELINES
- SHOW PIPELINE
- SHOW CREATE PIPELINE
- DESCRIBE PIPELINE
- SHOW WATCHERS
- SHOW WATCHER STATUS
- SHOW ENRICH POLICIES
- SHOW ENRICH POLICY
- SHOW CLUSTER NAME
- SHOW LICENSE
- REFRESH LICENSE
SELECT [DISTINCT] expr1, expr2, ...
FROM table_name [alias]
[WHERE condition]
[GROUP BY expr1, expr2, ...]
[HAVING condition]
[ORDER BY expr1 [ASC|DESC] [NULLS FIRST|NULLS LAST], ...]
[LIMIT n]
[OFFSET m];SELECT id,
name AS full_name,
profile.city AS city,
profile.followers AS followers
FROM dql_users
ORDER BY id ASC;profileis aSTRUCTcolumn.profile.cityandprofile.followersaccess nested fields.- Aliases (
AS full_name,AS city) are returned as column names.
A column name or an alias may be written quoted, in either of two spellings — the ANSI SQL-92 double quote or the MySQL backtick. Both are accepted everywhere an identifier is accepted, and both denote the same column.
SELECT `category`, COUNT(id) AS `n`
FROM bi_events
WHERE "category" IS NOT NULL
GROUP BY `category`
ORDER BY `category` ASC;Quoting is what lets a name be used that the bare spelling cannot express:
| Written | Meaning |
|---|---|
SELECT `select` |
a column literally named select — a quoted name bypasses the reserved-word rule |
SELECT `my col` |
a column whose name contains a space |
SELECT `Category` |
case is preserved verbatim; Elasticsearch field names are case-sensitive |
SELECT `1` |
the column named 1, never the first column by position |
SELECT `e`.`category` FROM bi_events e |
each part of a qualified name may be quoted independently — e.`category` and `e`.category are the same thing |
The delimiter is escaped by doubling it, in both spellings:
SELECT `a``b`, -- the column named a`b
"a""b" -- the column named a"b
FROM t;Inside a double-quoted name a backslash also escapes the next character ("a\"b" is the column
a"b). That form is not standard and is kept only because this engine has always accepted it; it is
deliberately not available inside backticks, where a backslash is an ordinary character.
An empty pair of double quotes ("") is not an identifier — it is the empty string literal, the
same as ''. An empty pair of backticks is rejected.
Statements are re-rendered with one canonical delimiter, the ANSI double quote, whichever
spelling was written. A rendered statement always re-parses to the same query, so
SELECT `category` comes back as SELECT "category". Only names that were written quoted are
re-emitted quoted; a bare name stays bare.
This engine accepts a double-quoted string literal, so "x" is read as a string wherever a
value is expected and as a column wherever a name is expected:
SELECT MAX("amount") -- column amount
FROM bi_events
WHERE category = "premium"; -- string 'premium'Use single quotes for strings and backticks for names if you would rather not rely on position.
A string literal is written between single quotes. Double quotes also delimit a string, but only in value position — see Double quotes are also string delimiters above — so single quotes are the unambiguous spelling.
The delimiter is escaped by doubling it, exactly as it is inside a quoted identifier. This is the SQL standard and what every client and BI tool emits:
SELECT 'O''Brien' AS name FROM t; -- the value O'Brien
SELECT id FROM t WHERE greeting = "say ""hi"""; -- the value say "hi"SELECT "say ""hi""" FROM t reads the field named say "hi", per Double quotes are also string
delimiters above — and a reference to a field that does not exist returns nulls, not an error.
Single quotes have only one reading and are the safe spelling for a string.
A backslash before the delimiter or before another backslash is also accepted ('it\'s', 'C:\\').
That form is not standard and is kept only because this engine has always accepted it. Any other
backslash sequence is literal: 'a\nb' is the four characters a, \, n, b — there is no
\n newline escape, and a path such as 'C:\logs' keeps its separator.
A value that ends in a single backslash must be written 'C:\\': a lone trailing backslash escapes
the closing quote and the literal never terminates.
Statements are re-rendered with the backslash form, whichever spelling was written, so
SELECT 'O''Brien' comes back as SELECT 'O\'Brien'. Both spellings parse, and a rendered
statement always re-parses to the same query.
The name after FROM (and after JOIN, and after DELETE FROM) may be written quoted, in either
spelling, and may carry a qualifier:
SELECT category FROM `bi_events`;
SELECT category FROM "bi_events";
SELECT category FROM `elastic`.`bi_events` `bi_events`;
SELECT category FROM "elastic"."bi_events" AS e;
SELECT o.id FROM `elastic`.`orders` o JOIN `elastic`.`customers` c ON o.cid = c.id;Elasticsearch index names may themselves contain dots (logs-2025.03), so a bare dot can never be
a separator. A qualifier is a leading run of parts that are each quoted AND each followed by a
dot; the index name is everything after that run. Nothing else separates the two readings.
| Written | Index read | Qualifier |
|---|---|---|
FROM bi_events |
bi_events |
— |
FROM elastic.bi_events |
elastic.bi_events |
— (a bare dot is part of the name) |
FROM logs-2025.03 |
logs-2025.03 |
— |
FROM "elastic".bi_events |
bi_events |
elastic |
FROM `elastic`.bi_events |
bi_events |
elastic |
FROM "elastic"."bi_events" |
bi_events |
elastic |
FROM "logs-2025.03" |
logs-2025.03 |
— (the dot is inside the quotes) |
FROM "elasticsearch"."prod-cluster"."bi_events" |
bi_events |
elasticsearch, prod-cluster |
The rule is the same in FROM, in every JOIN form and in DELETE FROM, so one statement can
never read one index on one leg and a different one on another.
⚠️ The two quote styles are interchangeable to the engine, but not yet on the federation path. Federation recognises a cross-cluster prefix by matching`catalog`.tableon the raw SQL before parsing — a BACKTICKED qualifier followed by a BARE table name. A fully quotedFROM `prod_us`.`orders`is not matched, so the leg is forwarded to the default cluster rather than the one named. Until that is fixed, leave the table name itself unquoted when you qualify it for Federation. See known limitations.
The engine records it and does not interpret it. The qualifier never becomes part of the index
name and is never resolved by the parser: what a leading qualifier means depends on who is
asking. The JDBC driver and the Flight SQL producer advertise it as the schema (the cluster
name; the catalog is the constant elasticsearch); Federation reads it as a catalog — a
servers.<name> alias, as in joins.md. A BI tool simply echoes back whatever schema
our own driver advertised, which is why a qualifier that names nothing in particular is accepted
rather than rejected.
Practically: send the qualifier your tool generates, and the engine will read the index you meant.
Before this release a JOIN source went through the column-name rules, which JOIN the parts of a
dotted name — so JOIN "prod_us".customers c read the index prod_us.customers while the FROM
leg of the same statement dropped its qualifier. Both legs now follow the table rule and read
customers. If you were relying on a quoted JOIN qualifier becoming part of the index name,
that statement now reads a different index — write the dotted name unquoted
(JOIN prod_us.customers c) to keep the old reading.
Qualifiers used to be dropped from the re-rendered SQL. They are not any more — a rendered statement carries the qualifier the original had, canonicalised to the ANSI double quote:
SELECT category FROM `elastic`.`bi_events`
-- renders as
SELECT category FROM "elastic"."bi_events"Unlike a column name, a table name is quoted as one lexeme — FROM "logs-2025.03", never
FROM "logs-2025"."03" — because a table name's dots are literal while a column name's separate
the alias from the field.
Everything above applies unchanged to INSERT, UPDATE, CREATE, DROP, TRUNCATE, ALTER,
COPY INTO and every SHOW/DESCRIBE — for the table name and for column names alike.
There is no per-statement-kind exception: a name you may write in a SELECT you may write
anywhere.
INSERT INTO `prod_eu`.dest SELECT a FROM src;
INSERT INTO "prod_eu".dest (`c`) VALUES ('a');
UPDATE `orders` SET "a" = 1 WHERE id = 1;
CREATE TABLE "dest" ("c" INTEGER);
DROP TABLE IF EXISTS `#Tableau_sid_1_Connect_Chec`;
ALTER TABLE "dest" ALTER COLUMN "c" SET DATA TYPE BIGINT;
DROP MATERIALIZED VIEW "mv1";
CREATE TABLE dest (c INTEGER) OPTIONS (`number_of_shards` = 1);Three details worth knowing:
- A column list is not a table reference. A column name has no qualifier run, so every part of
a dotted column name is kept:
INSERT INTO tbl ("sch".c)names the single columnsch.c— the same readingSELECT "e"."c"gives on the expression surface. - Renderings carry the quoting back. A table, view, pipeline, watcher or enrich-policy name
keeps the qualifier and the quoting it was written with, canonicalised to the ANSI double quote;
a column name is re-quoted only when the bare spelling could not be read back as the same name
(so
("c")renders(c), while("my col")renders("my col")). - Bare names are unchanged. Every statement that parsed before this release still parses to the
same statement, including the ones that name a table or column with a word this dialect reserves
elsewhere (
DROP TABLE count,CREATE TABLE t (min INTEGER)). Quoting is now simply also available for them.
Two spellings that were rejected before and still are: CREATE LOCAL TEMPORARY TABLE … (there is
no LOCAL TEMPORARY clause, quoted or not) and an empty quoted lexeme in any name position — an
empty pair of delimiters is a string literal, never a name.
SELECT without a FROM clause is the connection/health idiom of the JDBC/SQLAlchemy
ecosystem: Tableau re-issues SELECT 1 on every interaction, Superset's connection test sends
SELECT 1, its engine probe sends SELECT 1 LIMIT 100, and connection pools use it as
connectionTestQuery. All of these are supported:
SELECT 1;
SELECT 1 AS x;
SELECT 1 LIMIT 100;
SELECT 1 AS ok, UPPER('x') AS u, 1+1 AS two;
SELECT CURRENT_TIMESTAMP AS ts;
SELECT '125'::BIGINT AS c;Each returns exactly one row. Column names follow the usual convention: an explicit alias
wins, otherwise the rendered expression (SELECT 1 yields a column named 1).
A FROM-less SELECT executes against the Elasticsearch cluster — the select-list is
translated to Painless and evaluated by ES, exactly like the same expressions in a FROM-ful
query. Consequently, with the cluster unreachable, SELECT 1 fails with the propagated
connection error. A green handshake genuinely means "connected"; a pool's
connectionTestQuery = SELECT 1 is a real connection test.
The first FROM-less SELECT on a client lazily creates a dedicated index in the cluster:
- name:
softclient4es_handshake - settings: 1 shard, 0 replicas (single-node clusters stay green);
index.hidden: trueon ES ≥ 7.7 - mapping: a single
dummykeyword field - content: one seeded document (
PUT /softclient4es_handshake/_doc/1 {"dummy": "dummy"})
Creation is race-safe and idempotent, and happens once per client lifecycle. The index is
never listed by SHOW TABLES (any pattern) — which also keeps it out of JDBC
DatabaseMetaData.getTables and Arrow Flight GET_TABLES browsing. DESCRIBE TABLE softclient4es_handshake still works, deliberately, for debuggability. The index is never
deleted automatically.
If the account the BI tool connects with cannot create indices, have an administrator
pre-create and seed the index once — lazy creation then becomes a no-op existence probe, and
the read-only account only needs the read privilege on softclient4es_handshake.
Through SoftClient4ES itself (REPL, JDBC, or any connected client, with a privileged account):
CREATE TABLE IF NOT EXISTS softclient4es_handshake (dummy KEYWORD)
OPTIONS (settings = (number_of_shards = "1", number_of_replicas = "0"));
INSERT INTO softclient4es_handshake (dummy) VALUES ('dummy');The CREATE TABLE IF NOT EXISTS is a no-op when the index already exists; re-running the
INSERT just adds another row, which is harmless — the handshake reads a single one.
Or directly against Elasticsearch:
PUT /softclient4es_handshake
{
"settings": {"number_of_shards": 1, "number_of_replicas": 0},
"mappings": {"properties": {"dummy": {"type": "keyword"}}}
}
PUT /softclient4es_handshake/_doc/1
{"dummy": "dummy"}
Without pre-creation, a read-only session's first SELECT 1 fails with the cluster's own
security error (status preserved) plus an appended message naming both routes of this
guidance.
The select-list must be constant scalar expressions — literals or Painless-translatable
functions of literals. Rejected with a named reason (... requires a FROM clause):
- column references —
SELECT col, including embedded ones (SELECT UPPER(col)) SELECT *- aggregations (
SELECT COUNT(*)) and window functions EXCEPT(...), duplicate output column names, unbound?parameters, array literals, negativeLIMIT/OFFSET
Rejected at the grammar level: WHERE / GROUP BY / HAVING / ORDER BY / UNION ALL
after a FROM-less select-list, and DISTINCT literals. A constant cast works in every
spelling — CAST('125' AS BIGINT), CONVERT('125', BIGINT) and '125'::BIGINT all parse.
Prefer TRY_CAST('125' AS BIGINT) when the value may not convert: :: is always the
unsafe form and raises on a bad value.
The WHERE clause supports:
- comparison operators:
=,!=,<,<=,>,>= - logical operators:
AND,OR,NOT IN,NOT INBETWEENIS NULL,IS NOT NULLLIKE,RLIKE(regex)- conditions on nested fields (
profile.city,profile.followers)
A function in a
WHEREpredicate, and documents that do not carry the field. A predicate that applies a function to a column (WHERE UPPER(status) = 'A',WHERE ABS(amount) > 10) is executed by Elasticsearch as a Painless script. Since engine 0.23.0 such a predicate follows ANSI three-valued logic for a document in which the field is absent: the comparison is NULL, so the document does not match — and it does not match the negated form either (WHERE NOT UPPER(status) = 'A'leaves it out, becauseNOT NULLis NULL, not TRUE). Before 0.23.0 the emitted script did not compile at all and Elasticsearch rejected the whole query (script_exception: compile error, caused byclass_cast_exception: Cannot cast from [boolean] to [java.lang.Object]), so no such predicate ever ran.This holds for the comparisons listed here, not only
=:<,>,<>,LIKE,NOT LIKE,IN,NOT IN,BETWEENandNOT BETWEENover a function all follow the same rule, and aNOTwritten afterAND/OR(WHERE ABS(amount) > 10 AND NOT UPPER(status) = 'A') negates the criterion it qualifies, not the whole composite — so the same predicate returns the same rows whichever way round you write it. Before 0.23.0 several of these did not run at all:LIKEover a function produced an uncompilable script,NOT LIKEfailed inside the engine, andIN/BETWEENover a function were sent to Elasticsearch with an empty field name and rejected.A predicate with no function is not scripted — it becomes a term/range query — and
NOTover it is Elasticsearch'smust_not, which does return documents that lack the field. The two routes therefore differ for absent fields; useIS NULL/IS NOT NULLwhen that distinction matters.A projected function keeps its
NULL:SELECT UPPER(status) AS ureturnsu = NULLfor a document with nostatus, and aGROUP BY UPPER(status)has no bucket for it. The collapse to "no match" applies to a condition, never to a value.🔴
ORDER BYover a function of a column some documents do not carry LOSES ROWS SILENTLY. The engine emits a null-preserving sort script; Elasticsearch then fails the shard while building the comparator (null_pointer_exception). What you see depends on the shard count, and the dangerous case is the normal one:
- on a single-shard index the whole search is rejected — you get an error;
- on a multi-shard index the search returns HTTP 200 and the failing shard's documents are simply absent from the result. MEASURED on Elasticsearch 8.18.3, 3 shards, 7 documents with one lacking the field:
_shards.failed: 1,hits.total: 5— two rows gone, no error anywhere. The engine does not surface_shards.failures, so nothing reaches the caller.Until that is fixed, sort by the bare column, or keep the field present on every document. Do not rely on getting an error. The same applies to
ORDER BYover aCASE … ENDwith noELSE, which is NULL-valued for the rows no branch matches.
⚠️ Two limits of the rule above, stated rather than implied.NOT <function>(x) IS NULLis a PARSE rejection — the grammar takes a bare name afterNOTthere — so the rule covers the comparisons listed, not literally every clause you can write. And on a multi-valued field the scripted and non-scripted routes differ for a reason that has nothing to do with NULL: the native query matches if ANY value matches, while the script reads a single value.
⚠️ When aLIKEover a function needs a regular expression. The engine compiles such a predicate to whitelisted string operations when the pattern contains no_and uses%only at the ends ('A%','%A','%A%','A','','%'). Every other pattern — including one made only of%, such as'A%B'— compiles to a Painless regular expression, and Elasticsearch 6.8 disables those by default (script.painless.regex.enabled), answeringRegexes are disabled. On 7.x and later every pattern works.🔴 Changed in 0.23.0 —
LIKEreads only%and_as wildcards. Every other character in a pattern is now matched literally, on the scripted and the native path.WHERE status LIKE 'A.B%'previously matchedAXB1, because.reached Elasticsearch as a regular-expression wildcard; it now matches only values that really begin withA.B. Patterns that relied on the old reading must be rewritten with_(any single character) or%(any sequence).RLIKEis unaffected — its operand is a regular expression by definition.
Example
SELECT id, name, age
FROM dql_users
WHERE (age > 20 AND profile.followers >= 100)
OR (profile.city = 'Lyon' AND age < 50)
ORDER BY age DESC;Another example with multiple operators:
SELECT id,
age + 10 AS age_plus_10,
name
FROM dql_users
WHERE age BETWEEN 20 AND 50
AND name IN ('Alice', 'Bob', 'Chloe')
AND name IS NOT NULL
AND (name LIKE 'A%' OR name RLIKE '.*o.*');A string literal compared to a column mapped as date (with =, <>, !=, <, <=, >, >=,
BETWEEN or IN) is resolved against the column's mapping format before the query is sent to
Elasticsearch, so the SQL-standard spelling a BI tool emits selects the same rows as the ISO one:
WHERE event_ts >= '2026-06-04 00:00:00.000000' -- what Superset / SQLAlchemy render
WHERE event_ts >= '2026-06-04 00:00:00'
WHERE event_ts >= '2026-06-04T00:00:00' -- what Elasticsearch's default format accepts- Under a format that accepts ISO dates (the default
strict_date_optional_time||epoch_millis,date_optional_time,strict_date_optional_time_nanos) the space separator is rewritten toT; fraction digits and a trailing zone are preserved. ISO literals, date-only literals, epoch numbers and date math (now-1d/d,2026-06-04||/M-- whose date part is normalised the same way) are forwarded verbatim. - A column with a custom
format(for exampleyyyy-MM-dd HH:mm:ss) keeps working as before: a literal its format already parses is never rewritten. - Under the default (strict) format a literal that cannot be a date at all (
'not-a-date') or carries an invalid calendar or time value ('2026-02-30','2026-06-04 24:00:00') fails with an error naming the literal and the field (HTTP 400) instead of a raw Elasticsearchsearch_phase_execution_exception. A literal that starts like a date but has a shape the resolver does not model (a zone id, a signed year,2026-6-4) is forwarded verbatim and Elasticsearch decides; underdate_optional_timeor a custom format nothing is ever rejected. - The same resolution applies to the
WHEREclause ofUPDATEandDELETE. keyword/textcolumns,LIKE/RLIKEpatterns, function-wrapped columns (YEAR(event_ts)),date_nanoscolumns, columns qualified with aJOINalias (the FROM table's own columns are resolved) andHAVINGconditions are never touched.- The resolution needs the index mapping, loaded through the schema cache (one lookup per index per
TTL —
elastic.schema-cache.ttl, 5 minutes by default, and an index may set its own withALTER TABLE … SET SCHEMA CACHE TTL). An index alias over exactly one index resolves to that index's mapping (SHOW TABLE/DESCRIBEthrough such an alias resolve the same way); an alias over several indices is ambiguous and is treated as unresolvable. When the statement reads several indices or a wildcard, or the mapping cannot be loaded, the literal is forwarded verbatim as in previous releases, and a failed mapping lookup is remembered for the DEFAULT TTL (a miss has no index metadata to read a per-index one from) so it is not retried on every statement.
ORDER BY sorts the result set by one or more expressions.
- Supports multiple sort keys
- Supports
ASCandDESC - Supports
NULLS FIRST/NULLS LASTper sort key (see below) - Supports expressions and nested fields (e.g.,
profile.city) - When used inside a window function (
OVER),ORDER BYdefines the logical ordering of the window
Example
SELECT id, name, age
FROM dql_users
ORDER BY age DESC, name ASC
LIMIT 2 OFFSET 1;Each sort key may declare where NULL values appear in the result:
SELECT id, name, bonus
FROM dql_users
ORDER BY bonus DESC NULLS LAST;Mapped to Elasticsearch's sort.missing parameter:
NULLS FIRST→"missing": "_first"NULLS LAST→"missing": "_last"
When NULLS FIRST / NULLS LAST is omitted, defaults follow the Elasticsearch
convention:
ASC→ nulls lastDESC→ nulls first
Different null orderings can be combined within a single query:
SELECT id, name, bonus, hire_date
FROM dql_users
ORDER BY bonus DESC NULLS LAST, hire_date ASC NULLS FIRST;Caveat (ES6 Jest client): scroll / search_after queries in the ES6 Jest
client do not propagate NULLS FIRST / NULLS LAST reliably across batches
(search_after's null handling is implementation-defined in Jest). For ES6
scroll/search_after, prefer client-side null-bucketing or upgrade to ES7+.
LIMIT nrestricts the number of returned rows.OFFSET mskips the firstmrows.- Translated to Elasticsearch
from+size.
Example:
SELECT id, name, age
FROM dql_users
ORDER BY age DESC
LIMIT 10 OFFSET 20;UNION ALL combines the results of multiple SELECT queries without removing duplicates.
Bare
UNION(with row de-duplication) is not supported and is rejected at parse time — the two are not synonyms. It used to be accepted silently, returning only the first leg's rows.
All SELECT statements in a UNION ALL must be strictly compatible:
- same number of columns
- same column names (after alias resolution)
- same or implicitly compatible types
If these conditions are not met, the Gateway raises a validation error before executing the query.
Example
SELECT id, name FROM dql_users WHERE age > 30
UNION ALL
SELECT id, name FROM dql_users WHERE age <= 30;The SQL Gateway executes UNION ALL using Elasticsearch Multi‑Search (_msearch):
- Each SELECT query is translated into an independent ES search request.
- All requests are sent in a single
_msearchcall. - The Gateway concatenates the results in order, without deduplication.
- ORDER BY, LIMIT, OFFSET apply per SELECT, not globally (unless wrapped in a subquery, which is not supported).
UNION ALLdoes not sort or deduplicate results.- Column names in the final output are taken from the first SELECT.
- All subsequent SELECTs must produce columns with the same names.
- Type mismatches should result in a validation error before execution. (
⚠️ not implemented yet)
The Gateway supports a specific form of join: JOIN UNNEST on ARRAY<STRUCT> columns.
CREATE TABLE IF NOT EXISTS dql_orders (
id INT NOT NULL,
customer_id INT,
items ARRAY<STRUCT> FIELDS(
product VARCHAR OPTIONS (fielddata = true),
quantity INT,
price DOUBLE
) OPTIONS (include_in_parent = false)
);SELECT
o.id,
items.product,
items.quantity,
SUM(items.price * items.quantity) OVER (PARTITION BY o.id) AS total_price
FROM dql_orders o
JOIN UNNEST(o.items) AS items
WHERE items.quantity >= 1
ORDER BY o.id ASC;JOIN UNNEST behaves like a standard SQL UNNEST operation: each element of an ARRAY<STRUCT> becomes a separate output row, with parent fields duplicated — exactly like a relational unnest.
This means:
- the array's columns (
items.price,items.quantity) can be projected and filtered - a window over an arithmetic expression of UNNEST columns works (e.g.
SUM(items.price * items.quantity) OVER …); a window over a bare UNNEST column currently returns NULL - parent-level aggregations can be computed
- full row-level expansion produces one output row per array element
- multi-level nesting is handled recursively
Each array element is a separate (nested) Elasticsearch document, and a computed value is evaluated against ONE document — so:
- In SELECT, a function over an UNNEST column (
UPPER(items.product),items.price * items.quantity,CAST(items.product AS VARCHAR)) is refused, beside a window function too: it would be computed once per parent document, where the element's columns are not visible. Select the columns and compute the value from the returned rows. - In WHERE, a condition whose function is applied directly to the UNNEST column —
CAST(items.quantity AS VARCHAR) = '2',ISNULL(items.price) = FALSE, a date part such asYEAR(<column>) = 2025orEXTRACT(MONTH FROM <column>) = 2— is evaluated on each element and works. A condition whose function is not (UPPER(items.product) = 'A',ABS(items.quantity) = 2, aCASE,COALESCE, arithmetic), or that is evaluated on each element but also reads a parent column, is refused: filter the returned rows instead — unless the UNNEST column is only aCOALESCEargument after a non-null literal, whichCOALESCEnever returns (COALESCE('n/a', items.product) = 'n/a'works). - A condition without a function (
items.quantity >= 1,items.product IN ('A', 'B')), an aggregate over UNNEST columns, a window function over an UNNEST column, over arithmetic of UNNEST columns or over a function applied directly to one (SUM(CAST(items.quantity AS DOUBLE)) OVER …), and statements planned by the relational engine (cross-index JOIN, derived table, CTE) are not affected by this refusal.
⚠️ A derived table overJOIN UNNESTis answered by the arrow extension (the JDBC and ADBC drivers, the Flight SQL sidecar, federation, and the REPL when the extension is not excluded). An inner column without an alias is named by its short name, the last part of the reference:items.productisproduct— the name every clause of the outer query uses, and the labelSELECT *gives the column. Two inner columns whose short names coincide (o.idanditems.id) are refused with a message asking for an alias: alias one of them (items.id AS item_id). Otherwise the inner query needs no alias and noLIMIT— within the elements an UNNEST projection returns per parent (100 by default, see below). Measured on Elasticsearch 8.18 and 6.8:SELECT id, UPPER(product) AS product, total_price FROM (SELECT o.id, items.product, SUM(items.price * items.quantity) OVER (PARTITION BY o.id) AS total_price FROM dql_orders o JOIN UNNEST(o.items) AS items) d;
-- refused: A function over an UNNEST column is not supported in SELECT: UPPER(items.product) is
-- evaluated per parent document, where items.product is not visible. Select its columns and
-- compute it from the returned rows.
SELECT o.id, UPPER(items.product) AS product FROM dql_orders o JOIN UNNEST(o.items) AS items;
-- answered: select the column, upper-case it in the returned rows
SELECT o.id, items.product FROM dql_orders o JOIN UNNEST(o.items) AS items LIMIT 100;
-- refused as well: beside a window, the rows are computed the same way
SELECT o.id, UPPER(items.product) AS product,
SUM(items.price * items.quantity) OVER (PARTITION BY o.id) AS total_price
FROM dql_orders o JOIN UNNEST(o.items) AS items;
-- answered: the window stays, only UPPER moves to the returned rows
SELECT o.id, items.product,
SUM(items.price * items.quantity) OVER (PARTITION BY o.id) AS total_price
FROM dql_orders o JOIN UNNEST(o.items) AS items LIMIT 100;Without a LIMIT, an UNNEST projection returns up to 100 elements per parent, or up to the index's
own index.max_inner_result_window when that setting is lower. A parent holding more elements is
cut without an error: measured on Elasticsearch 6.8, 7.17, 8.18 and 9.0, a 101-element parent
returns 100 rows, its last element dropped.
Supported aggregate functions include:
COUNT(*),COUNT(expr)SUM(expr)AVG(expr)MIN(expr)MAX(expr)STDDEV(expr)/STDDEV_SAMP(expr)/STDDEV_POP(expr)VARIANCE(expr)/VAR_SAMP(expr)/VAR_POP(expr)
STDDEV defaults to sample standard deviation (Bessel-corrected, STDDEV ≡ STDDEV_SAMP) and
VARIANCE defaults to sample variance (VARIANCE ≡ VAR_SAMP). This matches PostgreSQL and
Snowflake; users coming from MySQL 5.5 or earlier should note that those releases defaulted
STDDEV to population.
SELECT department,
STDDEV(salary) AS sd,
VAR_POP(salary) AS vp
FROM emp
GROUP BY department;All six map to a single Elasticsearch extended_stats aggregation per call; the requested field
(std_deviation_sampling, variance_sampling for the sample variants; the un-suffixed
std_deviation, variance for the population variants) is projected from the response. Sample
variants require Elasticsearch 7.7+; population variants work on Elasticsearch 6+.
Over a transformed operand (STDDEV(YEAR(hire_date)), VARIANCE(ABS(salary)), plain or
windowed) the statistic is computed over the transform on Elasticsearch 7 and later; on
Elasticsearch 6 the query is refused with a 400 naming the release, because the client library
cannot emit the aggregation script there and used to return the statistic of the raw field silently.
See STDDEV / VARIANCE family.
PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY column)— ANSI ordered-set aggregate (optionally with a top-levelGROUP BY)PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY column) OVER (PARTITION BY ...)— value column fromWITHIN GROUP, partition fromOVERPERCENTILE_CONT(p) OVER (PARTITION BY ... ORDER BY column)— value column from theOVERORDER BYPERCENTILE_CONT(column, p)— column-first shorthand (many BI tools emit it)
The percentile literal p is a value in [0, 1] (e.g. 0.99 for p99); a value outside that range is
rejected at parse time. The value column is given by the ORDER BY clause (WITHIN GROUP or OVER),
or the shorthand's first argument; grouping is given by OVER (PARTITION BY ...) or a top-level
GROUP BY (or neither — a single percentile over the whole result set). Both functions map to the
Elasticsearch percentiles aggregation (TDigest). Elasticsearch has no native discrete percentile, so
PERCENTILE_DISC is continuous-backed — it returns the same interpolated value as PERCENTILE_CONT
rather than the nearest actual data point. All forms work on Elasticsearch 6+.
-- p99 request latency per endpoint (SRE latency analysis)
SELECT endpoint,
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY duration_ms) AS p99
FROM requests
GROUP BY endpoint;SELECT profile.city AS city,
COUNT(*) AS cnt,
AVG(age) AS avg_age
FROM dql_users
GROUP BY profile.city
HAVING COUNT(*) >= 1
ORDER BY COUNT(*) DESC;-
GROUP BYsupports nested fields (profile.city). -
HAVINGfilters groups based on aggregate conditions. -
Translated to Elasticsearch aggregations.
-
An aggregate referenced only in
HAVINGorORDER BYneeds no alias and noSELECTitem: it is computed for the filter or the sort and kept out of the result columns. Distinct aggregates over the same column stay distinct (HAVING COUNT(age) >= 1 AND MAX(age) > 45), and the aggregate may wrap a transform (HAVING MAX(YEAR(birthdate)) > 1990,ORDER BY MAX(ABS(age)) DESC). -
Arithmetic over aggregates is computed per group (
MAX(price) - MIN(price) AS price_range); the operands are computed as hidden aggregations of the group. -
HAVINGmay reference aSELECTaggregate by its alias (COUNT(*) AS cnt ... HAVING cnt > 1), including the alias of an arithmetic expression over aggregates (... AS price_range ... HAVING price_range > 10);BETWEEN,INandNOTapply to aggregates as to columns. -
Rejected with an explicit error: arithmetic over aggregates written inline in
HAVING(HAVING MAX(price) - MIN(price) > 10— alias it inSELECTand reference the alias), an aggregate function insideWHERE(useHAVING, and this covers a wrapped one such asWHERE ABS(COUNT(*)) > 1), and an alias that names one aggregate inSELECTand a different one inHAVING/ORDER BY. -
A function of an aggregate in
HAVINGis applied to the group, or the statement is rejected by name — it is never ignored.COALESCE,GREATEST,LEASTandSIGNover an aggregate filter the groups (HAVING COALESCE(COUNT(*), 0) > 30,HAVING GREATEST(MAX(price), 0) > 100), on either side of the comparison and underNOT;BETWEENandINsupport them in the tested POSITION (GREATEST(COUNT(*), 0) BETWEEN 1 AND 5) but not as a BOUND (COUNT(*) BETWEEN 1 AND ABS(MAX(price))is refused). Everything the engine cannot evaluate as a group filter is refused with the reason:- a rendering that can be NULL —
HAVING NULLIF(COUNT(*), 0) > 1; - a rendering that needs a local variable —
HAVING ROUND(SUM(price), 2) > 10; - a rendering that boxes a number —
HAVING ABS(COUNT(*)) > 1and the rest of the numeric function family (FLOOR,CEIL,SQRT,EXP,LOG,POWER). This is a deliberate over-approximation: the boxing conversion compiles in some positions of a group-filter script and not others, so the engine refuses it in all of them rather than guess. The same functions work normally inWHERE, in theSELECTlist and inORDER BY; CASE ... ENDinHAVING, which needs a document and a group filter has none;- a function applied to a
SELECTaggregate alias —COUNT(*) AS c ... HAVING NULLIF(c, 0) > 1; - an aggregate computed outside a nested grouping —
... JOIN UNNEST(t.emails) AS e GROUP BY e.name HAVING COALESCE(MAX(amount), 0) > 1, where no level of the aggregation can read the metric.
In every refused case the remedy is the same: compare the aggregate itself —
HAVING SUM(price) > 10rather thanHAVING ROUND(SUM(price), 2) > 10— and apply the function to the result outside the query. Aliasing the expression inSELECTdoes NOT help: a function of an aggregate is not a validSELECTitem under aGROUP BYeither (SELECT ABS(COUNT(*)) AS a ... GROUP BY cityis rejected as a non-aggregated field). That differs from arithmetic over aggregates, which IS a validSELECTitem (MAX(price) - MIN(price) AS price_range) and is the reason the rule above tells you to alias THAT one. - a rendering that can be NULL —
HAVING MAX(created) > '2019-01-01' and HAVING MIN(created) < '2020-01-01' are rejected at parse
time. A group filter reads every metric as a number — a date as epoch milliseconds — so the
generated comparison is text against a number and Elasticsearch fails the whole search with a
class_cast_exception. Compare in WHERE instead, or filter the result outside the query.
A HAVING condition over the grouping key filters GROUPS, and it is applied by the terms filter —
so it is correct for a multi-valued field, where one document belongs to several groups.
SELECT city, COUNT(*) AS cnt FROM dql_users GROUP BY city HAVING city = 'Paris';
SELECT city, COUNT(*) AS cnt FROM dql_users GROUP BY city HAVING city LIKE 'P%';
SELECT city, COUNT(*) AS cnt FROM dql_users GROUP BY city HAVING city <> 'Lyon';-
Supported: a direct comparison of the key —
=,<>,IN,LIKE/RLIKE. -
⚠️ Which COMBINATIONS are supported follows from how Elasticsearch applies them. Thetermsfilter carries one list of kept values and one list of removed values, and each is a UNION:combination supported why city = 'Paris' OR city = 'Lyon'✅ the kept list is a union, i.e. a disjunction city <> 'Paris' AND city <> 'Lyon'✅ not-in-A and not-in-B is not-in-(A ∪ B) city = 'Paris' AND city <> 'Lyon'✅ one kept list and one removed list, applied together city LIKE 'P%' AND city NOT LIKE 'L%'✅ one pattern in each of the two lists city <> 'Paris' OR city <> 'Lyon'❌ refused a union of removals is a conjunction, so this would be executed as one city = 'Paris' AND city = 'Lyon'❌ refused a union of kept values is a disjunction, so this would be executed as one city = 'Paris' OR city <> 'Lyon'❌ refused the two lists are applied together, i.e. ANDed city = 'Paris' OR city LIKE 'L%'❌ refused ⚠️ a pattern REPLACES the list — see belowcity LIKE 'P%' OR city LIKE 'L%'❌ refused one list holds one pattern, so the second is lost ⚠️ Being a union is necessary but not sufficient. Each of the two lists holds either a set of values or ONE pattern (LIKE/RLIKE), and a pattern replaces the set — so a pattern meeting anything else in the SAME list loses a side, even where the combination itself is a disjunction.HAVING city = 'Paris' OR city LIKE 'L%'used to return only theL…groups. Use a singleRLIKEcovering both alternatives, or split the query.The refused rows previously returned a plausible-looking but WRONG set of groups. Otherwise: split the query, or restate the condition as an
ORof equalities or anANDof inequalities. -
⚠️ A FUNCTION of the key is refused (HAVING UPPER(city) = 'PARIS',HAVING LENGTH(status) = 1). The terms filter can only express a direct comparison, and the alternatives are unsound: filtering documents instead would keep or drop a multi-valued document WHOLE, and would change the counts of surviving groups whenever the key is itself a function of the column (GROUP BY DAY(d) HAVING YEAR(d) = 2025). Compare the key itself, or filter inWHERE. -
A predicate naming a column that is neither the
GROUP BYkey nor an aggregate is refused (HAVING UPPER(name) = 'X'when the grouping is bycity) — with or without aGROUP BY. -
⚠️ AnORwhose branches need different stages is refused, because Elasticsearch applies the stages one inside the other, which is a conjunction:- different MECHANISMS — a group filter (
bucket_selector), a key filter (terms) and a nested filter:HAVING COUNT(*) > 1 OR city = 'Paris'; - different GROUPING KEYS — the two
termsaggregations are NESTED, soGROUP BY country, city HAVING country = 'FR' OR city = 'Paris'would return only the groups matching BOTH. AnORon ONE key, within one mechanism, is supported subject to the table above; the correspondingANDis always fine, because the nesting IS the conjunction.
- different MECHANISMS — a group filter (
-
An
ANDacross mechanisms is fine — each stage applies its own half. -
A group whose compared metric has no value (for instance
MAX(age)over a group whose documents all lackage) never passes aHAVINGcomparison, in either direction: the generated filter script null-checks every metric before comparing it.
GROUP BY with no aggregate in the SELECT list returns one row per group — the SQL-standard
spelling of DISTINCT:
SELECT profile.city AS city
FROM dql_users
GROUP BY profile.city;- Positions are supported:
GROUP BY 1andORDER BY 1name the firstSELECTitem. A position that names nothing (0, a negative, or one past the end of theSELECTlist) is a parse error naming the position. A column genuinely named with digits is unaffected, and a column named1is addressed asGROUP BY `1`. SELECTaliases are supported:SELECT country AS pays ... GROUP BY paysgroups bycountry, andORDER BY pays/HAVING pays <> 'x'address that group by the same name.- A constant is legal beside a
GROUP BYand carries its value on every row (SELECT category, 2 AS flag FROM t GROUP BY category) — it does not vary within a group, so it needs no grouping. - Grouping by a constant is also legal and means exactly one group
(
SELECT 2 AS flag ... GROUP BY flag, or the equivalent position... GROUP BY 1). It needs aSELECTalias, because the alias is the only name that group can be given. LIMITon aGROUP BYbounds the number of groups, not the number of rows — it is pushed down as the Elasticsearchtermssize. On a multi-columnGROUP BYit bounds each level, so the row count can exceed it.OFFSETis not supported withGROUP BYand is rejected: group results are not paginated.- With no
LIMIT, every level is sized at Elasticsearch'ssearch.max_bucketsceiling (65,536): a grouping wider than that fails loudly rather than truncating silently.
The SQL Gateway supports computing aggregations over nested arrays (e.g., ARRAY<STRUCT>) while keeping one row per parent document (the original nested array is preserved).
This pattern:
- reads the nested array (
JOIN UNNEST) - computes aggregations per parent document (
PARTITION BY parent_id) - returns one row per parent
- preserves the original nested array
- adds the aggregated value as a top-level field
Example
SELECT
o.id,
o.items,
SUM(items.price * items.quantity) OVER (PARTITION BY o.id) AS total_price
FROM dql_orders o
JOIN UNNEST(o.items) AS items
WHERE items.quantity >= 1
ORDER BY o.id ASC;Result
[
{
"id": 1,
"items": [
{"product": "A", "quantity": 2, "price": 10.0},
{"product": "B", "quantity": 1, "price": 20.0}
],
"total_price": 40.0
},
{
"id": 2,
"items": [
{"product": "C", "quantity": 3, "price": 5.0}
],
"total_price": 15.0
}
]- This is not a standard SQL window function (which would return one row per item).
- This is not an Elasticsearch nested aggregation (which would not return the items).
- This is a hybrid parent-level aggregation, unique to the SQL Gateway.
Window functions operate over a logical window of rows defined by OVER (PARTITION BY ... ORDER BY ...).
Supported window functions include:
SUM(expr) OVER (PARTITION BY ...)AVG(expr) OVER (PARTITION BY ...)MIN(expr) OVER (PARTITION BY ...)/MAX(expr) OVER (PARTITION BY ...)COUNT(expr) OVER (PARTITION BY ...), includingCOUNT(DISTINCT expr) OVER (PARTITION BY ...)STDDEV(expr) OVER (PARTITION BY ...)and its_SAMP/_POPvariantsVARIANCE(expr) OVER (PARTITION BY ...)and its_SAMP/_POPvariantsFIRST_VALUE(expr) OVER (...)LAST_VALUE(expr) OVER (...)ARRAY_AGG(expr) OVER (...)ROW_NUMBER() OVER ([PARTITION BY ...] ORDER BY ...)RANK() OVER ([PARTITION BY ...] ORDER BY ...)DENSE_RANK() OVER ([PARTITION BY ...] ORDER BY ...)PERCENTILE_CONT(p) OVER (PARTITION BY ... ORDER BY column)andPERCENTILE_DISC(p) OVER (PARTITION BY ... ORDER BY column)
PERCENTILE_CONT / PERCENTILE_DISC accept four equivalent spellings — the OVER (... ORDER BY column)
form above, WITHIN GROUP (ORDER BY column), the two combined, and the (column, p) shorthand. All four
normalize to the same canonical rendering, PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY column) [OVER (PARTITION BY ...)], so a statement round-tripped through the engine comes back in that form rather than
the one you typed. See Percentiles below for the spellings themselves, and Aggregate Functions for the full reference.
SELECT
product,
customer,
amount,
SUM(amount) OVER (PARTITION BY product) AS sum_per_product,
COUNT(_id) OVER (PARTITION BY product) AS cnt_per_product
FROM dql_sales
ORDER BY product, ts;ORDER BY is REQUIRED inside OVER for ranking functions (ANSI). PARTITION BY
is optional — when absent, the entire result set is treated as one partition.
SELECT name, salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS r,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dr
FROM emp;Tie semantics:
ROW_NUMBER— sequential within partition; no ties recognized (1, 2, 3, 4, …)RANK— ties share rank, next rank skips (1, 2, 2, 4, …)DENSE_RANK— ties share rank, next rank does NOT skip (1, 2, 2, 3, …)
Inline LIMIT N inside the OVER clause to limit the number of rows ranked per
partition. The engine pushes N down to the underlying Elasticsearch
top_hits.size parameter so only the top-N rows per partition are
materialised:
SELECT name, salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC LIMIT 3) AS r
FROM emp;Without an explicit LIMIT, top_hits.size defaults to 100 — the
Elasticsearch index.max_inner_result_window default. For larger partitions
either supply LIMIT N inline or raise the index setting.
SELECT
product,
customer,
amount,
SUM(amount) OVER (PARTITION BY product) AS sum_per_product,
COUNT(_id) OVER (PARTITION BY product) AS cnt_per_product,
FIRST_VALUE(amount) OVER (PARTITION BY product ORDER BY ts ASC) AS first_amount,
LAST_VALUE(amount) OVER (PARTITION BY product ORDER BY ts ASC) AS last_amount,
ARRAY_AGG(amount) OVER (PARTITION BY product ORDER BY ts ASC LIMIT 10) AS amounts_array
FROM dql_sales
ORDER BY product, ts;Notes:
PARTITION BYdefines the grouping key.ORDER BYinsideOVERdefines the window ordering.LIMITinsideARRAY_AGGrestricts the collected values.- Frame clauses (
ROWS BETWEEN ...) are not exposed; the engine uses a default frame per function semantics.
The SQL Gateway provides a rich set of SQL functions covering:
- numeric and trigonometric operations
- string manipulation
- date and time extraction, arithmetic, formatting and parsing
- geospatial functions
- conditional expressions
- type conversion
All functions operate on Elasticsearch documents and are evaluated by the SQL engine.
| Function | Description |
|---|---|
ABS(x) |
Absolute value |
CEIL(x) |
Round up |
FLOOR(x) |
Round down |
ROUND(x, n) |
Round to n decimals |
SQRT(x) |
Square root |
POW(x, y) |
Power |
EXP(x) |
Exponential |
LOG(x) |
Natural logarithm |
LOG10(x) |
Base-10 logarithm |
SIGN(x) |
Sign of x (−1, 0, 1) |
| Function | Description |
|---|---|
SIN(x) |
Sine |
COS(x) |
Cosine |
TAN(x) |
Tangent |
ASIN(x) |
Arc-sine |
ACOS(x) |
Arc-cosine |
ATAN(x) |
Arc-tangent |
ATAN2(y, x) |
Arc-tangent of y/x with quadrant |
PI() |
π constant |
RADIANS(x) |
Degrees → radians |
DEGREES(x) |
Radians → degrees |
Example
SELECT id,
ABS(age) AS abs_age,
SQRT(age) AS sqrt_age,
POW(age, 2) AS pow_age,
LOG(age) AS log_age,
SIN(age) AS sin_age,
ATAN2(age, 10) AS atan2_val
FROM dql_users;| Function | Description |
|---|---|
CONCAT(a, b, ...) |
Concatenate strings |
SUBSTRING(str, start, len) |
Extract substring |
LOWER(str) |
Lowercase |
UPPER(str) |
Uppercase |
TRIM(str) |
Trim both sides |
LTRIM(str) |
Trim left |
RTRIM(str) |
Trim right |
LENGTH(str) |
String length |
REPLACE(str, from, to) |
Replace substring |
LEFT(str, n) |
Left n chars |
RIGHT(str, n) |
Right n chars |
REVERSE(str) |
Reverse string |
POSITION(substr IN str) |
1-based position of substring |
| Function | Description |
|---|---|
REGEXP_LIKE(str, pattern) |
True if regex matches |
MATCH(str) AGAINST (query) |
Full-text match (backed by ES query_string / match) |
Example:
SELECT id,
CONCAT(name.raw, '_suffix') AS name_concat,
SUBSTRING(name.raw, 1, 2) AS name_sub,
LOWER(name.raw) AS name_lower,
LTRIM(name.raw) AS name_ltrim,
POSITION('o' IN name.raw) AS pos_o,
REGEXP_LIKE(name.raw, '.*o.*') AS has_o
FROM dql_users
ORDER BY id ASC;| Function | Description |
|---|---|
CURRENT_DATE |
Current date (UTC) |
TODAY() |
Alias for CURRENT_DATE |
CURRENT_TIMESTAMP | CURRENT_DATETIME |
Current timestamp (UTC) |
NOW() |
Alias for CURRENT_TIMESTAMP |
CURRENT_TIME |
Current time (UTC) |
| Function | Description |
|---|---|
YEAR(date) |
Year |
MONTH(date) |
Month |
DAY(date) |
Day of month |
WEEKDAY(date) |
Day of week |
YEARDAY(date) |
Day of year |
HOUR(ts) |
Hour |
MINUTE(ts) |
Minute |
SECOND(ts) |
Second |
MILLISECOND(ts) |
Millisecond |
MICROSECOND(ts) |
Microsecond |
NANOSECOND(ts) |
Nanosecond |
EXTRACT(unit FROM date_or_timestamp)Supported units include: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, etc.
Example:
SELECT id,
EXTRACT(YEAR FROM birthdate) AS year_b,
EXTRACT(MONTH FROM birthdate) AS month_b
FROM dql_users;| Function | Description |
|---|---|
DATE_ADD(date, INTERVAL n unit) |
Add interval |
DATE_SUB(date, INTERVAL n unit) |
Subtract interval |
DATETIME_ADD(ts, INTERVAL n unit) |
Add interval to timestamp |
DATETIME_SUB(ts, INTERVAL n unit) |
Subtract interval from timestamp |
DATE_DIFF(date1, date2, unit) |
Difference in units |
DATE_TRUNC(date, unit) |
Truncate to unit |
| Function | Description |
|---|---|
DATE_FORMAT(ts, pattern) |
Format date as string |
DATE_PARSE(str, pattern) |
Parse string into date |
DATETIME_FORMAT(ts, pattern) |
Format timestamp as string |
DATETIME_PARSE(str, pattern) |
Parse string into timestamp |
Supported MySQL-style Date/Time Patterns
| Function | Description |
|---|---|
LAST_DAY(date) |
Last day of month |
EPOCHDAY(date) |
Days since epoch (1970-01-01) |
OFFSET_SECONDS(date) |
Epoch seconds |
Example:
SELECT id,
YEAR(CURRENT_DATE) AS current_year,
MONTH(CURRENT_DATE) AS current_month,
DAY(CURRENT_DATE) AS current_day,
YEAR(birthdate) AS year_b,
DATE_DIFF(CURRENT_DATE, birthdate, YEAR) AS diff_years,
DATE_TRUNC(birthdate, MONTH) AS trunc_month,
DATETIME_FORMAT(birthdate, '%Y-%m-%d') AS birth_str
FROM dql_users;POINT(latitude, longitude)ST_DISTANCE(location, POINT(48.8566, 2.3522))Example:
CREATE TABLE IF NOT EXISTS dql_geo (
id INT NOT NULL,
location GEO_POINT,
PRIMARY KEY (id)
);
SELECT id,
ST_DISTANCE(location, POINT(48.8566, 2.3522)) AS dist_paris
FROM dql_geo;CASE
WHEN condition THEN value
[WHEN condition2 THEN value2]
[ELSE default]
ENDCOALESCE(a, b, c)Returns the first non-null value.
NULLIF(a, b)Returns NULL if a = b, otherwise a.
GREATEST(e1, e2, ...)
LEAST(e1, e2, ...)GREATEST returns the largest non-null numeric value among the given expressions; LEAST
returns the smallest. NULL arguments are ignored (ANSI semantics); the result is NULL only
when every argument is NULL. Both are emitted as Painless ternary chains over
Math.max / Math.min. They are row-level conditional functions, not aggregates —
GREATEST(...) OVER (...) is not supported.
SELECT GREATEST(price_us, price_eu, price_uk) AS max_price FROM products;
SELECT LEAST(0, base_price - rebate) AS net FROM orders;Example:
SELECT id,
CASE
WHEN age >= 50 THEN 'senior'
WHEN age >= 30 THEN 'adult'
ELSE 'young'
END AS age_group,
COALESCE(name, 'unknown') AS safe_name
FROM dql_users;CAST(value AS TYPE)Returns NULL instead of failing on invalid conversion.
TRY_CAST('123' AS INT)Alias for TRY_CAST.
value::TYPEExample:
SELECT id,
age::BIGINT AS age_bigint,
CAST(age AS DOUBLE) AS age_double,
TRY_CAST('123' AS INT) AS try_cast_ok,
SAFE_CAST('abc' AS INT) AS safe_cast_null
FROM dql_users;For large result sets, the Gateway uses Elasticsearch scroll or search-after mechanisms depending on backend capabilities (Scroll Search).
Notes:
LIMITandOFFSETare applied by the SQL engine after retrieving documents from Elasticsearch- Deep pagination may require scroll
- When using
search_after, an explicitORDER BYclause is required for deterministic pagination - Without
ORDER BY, result ordering is not guaranteed
| Feature | ES6 | ES7 | ES8 | ES9 |
|---|---|---|---|---|
| Basic SELECT | ✔ | ✔ | ✔ | ✔ |
| Nested fields | ✔ | ✔ | ✔ | ✔ |
| UNION ALL | ✔ | ✔ | ✔ | ✔ |
| Cross-index JOINs | ✔ | ✔ | ✔ | ✔ |
| JOIN UNNEST | ✔ | ✔ | ✔ | ✔ |
| Aggregations | ✔ | ✔ | ✔ | ✔ |
| Parent-level nested array aggs | ✔ | ✔ | ✔ | ✔ |
| Window functions | ✔ | ✔ | ✔ | ✔ |
| Geospatial functions | ✔ | ✔ | ✔ | ✔ |
| Date/time functions | ✔ | ✔ | ✔ | ✔ |
| String / math functions | ✔ | ✔ | ✔ | ✔ |
For the full picture of what works in R1, what's coming in R2a/R2b, and BI-tool workarounds, see Known Limitations & Roadmap.
Even though the DQL engine is powerful, some SQL features are not (yet) supported:
- Cross-index JOINs (
INNER/LEFT/RIGHT/FULL OUTER) are supported across indices and clusters — see Cross-Index JOIN.JOIN UNNESTonARRAY<STRUCT>is the single-index nested form, handled natively inside one index. - No correlated subqueries
- No arbitrary subqueries in
SELECTorWHERE(exceptINSERT ... AS SELECTin DML) - No
GROUPING SETS,CUBE,ROLLUP - No
DISTINCT ON - No explicit window frame clauses (
ROWS BETWEEN ...)
These constraints keep the translation to Elasticsearch efficient and predictable.
SHOW TABLES [LIKE 'pattern'];Returns a list of all tables with summary information (name, type, primary key, partitioning).
May be filtered using LIKE with SQL wildcard %.
*Example:
CREATE TABLE IF NOT EXISTS show_users (
id INT NOT NULL,
name VARCHAR FIELDS(
raw KEYWORD
) OPTIONS (fielddata = true),
age INT DEFAULT 0,
PRIMARY KEY (id)
);
SHOW TABLES LIKE 'show_%';| name | type | pk | partitioned |
|---|---|---|---|
| show_users | TABLE | id | |
| 📊 1 row(s) (7ms) |
Returns:
- schema summary
- primary key
- partitioning
- settings
- mappings
- ddl
CREATE TABLE IF NOT EXISTS users (
id INT NOT NULL COMMENT 'user identifier',
name VARCHAR FIELDS(raw Keyword COMMENT 'sortable') DEFAULT 'anonymous' OPTIONS (analyzer = 'french', search_analyzer = 'french'),
birthdate DATE,
age INT SCRIPT AS (DATEDIFF(birthdate, CURRENT_DATE, YEAR)),
ingested_at TIMESTAMP DEFAULT _ingest.timestamp,
profile STRUCT FIELDS(
bio VARCHAR,
followers INT,
join_date DATE,
seniority INT SCRIPT AS (DATEDIFF(profile.join_date, CURRENT_DATE, DAY))
) COMMENT 'user profile',
PRIMARY KEY (id)
) PARTITION BY birthdate (MONTH), OPTIONS (mappings = (dynamic = false));
SHOW TABLE users;📋 Table: users [TABLE]
| Field | Type | Null | Key | Default | Comment | Script | Extra |
|---|---|---|---|---|---|---|---|
| age | INT | yes | NULL | DATE_DIFF(birthdate, CURRENT_DATE, YEAR) | () | ||
| birthdate | DATE | yes | NULL | () | |||
| id | INT | no | PRI | NULL | user identifier | () | |
| ingested_at | TIMESTAMP | yes | _ingest.timestamp | () | |||
| name | VARCHAR | yes | anonymous | (analyzer = "french", search_analyzer = "french") | |||
| name.raw | KEYWORD | yes | NULL | sortable | () | ||
| profile | STRUCT | yes | NULL | user profile | () | ||
| profile.seniority | INT | yes | NULL | DATE_DIFF(profile.join_date, CURRENT_DATE, DAY) | () | ||
| profile.join_date | DATE | yes | NULL | () | |||
| profile.followers | INT | yes | NULL | () | |||
| profile.bio | VARCHAR | yes | NULL | () |
🔑 PRIMARY KEY id 📅 PARTITION BY birthdate (MONTH)
⚙️ Settings: default_pipeline: 'users_ddl_default_pipeline'
🗺️ Mappings: dynamic: false _meta: (primary_key = ('id'), partition_by = (column = 'birthdate', granularity = 'M'), columns = (...), type = 'regular', materialized_views = ())
📝 DDL:
CREATE OR REPLACE TABLE users (
age INT SCRIPT AS (DATE_DIFF(birthdate, CURRENT_DATE, YEAR)),
birthdate DATE,
id INT NOT NULL COMMENT 'user identifier',
ingested_at TIMESTAMP DEFAULT _ingest.timestamp,
name VARCHAR FIELDS (
raw KEYWORD COMMENT 'sortable'
) DEFAULT 'anonymous' OPTIONS (analyzer = "french", search_analyzer = "french"),
profile STRUCT FIELDS (
seniority INT SCRIPT AS (DATE_DIFF(profile.join_date, CURRENT_DATE, DAY)),
join_date DATE,
followers INT,
bio VARCHAR
) COMMENT 'user profile',
PRIMARY KEY (id)
)
PARTITION BY birthdate (MONTH),
OPTIONS = (
mappings = (dynamic = false, _meta = (primary_key = ["id"], partition_by = (column = "birthdate", granularity = "M"), columns = (...), type = "regular", materialized_views = [])),
settings = (default_pipeline = "users_ddl_default_pipeline")
)Returns the full, normalized DDL statement used to create the table, including all fields, types, options, comments, and scripts.
SHOW CREATE TABLE users;CREATE OR REPLACE TABLE users (
age INT SCRIPT AS (DATE_DIFF(birthdate, CURRENT_DATE, YEAR)),
birthdate DATE,
id INT NOT NULL COMMENT 'user identifier',
ingested_at TIMESTAMP DEFAULT _ingest.timestamp,
name VARCHAR FIELDS (
raw KEYWORD COMMENT 'sortable'
) DEFAULT 'anonymous' OPTIONS (analyzer = "french", search_analyzer = "french"),
profile STRUCT FIELDS (
seniority INT SCRIPT AS (DATE_DIFF(profile.join_date, CURRENT_DATE, DAY)),
join_date DATE,
followers INT,
bio VARCHAR
) COMMENT 'user profile',
PRIMARY KEY (id)
)
PARTITION BY birthdate (MONTH),
OPTIONS = (
mappings = (dynamic = false, _meta = (primary_key = ["id"], partition_by = (column = "birthdate", granularity = "M"), columns = (...), type = "regular", materialized_views = [])),
settings = (default_pipeline = "users_ddl_default_pipeline")
)DESCRIBE TABLE users;Returns the normalized SQL schema, including :
- fields
- types
- nulls
- keys
- defaults
- comments
- scripts
- STRUCT fields
- options
| Field | Type | Null | Key | Default | Comment | Script | Extra |
|---|---|---|---|---|---|---|---|
| age | INT | yes | NULL | DATE_DIFF(birthdate, CURRENT_DATE, YEAR) | () | ||
| birthdate | DATE | yes | NULL | () | |||
| id | INT | no | PRI | NULL | user identifier | () | |
| ingested_at | TIMESTAMP | yes | _ingest.timestamp | () | |||
| name | VARCHAR | yes | anonymous | (analyzer = "french", search_analyzer = "french") | |||
| name.raw | KEYWORD | yes | NULL | sortable | () | ||
| profile | STRUCT | yes | NULL | user profile | () | ||
| profile.seniority | INT | yes | NULL | DATE_DIFF(profile.join_date, CURRENT_DATE, DAY) | () | ||
| profile.join_date | DATE | yes | NULL | () | |||
| profile.followers | INT | yes | NULL | () | |||
| profile.bio | VARCHAR | yes | NULL | () |
SHOW PIPELINES;Description
- Returns a list of all user-defined pipelines with summary information (name, number of processors)
Example
SHOW PIPELINES;| name | processors_count |
|---|---|
| users_alter4_ddl_default_pipeline | 1 |
| user_pipeline | 6 |
| metrics-apm.transaction@default-pipeline | 3 |
| users_alter6_ddl_default_pipeline | 1 |
| tmp_truncate_ddl_default_pipeline | 1 |
| users_alter5_ddl_default_pipeline | 3 |
| logs@default-pipeline | 2 |
| dql_users_ddl_default_pipeline | 1 |
| apm@pipeline | 4 |
| logs-apm.error@default-pipeline | 3 |
| metrics-apm.service_transaction@default-pipeline | 3 |
| users_cr_ddl_default_pipeline | 1 |
| users_alter2_ddl_default_pipeline | 1 |
| metrics-apm.internal@default-pipeline | 6 |
| traces-apm.rum@default-pipeline | 3 |
| metrics-apm.app@default-pipeline | 3 |
| users_alter1_ddl_default_pipeline | 2 |
| tmp_drop_ddl_default_pipeline | 1 |
| dql_sales_ddl_default_pipeline | 1 |
| ent-search-generic-ingestion | 6 |
| logs@json-message | 4 |
| users_alter3_ddl_default_pipeline | 1 |
| dml_users_ddl_default_pipeline | 1 |
| traces-apm@default-pipeline | 3 |
| metrics-apm@pipeline | 3 |
| users_alter8_ddl_default_pipeline | 5 |
| dml_chain_ddl_default_pipeline | 1 |
| users_ddl_default_pipeline | 6 |
| copy_into_test_ddl_default_pipeline | 1 |
| accounts_src_ddl_default_pipeline | 1 |
| users_alter7_ddl_default_pipeline | 1 |
| dml_accounts_ddl_default_pipeline | 1 |
| reindex-data-stream-pipeline | 1 |
| behavioral_analytics-events-final_pipeline | 9 |
| logs@json-pipeline | 4 |
| logs-default-pipeline | 2 |
| dql_orders_ddl_default_pipeline | 1 |
| dml_logs_ddl_default_pipeline | 1 |
| search-default-ingestion | 6 |
| accounts_ddl_default_pipeline | 1 |
| dql_geo_ddl_default_pipeline | 1 |
| show_users_ddl_default_pipeline | 2 |
| logs-apm.app@default-pipeline | 3 |
| traces-apm@pipeline | 7 |
| desc_users_ddl_default_pipeline | 2 |
| metrics-apm.service_summary@default-pipeline | 3 |
| metrics-apm.service_destination@default-pipeline | 3 |
| 📊 47 row(s) (10ms) |
SHOW PIPELINE pipeline_name;Description
- Returns a high‑level view of the pipeline processors
Example
SHOW PIPELINE user_pipeline;🔄 Pipeline: user_pipeline
Processors: (6)
| processor_type | description | field | ignore_failure | options |
|---|---|---|---|---|
| set | DEFAULT 'anonymous' | name | yes | (value = "anonymous", if = "ctx.name == null") |
| script | age INT SCRIPT AS (DATE_DIFF(birthdate, CURRENT_DATE, YEAR)) | age | yes | (lang = "painless", source = "def param1 = ctx.birthdate; def param2 = ZonedDateTime.ofInstant(Instant.ofEpochMilli(ctx['_ingest']['timestamp']), ZoneId.of('Z')).toLocalDate(); ctx.age = (param1 == null) ? null : Long.valueOf(ChronoUnit.YEARS.between(param1, param2))") |
| set | DEFAULT _ingest.timestamp | ingested_at | yes | (value = "_ingest.timestamp", if = "ctx.ingested_at == null") |
| script | profile.seniority INT SCRIPT AS (DATE_DIFF(profile.join_date, CURRENT_DATE, DAY)) | profile.seniority | yes | (lang = "painless", source = "def param1 = ctx.profile?.join_date; def param2 = ZonedDateTime.ofInstant(Instant.ofEpochMilli(ctx['_ingest']['timestamp']), ZoneId.of('Z')).toLocalDate(); ctx.profile.seniority = (param1 == null) ? null : Long.valueOf(ChronoUnit.DAYS.between(param1, param2))") |
| date_index_name | PARTITION BY birthdate (MONTH) | birthdate | yes | (date_rounding = "M", date_formats = ["yyyy-MM"], index_name_prefix = "users-") |
| set | PRIMARY KEY (id) | _id | no | (value = "{{id}}", ignore_empty_value = false) |
📝 DDL:
CREATE OR REPLACE PIPELINE user_pipeline WITH PROCESSORS (
SET(
description = "DEFAULT 'anonymous'",
field = "name",
ignore_failure = true,
value = "anonymous",
if = "ctx.name == null"
),
SCRIPT(
description = "age INT SCRIPT AS (DATE_DIFF(birthdate, CURRENT_DATE, YEAR))",
lang = "painless",
source = "...",
ignore_failure = true
),
SET(
description = "DEFAULT _ingest.timestamp",
field = "ingested_at",
ignore_failure = true,
value = "_ingest.timestamp",
if = "ctx.ingested_at == null"
),
SCRIPT(
description = "profile.seniority INT SCRIPT AS (DATE_DIFF(profile.join_date, CURRENT_DATE, DAY))",
lang = "painless",
source = "...",
ignore_failure = true
),
DATE_INDEX_NAME(
description = "PARTITION BY birthdate (MONTH)",
field = "birthdate",
date_rounding = "M",
date_formats = ["yyyy-MM"],
index_name_prefix = "users-",
ignore_failure = true
),
SET(
description = "PRIMARY KEY (id)",
field = "_id",
value = "{{id}}",
ignore_failure = false,
ignore_empty_value = false)
)
)SHOW CREATE PIPELINE pipeline_name;Description
- Returns the full, normalized DDL statement used to create the pipeline, including all processors, options, and flags.
Example
SHOW CREATE PIPELINE user_pipeline;CREATE OR REPLACE PIPELINE user_pipeline WITH PROCESSORS (
SET(
description = "DEFAULT 'anonymous'",
field = "name",
ignore_failure = true,
value = "anonymous",
if = "ctx.name == null"
),
SCRIPT(
description = "age INT SCRIPT AS (DATE_DIFF(birthdate, CURRENT_DATE, YEAR))",
lang = "painless",
source = "...",
ignore_failure = true
),
SET(
description = "DEFAULT _ingest.timestamp",
field = "ingested_at",
ignore_failure = true,
value = "_ingest.timestamp",
if = "ctx.ingested_at == null"
),
SCRIPT(
description = "profile.seniority INT SCRIPT AS (DATE_DIFF(profile.join_date, CURRENT_DATE, DAY))",
lang = "painless",
source = "...",
ignore_failure = true
),
DATE_INDEX_NAME(
description = "PARTITION BY birthdate (MONTH)",
field = "birthdate",
date_rounding = "M",
date_formats = ["yyyy-MM"],
index_name_prefix = "users-",
ignore_failure = true
),
SET(
description = "PRIMARY KEY (id)",
field = "_id",
value = "{{id}}",
ignore_failure = false,
ignore_empty_value = false)
)
)DESCRIBE PIPELINE pipeline_name;Description
- Returns the full, normalized definition of the pipeline:
- processors in execution order
- full configuration of each processor (
SET,SCRIPT,REMOVE,RENAME,DATE_INDEX_NAME, etc.) - flags such as
ignore_failure,if,description
Example
DESCRIBE PIPELINE user_pipeline;| processor_type | description | field | ignore_failure | options |
|---|---|---|---|---|
| set | DEFAULT 'anonymous' | name | yes | (value = "anonymous", if = "ctx.name == null") |
| script | age INT SCRIPT AS (DATE_DIFF(birthdate, CURRENT_DATE, YEAR)) | age | yes | (lang = "painless", source = "def param1 = ctx.birthdate; def param2 = ZonedDateTime.ofInstant(Instant.ofEpochMilli(ctx['_ingest']['timestamp']), ZoneId.of('Z')).toLocalDate(); ctx.age = (param1 == null) ? null : Long.valueOf(ChronoUnit.YEARS.between(param1, param2))") |
| set | DEFAULT _ingest.timestamp | ingested_at | yes | (value = "_ingest.timestamp", if = "ctx.ingested_at == null") |
| script | profile.seniority INT SCRIPT AS (DATE_DIFF(profile.join_date, CURRENT_DATE, DAY)) | profile.seniority | yes | (lang = "painless", source = "def param1 = ctx.profile?.join_date; def param2 = ZonedDateTime.ofInstant(Instant.ofEpochMilli(ctx['_ingest']['timestamp']), ZoneId.of('Z')).toLocalDate(); ctx.profile.seniority = (param1 == null) ? null : Long.valueOf(ChronoUnit.DAYS.between(param1, param2))") |
| date_index_name | PARTITION BY birthdate (MONTH) | birthdate | yes | (date_rounding = "M", date_formats = ["yyyy-MM"], index_name_prefix = "users-") |
| set | PRIMARY KEY (id) | _id | no | (value = "{{id}}", ignore_empty_value = false) |
| 📊 6 row(s) (1ms) |
SHOW WATCHERS;Returns a list of all watchers with summary information (name, activation state, last execution time, ...).
Example:
CREATE OR REPLACE WATCHER my_watcher_interval AS
EVERY 5 SECONDS
FROM my_index WITHIN 1 MINUTE
ALWAYS DO
log_action AS LOG "Watcher triggered with {{ctx.payload.hits.total}} hits" AT INFO FOREACH "ctx.payload.hits.hits" LIMIT 500
END;
CREATE OR REPLACE WATCHER my_watcher_cron AS
AT SCHEDULE '* * * * * ?'
WITH INPUTS search_data AS FROM my_index WITHIN 1 MINUTE, http_data AS GET "https://jsonplaceholder.typicode.com/todos/1" HEADERS ("Accept" = "application/json") TIMEOUT (connection = "5s", read = "10s")
WHEN SCRIPT 'ctx.payload.hits.total > params.threshold' USING LANG 'painless' WITH PARAMS (threshold = 10) RETURNS TRUE
DO
log_action AS LOG "Watcher triggered with {{ctx.payload.hits.total}} hits" AT INFO FOREACH "ctx.payload.hits.hits" LIMIT 500
END;
SHOW WATCHERS;| id | active | status | status_emoji | severity | is_healthy | is_operational | last_checked | time_since_last_check_seconds | frequency_seconds | created_at | execution_status | execution_status_emoji | execution_severity | overall_status | overall_status_emoji | overall_severity |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| my_watcher_interval | true | Healthy | 🟢 | 0 | true | true | never | -1 | 5 | 2026-02-11T12:11:02.542Z | NULL | NULL | -1 | Healthy | 🟢 | 0 |
| my_watcher_cron | true | Healthy | 🟢 | 0 | true | true | never | -1 | 1 | 2026-02-11T12:11:02.816Z | NULL | NULL | -1 | Healthy | 🟢 | 0 |
| 📊 2 row(s) (9ms) |
Query watchers require Elasticsearch 7.11+.
SHOW WATCHER STATUS watcher_name;Returns:
- Activation state (active/inactive)
- Last execution time
- Last condition met time
- Execution statistics
Example:
SHOW WATCHER STATUS auto_refresh_orders_with_customers_mv_enrich_policies;| id | active | status | status_emoji | severity | is_healthy | is_operational | last_checked | time_since_last_check_seconds | frequency_seconds | created_at | execution_status | execution_status_emoji | execution_severity | overall_status | overall_status_emoji | overall_severity |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| auto_refresh_orders_with_customers_mv_enrich_policies | true | Healthy | 🟢 | 0 | true | true | 2026-02-11T10:28:20.581Z | 5 | 8 | 2026-02-11T10:28:12.174Z | Executed | 🟢 | 0 | Healthy | 🟢 | 0 |
| 📊 1 row(s) (9ms) |
SHOW ENRICH POLICIES;Returns a list of all enrich policies with their configurations.
*Example:
CREATE TABLE IF NOT EXISTS dql_users (
id INT NOT NULL,
name VARCHAR FIELDS(
raw KEYWORD
) OPTIONS (fielddata = true),
age INT,
birthdate DATE,
profile STRUCT FIELDS(
city VARCHAR OPTIONS (fielddata = true),
followers INT
)
);
CREATE OR REPLACE ENRICH POLICY my_policy
FROM dql_users
ON id
ENRICH name, profile.city
WHERE age > 10;
SHOW ENRICH POLICIES;
| name | type | indices | match_field | enrich_fields | query |
|---|---|---|---|---|---|
| my_policy | match | dql_users | id | name,profile.city | {"bool":{"filter":[{"range":{"age":{"gt":10}}}]}} |
| 📊 1 row(s) (4ms) |
SHOW ENRICH POLICY policy_name;Returns policy details, including:
- Name
- Type
- Indices
- Match field
- Enrich fields
- Query criteria (if any)
Example:
SHOW ENRICH POLICY my_policy;| name | type | indices | match_field | enrich_fields | query |
|---|---|---|---|---|---|
| my_policy | match | dql_users | id | name,profile.city | {"bool":{"filter":[{"range":{"age":{"gt":10}}}]}} |
| 📊 1 row(s) (4ms) |
SHOW CLUSTER NAME;Returns the name of the Elasticsearch cluster. The cluster name is cached after the first call.
Example:
SHOW CLUSTER NAME;| name |
|---|
| docker-cluster |
| 📊 1 row(s) (3ms) |
SHOW LICENSE;Returns the current license type, quota values, expiration date, and grace status.
Columns returned:
| Column | Description |
|---|---|
license_type |
Current license tier (Community, Pro, Enterprise). Shows "(trial)" suffix for trial licenses, "(degraded)" suffix if degraded from a higher tier. |
trial |
true if the license is a Pro trial, false otherwise |
platform |
Platform scope of the current license key (PRODUCTION, STAGING, DEVELOPMENT, INTEGRATION). Defaults to "PRODUCTION" when not platform-scoped. |
max_materialized_views |
Maximum number of materialized views allowed, or "unlimited" |
max_clusters |
Maximum number of federated clusters allowed, or "unlimited" |
max_result_rows |
Maximum rows returned per query, or "unlimited" |
max_joins |
Maximum number of JOIN operations allowed per query, or "unlimited" |
expires_at |
License expiration timestamp, or "never" for Community |
days_remaining |
Days until expiration, or -1 for Community (no expiry) |
status |
"Active", or grace period details if expired |
Example:
SHOW LICENSE;| license_type | trial | platform | max_materialized_views | max_clusters | max_result_rows | max_joins | expires_at | days_remaining | status |
|---|---|---|---|---|---|---|---|---|---|
| Community | false | PRODUCTION | 1 | 1 | 10000 | 2 | never | -1 | Active |
| 📊 1 row(s) (1ms) |
REFRESH LICENSE;Forces an immediate license refresh from the backend (API key fetch). Returns the previous and new tier information.
Columns returned:
| Column | Description |
|---|---|
previous_tier |
License tier before refresh |
new_tier |
License tier after refresh |
trial |
true if the new license is a Pro trial, false otherwise |
expires_at |
New expiration timestamp |
status |
"Refreshed" on success, "Failed" on error |
message |
Error details (empty on success) |
Example (no API key configured):
REFRESH LICENSE;| previous_tier | new_tier | trial | expires_at | status | message |
|---|---|---|---|---|---|
| Community | Community | false | never | Failed | License refresh is not supported in Community mode |
| 📊 1 row(s) (1ms) |
Note: Requires API key configuration. Without an API key, returns an informational failure message.