The SQL Gateway provides a full Data Query Language on top of Elasticsearch, translating SQL into search, aggregation, and scroll APIs.
DQL includes cross-index JOINs (INNER / LEFT / RIGHT / FULL OUTER) across indices and clusters — a capability Elasticsearch has no native support for. See Cross-Index JOIN for the full guide; this page covers the single-index query language, including JOIN UNNEST on ARRAY<STRUCT> columns.
SELECT
Basic syntax
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];
Nested Fields and Aliases
SELECT id,
nameAS full_name,
profile.cityAS city,
profile.followersAS followers
FROM dql_users
ORDER BY id ASC;
profile is a STRUCT column.
profile.city and profile.followers access 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_users returns the columns id and profile.city. The parent
object profile is normally left out of these rows. A row can still carry it, for example when
the SELECT list also names profile, contains *, or reads another field of profile under
an alias (profile.followers AS followers). Rows read into a typed entity (searchAs) keep the
parent object.
Quoted identifiers
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 named1, 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
Escaping
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.
How a quoted name is rendered back
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.
Double quotes are also string delimiters, in value position
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:
SELECTMAX("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.
String literals
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.
Escaping the quote
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'ASnameFROM t; -- the value O'Brien
SELECT id FROM t WHERE greeting ="say ""hi"""; -- the value say "hi"
⚠️ The second example is in a value position. In a SELECT list the same lexeme is a column:
SELECT "say ""hi""" FROM t reads the field namedsay "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.
How a string literal is rendered back
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.
Qualified and quoted table names
The name after FROM (and after JOIN, and after DELETE FROM) may be written quoted, in either
spelling, and may carry a qualifier:
SELECTo.idFROM`prod_eu`.`orders` o JOIN`prod_eu`.`customers` c ONo.cid=c.id;
Quoting is what makes a dot a qualifier
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`.table on the
raw SQL before parsing — a BACKTICKED qualifier followed by a BARE table name. A fully quoted
FROM `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.
What the qualifier means
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.
What changed for a JOIN leg
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.
It is preserved when the statement is rendered back
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.
The same rules bind DML and DDL
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.
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 column sch.c — the
same reading SELECT "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.
FROM-less SELECT (connection handshake)
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:
SELECT1;
SELECT1AS x;
SELECT1LIMIT100;
SELECT1AS ok, UPPER('x') AS u, 1+1AS two;
SELECT CURRENT_TIMESTAMP AS ts;
SELECT'125'::BIGINTAS 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).
Connection-check semantics
A FROM-less SELECTexecutes 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 handshake index
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: true on
ES ≥ 7.7
mapping: a single dummy keyword 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.
Read-only BI service accounts
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):
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.
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.
What stays rejected
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
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.
WHERE
The WHERE clause supports:
comparison operators: =, !=, <, <=, >, >=
logical operators: AND, OR, NOT
IN, NOT IN
BETWEEN
IS NULL, IS NOT NULL
LIKE, RLIKE (regex)
conditions on nested fields (profile.city, profile.followers)
A function in a WHERE predicate, 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, because NOT NULL is 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 by class_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, BETWEEN and NOT BETWEEN over a function all follow the same rule,
and a NOT written after AND / 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:
LIKE over a function produced an uncompilable script, NOT LIKE failed inside the engine, and
IN / BETWEEN over 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 NOT over
it is Elasticsearch’s must_not beside the column’s exists, so it leaves out the documents that
lack the field, as SQL reads NOT over a NULL: WHERE status <> 'A', NOT IN,
NOT BETWEEN, NOT LIKE and a negated MATCH answer the same rows as the scripted route. Use
IS NULL to keep the documents without a value.
A projected function keeps its NULL: SELECT UPPER(status) AS u returns u = NULL for a
document with no status, and a GROUP BY UPPER(status) has no bucket for it. The collapse to
“no match” applies to a condition, never to a value.
🔴 ORDER BY over 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 _shards and 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 LIMIT is 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 to ORDER BY over a CASE … END with no ELSE,
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 as keyword, boolean, date, byte, short, integer, long, float or double.
⚠️ Two limits of the rule above, stated rather than implied. NOT <function>(x) IS NULL is a
PARSE rejection — the grammar takes a bare name after NOT there — 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 a LIKE over 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), answering
Regexes are disabled. On 7.x and later every pattern works.
🔴 Changed in 0.23.0 — LIKE reads 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 matched AXB1, because . reached Elasticsearch as a regular-expression
wildcard; it now matches only values that really begin with A.B. Patterns that relied on the old
reading must be rewritten with _ (any single character) or % (any sequence). RLIKE is
unaffected — its operand is a regular expression by definition.
Example
SELECT id, name, age
FROM dql_users
WHERE (age >20ANDprofile.followers>=100)
OR (profile.city='Lyon'AND age <50)
ORDER BY age DESC;
Another example with multiple operators:
SELECT id,
age +10AS age_plus_10,
name
FROM dql_users
WHERE age BETWEEN20AND50
ANDnameIN ('Alice', 'Bob', 'Chloe')
ANDnameIS NOT NULL
AND (nameLIKE'A%'ORname RLIKE '.*o.*');
Temporal literals against date columns
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 to T;
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 example yyyy-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 Elasticsearch
search_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; under date_optional_time or a custom format nothing is ever rejected.
The same resolution applies to the WHERE clause of UPDATE and DELETE.
keyword/text columns, LIKE/RLIKE patterns, function-wrapped columns (YEAR(event_ts)),
date_nanos columns, columns qualified with a JOIN alias (the FROM table’s own columns are
resolved) and HAVING conditions 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 with
ALTER TABLE … SET SCHEMA CACHE TTL). An
index alias over exactly one index resolves to that index’s mapping (SHOW TABLE /
DESCRIBE through 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
ORDER BY sorts the result set by one or more expressions.
Supports multiple sort keys
Supports ASC and DESC
Supports NULLS FIRST / NULLS LAST per sort key (see below)
Supports expressions and nested fields (e.g., profile.city)
When used inside a window function (OVER), ORDER BY defines the logical ordering of the window
Example
SELECT id, name, age
FROM dql_users
ORDER BY age DESC, nameASC
LIMIT2 OFFSET 1;
NULLS FIRST / NULLS LAST
Each sort key may declare where NULL values appear in the result:
SELECT id, name, bonus
FROM dql_users
ORDER BY bonus DESCNULLSLAST;
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 last
DESC → nulls first
Different null orderings can be combined within a single query:
SELECT id, name, bonus, hire_date
FROM dql_users
ORDER BY bonus DESCNULLSLAST, hire_date ASCNULLSFIRST;
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 / OFFSET
LIMIT n restricts the number of returned rows.
OFFSET m skips the first m rows.
Translated to Elasticsearch from + size.
Example:
SELECT id, name, age
FROM dql_users
ORDER BY age DESC
LIMIT10 OFFSET 20;
SELECT TOP n — the same bound, spelled the T-SQL way
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.
SELECTTOP10 id, nameFROM dql_users ORDER BY age DESC;
SELECTTOP (10) id, nameFROM dql_users ORDER BY age DESC; -- parenthesised, also T-SQL
SELECT id, nameFROM dql_users ORDER BY age DESCLIMIT10; -- identical to both
Four things to know:
TOP is not reserved.SELECT top FROM t still selects a column called top, and
SELECT top - 1 AS x FROM t still computes it.
TOP and LIMIT together are refused by name. They are two spellings of one bound, and
guessing a precedence would be worse than saying so.
TOP carries no OFFSET. For paging, use LIMIT n OFFSET m.
TOP n PERCENT and TOP n WITH TIES are 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.
Set operators
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 EXCEPT set operator is not the SELECT * EXCEPT(col, …) column-exclusion clause. The
first removes rows, the second removes columns from SELECT *. Both work; they are unrelated.
Example
SELECT id, nameFROM dql_users WHERE age >30
UNION ALL
SELECT id, nameFROM 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;
Columns match by position, not by name
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 SELECT list 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 back NULL. Positional matching is both the
standard’s rule and what every other SQL engine does.
Compatibility rules
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 VARCHAR column, 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.
Precedence and grouping
INTERSECT binds tighter than UNION and EXCEPT; otherwise branches associate left to right
(SQL-92 §7.10). So this:
SELECT a FROM t1 UNIONSELECT a FROM t2 INTERSECTSELECT a FROM t3
is 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 UNIONSELECT a FROM t2) AS g
INTERSECT
SELECT a FROM t3;
ORDER BY and LIMIT
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 LIMIT10);
-- the whole result
SELECT*FROM (SELECT a FROM t1 UNIONSELECT a FROM t2) AS g ORDER BY a LIMIT10;
A first or middle branch carrying its own ORDER BY / LIMIT is unambiguous and is accepted without
parentheses.
Execution model
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 LIMIT within index.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 (a GROUP BY of several keys with an ORDER BY, a LIMIT or an OFFSET; a WHERE over an
UNNEST element) runs as its own search too, so each branch answers what the same SELECT
answers 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 BY orders 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.
Column types
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’s 8-prefixed mode;
a zone id or a zone name other than UTC, GMT and UT, in every format, the default
included: a region id (Europe/Paris in a VV or a Joda-Time ZZZ letter, 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_City is 17:00:00Z on Elasticsearch 8.18.3 and 18:00:00Z with older zone data,
and IST is 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’s 8-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-7 in a 8YYYY-ww-e field), 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:00Z in a time field);
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 ALLSELECT 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 UT
The 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 ALLSELECT 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 ALLSELECT x AS k FROM t;
-- refused, by name, before anything runs
SELECT d AS k FROM t UNION ALLSELECT 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 ALLSELECT 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 DATE
Inside 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 ALLSELECT s AS k FROM t;
-- x is a DOUBLE: ('250000000000.0', '1e-05', ..., 'a')
SELECT x AS k FROM t UNION ALLSELECT'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.
What is not supported
A set operation as a WHERE subquery 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.
Subqueries and derived tables
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.
Derived tables
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:
SELECTd.country, d.total
FROM (SELECT country, SUM(amount) AS total FROM orders GROUP BY country) AS d
WHEREd.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
SELECTo.id, d.total
FROM orders o
JOIN (SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id) AS d
ONo.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 UNIONSELECT a FROM t2) AS g;
-- the body's LIMIT bounds the body; the outer statement is ordered and limited separately
SELECTd.totalFROM (SELECTSUM(amount) AS total FROM orders GROUP BY country) AS d
ORDER BYd.totalDESCLIMIT5;
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:
SELECTMAX(1) AS TblMax FROM (SELECT*FROM orders) t;
How an unqualified name resolves — a deliberate deviation from SQL-92
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.
Column aliases on the derived table itself
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.
Subqueries in WHERE
Six spellings, all accepted in a SELECT, a DELETE and an UPDATE:
SELECTnameFROM employees WHERE department_id IN (SELECT id FROM departments WHERE region ='EU');
SELECTnameFROM employees WHERE department_id NOTIN (SELECT id FROM departments WHERE region ='EU');
SELECTnameFROM customers c WHEREEXISTS (SELECT1FROM orders o WHEREo.customer_id=c.id);
SELECTnameFROM customers c WHERENOTEXISTS (SELECT1FROM orders o WHEREo.customer_id=c.id);
SELECTnameFROM employees WHERE salary > (SELECTAVG(salary) FROM employees);
SELECTnameFROM employees WHERE department_id = ANY (SELECT id FROM departments);
SELECTnameFROM 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.
DELETEFROM orders WHERE customer_id IN (SELECT id FROM customers WHERE region ='EU');
UPDATE orders SETstatus='eu'WHERE customer_id IN (SELECT id FROM customers WHERE region ='EU');
The comparison must be written subquery-on-the-right
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
SELECTc.nameFROM customers c WHERE5< (SELECTCOUNT(*) FROM orders o WHEREo.customer_id=c.id);
How an uncorrelated WHERE subquery executes
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 terms filter, 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.
NOT IN and NULL
Standard SQL three-valued logic applies. If the inner query returns anyNULL, 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 NULL by re-running the
inner query with an IS NULL filter. When the inner query carries a GROUP BY, that probe is a
grouped query too, and Elasticsearch’s terms aggregation drops the missing-value group — so
a NULL in a GROUP BY body is invisible and NOT IN returns rows the rule above says it
should not. Filter the NULL out explicitly in that body rather than relying on the detection.
Correlated subqueries
A subquery whose body reads a column from the enclosing query is correlated, and it runs on the
relational engine:
SELECTc.nameFROM customers c
WHEREEXISTS (SELECT1FROM orders o WHEREo.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 = id stops 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-separated FROM, a
JOIN UNNEST, a derived table or a window function inside the body are refused; move the construct
to the outer FROM and 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.
What is not supported
A subquery in the SELECT list — 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 to WHERE.
LATERAL — a derived table that reads an alias from the enclosing FROM. 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 WHEN condition — refused by name; filter in WHERE, or compute the flag
in a separate query.
A subquery in a JOIN … ON clause. ⚠️ 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 to WHERE.
A WHERE subquery inside CREATE 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-less SELECT used as a set-operation branch — SELECT 1 UNION ALL SELECT id FROM t
is a syntax error. Worth knowing because SELECT 1 is the connection-handshake idiom; it does not
compose into a set operation.
Common table expressions
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.
Scope is left to right
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')
SELECTe.nameFROM employees e JOIN eu ONe.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.
A CTE referenced twice is executed twice
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)
SELECTl.category, l.total, r.totalAS again FROM m l JOIN m r ONl.category=r.category;
What is not supported
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 own SELECT list.
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.
WITH anywhere but the top of a SELECT — not in a subquery body, not in CREATE TABLE … AS SELECT, not in INSERT … SELECT, not in a materialized view.
A column or alias genuinely named recursive still works — quote it (WITH "recursive" AS (…)).
JOIN UNNEST
The Gateway supports a specific form of join: JOIN UNNEST on ARRAY<STRUCT> columns.
Table definition
CREATETABLEIFNOTEXISTS dql_orders (
id INTNOT NULL,
customer_id INT,
items ARRAY<STRUCT> FIELDS(
product VARCHAR OPTIONS (fielddata = true),
quantity INT,
price DOUBLE
) OPTIONS (include_in_parent = false)
);
Query with JOIN UNNEST and window function
SELECT
o.id,
items.product,
items.quantity,
SUM(items.price*items.quantity) OVER (PARTITIONBYo.id) AS total_price
FROM dql_orders o
JOIN UNNEST(o.items) AS items
WHEREitems.quantity>=1
ORDER BYo.idASC;
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
Functions over an UNNEST column
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,
::, CONVERT or DATE_TRUNC of 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_CAST answers NULL where CAST fails). A date to a
number, a number to a DATE or TIMESTAMP, a text to a DATE / TIMESTAMP, a BOOLEAN or TIME target,
a chain of functions, a conversion beside a window function and an ORDER BY over 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 as
YEAR(<column>) = 2025 or EXTRACT(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,
a CASE, 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 a
COALESCE argument after a non-null literal, which COALESCE never 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 over JOIN UNNEST is 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.product is product — the name every clause of the outer query uses, and the label
SELECT * gives the column. Two inner columns whose short names coincide (o.id and items.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 no LIMIT — 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 (SELECTo.id, items.product,
SUM(items.price*items.quantity) OVER (PARTITIONBYo.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.
SELECTo.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
SELECTo.id, items.productFROM dql_orders o JOIN UNNEST(o.items) AS items LIMIT100;
-- refused as well: beside a window, the rows are computed the same way
SELECTo.id, UPPER(items.product) AS product,
SUM(items.price*items.quantity) OVER (PARTITIONBYo.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
SELECTo.id, items.product,
SUM(items.price*items.quantity) OVER (PARTITIONBYo.id) AS total_price
FROM dql_orders o JOIN UNNEST(o.items) AS items LIMIT100;
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.
A condition over a nested column, without JOIN UNNEST
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 WHEREitems.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.
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.
Percentiles — PERCENTILE_CONT / PERCENTILE_DISC
PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY column) — ANSI ordered-set aggregate (optionally with a top-level GROUP BY)
PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY column) OVER (PARTITION BY ...) — value column from WITHIN GROUP, partition from OVER
PERCENTILE_CONT(p) OVER (PARTITION BY ... ORDER BY column) — value column from the OVERORDER BY
PERCENTILE_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)
SELECTendpoint,
PERCENTILE_CONT(0.99) WITHINGROUP (ORDER BY duration_ms) AS p99
FROM requests
GROUP BYendpoint;
GROUP BY and HAVING
SELECTprofile.cityAS city,
COUNT(*) AS cnt,
AVG(age) AS avg_age
FROM dql_users
GROUP BYprofile.city
HAVINGCOUNT(*) >=1
ORDER BYCOUNT(*) DESC;
GROUP BY supports 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 Elasticsearch date_histogram level with that calendar
interval;
a bare date column (GROUP BY created_at) is a terms level 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 a
terms level 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) answers 2024, n + 0 a number), ordered as a number
(ORDER BY DAY(created_at) DESC answers 31, 20, 11, 5, 3), beside a HAVING condition on the
key too, and text otherwise. A conditional
key (COALESCE(n, 2), GREATEST, LEAST, a CASE) is grouped as a number only when every one
of its arguments is that kind of number: over a scaled_float, ip or geo_point column, 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 as NULL, and its rows form no group (a GROUP BY level forms
no NULL group). A bare
DATE column’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') or
DATETIME_FORMAT(DATE_TRUNC(created_at, SECOND), …).
HAVING filters groups based on aggregate conditions.
Translated to Elasticsearch aggregations.
An aggregate referenced only in HAVING or ORDER BY needs no alias and no SELECT item: 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 (since 0.24.0): SELECT MAX(price) - MIN(price) AS price_range FROM t,
COUNT(*) * 2, MAX(created) + 1 (a DATE), DATEDIFF(MAX(created), MIN(created)). A
calculation is evaluated as it is per group, with the same rules — a GREATEST / LEAST over an
aggregate that can be NULL (GREATEST(MAX(created), MAX(updated))) is refused, as per group.
Every SELECT item must then be an aggregate, a calculation over aggregates or a constant.
Over no matching document, with no GROUP BY: a MAX, a MIN or an AVG is NULL, and so
is a calculation that reads one; COUNT(*) is 0 (COUNT(*) * 2 is 0, and
HAVING COUNT(*) = 0 keeps the one row). A calculation over COUNT(column),
COUNT(DISTINCT column) or SUM(column) is evaluated too — COUNT(n) * 2 is 0, SUM(x) * 2
is 0.0, SUM over no value being 0.0 — on Elasticsearch 7.17 and later. Elasticsearch 6.8
cannot evaluate it (it refuses the keep_values gap policy), so such a calculation is NULL
there.
HAVING may reference a SELECT aggregate 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, IN and NOT apply 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 in SELECT and reference the alias), an
aggregate function inside WHERE (use HAVING, and this covers a wrapped one such as
WHERE ABS(COUNT(*)) > 1), an alias that names one aggregate in SELECT and a different one
in HAVING / ORDER BY, and a full-text MATCH ... AGAINST in HAVING over an aggregate
(HAVING MATCH (MAX(title)) AGAINST ('x'), or over its SELECT alias) or over a column that is
neither an aggregate nor a GROUP BY key (GROUP BY city HAVING MATCH (title) AGAINST ('x'))
— put the MATCH in WHERE.
A function of an aggregate in HAVING is applied to the group, or the statement is rejected
by name — it is never ignored. COALESCE, GREATEST, LEAST and SIGN over an aggregate filter
the groups (HAVING COALESCE(COUNT(*), 0) > 30, HAVING GREATEST(MAX(price), 0) > 100), on
either side of the comparison and under NOT; BETWEEN and IN support 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(*)) > 1 and 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 in WHERE, in the SELECT list and in ORDER BY;
CASE ... END in HAVING, which needs a document and a group filter has none;
a function applied to a SELECT aggregate 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) > 10 rather than HAVING ROUND(SUM(price), 2) > 10 — and apply the function
to the result outside the query. Aliasing the expression in SELECT does NOT help: a function of
an aggregate is not a valid SELECT item under a GROUP BY either
(SELECT ABS(COUNT(*)) AS a ... GROUP BY city is rejected as a non-aggregated field). That
differs from arithmetic over aggregates, which IS a valid SELECT item
(MAX(price) - MIN(price) AS price_range) and is the reason the rule above tells you to alias
THAT one.
Comparing a DATE aggregate
⚠️ A comparison between a date aggregate and a date literal is refused, function or not:
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.
Conditions on the GROUP BY key
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 in GROUP 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 (5 as "5" and "5.0"), so GROUP BY SQRT(n) HAVING SQRT(n) = 2, GROUP BY LENGTH(code) HAVING LENGTH(code) = 2.0 and GROUP BY MONTH(d) HAVING MONTH(d) IN (1.0, 3.0) are answered (since 0.24.0);
a function that returns text computed from text columns alone (UPPER, LOWER,
SUBSTRING, CONCAT, TRIM of text columns) matches the text written.
Otherwise it is refused by name — a function over a REAL (float) column or a cast to REAL
(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 a keyword, integer, long, double or boolean key, =, <>, IN and NOT IN keep 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.
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'
and GROUP BY city HAVING c.city = 'Paris' filter exactly the groups GROUP 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 the GROUP BY does 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 in GROUP BY
and HAVING (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, whose HAVING the 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.5 and HAVING year IN (2024.5, 2025.5)
keep no group, HAVING year <> 2025.5 and HAVING 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) keeps 2025) — 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 group
2025. 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 an UNNEST, 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
(integer in one index and long in another is a BIGINT, float and double a DOUBLE, a
whole-number type and float a REAL). 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 (long in one, keyword in 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).
⚠️ A float key compared with a value no float holds exactly is refused
(GROUP BY r HAVING r = 0.1, r mapped float): Elasticsearch prints the key as the float
widened to a double (0.10000000149011612) and reads the value as the double 0.1, so no group
would match, where SQL casts 0.1 to the key’s FLOAT and keeps the group. The refusal names
the widened value; filter in WHERE instead, 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 one RLIKE pattern, 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. The terms
filter 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 below
city 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 the L… groups. Use a single
RLIKE covering 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 OR of equalities or an AND of inequalities.
On a keyword, integer or double key, and on a function key of the kinds above, a combination
is decided on the two lists Elasticsearch actually receives (since 0.24.0): it is answered
when they keep exactly the groups SQL keeps, whatever its shape, and refused by name otherwise.
So HAVING city = 'Lyon' OR city LIKE 'L%' is answered — the pattern already covers the value —
while city = 'Paris' OR city LIKE 'L%' stays refused. On an integer or double key, 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. An RLIKE beside another condition on the key is refused
(see Known limitations).
HAVING city IS NOT NULL keeps every group, on any key (since 0.24.0; it was refused),
and so do ISNOTNULL(city) and NOT ISNULL(city) written after an operator: a GROUP BY level
forms no NULL group, so no group is removed. Beside an aggregate condition it drops out
(HAVING city IS NOT NULL AND COUNT(*) > 1 is 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' keeps
Paris, while city IS NOT NULL OR city = 'Paris' is refused — it keeps every group, so remove
the OR. HAVING city IS NULL is refused.
⚠️ A FUNCTION of the key is refused when the GROUP BY key 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 in
WHERE.
⚠️ A condition on a date grouping key is refused when the key is computed:
a DATE_TRUNC key
(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), COALESCE over 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 WHERE instead, with the same condition:
WHERE LAST_DAY(created) = '2024-01-31' GROUP BY LAST_DAY(created), or for a DATE_TRUNC key
WHERE 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 predicate naming a column that is neither the GROUP BY key nor an aggregate is refused
(HAVING UPPER(name) = 'X' when the grouping is by city) — with or without a GROUP BY.
⚠️ An OR whose 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 terms aggregations are NESTED, so
GROUP BY country, city HAVING country = 'FR' OR city = 'Paris' would return only the groups
matching BOTH. An OR on ONE key, within one mechanism, is supported subject to the table
above; the corresponding AND is 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 the AND of the two is accepted.
A MATCH (city) AGAINST ('…') on the key is a key filter like RLIKE and follows the same
rules: it can be combined with an aggregate by AND, not by OR; a MATCH over two grouping
keys (GROUP BY country, city HAVING MATCH (country, city) AGAINST ('x')) is an OR across them
and is refused; and a MATCH on a column that is not a GROUP BY key is refused, as stated
above.
An AND across 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 lack age) never passes a HAVING comparison, in either direction: the generated filter
script null-checks every metric before comparing it. For such a group IS NULL is true and
IS NOT NULL is false (written ISNULL(MAX(age)) and ISNOTNULL(MAX(age)) in HAVING).
🔴 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] NULL and COALESCE
over the MIN, MAX, AVG or 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.
Keys from JOIN UNNEST
An aggregate over the array is answered per group, including an aggregate of the grouping
field itself: MIN(items.quantity) grouped by items.quantity returns each group’s own value
(since 0.24.0; it answered NULL on every group before).
When the index mapping is known, an UNNEST key listed before a root key is grouped exactly
as the root-first spelling: GROUP BY items.quantity, o.customer_id and
GROUP BY o.customer_id, items.quantity return 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 an UNNEST key is not
answered per group yet. Aggregate a field of the array instead (COUNT(items.quantity)).
⚠️ With two UNNESTs in one statement, an aggregate counts the entries of its own array only:
the other array does not multiply its rows.
SELECTitems.quantity, MIN(items.quantity) AS lowest, SUM(items.price) AS total
FROM dql_orders o JOIN UNNEST(o.items) AS items
GROUP BYitems.quantity;
SELECTitems.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 BYitems.quantity, o.customer_id;
GROUP BY without an aggregate
GROUP BY with no aggregate in the SELECT list returns one row per group — the SQL-standard
spelling of DISTINCT:
SELECTprofile.cityAS city
FROM dql_users
GROUP BYprofile.city;
Positions are supported: GROUP BY 1 and ORDER BY 1 name the first SELECT item. A position
that names nothing (0, a negative, or one past the end of the SELECT list) is a parse error
naming the position. A column genuinely named with digits is unaffected, and a column named 1
is addressed as GROUP BY `1`.
SELECT aliases are supported: SELECT country AS pays ... GROUP BY pays groups by country,
and ORDER BY pays / HAVING pays <> 'x' address that group by the same name.
A constant is legal beside a GROUP BY and 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 a
SELECT alias, because the alias is the only name that group can be given.
LIMIT on a GROUP BY bounds the number of groups — it is pushed down as the
Elasticsearch terms size. On a multi-column GROUP BY it still answers at most LIMIT
groups: each level is sent with that size, and the rows are cut to the LIMIT once they are put
together (GROUP BY a, b LIMIT 2 answers two rows, not two per value of a).
On a multi-column GROUP BY, ORDER BY sorts 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 the SELECT or not:
GROUP BY city, created_at ORDER BY created_at DESC orders 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 the LIMIT; an aggregate the SELECT does not return is read for the sort
only. A NULL aggregate is placed where the same ORDER BY over one column places it: a MAX
with no value last descending and first ascending, any other NULL last 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 the LIMIT (GROUP BY city, created_at ORDER BY created_at DESC LIMIT 10 asks every city, and 10 dates in each).
LIMIT n OFFSET m on a GROUP BY skips 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 a GROUP BY key of an
UNNEST element, where the groups cannot be skipped in that order.
With no LIMIT, every level is sized at Elasticsearch’s search.max_buckets ceiling (65,536):
a grouping wider than that fails loudly rather than truncating silently.
Parent-Level Aggregations on Nested Arrays
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 (PARTITIONBYo.id) AS total_price
FROM dql_orders o
JOIN UNNEST(o.items) AS items
WHEREitems.quantity>=1
ORDER BYo.idASC;
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
}
]
Notes
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
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 ...), including COUNT(DISTINCT expr) OVER (PARTITION BY ...)
STDDEV(expr) OVER (PARTITION BY ...) and its _SAMP / _POP variants
VARIANCE(expr) OVER (PARTITION BY ...) and its _SAMP / _POP variants
FIRST_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) and PERCENTILE_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.
Basic window example
SELECT
product,
customer,
amount,
SUM(amount) OVER (PARTITIONBY product) AS sum_per_product,
COUNT(_id) OVER (PARTITIONBY product) AS cnt_per_product
FROM dql_sales
ORDER BY product, ts;
ROW_NUMBER / RANK / DENSE_RANK (ranking windows)
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.
SELECTname, salary,
ROW_NUMBER() OVER (PARTITIONBY department ORDER BY salary DESC) AS rn,
RANK() OVER (PARTITIONBY department ORDER BY salary DESC) AS r,
DENSE_RANK() OVER (PARTITIONBY 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, …)
Top-N per group (push-down via LIMIT inside OVER)
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:
SELECTname, salary,
RANK() OVER (PARTITIONBY department ORDER BY salary DESCLIMIT3) 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.
FIRST_VALUE / LAST_VALUE / ARRAY_AGG
SELECT
product,
customer,
amount,
SUM(amount) OVER (PARTITIONBY product) AS sum_per_product,
COUNT(_id) OVER (PARTITIONBY product) AS cnt_per_product,
FIRST_VALUE(amount) OVER (PARTITIONBY product ORDER BY ts ASC) AS first_amount,
LAST_VALUE(amount) OVER (PARTITIONBY product ORDER BY ts ASC) AS last_amount,
ARRAY_AGG(amount) OVER (PARTITIONBY product ORDER BY ts ASCLIMIT10) AS amounts_array
FROM dql_sales
ORDER BY product, ts;
Notes:
PARTITION BY defines the grouping key.
ORDER BY inside OVER defines the window ordering.
LIMIT inside ARRAY_AGG restricts the collected values.
Frame clauses (ROWS BETWEEN ...) are not exposed; the engine uses a default frame per function semantics.
Functions
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.
Numeric & Trigonometric
Arithmetic:
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)
Formatting & parsing:
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
Special functions:
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;
Trigonometric:
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;
String
Manipulation:
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
Pattern matching:
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'INname.raw) AS pos_o,
REGEXP_LIKE(name.raw, '.*o.*') AS has_o
FROM dql_users
ORDER BY id ASC;
Date & Time
Current :
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)
Extraction:
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:
EXTRACT(unit FROM date_or_timestamp)
Supported units include: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, etc.
Example:
SELECT id,
EXTRACT(YEARFROM birthdate) AS year_b,
EXTRACT(MONTHFROM birthdate) AS month_b
FROM dql_users;
Arithmetic:
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
Geospatial
POINT:
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:
ST_DISTANCE(location, POINT(48.8566, 2.3522))
Example:
CREATETABLEIFNOTEXISTS dql_geo (
id INTNOT NULL,
location GEO_POINT,
PRIMARY KEY (id)
);
SELECT id,
ST_DISTANCE(location, POINT(48.8566, 2.3522)) AS dist_paris
FROM dql_geo;
Conditional
CASE WHEN
CASE
WHEN condition THENvalue
[WHEN condition2 THEN value2]
[ELSE default]
END
COALESCE
COALESCE(a, b, c)
Returns the first non-null value.
NULLIF
NULLIF(a, b)
Returns NULL if a = b, otherwise a.
GREATEST / LEAST
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.
SELECTGREATEST(price_us, price_eu, price_uk) AS max_price FROM products;
SELECTLEAST(base_price, promo_price) AS best_price FROM orders;
Example:
SELECT id,
CASE
WHEN age >=50THEN'senior'
WHEN age >=30THEN'adult'
ELSE'young'
ENDAS age_group,
COALESCE(name, 'unknown') AS safe_name
FROM dql_users;
Type Conversion
CAST
CAST(valueASTYPE)
TRY_CAST
Returns NULL instead of failing on invalid conversion.
TRY_CAST('123'ASINT)
SAFE_CAST
Alias for TRY_CAST.
PostgreSQL-style operator
value::TYPE
Example:
SELECT id,
age::BIGINTAS age_bigint,
CAST(age AS DOUBLE) AS age_double,
TRY_CAST('123'ASINT) AS try_cast_ok,
SAFE_CAST('abc'ASINT) AS safe_cast_null
FROM dql_users;
Scroll & Pagination
For large result sets, the Gateway uses Elasticsearch scroll or search-after mechanisms depending on backend capabilities (Scroll Search).
Notes:
LIMIT and OFFSET are 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 no ORDER BY: without one the rows are
paged in _id order, and with one _id is the tie-breaker. Since 0.24.0, on Elasticsearch 6,
a statement without LIMIT over a pattern that includes an index created without any mapping is
read in full, even when that index is empty: the _id order no longer fails that index’s shards
Without ORDER BY, result ordering is not guaranteed
When some shards fail
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 400 when 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.
SHOW and DESCRIBE Commands
-- Tables
SHOW TABLES [LIKE 'pattern'];
SHOW TABLE table_name;
SHOW CREATETABLE table_name;
DESCRIBE TABLE table_name;
-- Pipelines
SHOW PIPELINES;
SHOW PIPELINE pipeline_name;
SHOW CREATE PIPELINE pipeline_name;
DESCRIBE PIPELINE pipeline_name;
-- Watchers
SHOW WATCHERS;
SHOW WATCHER STATUS watcher_name;
-- Enrich Policies
SHOW ENRICH POLICIES;
SHOW ENRICH POLICY policy_name;
-- Cluster
SHOW CLUSTER NAME;
-- License
SHOW LICENSE;
REFRESH LICENSE;
SHOW TABLES
SHOW TABLES [LIKE 'pattern'];
Returns a list of all tables with summary information (name, type, primary key, partitioning).
Returns a list of all enrich policies with their configurations.
*Example:
CREATETABLEIFNOTEXISTS dql_users (
id INTNOT NULL,
nameVARCHAR FIELDS(
raw KEYWORD
) OPTIONS (fielddata = true),
age INT,
birthdate DATE,
profile STRUCT FIELDS(
city VARCHAR OPTIONS (fielddata = true),
followers INT
)
);
CREATEORREPLACE 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
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
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
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
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.
Version Compatibility
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.
Limitations
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 UNNEST on ARRAY<STRUCT> is the single-index nested form, handled natively inside one index.
Since engine 0.24.0, subqueries in WHERE (IN / NOT IN / EXISTS / NOT EXISTS / scalar / quantified) and derived tables in FROM / JOIN are 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 the SELECT list, a subquery in HAVING, 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 a SELECT, 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 a WITH clause anywhere other than the top of a SELECT (not in a subquery body, CTAS, INSERT … SELECT or 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.