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,OFFSET- set operators:
UNION ALL,UNION/UNION DISTINCT,INTERSECT/INTERSECT ALL,EXCEPT/EXCEPT 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
- Set operators
- Subqueries and derived tables
- Common table expressions
- 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. - An un-aliased nested field is returned under its dotted name:
SELECT id, profile.city FROM dql_usersreturns the columnsidandprofile.city. The parent objectprofileis normally left out of these rows. A row can still carry it, for example when theSELECTlist also namesprofile, contains*, or reads another field ofprofileunder an alias (profile.followers AS followers). Rows read into a typed entity (searchAs) keep the parent object.
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 `prod_eu`.`bi_events` `bi_events`;
SELECT category FROM "prod_eu"."bi_events" AS e;
SELECT o.id FROM `prod_eu`.`orders` o JOIN `prod_eu`.`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 prod_eu.bi_events |
prod_eu.bi_events |
— (a bare dot is part of the name) |
FROM logs-2025.03 |
logs-2025.03 |
— |
FROM "prod_eu".bi_events |
bi_events |
prod_eu |
FROM `prod_eu`.bi_events |
bi_events |
prod_eu |
FROM "prod_eu"."bi_events" |
bi_events |
prod_eu |
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 `prod_eu`.`bi_events`
-- renders as
SELECT category FROM "prod_eu"."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 / a set operator
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_notbeside the column'sexists, so it leaves out the documents that lack the field, as SQL readsNOTover aNULL:WHERE status <> 'A',NOT IN,NOT BETWEEN,NOT LIKEand a negatedMATCHanswer the same rows as the scripted route. UseIS NULLto keep the documents without a value.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 answers an error. The engine emits a null-preserving sort script; Elasticsearch then fails every shard that holds such a document while building the comparator (null_pointer_exception). The statement fails whatever the shard count:
- on a single-shard index Elasticsearch rejects the whole search (HTTP 500,
all shards failed);- on a multi-shard index Elasticsearch answers HTTP 200 with the other shards' documents only; the engine reads
_shardsand answers an error instead, with no status code:1 of 3 shards failed: null_pointer_exception: Cannot invoke "java.lang.CharSequence.length()" because "text" is null. MEASURED on Elasticsearch 8.18.3, 3 shards, 7 documents with one lacking the field (_shards.failed: 1,hits.total: 5). Before 0.24.0 the engine returned those 5 rows as the whole result, with no error.A statement without
LIMITis read page by page and fails the same way (PIT search_after failed: …). To sort such a column, sort by the bare column, or keep the field present on every document. The same applies toORDER BYover aCASE … ENDwith noELSE, which is NULL-valued for the rows no branch matches.Since 0.24.0, a sort by the bare column over an index pattern or list also answers the documents of an index that does not map that column, sorted as
NULL, when the other indices map it askeyword,boolean,date,byte,short,integer,long,floatordouble.
⚠️ 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;SELECT TOP n is accepted as a spelling of LIMIT n, because it is what BI tools emit in their
SQL-92 dialect. It is not a second row bound: the parser folds it into the statement's LIMIT, so
the two statements below are the same statement, and the engine renders both as LIMIT.
SELECT TOP 10 id, name FROM dql_users ORDER BY age DESC;
SELECT TOP (10) id, name FROM dql_users ORDER BY age DESC; -- parenthesised, also T-SQL
SELECT id, name FROM dql_users ORDER BY age DESC LIMIT 10; -- identical to bothFour things to know:
TOPis not reserved.SELECT top FROM tstill selects a column calledtop, andSELECT top - 1 AS x FROM tstill computes it.TOPandLIMITtogether are refused by name. They are two spellings of one bound, and guessing a precedence would be worse than saying so.TOPcarries noOFFSET. For paging, useLIMIT n OFFSET m.TOP n PERCENTandTOP n WITH TIESare refused by name — they are real T-SQL that this engine does not implement. See Known limitations.
SELECT DISTINCT TOP n is not accepted (write SELECT DISTINCT … LIMIT n); the order
SELECT TOP n DISTINCT happens to parse but is not valid T-SQL, so do not rely on it.
A set operator combines the rows of two or more SELECT branches. Seven spellings are accepted:
| Spelling | Rows | Duplicates | Where it runs |
|---|---|---|---|
UNION ALL |
every row of every branch | kept | Elasticsearch (_msearch, or one search per branch) — every venue |
UNION / UNION DISTINCT |
rows in either branch | removed | relational engine |
INTERSECT |
rows in both branches | removed | relational engine |
INTERSECT ALL |
rows in both branches | kept (per-branch multiplicity) | relational engine |
EXCEPT |
rows in the first branch and not the second | removed | relational engine |
EXCEPT ALL |
rows in the first branch and not the second | kept | relational engine |
UNION and UNION DISTINCT are the same operator — SQL's default for a bare UNION is
de-duplication, and DISTINCT just says so out loud. UNION ALL is the one that keeps duplicates,
and it is the only one Elasticsearch can answer by itself.
The
EXCEPTset operator is not theSELECT * EXCEPT(col, …)column-exclusion clause. The first removes rows, the second removes columns fromSELECT *. Both work; they are unrelated.
Example
SELECT id, name FROM dql_users WHERE age > 30
UNION ALL
SELECT id, name FROM dql_users WHERE age <= 30;-- Customers who ordered in both quarters, de-duplicated
SELECT customer_id FROM orders_q1
INTERSECT
SELECT customer_id FROM orders_q2;Branches are matched column by column, left to right — SQL-92 §7.10 — and the result takes the first branch's column names. The names the other branches use play no part in the matching:
SELECT id AS x FROM left_index
UNION ALL
SELECT id AS y FROM right_index;
-- One column, named x, carrying both branches' ids.Two consequences worth knowing:
- Reordering a branch's
SELECTlist changes the result, even though the column names still look right.SELECT a, b … UNION ALL SELECT b, a …interleaves the two. - A branch written as a bare
SELECT *declares no column list, so there is nothing to match positionally. Such a branch is matched by name instead, and the engine cannot check its width. Name the columns explicitly whenever a branch's shape matters.
Changed in
0.24.0: before this release the engine matched branches by name, so a column the other branch did not name came backNULL. Positional matching is both the standard's rule and what every other SQL engine does.
- Same number of columns. Checked at parse time, between the branches that declare a column list — a mismatch is rejected before anything runs, naming both branches and their projections.
- Types that combine, position by position, by the rule of Column types below:
a text branch beside any other type makes a
VARCHARcolumn, a number beside a date with no text branch is refused. A bare column has no type until the index mapping is read, so the parser lets it through; the check runs again once the schema is attached, and a refused pair is refused before any request is sent. - Column names are never compared. They are output labels, taken from the first branch.
INTERSECT binds tighter than UNION and EXCEPT; otherwise branches associate left to right
(SQL-92 §7.10). So this:
SELECT a FROM t1 UNION SELECT a FROM t2 INTERSECT SELECT a FROM t3is t1 UNION (t2 INTERSECT t3).
Parenthesising a set operation is rejected — both as a branch ((a UNION b) INTERSECT c) and as
a whole statement. To group differently, use a derived table:
SELECT * FROM (SELECT a FROM t1 UNION SELECT a FROM t2) AS g
INTERSECT
SELECT a FROM t3;For UNION ALL they apply per branch, exactly as they always have — each SELECT is its own
Elasticsearch request, and the results are concatenated in branch order.
For every other operator a trailing ORDER BY / LIMIT after the last branch is rejected,
because a set has no branch order to inherit and an analyst writing it almost certainly meant the
whole result. Say which you meant:
-- this branch only
SELECT a FROM t1 UNION (SELECT a FROM t2 ORDER BY a LIMIT 10);
-- the whole result
SELECT * FROM (SELECT a FROM t1 UNION SELECT a FROM t2) AS g ORDER BY a LIMIT 10;A first or middle branch carrying its own ORDER BY / LIMIT is unambiguous and is accepted without
parentheses.
UNION ALL is answered by Elasticsearch, branch by branch:
- Each branch is translated into an independent ES search request.
- When every branch is bounded — a
LIMITwithinindex.max_result_window(10,000), or an aggregation — the requests are sent in one Multi-Search (_msearch) call. - A branch that returns rows with no
LIMIT(or with one past that window) is read in full by its own paged search, and a branch whose rows the engine still orders, skips or cuts once they are read (aGROUP BYof several keys with anORDER BY, aLIMITor anOFFSET; aWHEREover anUNNESTelement) runs as its own search too, so each branch answers what the sameSELECTanswers on its own. The branches then run one after the other. - Results are concatenated in branch order, without de-duplication or sorting. Within a branch
read by a paged search, rows that no
ORDER BYorders may come back in another order.
Every other operator needs de-duplication or set arithmetic across branches, which Elasticsearch has
no operation for, so the statement runs on the relational engine — engine 0.24.0 with
arrow-extensions 0.3.4, the same engine that executes cross-index JOINs and derived tables. Each
branch is executed as its own Elasticsearch query and the set operation is applied to the results. A
venue without that engine refuses the statement with a clear error rather than answering from one
branch. See Known Limitations & Roadmap.
Since 0.24.0, a set operation is typed by ONE rule, the same at every depth: a column, each field
of an object (a STRUCT, a GEO_POINT), each element of a list (an ARRAY), and so on inside
them. The rule gives the type DuckDB gives — DuckDB being the engine that runs UNION,
INTERSECT and EXCEPT, so a statement answers the same types on both paths — VARCHAR where
DuckDB gives none, and refuses only what the relational engine fails on when the statement runs.
At one position, over the branches of one node (below):
| Branches | Type | Example |
|---|---|---|
numbers, and BOOLEAN |
the widest: TINYINT < SMALLINT < INT < BIGINT < REAL < DOUBLE; a BOOLEAN reads as 1 / 0 |
n (INT) with x (DOUBLE) is DOUBLE |
a DATE and a TIMESTAMP |
TIMESTAMP, the date at midnight UTC |
d with ts |
a text branch (KEYWORD, TEXT, a string literal) with any other type |
VARCHAR, each value spelled as DuckDB spells its type (below) |
id with n, 1 with 'a', ts with s |
a VARBINARY with text branches only |
VARBINARY (bytes), DuckDB's BLOB: a binary value is the bytes of its base64 text, a text value is cast to bytes (below) |
payload with name |
a NULL literal |
the other branches' type | n with NULL is INT |
a DATE or a TIMESTAMP with a number or a BOOLEAN, and neither a text nor a VARBINARY branch |
refused | d with n |
any other pair DuckDB gives no type: a VARBINARY with a number, a temporal or a BOOLEAN; a TIME with a DATE, a TIMESTAMP, a number or a BOOLEAN |
VARCHAR, each value spelled as text (below) |
payload with price, CAST(ts AS TIME) with d |
an ARRAY, a STRUCT or a GEO_POINT with a type of another family, text included |
refused | location with name |
| lists | a list of the type this rule gives their elements | ARRAY<INT> with ARRAY<KEYWORD> is ARRAY<VARCHAR> |
| objects and points | DuckDB's merged STRUCT: every field of every branch, in the order it first appears, matched whatever its case; a field a branch does not have is NULL, and each field takes the type this rule gives it over the branches that have it; points alone stay a GEO_POINT |
location with address |
So a field a that is an INT in one object and a TIME in another is a VARCHAR, as two such
columns are, and an INT beside a DATE is refused in a field as in a column, the field named by
its path. A VARBINARY counts as text beside a date and a number — the relational engine reads a
binary field as its base64 text — so payload, d and n make a VARCHAR column.
A GEO_POINT is DuckDB's STRUCT(lat DOUBLE, lon DOUBLE): in a column of points, or of points and
objects, every point is a {lat, lon} map whatever way Elasticsearch stored it — an object, a
"lat,lon" text, an array [lon, lat], a geohash (the south-west corner of its cell, as
Elasticsearch reads it), a WKT POINT (lon lat) or a GeoJSON point. A STRUCT's fields are taken
by name, in name order. A value that cannot be read as its branch's point or object, or several of
them in one row, fails the statement. A column read without a type (ANY), as an ARRAY<INT>
column of a mapping is read today, refuses nothing.
A whole number literal is an INT (SELECT 1), a BIGINT past the INT range. The type
compares every branch, so d, n and '1' make a VARCHAR column although d and n alone
are refused; and each branch's value goes to the column's type from its OWN type (d, ts and
'1' spell d as a date, not as a midnight timestamp).
A chain that mixes operators is typed as a tree, as DuckDB types it: INTERSECT binds first, then
the operators apply left to right, INTERSECT and EXCEPT typing their two sides, and a run of
ONE union kind (UNION ALL after UNION ALL, or UNION after UNION) is typed as one node,
whatever its order; a UNION beside a UNION ALL, a derived table, are separate nodes. Each node
of the tree takes the rule above, at every depth, over all its branches at once — a subtree's type
being one of them — so their order never changes the type. So n UNION d UNION s and payload INTERSECT d INTERSECT n are VARCHAR columns, while n INTERSECT d INTERSECT s, d INTERSECT n INTERSECT payload and n EXCEPT x UNION ALL d are refused: n INTERSECT d, and n EXCEPT x
beside d, are a number beside a date with no text. The relational engine runs these operators;
core runs UNION ALL itself.
Every value holds its position's type, at every depth: wherever the branches' types differ at a
position — a column, a field, an element — every branch's value there is converted, and the
position holds ONE class: a DOUBLE a Double, a BIGINT a Long, a TIMESTAMP a
ZonedDateTime (a date-only, zone-less or epoch-milliseconds value included), a VARBINARY
bytes, a VARCHAR a string, a list a list of its element's type, an object a map of its fields
in order — which is the type JDBC and Arrow report, since they read it from the first value. A
position whose branches already have one type is returned as each branch reads it, nothing
converted: a branch over an aggregate keeps the form an aggregate answers, a Double, so SELECT MAX(n) ... UNION ALL SELECT n ... answers 10.0 beside 3 and 5.
Each value is first read as the type its field is declared with, as Elasticsearch indexed it, and
only then converted: a value Elasticsearch accepted as text is the value its queries see. In an
integer field "5", "5.0", "5.5" and 5.5 are 5 (truncated toward zero), "5e2" is 500;
in a DOUBLE field "5" is 5.0 (spelled 5.0 in a VARCHAR position) and "1e3" is 1000.0;
in a BOOLEAN field "true" is true and an empty text false; an empty text in a number field
is no value.
At a position that converts, a date field's value is read with the field's mapping format —
its own, or Elasticsearch's default strict_date_optional_time||epoch_millis — as the Elasticsearch major the statement runs
on reads that format: Elasticsearch 6 parses with Joda-Time, Elasticsearch 7 and later with
java.time, and the same mapping reads some texts differently across them. A TIMESTAMP is the
instant that major indexed, to the millisecond, as Elasticsearch keeps it, and a DATE its day, in
UTC. So 1706697000 in an epoch_second field is 2024-01-31 10:30:00, not a moment of 1970;
"1706697000.5" there keeps its half second on Elasticsearch 7 and 8 and is truncated to the
second on Elasticsearch 6; "2024/01/31" in a yyyy/MM/dd field is a date, not a text; a format's
|| alternatives are tried in order, and the first one that parses a value decides; in the default
format "2024" and 2024 are the year 2024. A value computed by the statement (a function, a
CAST) has no format: there, digits are epoch milliseconds and an ISO text is its instant. A date
whose branches already agree -- a column, or a field of an object or of a list of objects at any
depth -- is read the same way, in every branch, as soon as one of its branches is a field with a
format of its own (epoch_second, yyyy/MM/dd, ...), every other value of the row as read, while
a date position whose branches all have the default format, a multi-valued value and a date core
cannot read (a zone name, say) are returned as stored.
A value is read only where its reading is the same on every Java version core runs on (8, 11, 17
and 21) and equal to the instant the Elasticsearch major indexed, measured value by value on
Elasticsearch 6.8.23, 7.17.29 and 8.18.3. That covers the built-in formats of each major's
documentation (the epoch, ISO, ordinal, week, time and basic_ formats; in camel case on
Elasticsearch 6 and 7; with a leading 8), and custom patterns — the pattern letters at each
width, alone and in the combinations measured, with java.time on Elasticsearch 7 and later and
in Elasticsearch 6.8's 8-prefixed mode, with Joda-Time on Elasticsearch 6 — with a zone written
as Z, an offset, UTC, GMT or UT, together with epochs, negative ones included, and JSON
numbers. Elasticsearch's own readings are kept, however odd: in a YYYY-MM-dd field (a week-based year)
Elasticsearch 8 indexes 2024-01-31 as 2023-12-31, the first day of the week-based year's first
week, and Elasticsearch 7 as 2024-01-01; a year with no month and an hour (2024T10 in a
strict_date_optional_time field) is 1970-01-01 10:00:00.
A value its field's format does not read fails the statement, naming the value, the field and the format, and so does a value core cannot read without guessing, rather than be read as a date it may not be:
- a pattern letter, a width or a combination of letters that no measurement covers — two zone
letters included (an offset beside a zone id, Joda-Time's
ZZ ZZZ) — and a name of Elasticsearch 6.8's8-prefixed mode; - a zone id or a zone name other than
UTC,GMTandUT, in every format, the default included: a region id (Europe/Parisin aVVor a Joda-TimeZZZletter, in the default format, or in brackets after an offset,+05:00[Europe/Paris]) and an abbreviation or a long name (CET,PST,Pacific Time). Its offset comes from zone data, and the zone data core's Java version holds is not the one Elasticsearch indexed the value with:2023-07-01T12:00:00 America/Mexico_Cityis17:00:00Zon Elasticsearch 8.18.3 and18:00:00Zwith older zone data, andISTis UTC on Elasticsearch 8 and Israel time on Elasticsearch 7; - a value whose reading would depend on the Java version core runs on: a localized offset (
O,OOOO,ZZZZ:GMT+1), which Java 8 parses differently from later versions; a fraction right after digits of a variable width (yyyyMMddHHmmssSSS); and, in Elasticsearch 6.8's8-prefixed mode, an AM/PM with no hour (8yyyy a), which Java 16 and later set to 06:00 or 18:00, and a week past the last of its week-based year (2020-53-7in a8YYYY-ww-efield), which Java 21 rejects; - a value the response already gives as a date or a time, without the offset or the digits its
format reads (
10:30:00Zin atimefield); - a fractional JSON number (
1706697000.0) whose digits, which Elasticsearch read, the number no longer pins to the millisecond.
-- ts is a TIMESTAMP field of format 'yyyy/MM/dd' holding '31-01-2024', s a KEYWORD
SELECT ts AS k FROM t UNION ALL SELECT s AS k FROM t;
-- Error: Conversion Error: Could not read '31-01-2024' as a TIMESTAMP when casting from source column k: the date format 'yyyy/MM/dd' of field ts does not read it as Elasticsearch 8 does
-- a zone name
-- Error: Conversion Error: Could not read '2024-01-31T10:30:00 CET' as a TIMESTAMP when casting from source column k: core cannot read it with the date format 'yyyy-MM-dd'T'HH:mm:ss z' of field ts as Elasticsearch 8 does: the zone 'CET' is a region id or a zone name, whose offset comes from the zone data of the JVM reading it, not Elasticsearch's: core reads only Z, an offset, UTC, GMT and UTThe readings were measured on Elasticsearch 6.8.23, 7.17.29 and 8.18.3. The other minor versions of a major are read as its measured one, and Elasticsearch 9 as Elasticsearch 8.
The major is the cluster's version, asked once per client (GET /). For a user whose role may not
read it, dates are read as the major the client is built for (the Elasticsearch 8 client reads them
as Elasticsearch 8), at a position that converts as where the branches agree. The version is kept
for the life of the client: a privilege granted later is seen once the client restarts.
A value with several values — a multi-valued field — has no one value of its position's type, so
at a position that converts its values it fails the statement, in the words DuckDB uses for the
same failure: Conversion Error: Unimplemented type for cast (INTEGER -> VARCHAR[]) when casting from source column k for a multi-valued keyword beside an INT. A position whose branches have
one type converts nothing, and answers such a value as its branch reads it.
-- id is a KEYWORD, n an INT: one VARCHAR column ('r1', ..., '3', ...)
SELECT id AS k FROM t UNION ALL SELECT n AS k FROM t;
-- n is an INT, x a DOUBLE: one DOUBLE column (3.0, ..., 0.25, ...)
SELECT n AS k FROM t UNION ALL SELECT x AS k FROM t;
-- refused, by name, before anything runs
SELECT d AS k FROM t UNION ALL SELECT n AS k FROM t;
-- Error: Set operation branches must project compatible types at column 1: branch 1 'k' is DATE, branch 2 'k' is INT
-- two objects whose field a is an INT and a DATE: refused, naming the field
SELECT oi AS k FROM t UNION ALL SELECT od AS k FROM t;
-- Error: Set operation branches must project compatible types at column 1: branch 1 'k' field 'a' is INT, branch 2 'k' field 'a' is DATEInside a VARCHAR column, each value is spelled as DuckDB spells its own type when it casts it to
text, so both paths answer the same string:
| Branch type | Text | Examples |
|---|---|---|
| a whole number | its digits | 3, -2, 3000000000 |
DOUBLE, REAL |
the shortest digits, with a fractional part; an exponent below 1e-04 or from 1e+16 |
250000000000.0, -2.5, 5.0, 1e-05, 1e+16 |
BOOLEAN |
true / false |
|
DATE |
YYYY-MM-DD |
2024-01-31, 0044-03-15 (BC) |
TIMESTAMP |
YYYY-MM-DD HH:MM:SS, in UTC, the fraction of a second only when there is one |
2024-01-31 10:30:00, 2023-12-25 23:59:59.999, 2024-01-31 00:00:00 for a date-only value |
TIME |
HH:MM:SS, the same fraction |
10:30:00, 06:00:00.5 |
VARBINARY |
its base64 text, as Elasticsearch holds a binary field |
YWI= |
-- ts is a TIMESTAMP, s a KEYWORD: one VARCHAR column ('2024-01-31 10:30:00', ..., 'a', ...)
SELECT ts AS k FROM t UNION ALL SELECT s AS k FROM t;
-- x is a DOUBLE: ('250000000000.0', '1e-05', ..., 'a')
SELECT x AS k FROM t UNION ALL SELECT 'a' AS k FROM t;A text value becomes bytes in a VARBINARY column as DuckDB casts a VARCHAR to a BLOB: each
ASCII character is its byte and \xHH (two hexadecimal digits) is the byte HH; a character
outside ASCII, or a backslash not followed by x and two hexadecimal digits, fails the statement,
as it fails in DuckDB (Conversion Error: Invalid byte encountered in STRING -> BLOB conversion).
A DOUBLE is spelled as DuckDB spells it, digit for digit, including the few values its algorithm
does not shorten (7.168e25 is 7.1680000000000004e+25). A decimal literal is a DOUBLE here
(1.50 is 1.5), where DuckDB keeps it a DECIMAL (1.50). A value Elasticsearch returns as
text, in a column of another type, stays that text.
The types are compared once each branch's mapping is read; with no mapping to read, nothing is refused on types and Elasticsearch answers.
A branch may carry anything a SELECT can carry — GROUP BY, a JOIN, a derived table, a CTE, a
correlated subquery. A branch that needs the relational engine on its own account is planned as its
own nested query, so the whole statement routes there.
- A set operation as a
WHEREsubquery body.WHERE a IN (SELECT … UNION SELECT …)is rejected; write one subquery per branch. - A set operation across catalogs. Combining branches with catalog-qualified names
(
`cluster_b`.orders) is refused by name: catalogs are resolved by their position in the SQL text, so a branch could silently run on the wrong cluster. Run each branch as its own statement, or drop the catalog prefix. CORRESPONDING/CORRESPONDING BY— SQL's opt-in for name-based matching. Not implemented; positional matching is the only mode.
Since engine 0.24.0. A subquery is a SELECT nested inside another statement. Two positions are
accepted, and they behave very differently — one runs on Elasticsearch, the other on the relational
engine:
| Position | Spelling | Where it runs |
|---|---|---|
FROM / JOIN — a derived table |
FROM (SELECT …) AS d, JOIN (SELECT …) AS d ON … |
relational engine (arrow-extensions 0.3.4) |
WHERE, uncorrelated |
IN / NOT IN / EXISTS / NOT EXISTS / scalar / quantified |
Elasticsearch, in two phases — every venue, the plain REPL included |
WHERE, correlated (the body reads an outer alias) |
the same spellings | relational engine |
That middle row is the one worth remembering: "subqueries are supported" is true everywhere for the
uncorrelated WHERE forms and only at an engine venue for the other two. See
Known Limitations & Roadmap for the
per-venue table.
A derived table is a SELECT in FROM or JOIN position. The alias is mandatory — SQL-92 calls
it a correlation name — and leaving it off is refused by name rather than guessed at:
SELECT d.country, d.total
FROM (SELECT country, SUM(amount) AS total FROM orders GROUP BY country) AS d
WHERE d.total > 1000;AS is optional (… ) d is the same thing), so the SQL the BI tools generate on their own —
Tableau's connection and row-count probes, Superset's series limit — is accepted as written.
-- in JOIN position
SELECT o.id, d.total
FROM orders o
JOIN (SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id) AS d
ON o.customer_id = d.customer_id;Bodies nest to any depth, and a body may itself carry a JOIN, a GROUP BY, its own ORDER BY
/ LIMIT, or a set operation:
-- a set operation grouped by a derived table, the spelling that replaces parentheses
SELECT * FROM (SELECT a FROM t1 UNION SELECT a FROM t2) AS g;
-- the body's LIMIT bounds the body; the outer statement is ordered and limited separately
SELECT d.total FROM (SELECT SUM(amount) AS total FROM orders GROUP BY country) AS d
ORDER BY d.total DESC LIMIT 5;A body written as SELECT * is opaque: it declares no column list, so the engine cannot know its
width until the index mapping is read. That is legal and is exactly what the BI probes emit:
SELECT MAX(1) AS TblMax FROM (SELECT * FROM orders) t;When a name in the outer query is not qualified with an alias, it resolves to the sole plain
index in the FROM clause, not to the derived table — even though the derived table is also in
scope:
SELECT country, SUM(amount) AS total
FROM orders
JOIN (SELECT country AS c2 FROM orders GROUP BY country) AS d ON country = c2
GROUP BY country;
-- `country` and `amount` resolve against `orders`; `c2` is the only name `d` projects.Strict SQL-92 would call country ambiguous. Resolving it this way is what makes Superset's
series-limit query — which projects renamed columns from its inner query precisely so the outer names
stay unambiguous — run as written. Qualify the name (orders.country, d.c2) whenever you want
to be explicit; a qualified name always wins.
FROM (SELECT id FROM orders) AS d (x) — a column list on the correlation name — is not
supported. Alias the columns inside the body instead: (SELECT id AS x FROM orders) AS d.
Six spellings, all accepted in a SELECT, a DELETE and an UPDATE:
SELECT name FROM employees WHERE department_id IN (SELECT id FROM departments WHERE region = 'EU');
SELECT name FROM employees WHERE department_id NOT IN (SELECT id FROM departments WHERE region = 'EU');
SELECT name FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
SELECT name FROM customers c WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
SELECT name FROM employees WHERE salary > (SELECT AVG(salary) FROM employees);
SELECT name FROM employees WHERE department_id = ANY (SELECT id FROM departments);
SELECT name FROM employees WHERE department_id <> ALL (SELECT id FROM departments);SOME is a synonym for ANY. The ordering quantifiers (> ALL, >= ANY, < ALL, <= SOME, …) are
accepted too and are reduced against the value list rather than sent to Elasticsearch as such.
DELETE FROM orders WHERE customer_id IN (SELECT id FROM customers WHERE region = 'EU');
UPDATE orders SET status = 'eu' WHERE customer_id IN (SELECT id FROM customers WHERE region = 'EU');A scalar or quantified subquery is accepted only on the right-hand side of the comparison.
WHERE (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) > 5 is rejected — and the message
it produces (Unbalanced parentheses) does not say why. Flip the comparison:
-- rejected
-- SELECT c.name FROM customers c WHERE (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) > 5;
-- accepted, same meaning
SELECT c.name FROM customers c WHERE 5 < (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id);In two phases, both on Elasticsearch, which is why these forms work at every venue including the plain REPL:
- The inner query runs first, and its distinct values are collected.
- Those values are injected into the outer query as a literal
termsfilter, and the outer query runs as an ordinary search.
The inner result set is therefore bounded: past 65,536 distinct values the
statement fails loudly rather than truncating silently. The bound is fixed: it is the default of
Elasticsearch's index.max_terms_count, and raising that setting does not lift it. Narrow the inner
query, or rewrite the statement as a JOIN.
Elasticsearch's own terms lookup is not what this uses and could not be: it reads one document by
id, and cannot express a query.
Standard SQL three-valued logic applies. If the inner query returns any NULL, x NOT IN (SELECT …) is UNKNOWN for every row and the statement returns nothing. That is correct SQL and a
frequent surprise — add WHERE <col> IS NOT NULL to the inner query when the column is nullable.
One carve-out, and it fails the other way. The engine detects the
NULLby re-running the inner query with anIS NULLfilter. When the inner query carries aGROUP BY, that probe is a grouped query too, and Elasticsearch'stermsaggregation drops the missing-value group — so aNULLin aGROUP BYbody is invisible andNOT INreturns rows the rule above says it should not. Filter theNULLout explicitly in that body rather than relying on the detection.
A subquery whose body reads a column from the enclosing query is correlated, and it runs on the relational engine:
SELECT c.name FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);One rule the engine enforces by name, and one convention nothing can enforce for you:
⚠️ The outer reference must be QUALIFIED — and this one is not refused. A bare name inside the body is read as the body's own column, which is a perfectly legal statement, so nothing rejects it:… WHERE o.customer_id = idstops being correlated and runs as an ordinary uncorrelated subquery, returning different rows with HTTP 200. It is the only mistake in this family that fails silently. Always write the outer alias —… WHERE o.customer_id = c.id.- The outer reference must be UNQUOTED —
"c"."id"is refused by name. The engine rewrites an outer reference onto the extracted leg, and a quoted identifier is not rewritten. - The body must be a single Elasticsearch source. Its own
JOIN, a comma-separatedFROM, aJOIN UNNEST, a derived table or a window function inside the body are refused; move the construct to the outerFROMand correlate against it.
A correlated subquery costs one relational operation against your plan's maxJoins allowance; a
derived table costs nothing on its own. See
Known Limitations & Roadmap.
- A subquery in the
SELECTlist —SELECT (SELECT MAX(amount) FROM orders) AS m …does not parse. - A subquery in
HAVING— any subquery, correlated or not, refused by name. Compute the value separately or move the condition toWHERE. LATERAL— a derived table that reads an alias from the enclosingFROM. Refused by name, with the rewrite in the message.- A set-operation body —
IN (SELECT … UNION ALL SELECT …). Write one subquery per branch. - A
FROM-less body —IN (SELECT 1). Write the literal list. - More than one projected column in an
IN/ quantified / scalar body. - A subquery in a
CASE WHENcondition — refused by name; filter inWHERE, or compute the flag in a separate query. - A subquery in a
JOIN … ONclause.⚠️ Its message names neither subqueries nor a rewrite — it reads "ON clause … must use either equality operator or AND predicate". Join on a plain equality and move the subquery toWHERE. - A
WHEREsubquery insideCREATE MATERIALIZED VIEW— refused by name: an Elasticsearch transform cannot run the inner query. Resolve it into the view's source, or keep it in the queries you run against the view. - A
FROM-lessSELECTused as a set-operation branch —SELECT 1 UNION ALL SELECT id FROM tis a syntax error. Worth knowing becauseSELECT 1is the connection-handshake idiom; it does not compose into a set operation.
Since engine 0.24.0. A WITH clause names one or more subqueries at the top of a SELECT:
WITH monthly AS (SELECT category, SUM(amount) AS total FROM orders GROUP BY category)
SELECT category, total FROM monthly;A CTE reference is a derived table — the same construct under a name — so a statement carrying a
WITH runs where derived tables run: on the relational engine, at a venue that has it.
Each CTE may reference the CTEs declared before it, never itself and never a later one:
WITH a AS (SELECT id FROM orders),
b AS (SELECT id FROM a)
SELECT id FROM b;A CTE may be referenced wherever the statement reads a table — in FROM and in a JOIN:
WITH eu AS (SELECT id FROM departments WHERE region = 'EU')
SELECT e.name FROM employees e JOIN eu ON e.department_id = eu.id;Naming a CTE inside a WHERE subquery body parses but does not run: the reference inside the
body is never resolved, so the body reaches Elasticsearch asking for an index with the CTE's name and
the statement fails with a 404 index_not_found_exception naming it. Loud, never silent. Read the
CTE in FROM or JOIN instead.
The semantics are inline, not materialised: naming a CTE does not cache it. A CTE referenced twice runs twice, which costs twice and — on a changing index — may not return identical rows both times.
WITH m AS (SELECT category, SUM(amount) AS total FROM orders GROUP BY category)
SELECT l.category, l.total, r.total AS again FROM m l JOIN m r ON l.category = r.category;WITH RECURSIVE— refused by name. There is no rewrite that recovers arbitrary-depth recursion; flatten the hierarchy at index time (store a path or a level on each document), or run one statement per level.- CTE column lists —
WITH a (x, y) AS (…). Alias the columns in the CTE's ownSELECTlist. - A body that names the CTE itself —
WITH orders AS (SELECT … FROM orders)is refused, with a rename suggested in the message. PostgreSQL binds such a name to the base table; this engine does not guess. Give the CTE a different name. WITHanywhere but the top of aSELECT— not in a subquery body, not inCREATE TABLE … AS SELECT, not inINSERT … SELECT, not in a materialized view.
A column or alias genuinely named recursive still works — quote it (WITH "recursive" AS (…)).
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. Over an index pattern or list, an index that does not map the UNNEST path contributes no rows, when another index maps it as nested.
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 cast or conversion of a bare UNNEST column —
CAST,TRY_CAST,SAFE_CAST,::,CONVERTorDATE_TRUNCof the column itself (CAST(items.product AS VARCHAR),SAFE_CAST(items.quantity AS SHORT),items.quantity::DOUBLE,items.date::DATE) — is answered: each element's value is converted as DuckDB does (text,TINYINT…BIGINT,DOUBLE/REAL,DATE,TIMESTAMP;TRY_CASTanswers NULL whereCASTfails). A date to a number, a number to aDATEorTIMESTAMP, a text to aDATE/TIMESTAMP, aBOOLEANorTIMEtarget, a chain of functions, a conversion beside a window function and anORDER BYover a conversion are refused. Any other function over an UNNEST column (UPPER(items.product),items.price * items.quantity) 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;A WHERE over an UNNEST element answers one row per matching element, as JOIN UNNEST does
everywhere (SELECT o.id FROM dql_orders o JOIN UNNEST(o.items) AS items WHERE items.quantity >= 2
answers an order once for each of its items with a quantity of 2 or more), whether or not the
SELECT reads an element column.
Without a LIMIT, an UNNEST projection, and a WHERE over an UNNEST element, return 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.
Without a JOIN UNNEST, a WHERE condition over a column of a nested field keeps the documents
one of whose elements satisfies it, as EXISTS over the unnested list does in SQL — one row per
document, not one per element (since 0.24.0):
-- the orders that hold at least one item with a quantity of 2 or more
SELECT id FROM dql_orders WHERE items.quantity >= 2;Conditions over the same nested path, joined by AND or OR, are tested on one element, as over
a JOIN UNNEST; conditions over different paths are each tested on their own path, and so are two
NESTED(…) conditions over two paths. A function over the column (UPPER(items.product) = 'A')
is tested on the element too. A condition no single nested query can test — one that reads two
nested paths, or a nested column beside a column of the document, or a path nested below another —
is refused by name before any request. Before 0.24.0, such a condition matched no document, and a
WHERE NESTED(…) written without a JOIN UNNEST matched every document. In a materialized view's
WHERE, write the NESTED(…) condition: the implicit form matches nothing there.
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. Both
work on Elasticsearch 6+: before 7.7 the response has no sample keys, and the sample variants are
computed from the count, sum and sum_of_squares it reports. A group with no value answers
NULL, and so does a sample variant over one value.
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). -
A key over a date column, or over a function of any column, groups by the key's own value from a single index, an index pattern or a list of indices:
DATE_TRUNC(created_at, MONTH)is one Elasticsearchdate_histogramlevel with that calendar interval;- a bare date column (
GROUP BY created_at) is atermslevel on the field: one group per stored value; - any other function of a column (
YEAR(created_at),LAST_DAY(created_at),WEEKDAY(created_at),DATE_FORMAT(created_at, '%Y-%m'),ISNULL(city),UPPER(city)) is atermslevel on the function's script: one group per value of the function.
The key of such a function reads back as the type it reports: a number for a function that answers one (
YEAR(created_at)answers2024,n + 0a number), ordered as a number (ORDER BY DAY(created_at) DESCanswers31, 20, 11, 5, 3), beside aHAVINGcondition on the key too, and text otherwise. A conditional key (COALESCE(n, 2),GREATEST,LEAST, aCASE) is grouped as a number only when every one of its arguments is that kind of number: over ascaled_float,iporgeo_pointcolumn, or a column no mapping types, it keeps text keys, so no two values are merged. A key over a field the index does not map reads that field asNULL, and its rows form no group (aGROUP BYlevel forms noNULLgroup). A bareDATEcolumn's key reads back as a timestamp at midnight UTC. On Elasticsearch 6, a key that formats a date column as stored (DATE_FORMAT(created_at, …),DATETIME_FORMAT(created_at, …)) is refused: truncate the column first,DATE_FORMAT(DATE_TRUNC(created_at, DAY), '%Y-%m')orDATETIME_FORMAT(DATE_TRUNC(created_at, SECOND), …). -
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. -
With no
GROUP BY, the whole table is one group and the statement answers one row, calculations included (since0.24.0):SELECT MAX(price) - MIN(price) AS price_range FROM t,COUNT(*) * 2,MAX(created) + 1(aDATE),DATEDIFF(MAX(created), MIN(created)). A calculation is evaluated as it is per group, with the same rules — aGREATEST/LEASTover an aggregate that can be NULL (GREATEST(MAX(created), MAX(updated))) is refused, as per group. EverySELECTitem must then be an aggregate, a calculation over aggregates or a constant. -
Over no matching document, with no
GROUP BY: aMAX, aMINor anAVGis NULL, and so is a calculation that reads one;COUNT(*)is0(COUNT(*) * 2is0, andHAVING COUNT(*) = 0keeps the one row). A calculation overCOUNT(column),COUNT(DISTINCT column)orSUM(column)is evaluated too —COUNT(n) * 2is0,SUM(x) * 2is0.0,SUMover no value being0.0— on Elasticsearch 7.17 and later. Elasticsearch 6.8 cannot evaluate it (it refuses thekeep_valuesgap policy), so such a calculation is NULL there. -
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), an alias that names one aggregate inSELECTand a different one inHAVING/ORDER BY, and a full-textMATCH ... AGAINSTinHAVINGover an aggregate (HAVING MATCH (MAX(title)) AGAINST ('x'), or over itsSELECTalias) or over a column that is neither an aggregate nor aGROUP BYkey (GROUP BY city HAVING MATCH (title) AGAINST ('x')) — put theMATCHinWHERE. -
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— whether the key is a column or a function of columns written as inGROUP BY(GROUP BY UPPER(city) HAVING UPPER(city) = 'PARIS',GROUP BY LENGTH(code) HAVING LENGTH(code) IN (2, 3)). Elasticsearch compares a function key with the condition as the text of its value, so such a condition is applied where that text is known, from the index mapping:- a function that returns a number, over whole-number,
double, text, date or boolean columns, matches the number however it is written, quoted or not: the condition is sent in every text a number of that value prints (5as"5"and"5.0"), soGROUP BY SQRT(n) HAVING SQRT(n) = 2,GROUP BY LENGTH(code) HAVING LENGTH(code) = 2.0andGROUP BY MONTH(d) HAVING MONTH(d) IN (1.0, 3.0)are answered (since0.24.0); - a function that returns text computed from text columns alone (
UPPER,LOWER,SUBSTRING,CONCAT,TRIMof text columns) matches the text written.
Otherwise it is refused by name — a function over a
REAL(float) column or a cast toREAL(ABS(r) = 0.1,r * 10 = 1), whose script computes in double where SQL computes in single precision; a function that returns text from a date or a number (CONCAT(created, '')); a key that returns a date (below); or any such condition where no mapping of the source can be read (an index that did not exist when it was last queried, a client that reads no mapping). On akeyword,integer,long,doubleorbooleankey,=,<>,INandNOT INkeep or remove exactly the values written, on every supported release: a.,*,(,\or'in a value is an ordinary character, and a numeric or boolean key is compared as a number or a boolean. - a function that returns a number, over whole-number,
-
The key may be written with or without its table qualifier, on either side — the spelling BI tools generate:
GROUP BY c.city HAVING c.city = 'Paris',GROUP BY c.city HAVING city = 'Paris'andGROUP BY city HAVING c.city = 'Paris'filter exactly the groupsGROUP BY city HAVING city = 'Paris'does. An unqualified name is the main table's column, as everywhere else in a statement. In a JOIN, a column theGROUP BYdoes not group for that table (GROUP BY a.city HAVING b.city = 'x') is refused as neither a GROUP BY key nor an aggregate. A table's own name is not a qualifier once the table is aliased (FROM customers c … HAVING customers.city = 'x'is refused). A key whose function renders its own argument (CAST,TRY_CAST,SAFE_CAST,ISNULL,ISNOTNULL) is matched by its spelling: write it the same way inGROUP BYandHAVING(GROUP BY CAST(c.city AS VARCHAR) HAVING CAST(c.city AS VARCHAR) = 'x'); a mixed spelling is refused, except in a JOIN, a derived table or a CTE, whoseHAVINGthe relational engine evaluates. -
An integer key compared with a number that has a fractional part is answered as SQL answers it: no integer equals that number.
HAVING year = 2025.5andHAVING year IN (2024.5, 2025.5)keep no group,HAVING year <> 2025.5andHAVING year NOT IN (2025.5)keep every group (the condition drops out), and a list holding whole numbers too keeps them alone (HAVING year IN (2024.5, 2025)keeps2025) — whether the key is named by its column, its SELECT alias or its GROUP BY position. Sent as it is, Elasticsearch would round the number down and keep the group2025. A comparison the key filter cannot express (HAVING year < 2025.5) is refused as for any key, and so is a fractional comparison on a key grouped from anUNNEST, whose condition sits in a nested relation. The rule reads the index mapping: that of the index, or, over an index pattern or several indices, the mapping of every index they match, merged into the wider type (integerin one index andlongin another is aBIGINT,floatanddoubleaDOUBLE, a whole-number type andfloataREAL). It cannot apply where no mapping is known — a pattern that matches no index, a mapping that cannot be read, or a key the matched indices map with types that have no common type (longin one,keywordin another). There, Elasticsearch rounds the number down and answers without an error (on Elasticsearch 6.8, a comparison with a single value used to fail instead). -
⚠️ Afloatkey compared with a value nofloatholds exactly is refused (GROUP BY r HAVING r = 0.1,rmappedfloat): Elasticsearch prints the key as the float widened to a double (0.10000000149011612) and reads the value as the double0.1, so no group would match, where SQL casts0.1to the key'sFLOATand keeps the group. The refusal names the widened value; filter inWHEREinstead, where Elasticsearch reads the value as a float, or compare with a value a float holds exactly (0.5,0.25), which is answered. -
⚠️ Elasticsearch 6 only: a pattern (LIKE/RLIKE) in one list and two or more values in the other (city LIKE 'P%' AND city NOT IN ('Pau', 'Pise')) is refused, because that release applies the kept and removed terms together only when both are patterns or both are values. Write the value list as oneRLIKEpattern, escaping any regular-expression metacharacter a value carries —city LIKE 'P%' AND city NOT RLIKE 'Pau|Pise'— which is answered on every release. Elasticsearch 7.11 and later apply both as written. See Known limitations. -
⚠️ 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.On a
keyword, integer ordoublekey, and on a function key of the kinds above, a combination is decided on the two lists Elasticsearch actually receives (since0.24.0): it is answered when they keep exactly the groups SQL keeps, whatever its shape, and refused by name otherwise. SoHAVING city = 'Lyon' OR city LIKE 'L%'is answered — the pattern already covers the value — whilecity = 'Paris' OR city LIKE 'L%'stays refused. On an integer ordoublekey, values are compared as numbers there:HAVING n = 5 AND (n <> 7 OR NOT n = 5.0)is refused, because the lists would keep and remove the same group. AnRLIKEbeside another condition on the key is refused (see Known limitations). -
HAVING city IS NOT NULLkeeps every group, on any key (since0.24.0; it was refused), and so doISNOTNULL(city)andNOT ISNULL(city)written after an operator: aGROUP BYlevel forms noNULLgroup, so no group is removed. Beside an aggregate condition it drops out (HAVING city IS NOT NULL AND COUNT(*) > 1is the group filter alone). Beside a value of the same key it is decided like any combination below:city IS NOT NULL AND city = 'Paris'keepsParis, whilecity IS NOT NULL OR city = 'Paris'is refused — it keeps every group, so remove theOR.HAVING city IS NULLis refused. -
⚠️ A FUNCTION of the key is refused when theGROUP BYkey is the column itself (GROUP BY city HAVING UPPER(city) = 'PARIS',GROUP BY status 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 condition on a date grouping key is refused when the key is computed:- a
DATE_TRUNCkey (GROUP BY DATE_TRUNC(created, MONTH) HAVING DATE_TRUNC(created, MONTH) = '2024-01-01'): Elasticsearch groups it with a date histogram, and a date histogram has no key filter; - any other key whose function returns a date, a time or a timestamp (
LAST_DAY,DATE_ADD,DATETIME_ADD,TIMESTAMPADD,TO_DATE,CAST(… AS DATE),COALESCEover a date column): Elasticsearch computes such a key with a script and compares the condition with the text of its value, which a date literal does not match.
Restrict the rows in
WHEREinstead, with the same condition:WHERE LAST_DAY(created) = '2024-01-31' GROUP BY LAST_DAY(created), or for aDATE_TRUNCkeyWHERE created >= '2024-01-01' AND created < '2024-02-01' GROUP BY DATE_TRUNC(created, MONTH). A bare date or timestamp column key is filtered:GROUP BY created HAVING created = '2024-01-03'. - a
-
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. The same holds for two functions of one column (GROUP BY YEAR(created), MONTH(created) HAVING YEAR(created) = 2024 OR MONTH(created) = 1): refused, while theANDof the two is accepted.
A
MATCH (city) AGAINST ('…')on the key is a key filter likeRLIKEand follows the same rules: it can be combined with an aggregate byAND, not byOR; aMATCHover two grouping keys (GROUP BY country, city HAVING MATCH (country, city) AGAINST ('x')) is anORacross them and is refused; and aMATCHon a column that is not aGROUP BYkey is refused, as stated above. - 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. For such a groupIS NULLis true andIS NOT NULLis false (writtenISNULL(MAX(age))andISNOTNULL(MAX(age))inHAVING).
🔴 Changed in 0.24.0 — an empty group's aggregate is NULL in
HAVING. Before 0.24.0,<>, a negated comparison (NOT MAX(age) = 30,NOT BETWEEN,NOT IN),IS [NOT] NULLandCOALESCEover theMIN,MAX,AVGor a percentile of a group with no value could keep or drop the wrong groups: the group filter was handed Elasticsearch's placeholder for an aggregate over no value (NaN), not NULL.
- An aggregate over the array is answered per group, including an aggregate of the grouping
field itself:
MIN(items.quantity)grouped byitems.quantityreturns each group's own value (since 0.24.0; it answered NULL on every group before). - When the index mapping is known, an
UNNESTkey listed before a root key is grouped exactly as the root-first spelling:GROUP BY items.quantity, o.customer_idandGROUP BY o.customer_id, items.quantityreturn the same groups and the same aggregates of the array (since 0.24.0). ⚠️ An aggregate of a root field (COUNT(*),COUNT(o.id)) beside anUNNESTkey is not answered per group yet. Aggregate a field of the array instead (COUNT(items.quantity)).⚠️ With twoUNNESTs in one statement, an aggregate counts the entries of its own array only: the other array does not multiply its rows.
SELECT items.quantity, MIN(items.quantity) AS lowest, SUM(items.price) AS total
FROM dql_orders o JOIN UNNEST(o.items) AS items
GROUP BY items.quantity;
SELECT items.quantity, o.customer_id, COUNT(items.quantity) AS cnt, SUM(items.price) AS total
FROM dql_orders o JOIN UNNEST(o.items) AS items
GROUP BY items.quantity, o.customer_id;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 — it is pushed down as the Elasticsearchtermssize. On a multi-columnGROUP BYit still answers at mostLIMITgroups: each level is sent with that size, and the rows are cut to theLIMITonce they are put together (GROUP BY a, b LIMIT 2answers two rows, not two per value ofa).- On a multi-column
GROUP BY,ORDER BYsorts the whole result, over a key other than the first or over an aggregate too — named by its alias (ORDER BY c) or by its expression (ORDER BY COUNT(*),ORDER BY MAX(amount)), returned by theSELECTor not:GROUP BY city, created_at ORDER BY created_at DESCorders every group by its date, across the cities. When the levels cannot give that order themselves, the rows are sorted once they are put together, then cut to theLIMIT; an aggregate theSELECTdoes not return is read for the sort only. ANULLaggregate is placed where the sameORDER BYover one column places it: aMAXwith no value last descending and first ascending, any otherNULLlast ascending and first descending. The levels above the first sort key's own level are asked for all their groups; that level and the ones below it keep theLIMIT(GROUP BY city, created_at ORDER BY created_at DESC LIMIT 10asks every city, and 10 dates in each). LIMIT n OFFSET mon aGROUP BYskips the first m groups, then answers n, in the order the statement asks for: a level is sent with n + m buckets, and the groups are skipped once the rows are put together. It is refused beside a window function and over aGROUP BYkey of anUNNESTelement, where the groups cannot be skipped in that order.- 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 |
TIMESTAMPADD(unit, n, ts) |
Add n units, ODBC unit-first order; n may be negative, SQL_TSI_* unit names accepted |
DATE_DIFF(date1, date2, unit) |
date1 - date2 in units: the calendar boundaries crossed (UTC, weeks start on Monday) |
TIMESTAMPDIFF(unit, date1, date2) |
date2 - date1 in units: the whole units elapsed, as MySQL counts them |
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)Latitude comes first and longitude second — not the GeoJSON order. Latitude runs from -90 to 90,
longitude from -180 to 180, in the WGS84 (EPSG:4326) reference system. POINT has no standalone
form: it is only accepted as an argument to ST_DISTANCE. Each coordinate must be written with a
decimal point — POINT(1.0, 2.0) is accepted, POINT(1, 2) is not. Supported distance units: km,
m, cm, mm, mi, yd, ft, in, nmi.
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 value among the given expressions; LEAST
returns the smallest. The arguments are all numeric, or all dates and timestamps (a DATE
compares as the start of its day, UTC; the result is a DATE over dates and a TIMESTAMP when
they mix). Over numbers the result is the common type of all the arguments on every row: the
widest whole type when every argument is a whole number, a DOUBLE as soon as one is fractional.
NULL arguments are ignored (ANSI semantics); the result is NULL only
when every argument is NULL. Both are emitted as Painless ternary chains — over whole numbers the
winner is chosen by a comparison, over fractional numbers by Math.max / Math.min, over dates
over the epoch milliseconds each one denotes. They are
conditional functions, not aggregates — GREATEST(...) OVER (...) is not supported. Over
aggregates that are never NULL (COUNT, SUM) they are computed per group; over an aggregate that
can be NULL (MAX, MIN, AVG, ...) they are answered in a HAVING and refused in a SELECT,
where Elasticsearch would skip a group with no value and answer NULL for it.
SELECT GREATEST(price_us, price_eu, price_uk) AS max_price FROM products;
SELECT LEAST(base_price, promo_price) AS best_price 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
search_after(Elasticsearch 6, and 7 before 7.12) needs noORDER BY: without one the rows are paged in_idorder, and with one_idis the tie-breaker. Since0.24.0, on Elasticsearch 6, a statement withoutLIMITover a pattern that includes an index created without any mapping is read in full, even when that index is empty: the_idorder no longer fails that index's shards- Without
ORDER BY, result ordering is not guaranteed
Elasticsearch answers a search on which some shards fail with HTTP 200 and the rows of the
shards that succeeded. Since 0.24.0 such a search answers an error instead, never the partial
rows — a one-shot search, a UNION ALL branch, and every scroll, search_after or point-in-time
page alike:
- the message reads
n of m shards failed: <type>: <reason> (caused by <type>: <reason>), and a search on which every shard fails ends with the failed shard's own reason (… all shards failed; runtime error: For input string: "x"); - the status is
400when every failure is the query's own — a script error, a query that does not apply to a mapping — and is otherwise not set; such a page fails at once and is not retried; - there is no setting that returns the partial rows.
Three shapes that would otherwise fail over an index pattern or a list are answered instead: a sort
over a column some of the indices do not map sorts their documents as NULL; a nested, UNNEST,
CHILD(…) or PARENT(…) condition matches none of the documents of an index that does not map its
path or relation, when another index of the pattern does; and a distance filter over a geo column
some indices do not map matches none of theirs. An un-LIMITed query with a window function whose
window computation fails answers an error too, instead of rows without their window values.
| Feature | ES6 | ES7 | ES8 | ES9 |
|---|---|---|---|---|
| Basic SELECT | ✔ | ✔ | ✔ | ✔ |
| Nested fields | ✔ | ✔ | ✔ | ✔ |
| Set operators | ✔ | ✔ | ✔ | ✔ |
| Derived tables | ✔ | ✔ | ✔ | ✔ |
WHERE subqueries |
✔ | ✔ | ✔ | ✔ |
| Correlated subqueries | ✔ | ✔ | ✔ | ✔ |
| CTEs (non-recursive) | ✔ | ✔ | ✔ | ✔ |
| Cross-index JOINs | ✔ | ✔ | ✔ | ✔ |
| JOIN UNNEST | ✔ | ✔ | ✔ | ✔ |
| Aggregations | ✔ | ✔ | ✔ | ✔ |
| Parent-level nested array aggs | ✔ | ✔ | ✔ | ✔ |
| Window functions | ✔ | ✔ | ✔ | ✔ |
| Geospatial functions | ✔ | ✔ | ✔ | ✔ |
| Date/time functions | ✔ | ✔ | ✔ | ✔ |
| String / math functions | ✔ | ✔ | ✔ | ✔ |
A ✔ here means the Elasticsearch major does not stand in the way — every row was executed against
that version. It is not a statement about the venue: derived tables, CTEs, correlated subqueries and
the set operators other than UNION ALL need the relational engine wherever they run, on every one
of these versions alike. See
Known Limitations & Roadmap.
For the full picture of what works in this release, what is refused and why, and BI-tool notes, 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. - Since engine
0.24.0, subqueries inWHERE(IN/NOT IN/EXISTS/NOT EXISTS/ scalar / quantified) and derived tables inFROM/JOINare supported, correlated or not — see Subqueries and derived tables for the reference and Known Limitations & Roadmap for the venue requirements. Set operators —UNION ALL,UNION/UNION DISTINCT,INTERSECT/INTERSECT ALL,EXCEPT/EXCEPT ALL— are supported too; see Set operators. Still not supported: a subquery in theSELECTlist, a subquery inHAVING,LATERAL, and a set operation used as a subquery body. - Since engine
0.24.0, non-recursive CTEs (WITH name AS (SELECT …)) are supported at the top of aSELECT, each one able to reference the CTEs declared before it — see Common table expressions. Still not supported:WITH RECURSIVE, CTE column lists (WITH a (x, y) AS …), a CTE body that names the CTE itself, and aWITHclause anywhere other than the top of aSELECT(not in a subquery body, CTAS,INSERT … SELECTor a materialized view). - 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 (TIMESTAMPDIFF(YEAR, birthdate, CURRENT_DATE)),
ingested_at TIMESTAMP DEFAULT _ingest.timestamp,
profile STRUCT FIELDS(
bio VARCHAR,
followers INT,
join_date DATE,
seniority INT SCRIPT AS (DATEDIFF(CURRENT_DATE, profile.join_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 | TIMESTAMPDIFF(YEAR, birthdate, CURRENT_DATE) | () | ||
| 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(CURRENT_DATE, profile.join_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 (TIMESTAMPDIFF(YEAR, birthdate, CURRENT_DATE)),
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(CURRENT_DATE, profile.join_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 (TIMESTAMPDIFF(YEAR, birthdate, CURRENT_DATE)),
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(CURRENT_DATE, profile.join_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 | TIMESTAMPDIFF(YEAR, birthdate, CURRENT_DATE) | () | ||
| 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(CURRENT_DATE, profile.join_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 (TIMESTAMPDIFF(YEAR, birthdate, CURRENT_DATE)) | 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(CURRENT_DATE, profile.join_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 (TIMESTAMPDIFF(YEAR, birthdate, CURRENT_DATE))",
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(CURRENT_DATE, profile.join_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 (TIMESTAMPDIFF(YEAR, birthdate, CURRENT_DATE))",
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(CURRENT_DATE, profile.join_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 (TIMESTAMPDIFF(YEAR, birthdate, CURRENT_DATE)) | 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(CURRENT_DATE, profile.join_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.