Use ES|QL subqueries with IN and NOT IN
An ES|QL query wrapped in parentheses can be used as a subquery on the
right-hand side of the IN and NOT IN
operators. The subquery can appear in the
WHERE command, the
EVAL command, and the
per-aggregate WHERE filter of
STATS and
INLINE STATS
The subquery compares a field, expression, or tuple of expressions against the
set of values it returns. This lets you match against the current results of
another query without running it separately and copying its values into a
literal IN list.
Use IN to keep rows whose values match subquery results and NOT IN to
exclude them. In EVAL, the same predicates produce a boolean column instead of
filtering rows.
... | WHERE <expression> IN (FROM index_pattern [| processing_commands]) | ...
... | WHERE (<e1>, <e2>[, ...]) IN (FROM index_pattern [| processing_commands]) | ...
... | EVAL <column> = <expression> IN (FROM index_pattern [| processing_commands]) | ...
... | STATS <agg> WHERE <expression> IN (FROM index_pattern [| processing_commands]) | ...
... | INLINE STATS <agg> WHERE <expression> IN (FROM index_pattern [| processing_commands]) | ...
NOT IN is supported in the same positions. The tuple form
EVAL and in the per-aggregate WHERE of STATS and INLINE STATS.
The subquery starts with a source command followed by zero or more piped
processing commands, all enclosed in parentheses. The source command is usually
FROM, and
ROW and
TS are also supported.
For a single-column IN subquery, the subquery must return exactly one column,
whose values are compared against the left-hand side of the IN subquery. For a
multi-column IN subquery, wrap two or more expressions in parentheses on the
left-hand side
(emp_no) IN (...) is still a single-column IN subquery, not a
multi-column IN subquery.
The outer query is not limited to FROM either: it can also start with ROW or
TS and still use an IN subquery.
An IN subquery is non-correlated: it runs independently and cannot reference
columns from the outer query. Because it runs at query time, its results reflect
the current state of the data.
Unlike a subquery in a FROM command,
which contributes rows to the combined result set, an IN subquery returns
either one column or a tuple of columns. The outer IN or NOT IN predicate
uses those values as its comparison set.
An IN subquery can itself contain another IN subquery, and multiple IN
subqueries can be combined with other predicates using AND, OR, and NOT.
An IN subquery can also sit inside
CASE,
COALESCE,
IS NULL,
IS NOT NULL,
==,
and !=
For the full list of supported source and processing commands inside a subquery, refer to ES|QL subqueries.
The following examples show how to use IN subqueries.
Use IN to keep only the rows whose value is contained in the subquery result:
FROM employees
| WHERE emp_no IN (FROM employees | WHERE salary > 70000 | KEEP emp_no)
| SORT emp_no
| KEEP emp_no
| emp_no:integer |
|---|
| 10007 |
| 10019 |
| 10027 |
| 10029 |
| 10033 |
| 10045 |
| 10097 |
| 10099 |
The subquery selects the emp_no of every employee earning more than 70000,
and the outer query keeps only those employees.
Use NOT IN to keep only the rows whose value is not contained in the subquery
result:
FROM employees
| WHERE emp_no NOT IN (FROM employees | WHERE salary < 10000 | KEEP emp_no)
| SORT emp_no
| KEEP emp_no
| LIMIT 3
| emp_no:integer |
|---|
| 10001 |
| 10002 |
| 10003 |
Here the subquery selects every employee earning less than 10000. No employee
matches, so the subquery returns an empty result. NOT IN therefore excludes
nothing and every employee is kept, so the first three rows by emp_no are
10001 through 10003.
Use STATS inside the subquery to compare against aggregated values:
FROM employees
| WHERE salary IN (FROM employees | STATS m = max(salary) by languages | KEEP m)
| KEEP emp_no, salary, languages
| SORT emp_no
| emp_no:integer | salary:integer | languages:integer |
|---|---|---|
| 10007 | 74572 | 4 |
| 10019 | 73717 | 1 |
| 10029 | 74999 | null |
| 10045 | 74970 | 3 |
| 10094 | 66817 | 5 |
| 10099 | 73578 | 2 |
The subquery computes the maximum salary per language group, and the outer
query keeps only the employees whose salary matches one of those maximums.
Multiple IN subqueries can be combined with other predicates using AND, OR,
and NOT:
FROM employees
| WHERE emp_no NOT IN (FROM employees | WHERE languages IN (1, 2) | KEEP emp_no)
AND emp_no IN (FROM employees | WHERE salary > 70000 | KEEP emp_no)
AND languages > 0
| SORT emp_no
| KEEP emp_no, languages, salary
| emp_no:integer | languages:integer | salary:integer |
|---|---|---|
| 10007 | 4 | 74572 |
| 10045 | 3 | 74970 |
| 10097 | 3 | 71165 |
This query keeps employees who do not speak the two languages, earn more than
70000, and have a known number of languages.
Use a lookup join inside the subquery before building the list of values:
FROM employees
| WHERE emp_no IN (
FROM employees
| EVAL language_code = languages
| LOOKUP JOIN languages_lookup ON language_code
| WHERE language_name == "German"
| KEEP emp_no
)
| SORT emp_no
| KEEP emp_no, first_name
| LIMIT 5
| emp_no:integer | first_name:keyword |
|---|---|
| 10003 | Parto |
| 10007 | Tzvetan |
| 10010 | Duangkaew |
| 10031 | null |
| 10036 | null |
The subquery joins each employee's languages code with the languages_lookup
index, keeps only German speakers, and returns their emp_no. The outer query
then keeps only those employees.
The subquery can start with a ROW
source command to match against an inline list of values:
FROM employees
| WHERE emp_no IN (ROW x = [10001, 10002, 10003] | MV_EXPAND x)
| SORT emp_no
| KEEP emp_no, first_name
| emp_no:integer | first_name:keyword |
|---|---|
| 10001 | Georgi |
| 10002 | Bezalel |
| 10003 | Parto |
The ROW command builds a multivalued field, MV_EXPAND turns it into one value
per row, and the outer query keeps only the employees whose emp_no is one of
those values.
The outer query can also start with ROW, using an IN subquery to keep only the
values that exist in another index:
ROW emp_no = [10001, 10002, 99999]
| MV_EXPAND emp_no
| WHERE emp_no IN (FROM employees | KEEP emp_no)
| SORT emp_no
| emp_no:integer |
|---|
| 10001 |
| 10002 |
The ROW command provides a list of candidate emp_no values, and the subquery
filters out 99999, which does not match any employee.
The subquery can start with a TS
source command to build the list of values from time series data. In this example
the outer query reads the downsampled k8s-downsampled index with FROM, while
the subquery uses TS over the raw k8s index:
FROM k8s-downsampled
| WHERE cluster IN (TS k8s
| STATS max_bytes = max(to_long(network.total_bytes_in)) BY cluster
| WHERE max_bytes > 10000
| KEEP cluster)
| STATS buckets = COUNT(*) BY cluster
| SORT cluster
| buckets:long | cluster:keyword |
|---|---|
| 9 | prod |
| 9 | qa |
The subquery aggregates the raw time series metrics to find the clusters whose peak
ingest exceeds a threshold, and the outer query then reports the downsampled buckets
for those clusters. The outer FROM and the TS subquery reference different
indices because a single index cannot be read as both a standard source (FROM)
and a time series source (TS) in the same query.
The outer query can also start with TS, so both the outer query and the subquery
read from time series data:
TS k8s
| WHERE cluster IN (TS k8s
| STATS max_bytes = max(to_long(network.total_bytes_in)) BY cluster
| WHERE max_bytes > 10000
| KEEP cluster)
| STATS max_bytes = max(to_long(network.total_bytes_in)) BY cluster
| SORT cluster
| max_bytes:long | cluster:keyword |
|---|---|
| 10277 | prod |
| 10797 | qa |
The subquery keeps only the clusters whose maximum ingested bytes exceed 10000,
and the outer query reports those clusters.
A subquery can union several source commands with the FROM (...) syntax, mixing
ROW,
FROM, and
TS branches. Each branch must
produce the same single column:
FROM k8s-downsampled
| WHERE cluster IN (FROM
(ROW cluster = "staging"),
(FROM employees | WHERE emp_no == 10001 | EVAL cluster = "prod" | KEEP cluster),
(TS k8s
| STATS max_bytes = max(to_long(network.total_bytes_in)) BY cluster
| WHERE max_bytes > 10500
| KEEP cluster)
)
| STATS buckets = COUNT(*) BY cluster
| SORT cluster
| buckets:long | cluster:keyword |
|---|---|
| 9 | prod |
| 9 | qa |
| 9 | staging |
The ROW branch contributes staging, the FROM branch contributes prod, and
the TS branch contributes qa. Their union is the list of values the outer query
matches against, so all three clusters are kept.
IN and NOT IN subqueries can be nested inside arbitrarily complex boolean
conditions built from AND, OR, and NOT:
FROM employees
| WHERE emp_no IN (FROM employees | WHERE salary > 73000 | KEEP emp_no)
OR (gender == "F" AND (salary > 70000 OR emp_no NOT IN (FROM employees | WHERE languages > 1 | KEEP emp_no)))
| SORT emp_no
| KEEP emp_no, gender, languages, salary
| LIMIT 15
| emp_no:integer | gender:keyword | languages:integer | salary:integer |
|---|---|---|---|
| 10007 | F | 4 | 74572 |
| 10009 | F | 1 | 66174 |
| 10019 | null | 1 | 73717 |
| 10023 | F | null | 47896 |
| 10024 | F | null | 64675 |
| 10027 | F | null | 73851 |
| 10029 | M | null | 74999 |
| 10041 | F | 1 | 56415 |
| 10044 | F | 1 | 39728 |
| 10045 | M | 3 | 74970 |
| 10092 | F | 1 | 25976 |
| 10099 | F | 2 | 73578 |
The outer query keeps an employee when either their emp_no is returned by the
first IN subquery (salaries above 73000), or they are female and either earn more
than 70000 or their emp_no is not returned by the NOT IN subquery.
An IN subquery can also appear inside a subquery in the FROM command.
Here the FROM command unions two employee sources, and the second branch is
filtered with an IN subquery that itself nests a NOT IN subquery:
FROM
(FROM employees | WHERE salary > 70000 | KEEP emp_no, first_name, salary, languages),
(FROM employees
| WHERE emp_no IN (
FROM employees
| WHERE salary NOT IN (
FROM employees | SORT salary DESC | LIMIT 3 | KEEP salary
)
| KEEP emp_no)
| WHERE languages == 1
| KEEP emp_no, first_name, salary, languages)
| SORT emp_no
| KEEP emp_no, first_name, salary, languages
| LIMIT 6
| emp_no:integer | first_name:keyword | salary:integer | languages:integer |
|---|---|---|---|
| 10005 | Kyoichi | 63528 | 1 |
| 10007 | Tzvetan | 74572 | 4 |
| 10009 | Sumant | 66174 | 1 |
| 10013 | Eberhardt | 48735 | 1 |
| 10019 | Lillian | 73717 | 1 |
| 10019 | Lillian | 73717 | 1 |
The first FROM branch keeps every employee earning more than 70000. The second
branch keeps employees who speak language one and whose emp_no is returned by
the IN subquery, which in turn excludes the three highest salaries with a nested
NOT IN subquery. The outer query reports the union of both branches.
IN subqueries can be used inside FORK
branches, so each branch can apply its own subquery-based filter:
FROM employees
| FORK (WHERE emp_no IN (FROM employees | WHERE salary > 74000 | KEEP emp_no) | KEEP emp_no, salary)
(WHERE emp_no IN (FROM employees | WHERE salary < 30000 | KEEP emp_no) | KEEP emp_no, salary)
| SORT emp_no, _fork
| KEEP emp_no, salary, _fork
| LIMIT 5
| emp_no:integer | salary:integer | _fork:keyword |
|---|---|---|
| 10007 | 74572 | fork1 |
| 10015 | 25324 | fork2 |
| 10026 | 28336 | fork2 |
| 10029 | 74999 | fork1 |
| 10035 | 25945 | fork2 |
The first FORK branch keeps the high earners returned by its IN subquery, the
second branch keeps the low earners returned by its IN subquery, and the _fork
column records which branch produced each row.
Use EVAL to store the IN result as a boolean column. Every input row is kept:
FROM employees
| EVAL m = emp_no IN (FROM employees | SORT emp_no ASC | LIMIT 3 | KEEP emp_no)
| WHERE emp_no <= 10004
| SORT emp_no ASC
| KEEP emp_no, m
| emp_no:integer | m:boolean |
|---|---|
| 10001 | true |
| 10002 | true |
| 10003 | true |
| 10004 | false |
The first three employees match the subquery, so m is true for those rows and
false for 10004.
Use an IN subquery in the per-aggregate WHERE of
STATS to include only
matching rows in that aggregation:
FROM employees
| STATS c = COUNT(*) WHERE emp_no IN (FROM employees | SORT emp_no ASC | LIMIT 3 | KEEP emp_no)
| c:long |
|---|
| 3 |
Multiple aggregations can each have their own IN filter, and you can mix them
with an unfiltered aggregation:
FROM employees
| STATS in_top3 = COUNT(*) WHERE emp_no IN (FROM employees | SORT emp_no ASC | LIMIT 3 | KEEP emp_no),
not_in_top3 = COUNT(*) WHERE emp_no NOT IN (FROM employees | SORT emp_no ASC | LIMIT 3 | KEEP emp_no),
total = COUNT(*)
| in_top3:long | not_in_top3:long | total:long |
|---|---|---|
| 3 | 97 | 100 |
The same per-aggregate filter works with
INLINE STATS. The
count is appended to every input row:
FROM employees
| KEEP emp_no
| INLINE STATS c = COUNT(*) WHERE emp_no IN (FROM employees | SORT emp_no ASC | LIMIT 3 | KEEP emp_no)
| WHERE emp_no <= 10005
| SORT emp_no
| emp_no:integer | c:long |
|---|---|
| 10001 | 3 |
| 10002 | 3 |
| 10003 | 3 |
| 10004 | 3 |
| 10005 | 3 |
Three employees match the subquery, so c is 3 on every returned row.
An IN subquery can be nested inside
CASE,
COALESCE,
IS NULL,
IS NOT NULL,
==,
and !=.
Use CASE to treat the subquery as a condition:
FROM employees
| WHERE CASE(emp_no IN (FROM employees | SORT emp_no ASC | LIMIT 3 | KEEP emp_no), true, false)
| SORT emp_no
| KEEP emp_no, first_name
| emp_no:integer | first_name:keyword |
|---|---|
| 10001 | Georgi |
| 10002 | Bezalel |
| 10003 | Parto |
In EVAL, CASE can map the same boolean result to another value:
FROM employees
| EVAL m = CASE(emp_no IN (FROM employees | SORT emp_no ASC | LIMIT 3 | KEEP emp_no), "yes", "no")
| WHERE emp_no <= 10004
| SORT emp_no ASC
| KEEP emp_no, m
| emp_no:integer | m:keyword |
|---|---|
| 10001 | yes |
| 10002 | yes |
| 10003 | yes |
| 10004 | no |
Use COALESCE to replace a possible null match with a default:
FROM employees
| WHERE COALESCE(emp_no IN (FROM employees | SORT emp_no ASC | LIMIT 3 | KEEP emp_no), false)
| SORT emp_no
| KEEP emp_no, first_name
| emp_no:integer | first_name:keyword |
|---|---|
| 10001 | Georgi |
| 10002 | Bezalel |
| 10003 | Parto |
Use IS NULL or IS NOT NULL to test whether the match itself is null. Here
every emp_no produces a definite true or false, so IS NULL matches no
rows:
FROM employees
| WHERE (emp_no IN (FROM employees | SORT emp_no ASC | LIMIT 3 | KEEP emp_no)) IS NULL
| STATS c = COUNT(*)
| c:long |
|---|
| 0 |
Compare the boolean result with == or !=. This example uses ==:
FROM employees
| WHERE (emp_no IN (FROM employees | SORT emp_no ASC | LIMIT 3 | KEEP emp_no)) == true
| SORT emp_no
| KEEP emp_no, first_name
| emp_no:integer | first_name:keyword |
|---|---|
| 10001 | Georgi |
| 10002 | Bezalel |
| 10003 | Parto |
Wrap two or more expressions in parentheses to compare a tuple against the subquery. Columns are matched by position, not by name, so the subquery must return the same number of columns in the same order, and each pair must have compatible types:
FROM employees
| WHERE (emp_no, salary) IN (FROM employees | SORT emp_no ASC | LIMIT 3 | KEEP emp_no, salary)
| SORT emp_no
| KEEP emp_no, first_name, salary
| emp_no:integer | first_name:keyword | salary:integer |
|---|---|---|
| 10001 | Georgi | 57305 |
| 10002 | Bezalel | 56371 |
| 10003 | Parto | 61805 |
The subquery returns the (emp_no, salary) pairs of the first three employees,
and the outer query keeps only those exact pairs.
Use NOT IN to exclude matching tuples:
FROM employees
| WHERE (emp_no, salary) NOT IN (FROM employees | SORT emp_no ASC | LIMIT 3 | KEEP emp_no, salary)
| SORT emp_no
| KEEP emp_no, first_name
| LIMIT 3
| emp_no:integer | first_name:keyword |
|---|---|
| 10004 | Chirstian |
| 10005 | Kyoichi |
| 10006 | Anneke |
You can store a tuple match as a boolean column in EVAL:
FROM employees
| EVAL m = (emp_no, salary) IN (FROM employees
| SORT emp_no ASC
| LIMIT 3
| KEEP emp_no, salary)
| WHERE m
| SORT emp_no
| KEEP emp_no
| emp_no:integer |
|---|
| 10001 |
| 10002 |
| 10003 |
Or use a tuple as a per-aggregate STATS filter:
FROM employees
| STATS c = COUNT(*) WHERE (emp_no, salary) IN (FROM employees | SORT emp_no ASC | LIMIT 3 | KEEP emp_no, salary)
| c:long |
|---|
| 3 |
A tuple IN can also sit inside CASE, COALESCE, IS [NOT] NULL, ==, and
!=:
FROM employees
| WHERE CASE((emp_no, salary) IN (FROM employees | WHERE emp_no == 10001 | KEEP emp_no, salary), true, false)
| SORT emp_no
| KEEP emp_no, salary
| emp_no:integer | salary:integer |
|---|---|
| 10001 | 57305 |
You can combine single-column and multi-column IN subqueries with AND and
OR.
An IN subquery can appear in the
WHERE command. It can also
appear in the EVAL command
and the per-aggregate WHERE filter of
STATS and
INLINE STATS
It is not supported in the other commands such as SORT,
LIMIT ... BY, as a STATS or INLINE STATS grouping expression, or as an
argument of an aggregation function such as
STATS c = SUM(CASE(x IN (...), 1, 0)).
A single-column IN subquery must return exactly one column. A multi-column
IN subquery must return the same number of columns as the left-hand tuple
An IN subquery can be combined with other predicates using AND, OR, and
NOT. It can also be nested inside
CASE,
COALESCE,
IS NULL,
IS NOT NULL,
==,
and !=
CASE, COALESCE, or IS [NOT] NULL, that whole
expression can itself be nested in another expression.
Other functions, or a computed left-hand side such as ABS(emp_no) IN (...)
are not supported. In a STATS or INLINE STATS per-aggregate WHERE, the
left-hand side also cannot be a grouping alias.
The subquery is executed independently and cannot reference columns from the outer query.
- ES|QL subqueries: canonical definition and supported commands.
- Use subqueries in a
FROMcommand: combine result sets from independently processed sources. WHEREcommand: full reference for theWHEREcommand.EVALcommand: compute a boolean column from anINsubquery.STATScommand: per-aggregateWHEREfilters, includingINsubqueries.INLINE STATScommand: per-aggregateWHEREfilters that preserve input rows.INoperator: the operator used to match against a list of literal values or a subquery.