Elasticsearch Esql
ElasticOfficialCreated by elastic
- Skill
- Tool integration
- Data and AI
- Apache-2.0
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a structured approach to executing ES|QL (Elasticsearch Query Language) queries against Elasticsearch deployments. It guides users through verifying cluster connectivity, discovering index schemas, and generating piped queries for data analysis, log exploration, and metric aggregation. The skill emphasizes version-aware query generation, ensuring that features are used only when compatible with the target cluster, and provides fallback patterns for older environments.
What it does
- Verifies cluster connectivity and detects deployment type (Stack vs. Serverless).
- Discovers index schemas and field types to ensure accurate query generation.
- Generates ES|QL queries using piped commands for filtering, transformation, and aggregation.
- Provides version-specific guidance and fallback patterns for advanced ES|QL features.
- Supports time series data analysis using specialized TS commands and TBUCKET functions.
- Executes queries via the elastic CLI and returns results in a tabular format.
When to use it
- When you need to perform ad-hoc analysis or log exploration on Elasticsearch data.
- When you want to aggregate metrics or create time-series visualizations.
- When you need to transform data using piped operations like EVAL, STATS, and SORT.
- When you are migrating from legacy Query DSL to the piped ES|QL syntax.
Advantages
- Ensures query accuracy by mandating schema discovery before generation.
- Prevents compatibility errors by checking cluster version against feature availability.
- Provides standardized fallback patterns for environments lacking newer features.
- Promotes clean, readable query structures using the piped ES|QL paradigm.
Usage examples
- “Show me the top 10 error logs from the last 24 hours.”
- The agent discovers the relevant index, generates an ES|QL query using FROM, WHERE, and LIMIT, and executes it via the elastic CLI.
- “Calculate the average CPU usage per hour for my metrics index.”
- The agent identifies the index as a time series, uses the TS command with TBUCKET, and returns the aggregated hourly averages.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/elastic/elasticsearch-esql/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip elasticsearch-esql.zip -d .agents/skills
Documentation by elastic
Elasticsearch ES|QL
Execute ES|QL queries against Elasticsearch: discover the schema, choose the right ES|QL feature for the task, generate the simplest correct query, and run it.
<!-- begin-partial: preamble -->
Environment Configuration
This skill executes Elasticsearch operations through the elastic CLI. If the
elastic CLI is not installed, tell the user what it is needed for. Do
not guess credentials, call the HTTP API directly, or attempt other workarounds.
This skill references operations in HTTP-shorthand form (e.g., GET /, GET /_cat/indices, GET /{index}/_mapping,
GET /{index}/_settings/index.mode, POST /_query). The Operations table at the end of this document
maps each shorthand to the equivalent elastic CLI command — always use the CLI rather than calling the HTTP API
directly.
<!-- end-partial: preamble -->
What is ES|QL?
ES|QL (Elasticsearch Query Language) is a piped query language for Elasticsearch. It is NOT the same as:
Elasticsearch Query DSL (JSON-based)
SQL
EQL (Event Query Language)
ES|QL uses pipes (|) to chain commands:
FROM index | WHERE condition | STATS aggregation BY field | SORT field | LIMIT n
Prerequisite: ES|QL requires
_sourceto be enabled on queried indices. Indices with_sourcedisabled (e.g.,"_source": { "enabled": false }) will cause ES|QL queries to fail.Version Compatibility: ES|QL was introduced in 8.11 (tech preview) and became GA in 8.14. Features like
LOOKUP JOIN(8.18+),MATCH(8.17+), andINLINE STATS(9.2+) were added in later versions. On pre-8.18 clusters, useENRICHas a fallback forLOOKUP JOIN(see generation tips).INLINE STATSand counter-fieldRATE()have no fallback before 9.2. Check references/esql-version-history.md for feature availability by version.Cluster Detection: Call
GET /to determine the cluster type and version:
build_flavor: "serverless"— Elastic Cloud Serverless.version.numbertracks the stack line under active development (next minor from main), so clients that only semver-compare may treat Serverless as “latest.” Do not useversion.numberto gate features: ifbuild_flavoris"serverless", assume all GA and preview ES|QL features are available.
build_flavor: "default"— Stack (self-managed or Cloud-hosted). Useversion.numberfor feature availability.Snapshot builds have
version.numberlike9.4.0-SNAPSHOT. Strip the-SNAPSHOTsuffix and use the major.minor for version checks. Snapshot builds include all features from that version plus potentially unreleased features from development — if a query fails with an unknown function/command, it may simply not have landed yet. Elastic employees commonly use snapshot builds for testing.
Process
Verify the connection and detect the deployment type. Call
GET /first. This confirms connectivity and detects whether the deployment is a Serverless project (all features available) or a versioned cluster (features depend on version). Thebuild_flavorfield is the authoritative signal — if it equals"serverless", ignore the reported version number and use all ES|QL features freely. If the call fails, stop and point the user at the CLI configuration instructions rather than guessing endpoints or credentials.Discover the schema (required — never guess index or field names). List candidate indices with
GET /_cat/indices(pass a pattern to narrow), then fetch field types for the chosen index withGET /{index}/_mapping.Always run schema discovery before generating queries. Index names and field names vary across deployments and cannot be reliably guessed. Even common-sounding data (e.g., "logs") may live in indices named
logs-test,logs-app-*, orapplication_logs. Field names may use ECS dotted notation (source.ip,service.name) or flat custom names — the only way to know is to check.Prefer simplicity: Query a single index unless the user explicitly asks for data across multiple sources. Do not combine indices with different schemas using
COALESCEunless specifically requested — pick the single most relevant index for the question. When multiple indices contain similar data, prefer the one with the most complete schema for the task at hand.Detect time series indices. Check the index mode with
GET /{index}/_settings/index.mode. If it istime_series, useTS <data-stream>(notFROM),TBUCKET(interval)(notDATE_TRUNC), and wrap counter fields withSUM(RATE(...)). Read the full TS section in Generation Tips before writing any time series query. For TSDS indices on 9.4+, prefer the in-language discovery commandsMETRICS_INFOandTS_INFO(both GA) over inspecting mappings — they enumerate the metric catalogue and the dimension labels of each time series directly, and are run as ES|QL queries viaPOST /_query. TreatMETRICS_INFOas authoritative formetric_type(counter/gauge/histogram) andfield_type(histogram,tdigest,exponential_histogramfor distribution metrics). Both must followTSand must precedeSTATS/SORT/LIMIT. See Time Series Queries:TS metrics-tsds | METRICS_INFO | SORT metric_name TS metrics-tsds | TS_INFO | KEEP metric_name, dimensions | SORT metric_nameChoose the right ES|QL feature for the task. Version gate (mandatory on Stack): before writing any query, compare
version.numberto the version tag of every command and function you plan to use. For each feature newer than the cluster (for example a 9.5 feature on 9.4), use its fallback from the table below or tell the user it is unavailable on their version — never emit it, and never silently drop the part of the question it answers. Serverless has every released GA and preview feature. Then match the user's intent to the most appropriate ES|QL feature, preferring a single advanced query over multiple basic ones:"find patterns," "categorize," "group similar messages" →
CATEGORIZE(field)"spike," "dip," "anomaly," "when did X change" →
CHANGE_POINT value ON key"trend over time," "time series" →
STATS ... BY BUCKET(@timestamp, interval)orTSfor TSDB"PromQL", "Prometheus query/dashboard/alert",
sum by (instance) (...), label matchers like{cluster="prod"}→PROMQLsource command (GA in 9.5+/Serverless, preview in 9.4); see PROMQL Command. PreferTSfor native ES|QL phrasing."search," "find documents matching" →
MATCH(default),QSTR(advanced boolean),KQL(Kibana migration). For content/document relevance search, follow the ES|QL Search Strategy"count," "average," "breakdown" →
STATSwith aggregation functions"approximate," "estimate," "rough numbers," "fast/cheap stats on huge data" →
SET approximation=true;before aSTATSquery (GA in 9.5+/Serverless, preview in 9.4); see Query Approximation"random sample," "random subset of rows" →
| SAMPLE 0.1(row probability; preview 8.19/9.1, GA 9.5), thenLIMIT nif a row count is asked; there is noRANDOM()function"latest/first value per group," "most recent status per host" →
STATS LAST(field, @timestamp) BY group(orFIRST), notSORT+LIMIT 1allow list, deny list, "exclude known IPs/users" from another index →
WHERE field [NOT] IN (FROM idx | KEEP col)(preview 9.5); useLOOKUP JOINonly when you need the other index's columns"unique combinations," "no duplicates" →
STATS BY a, b; Serverless only:KEEP a, b | DEDUP(preview)IPs mapped as
keyword→ convert withTO_IP()beforeCIDR_MATCH, IP comparisons,IN (FROM …), orLOOKUP JOINon anipfield; for the last twoEVAL ip = TO_IP(src)first (the left side must be a column)"country/city of an IP," "geolocate IPs," ASN →
| IP_LOCATION geo = ip_fieldpipe command (preview 9.5)parse URLs (domain, path, query) →
| URI_PARTS parts = url_fieldpipe command (preview 9.4), notDISSECT/GROK"in my local time," "per day in New York" →
SET time_zone = "America/New_York";(9.4+), not manual offsetsflattenedfield sub-keys (e.g. OTelresource.attributes) →FIELD_EXTRACT(field, "host.name")(preview 9.5), notfield.host.name; it returnskeyword, so cast (TO_INTEGER(...)) before numeric comparisonsembed text/images at query time for vector search →
EMBEDDING(value, inference_id)(preview 9.5) orTEXT_EMBEDDING(text, inference_id)insideKNNquery files in S3 (Parquet/CSV/NDJSON) without ingesting, or a named federated dataset →
FROM <dataset>via Data Federation (experimental 9.5, not for production; ECH and self-managed only, not Serverless); only when asked, and warn
9.5 or Serverless-only feature Fallback on Stack without the feature DEDUP(Serverless preview only)LIMIT 1 BY cols(9.4+;SORTfirst picks the kept row) orSTATS BY colsWHERE x NOT IN (FROM idx | KEEP c)LOOKUP JOIN idx ON x == c | WHERE c IS NULL(IN:IS NOT NULL);ONexpressions need 9.2+, elseRENAME x AS cfirstIP_LOCATIONNo query-time GeoIP: ENRICHwith an IP-range enrich policy, or the ingestgeoipprocessorFIELD_EXTRACT(or an unmapped key)FROM idx METADATA _source | EVAL v = JSON_EXTRACT(_source, "$.labels.key")— notlabels.key,labels["key"], orJSON_EXTRACT(labels, …);COALESCEwith"$.labels[\"k.sub\"]"for pre-dotted keysRead only the reference for the feature you need before generating the query (the bold "read before" ones are mandatory for their feature). Do not read every reference up front; open another only if the query fails:
Generation Tips - key patterns (TS/TBUCKET/RATE, per-agg WHERE, LOOKUP JOIN, CIDR_MATCH), common templates, and ambiguity handling
Time Series Queries - read before any TS query: inner/outer aggregation model, TBUCKET syntax, RATE constraints, histogram metrics
PROMQL Command — read before any PROMQL query: options, output schema, handling unsupported PromQL, and
PROMQLvsTSdecision matrix (GA in 9.5+/Serverless, preview in 9.4)ES|QL Complete Reference - full syntax for all commands and functions
ES|QL Search Strategy — for content/document relevance search (retrieve → fuse → rerank)
ES|QL Search Reference — for full-text search function syntax (MATCH, QSTR, KQL, scoring)
Query Approximation — read before using
SET approximation: output columns, sampling/confidence-level tuning, unsupported functions and patterns (GA in 9.5+/Serverless, preview in 9.4)
Generate the query following ES|QL syntax. Prefer the simplest query that answers the question — do not add extra indices, fields, or transformations unless the user asks for them. Only include fields in
KEEPthat directly answer the question. Do not add extra filter conditions beyond what the user specified (e.g., don't addOR level == "ERROR"when the user just said "errors").Start with
FROM index-pattern(orTS index-patternfor time series indices)Add
WHEREfor filtering (useTRANGEfor time ranges on 9.3+)Use
EVALfor computed fieldsUse
STATS ... BYfor aggregationsFor time series metrics:
TSwithSUM(RATE(...))for counters,AVG(...)for gauges, standard aggregations (SUM,AVG,PERCENTILE, … — not*_OVER_TIME) for histogram metrics, andTBUCKET(interval)for time bucketing — see the TS section in Generation Tips and Histogram MetricsFor detecting spikes, dips, or anomalies, use
CHANGE_POINTafter time-bucketed aggregationAdd
SORTandLIMITas neededRe-check the version gate before answering: on Stack 9.4 or earlier the query must not contain
IN (FROM,IP_LOCATION,FIELD_EXTRACT, or any other 9.5 feature, and on any Stack noDEDUP— rewrite with the fallback.
Execute the query with
POST /_query. Request tabular (TSV) output for clean, decoration-free results that are easy to read and post-process.
ES|QL Quick Reference
Version availability: This section omits version annotations for readability. Check ES|QL Version History for feature availability by Elasticsearch version.
Basic Structure
FROM index-pattern
| WHERE condition
| EVAL new_field = expression
| STATS aggregation BY grouping
| SORT field DESC
| LIMIT nCommon Patterns
Filter and limit:
FROM logs-*
| WHERE @timestamp > NOW() - 24 hours AND level == "error"
| SORT @timestamp DESC
| LIMIT 100Aggregate by time: For time series (TSDS) indices, prefer TS with TRANGE and TBUCKET over FROM +
DATE_TRUNC (see the time series section below).
TS metrics-*
| WHERE TRANGE(7 days)
| STATS avg_cpu = AVG(cpu.percent) BY bucket = TBUCKET(1 hour)
| SORT bucket DESCTop N with count:
FROM web-logs
| STATS count = COUNT(*) BY response.status_code
| SORT count DESC
| LIMIT 10Text search (8.17+): Use MATCH as the default for full-text search instead of LIKE/RLIKE — it is significantly
faster and supports relevance scoring. MATCH on a text field is usually sufficient on its own — do not add redundant
keyword equality filters (e.g., category == "X") alongside MATCH unless the user explicitly requests filtering. Use
QSTR only when you need advanced boolean logic, wildcards, or multi-field searches in a single expression. The first
argument to MATCH must be one real field name — not a string listing several fields (e.g. "title,content") and
not multiple field arguments; combine fields with MATCH(a, "q") OR MATCH(b, "q"). KQL is available from 8.18/9.0+.
For content/document search use cases, follow the ES|QL Search Strategy. See
ES|QL Search Reference for the full function guide.
FROM documents METADATA _score
| WHERE MATCH(content, "search terms")
| SORT _score DESC
| LIMIT 20String extraction: Use DISSECT for structured delimiter-based patterns (preferred — produces named fields) and
GROK for regex-based extraction. For simple cases, SUBSTRING(s, start, len) for fixed-position extraction,
SPLIT(s, delim) to split into a multivalue, LOCATE(substr, s) to find a character position. SPLIT returns a
multivalue — use MV_FIRST, MV_LAST, or MV_SLICE to pick elements. INSTR and STRPOS do not exist — use
LOCATE. REGEXP_EXTRACT does not exist — use GROK.
// Extract domain from email using DISSECT (preferred — produces named fields)
FROM customers
| DISSECT email "%{local}@%{domain}"
| STATS count = COUNT(*) BY domain
// Alternative: extract domain from email using SPLIT
FROM customers
| EVAL domain = MV_LAST(SPLIT(email, "@"))
| STATS count = COUNT(*) BY domainLog categorization (Platinum license): Use CATEGORIZE to auto-cluster log messages into pattern groups. Prefer
this over running multiple STATS ... BY field queries when exploring or finding patterns in unstructured text.
CATEGORIZE(field) must be the first grouping key in STATS ... BY — any other grouping (a time bucket, a service
name) has to come after it: BY category = CATEGORIZE(message), bucket = BUCKET(@timestamp, 1 hour), never the reverse
(it fails to parse).
FROM logs-*
| WHERE @timestamp > NOW() - 24 hours
| STATS count = COUNT(*) BY category = CATEGORIZE(message)
| SORT count DESC
| LIMIT 20Change point detection (Platinum license): Use CHANGE_POINT to detect spikes, dips, and trend shifts in a metric
series. Prefer this over manual inspection of time-bucketed counts.
FROM logs-*
| STATS c = COUNT(*) BY t = BUCKET(@timestamp, 30 seconds)
| SORT t
| CHANGE_POINT c ON t
| WHERE type IS NOT NULLTime series metrics: With TS, use TRANGE for time filtering (9.3+) or omit it entirely — do not add a
redundant WHERE @timestamp > NOW() - ... alongside TBUCKET. The TBUCKET duration defines the aggregation window.
// Counter metric: SUM(RATE(...)) with TBUCKET(duration)
TS metrics-tsds
| WHERE TRANGE(1 hour)
| STATS SUM(RATE(requests)) BY TBUCKET(1 hour), host
// Gauge metric: AVG(...) — no RATE needed
TS metrics-tsds
| STATS avg_cpu = AVG(cpu) BY service.name, bucket = TBUCKET(5 minutes)
| SORT bucket
// Histogram metric: standard aggregation (merge); cast for wildcard/mixed streams
TS metrics-*
| STATS total_gc = SUM(jvm.gc.duration::exponential_histogram) BY TBUCKET(1 hour), service.nameTime series with PromQL syntax (GA in 9.5+/Serverless, preview in 9.4): Use the PROMQL source command when the
user explicitly asks for PromQL, references Prometheus syntax (sum by (instance) (...), label matchers like
{cluster="prod"}), or is migrating a Prometheus dashboard or alert. Otherwise prefer TS. Its output flows into the
rest of the ES|QL pipeline; the time column is step, and there is no @timestamp column. Not every PromQL construct
is supported yet. Read PROMQL Command before writing a PROMQL query: it mirrors the
Elasticsearch docs, including best practices, single-value results, and the unsupported constructs.
// Adaptive Kibana query — date picker drives time range and step
PROMQL index=metrics-* http_rate=(sum(rate(http_requests_total)))
// (9.5+; Serverless) Current value for a metric or gauge panel — instant query at the end of the date picker range
PROMQL index=metrics-* time=?_tend load=(avg(node_load1))
// Named result, post-processed with ES|QL
PROMQL index=k8s step=1h bytes=(max by (cluster) (network.bytes_in))
| STATS max_bytes = MAX(bytes) BY cluster
| SORT clusterData enrichment with LOOKUP JOIN: The basic ON clause matches fields by name in both indices
(LOOKUP JOIN idx ON field_name). When the join key has a different name in the source, use RENAME first to align
names. 9.2+ tech preview also supports expression predicates (ON expr == expr); see
ES|QL Complete Reference for details. After LOOKUP JOIN, lookup columns are available
by their original field names — do not table-qualify them (e.g., write threat_level, not
threat_intel.threat_level). Ordering tip: when the question asks for top-N results, SORT and LIMIT before
LOOKUP JOIN to reduce enrichment cost. For general listings or full enrichment, place LOOKUP JOIN right after
FROM/WHERE.
// Field name mismatch — RENAME before joining
FROM support_tickets
| RENAME product AS product_name
| LOOKUP JOIN knowledge_base ON product_name
// Aggregate, limit, THEN enrich (top-N only)
FROM orders
| STATS total_spent = SUM(total) BY customer_id
| SORT total_spent DESC
| LIMIT 3
| LOOKUP JOIN customers_lookup ON customer_id
| KEEP name, customer_id, total_spent
// Multi-field join (9.2+)
FROM application_logs
| LOOKUP JOIN service_registry ON service_name, environment
| KEEP service_name, environment, owner_teamMultivalue field filtering: Use MV_CONTAINS to check if a multivalue field contains a specific value. Use
MV_COUNT to count values.
// Filter by multivalue membership
FROM employees
| WHERE MV_CONTAINS(languages, "Python")
// Find entries matching multiple values
FROM employees
| WHERE MV_CONTAINS(languages, "Java") AND MV_CONTAINS(languages, "Python")
// Count multivalue entries
FROM employees
| EVAL num_languages = MV_COUNT(languages)
| SORT num_languages DESCChange point detection (alternate example): Use when the user asks about spikes, dips, or anomalies. Requires
time-bucketed aggregation, SORT, then CHANGE_POINT.
FROM logs-*
| STATS error_count = COUNT(*) BY bucket = DATE_TRUNC(1 hour, @timestamp)
| SORT bucket
| CHANGE_POINT error_count ON bucket AS type, pvalueApproximate STATS (GA in 9.5+/Serverless, preview in 9.4): Prepend SET approximation=true; to a STATS query to
get fast estimates via sampling and extrapolation on large datasets when exact values are not required. The result adds
_approximation_confidence_interval(col) and _approximation_certified(col) columns per estimated quantity — report
those bounds, do not present estimates as exact. COUNT_DISTINCT, MIN, MAX, FIRST, LAST, TOP (and a few
others) are not supported and fall back to exact execution; use the SAMPLE command for those. Pipelines with 2+
STATS, or using the TS/PROMQL source command, also fall back. To tune it, pass a map instead of true:
SET approximation={"confidence_level":0.95}; or SET approximation={"rows":N}; (N ≥ 10,000), or both in one map —
never SET approximation=true, confidence_level=0.95;. See Query Approximation.
SET approximation=true;
FROM web_traffic
| WHERE @timestamp >= NOW() - 1 week
| STATS total_hits = COUNT(*), avg_load_time = AVG(page_load_ms) BY country_code
| SORT total_hits DESC
| LIMIT 5Full Reference
For complete ES|QL syntax including all commands, functions, and operators, read:
ES|QL Search Reference - Full-text search: MATCH, QSTR, KQL, MATCH_PHRASE, scoring, semantic search
ES|QL Search Strategy - Relevance search strategy for content indices: retrieve → fuse → rerank
ES|QL Version History - Feature availability by Elasticsearch version
Query Patterns - Natural language to ES|QL translation
Generation Tips - Best practices for query generation
Time Series Queries - TS command, time series aggregation functions, TBUCKET
PROMQL Command - PromQL source command for TSDS indices (GA in 9.5+/Serverless, preview in 9.4)
Query Approximation - Approximate STATS via sampling/extrapolation (GA in 9.5+/Serverless, preview in 9.4)
DSL to ES|QL Migration - Convert Query DSL to ES|QL
Error Handling
When query execution fails, read the error message from Elasticsearch and correct the query. Common issues:
Field doesn't exist → Always inspect the mapping (
GET /{index}/_mapping) and list indices (GET /_cat/indices) before writing a query. Never guess field or index names — they vary across deployments.Type mismatch → Use type conversion functions (TO_STRING, TO_INTEGER, etc.)
Syntax error → Review ES|QL reference for correct syntax. Always use double quotes for strings, never single quotes.
Reserved keywords → Never use
first,last,by,in,is,on,with(orand,or,not,like,rlike,null,true,false,asc,desc,nulls) bare as a column name or dotted segment. Rename aliases (first_seen) or backquote real field names. InsideFUSE, backquote a column namedscore,keyorgroupafterSCORE BY/KEY BY/GROUP BY. Backquoting any identifier always works; on an unexplained parse error on a name, backquote it. See Generation Tips.No results → Check time range and filter conditions
Wrong function name → ES|QL uses underscored names:
STD_DEV()notSTDDEV(),MEDIAN_ABSOLUTE_DEVIATION()notMAD(). UseCONCAT()for strings, not+. UseCASE(cond, val, ...)notCASE WHEN...THEN...END.Wrong date part →
DATE_EXTRACTuses ES|QL part names:"hour_of_day"not"hour","day_of_month"not"day","month_of_year"not"month". UseDATE_DIFF("day", start, end)for date arithmetic, not subtraction.
Examples
Each example follows the process: inspect the mapping first, then write the simplest correct query.
"Top 10 source IPs by request count in the last hour" — filter by time window, then aggregate and rank:
FROM logs-*
| WHERE @timestamp > NOW() - 1 hour
| STATS requests = COUNT(*) BY source.ip
| SORT requests DESC
| LIMIT 10"Average response time per service, only for 5xx responses" — filter to errors before aggregating:
FROM traces-*
| WHERE http.response.status_code >= 500
| STATS avg_ms = AVG(duration_ms) BY service.name
| SORT avg_ms DESC"Error count per day for the last week" — bucket by day with DATE_TRUNC:
FROM logs-*
| WHERE log.level == "error" AND @timestamp > NOW() - 7 days
| STATS errors = COUNT(*) BY day = DATE_TRUNC(1 day, @timestamp)
| SORT day ASCGuidelines
Inspect before querying. Read the mapping (
GET /{index}/_mapping) and list indices (GET /_cat/indices) before writing a query — never guess field or index names.Filter early. Put
WHEREbeforeSTATSso aggregation runs over the smallest row set.Always bound results. End exploratory queries with
LIMIT.Quote correctly. Use double quotes for string literals, never single quotes.
Respect version gating. Confirm feature availability with
GET /(build_flavor,version.number) and references/esql-version-history.md before using newer commands such asLOOKUP JOINorINLINE STATS.Correct on error, do not guess. Read the Elasticsearch error, fix the specific issue, and re-run.
Operations
| HTTP API (shorthand) | elastic CLI command |
|---|---|
GET / | elastic es info |
GET /_cat/indices | elastic es cat indices --index '<pattern>' |
GET /{index}/_mapping | elastic es indices get-mapping --index '<index>' |
GET /{index}/_settings/index.mode | elastic es indices get-settings --index '<index>' --name index.mode |
GET /_resolve/index/{name} | elastic es indices resolve-index --name '<name>' |
POST /_query | elastic es esql query --format tsv --query "<esql>" |
Files
- ATTRIBUTION.mdAdded by AutoGineer977 B
- LICENSELicence file added by AutoGineer11.1 KiB
- SKILL.md28.3 KiB
- references/dsl-to-esql-migration.md38.5 KiB
- references/esql-reference.md100.8 KiB
- references/esql-search-strategy.md10.6 KiB
- references/esql-search.md26.3 KiB
- references/esql-version-history.md46.4 KiB
- references/generation-tips.md31.9 KiB
- references/promql-command.md26.0 KiB
- references/query-approximation.md11.2 KiB
- references/query-patterns.md14.7 KiB
- references/time-series-queries.md31.3 KiB
references/esql-reference.md
ES|QL Complete Reference
ES|QL (Elasticsearch Query Language) is a piped query language for filtering, transforming, and analyzing data in
Elasticsearch. It uses pipes (|) to chain commands together.
Serverless vs Stack: Version annotations in this document (e.g., "9.2+") apply to Elastic Stack (self-managed and Cloud-hosted). Detect cluster type via
build_flavorin theGET /response:"serverless"means all GA and preview features are available — do not gate onversion.numberfor Serverless (it tracks the next minor from main; semver-only checks may treat it as “latest”). For Stack, useversion.number(strip any-SNAPSHOTsuffix) for feature checks.
Table of Contents
Query Structure
Query Directives
Source Commands
Processing Commands
Aggregate Functions
Time Series Aggregation Functions
String Functions
Math Functions
Date/Time Functions
Type Conversion Functions
IP Functions
Spatial Functions
Dense Vector Functions
Multivalue Functions
Conditional Functions
Full-Text Search Functions
Operators
Syntax Details
Metadata Fields
Best Practices
Example Queries
Query Structure
source-command
| processing-command1
| processing-command2
| ...An ES|QL query starts with a source command followed by zero or more processing commands separated by pipes.
Query Directives
Query directives modify the behavior of an ES|QL query. They appear before the source command.
SET (preview 9.3, GA 9.4)
Controls query-level settings. Every SET directive must end with a semicolon before the source command. Since 9.5, SET
directives and request-body parameters are unified — the same parameter can be specified in either location, with the
request body taking precedence when both are specified.
Syntax:
SET setting = "value"; [SET setting = "value";]
source-command
| processing-commandsunmapped_fields (9.3+ preview, GA in 9.5) -- controls how unmapped fields are treated. Accepted values are
"default", "nullify", and "load" (case-insensitive); any other value is rejected:
"default"-- the query fails if it references a fully unmapped field (this is the default)"nullify"-- treats referenced unmapped fields as null values"load"(preview 9.4, GA 9.5) -- loads unmapped fields from stored_sourceaskeywordtype (or nullifies if absent from_source). Partially mapped fields are also loaded from_sourcewhere unmapped."load"works inside FROM subqueries and views on 9.4; inside FORK it needs 9.5+ (9.4 rejects it).PROMQLand subfields offlattenedparents are not supported with"load".Full-text functions on a field loaded via
"load"need an explicitTO_TEXT(9.5+): writeWHERE MATCH(TO_TEXT(note), "quota")—MATCH(note, "quota")silently matches nothing because the loaded value iskeyword. Partially mappedtextfields also staynullwhere unmapped unless wrapped inTO_TEXT.
time_zone (9.4+ GA; Serverless) -- sets the default timezone for the query, overriding UTC default. Accepts any
IANA timezone string or UTC offset. Applies to all date/time operations: DATE_TRUNC, DATE_FORMAT, NOW(), etc.
approximation (9.5+ GA; Serverless; preview in 9.4) -- approximates STATS aggregations via random sampling and
extrapolation, returning estimates with confidence intervals for far faster results on large datasets:
true-- enable approximation with default settings (1,000,000 sampled rows when grouped, 100,000 otherwise; central 90% confidence interval)false(default) -- exact executionmap value -- enable with custom settings:
{"rows":N}(sampled rows,N≥ 10,000) and/or{"confidence_level":X}(default0.90;nulldisables interval computation)
Adds _approximation_confidence_interval(col) and _approximation_certified(col) columns per estimated quantity.
COUNT_DISTINCT, MIN, MAX, FIRST, LAST, TOP, and several others are not supported and fall back to exact
execution — use the SAMPLE command for those. See query-approximation.md for the
full reference.
column_metadata (preview 9.5; Serverless preview) -- true adds _meta properties to the response columns;
currently only _meta.bucket (interval and unit) for BUCKET columns. Defaults to false.
project_routing (Serverless only; unavailable on Stack) -- limits a cross-project search to projects matching a
project-tag expression, e.g. SET project_routing="_alias:my-project";. Stack clusters reject it. Elasticsearch API
keys reach only the origin project; routing to linked projects needs a Cloud API key.
Examples:
SET unmapped_fields = "nullify";
FROM employees
| KEEP emp_no, foo
| SORT emp_no
| LIMIT 1
SET time_zone = "America/Los_Angeles";
FROM error_triage
| EVAL hour = DATE_TRUNC(1 hour, @timestamp)
| STATS errors = COUNT(*) BY hour, service
| SORT hour DESC
SET time_zone = "+05:00";
TS k8s
| WHERE @timestamp == "2024-05-10T00:04:49.000Z"
| STATS BY @timestamp, bucket = TBUCKET(3 hours)
SET approximation = true;
FROM web_traffic
| WHERE @timestamp >= NOW() - 1 week
| STATS total_hits = COUNT(*), avg_load_time = AVG(page_load_ms) BY country_code
| SORT total_hits DESC
| LIMIT 5
SET approximation = {"confidence_level": 0.95};
FROM web_traffic
| STATS avg_load_time = AVG(page_load_ms)When to use:
unmapped_fieldsis useful when querying across multiple indices where some indices may not have all fields mapped.time_zoneshifts date functions and display to a non-UTC zone. There is no per-function timezone argument —DATE_TRUNC(1 hour, @timestamp, "America/Los_Angeles")does not work.approximationtrades exactness for speed on largeSTATSsummaries — see query-approximation.md.Restriction:
SETdirectives cannot be used inside view definitions. The caller must applySETwhen querying the view.
Source Commands
Source commands produce tables, typically from Elasticsearch data.
FROM
Retrieves data from indices, data streams, or aliases.
Syntax:
FROM index_pattern [METADATA fields]Examples:
// Basic usage
FROM logs-*
// Multiple indices
FROM employees-00001, other-employees-*
// With metadata
FROM logs-* METADATA _id, _index
// Date math
FROM <logs-{now/d}>
// Cross-cluster search
FROM cluster_one:logs-*, cluster_two:logs-*Subqueries (preview in 9.4, GA in 9.5; Serverless): FROM supports parenthesized subqueries with UNION ALL
semantics. Each branch is a complete ES|QL pipeline. Columns present in one branch but not another are filled with
null. In 9.5+, ROW and TS are also valid as subquery source commands.
// Combine logs from different indices with independent pipelines
FROM
(FROM web_logs
| WHERE status_code >= 500
| KEEP @timestamp, message, service.name),
(FROM app_logs
| WHERE level == "error"
| KEEP @timestamp, message, service.name)
| STATS errors = COUNT(*) BY service.name
// Mix bare index patterns and subqueries
FROM raw_index, (FROM other_index | WHERE active == true | KEEP id, name)Subquery constraints:
Non-correlated only — branches cannot reference columns from the outer query
Columns with the same name must have compatible types across branches
FORKcannot be used inside or after subqueries
Subqueries vs FORK: Different data sources → subqueries. Same data, different analyses → FORK.
Note: Without explicit LIMIT, queries default to 1000 rows (or whatever the cluster setting
esql.query.result_truncation_default_size is set to).
ROW
Creates a row with literal values. Useful for testing.
Syntax:
ROW column1 = value1 [, column2 = value2, ...]Examples:
ROW a = 1, b = "two", c = null
ROW x = [1, 2, 3]
ROW greeting = "hello", pi = 3.14159Intra-row field references (9.4+; Serverless): Columns defined earlier in the same ROW can be referenced by later
columns:
ROW a = 5, b = a * 2, c = a + bViews (preview 9.4)
Views are virtual indices backed by ES|QL queries. Query a view with FROM view_name like any index. Views are managed
via the /_query/view REST API.
Create / update a view:
PUT /_query/view/active_employees
{
"query": "FROM employees | WHERE is_active == true | KEEP emp_no, name, department"
}Query a view:
FROM active_employees
| STATS headcount = COUNT(*) BY departmentConstraints:
SETdirectives cannot be used inside view definitions; the caller appliesSETwhen queryingA caller's
SET unmapped_fields = "load"applies to fields the view's query references (9.4+)Views resolve across Cross-Project Search boundaries (9.5+)
Views are available on both Stack and Serverless
TS
Retrieves data from time series data streams (TSDS). Similar to FROM but enables time series aggregation functions in
STATS and targets only time series indices. Preview from 9.2 to 9.3, GA since 9.4; GA on Elastic Cloud Serverless.
Syntax:
TS index_pattern [METADATA fields]Key behavior:
Enables time series aggregation functions (
RATE,AVG_OVER_TIME, etc.) in the firstSTATScommandTime series functions are evaluated per time series first, then aggregated by group using an outer function
If no inner time series function is specified, the default depends on field type:
LAST_OVER_TIME()for numeric/gauge fields; histogram merge forexponential_histogram/tdigest(plainhistogramneeds a cast — see Histogram Metrics)Cannot be combined with
FORKbeforeSTATSis appliedWhen the query has no
STATS,TSreturns rows sorted by@timestampdescending by defaultWhen the first
STATSafterTSuses a bare time series function (not wrapped in an outer aggregation likeAVG()/SUM()), results are implicitly grouped by every dimension and include a_timeseriesJSON column. UseBY WITHOUT(dim, ...)(GA in 9.4) to narrow this grouping. Bare dimension columns inBYare rejected; only grouping functions (TBUCKET,WITHOUT) are allowed alongside a bare time series function.
Examples:
// Rate of search requests per host per hour
TS metrics
| WHERE @timestamp >= NOW() - 1 hour
| STATS SUM(RATE(search_requests)) BY TBUCKET(1 hour), host
// Gauge — average of last values per time series (implicit LAST_OVER_TIME)
TS metrics
| STATS AVG(memory_usage)
// Average of per-time-series averages (explicit inner function)
TS metrics
| STATS AVG(AVG_OVER_TIME(memory_usage))
// Bare time series function — group by every dimension except `pod` (9.4+ GA)
TS k8s
| STATS total_cost = SUM(network.cost) BY WITHOUT(pod)
| SORT total_costBest practices:
Add a time range filter on
@timestampto limit data volumeUse
TSinstead ofFROMfor aggregations on time series dataAvoid aggregating metrics with different dimensional cardinalities in the same query
PROMQL
Queries time series data streams (TSDS) using Prometheus Query Language (PromQL) instead of ES|QL syntax. Like TS,
it produces a table that the rest of the ES|QL pipeline can process. GA on Elastic Cloud Serverless and Elastic
Stack 9.5+, preview in 9.4. See promql-command.md for the full reference.
Syntax:
PROMQL [ <option> ... ] [ <result_name> = ] ( <PromQL expression> )Options:
index— indices/streams/aliases (defaultmetrics-*)step— query resolution step widthbuckets— target bucket count for auto-step (default100, mutually exclusive withstep)start,end— explicit time range (defaults to Kibana date picker, otherwise unrestricted)time(9.5+; Serverless) — evaluate once at that time (instant query); cannot be combined withstep,buckets,start, orendscrape_interval— expected metric collection interval (default1m); used for the implicit range selector window<result_name>=( ... )— name the metric output column
Output columns:
The PromQL expression (or
<result_name>) asdouble— the metric valuestep(date) — timestamp for each evaluation step (instant query: thetime); there is no@timestampcolumnOne
keywordcolumn perbygrouping label; no label columns for an aggregation without grouping (sum(...)); otherwise (no cross-series aggregation, orwithout) a single_timeseriesJSON column
Examples:
// Fully adaptive Kibana query — date picker drives time range and step
PROMQL index=metrics-* http_rate=(sum(rate(http_requests_total)))
// (9.5+; Serverless) Instant query — current value at the end of the date picker range
PROMQL index=metrics-* time=?_tend load=(avg(node_load1))
// Explicit range query with a named result column
PROMQL index=k8s step=1h cost=(max by (cluster) (network.total_bytes_in{cluster!="prod"}))
| SORT cluster
// Post-process with ES|QL after the PROMQL stage
PROMQL index=k8s step=1h bytes=(max by (cluster) (network.bytes_in))
| STATS max_bytes = MAX(bytes) BY cluster
| SORT cluster
// Enrich PromQL results with a lookup index
PROMQL index=metrics-*
http_rate=(sum by (instance) (rate(http_requests_total)))
| LOOKUP JOIN instance_metadata ON instanceImplicit range selectors: Range vector functions can omit the range selector (rate(http_requests_total) instead of
rate(http_requests_total[5m])); the engine uses max(step, scrape_interval) as the window. This makes the query scale
with the date picker.
Limitations: Not every PromQL construct is supported, and coverage varies by version. Elasticsearch rejects
unsupported constructs with a verification_exception naming them. See
promql-command.md for the unsupported constructs and
how to handle them.
SHOW
Returns information about the deployment.
Syntax:
SHOW INFOData Federation (9.5 experimental — not for production use)
Query files in Amazon S3 (Parquet, CSV, TSV, NDJSON) in place, without ingesting them. A data source holds the
connection and credentials; a dataset names the files to read. Query a dataset with FROM <dataset> like an index.
See ES|QL Data Federation.
Status: Experimental in 9.5 and not for production use (no guarantees on performance, scale, or stability). Available on ECH and self-managed only (not Serverless). Off by default, requires an Enterprise license, and not supported on Windows. Use it only when the user asks to query external or S3 data, and state this status when you do.
Enable: set esql.federation.enabled: true (static node setting; on ECH, add it to the deployment's Elasticsearch
user settings) and xpack.dataFederation.enabled: true in Kibana for the UI (Data management > ES|QL Data
Federation). GET /_query/data_source returns 200 when the feature is enabled.
Create a data source (cluster manage, or the global.data_source privilege):
PUT /_query/data_source/public_s3
{
"type": "s3",
"settings": { "auth": "anonymous", "region": "us-east-1" }
}auth:anonymous(public buckets),static_credentials(access_key,secret_key; stored encrypted and returned as::es_redacted::),federated_identity(ECH only;role_arn), ormanaged_identity(operator-only).In 9.5, set
regionon the data source; datasets reject aregionsetting.Creating it does not test connectivity. Bad credentials, regions, or paths surface at query time.
Create a dataset (index manage privilege on the dataset name, plus read on the data source):
PUT /_query/dataset/noaa_tmax
{
"data_source": "public_s3",
"resource": "s3://noaa-ghcn-pds/parquet/by_station/STATION=USW00094728/ELEMENT=TMAX/*.parquet"
}resourceis a file, a glob (*,**,{a,b}), or a comma-separated list. The format comes from the extension (.parquet,.csv,.tsv,.ndjson/.jsonl/.json;.gz/.zstcompression for text formats). Set"settings": {"format": "csv"}for extensionless or mixed resources. Use one format per dataset.The schema is inferred (Parquet metadata, sampled rows for text formats). Hive-style
key=valuepath segments become columns (STATION,ELEMENTabove).Declare types or rename columns with
mappings(dynamic: falsemakes the declaration the complete schema):"mappings": {"dynamic": false, "properties": {"@timestamp": {"type": "date", "path": "DATE", "format": "yyyyMMdd"}, "value": {"type": "long", "path": "DATA_VALUE"}}}.Dataset names share a namespace with indices, aliases, and views.
GET /_query/dataset[/<name>]lists definitions. Delete datasets before their data source (DELETE /_query/dataset/<name>), or the data source delete returns 409.
Discover schema: datasets do not appear in GET /_cat/indices, GET /{index}/_mapping, _field_caps, or
_resolve/index. List them with GET /_query/dataset and read the columns with FROM <dataset> | LIMIT 0.
Query: every processing command that works on an index works on a dataset (WHERE, EVAL, STATS, INLINE STATS,
SORT, KEEP, MV_EXPAND, FORK, CHANGE_POINT, SAMPLE, subqueries).
FROM noaa_tmax
| WHERE DATE >= "20200101"
| EVAL year = SUBSTRING(DATE, 1, 4), tmax_c = DATA_VALUE / 10.0
| STATS avg_tmax_c = ROUND(AVG(tmax_c), 1), max_tmax_c = MAX(tmax_c) BY year
| SORT yearEvery query reads from S3 (only listings and schemas are cached). Add WHERE, KEEP, and LIMIT: Parquet pushes
filters and column selection into the reader, while CSV and NDJSON read whole rows. Filters on Hive partition columns
skip files when they come before any STATS, SORT, or LIMIT.
Combine with local data:
// Enrich S3 rows from a lookup index: the dataset is the source, the lookup index is the target
FROM noaa_daily
| WHERE ELEMENT IN ("TMAX", "TMIN") AND DATE >= "20240101"
| LOOKUP JOIN noaa_elements ON ELEMENT
| STATS avg_value = ROUND(AVG(DATA_VALUE * scale), 1) BY element_name
// Union a dataset with an index; columns missing from one source are null
FROM noaa_tmax, noaa_elements METADATA _index
| KEEP _index, ELEMENT, DATE, element_name
| LIMIT 10Subqueries (FROM idx, (FROM dataset | ...)), WHERE x IN (FROM idx | KEEP x) over a dataset, and
views whose query reads a dataset also work.
Limitations and gotchas (9.5):
Not supported on datasets:
TS,KNN,QSTR,KQL,LOOKUP JOINwith a dataset as the lookup target, cross-cluster datasets, DLS/FLS on the dataset'sreadgrant, more than 8 sources in oneFROM, and snapshot/restore.MATCHruns as a row-by-row scan and dataset rows get no_score. Inferred strings arekeyword, soMATCHmatches whole values; useLIKE/RLIKEfor substrings or declare the column astextinmappings.METADATA _indexreturns the dataset name;_file.path,_file.name,_file.size, and_file.modifiedidentify the source file._nameand_classare 9.6+. Grouping directly on a metadata column (STATS ... BY _index) fails with HTTP 500 on 9.5; copy it first:EVAL src = CONCAT(_index, "") | STATS n = COUNT(*) BY src.On 9.5, wildcard patterns such as
FROM noaa*also match datasets (9.6 needsSET wildcards_match_datasets = true). Name datasets explicitly. Columns whose types conflict across matched sources becomeunsupported.Inferred types depend on the format: the same NOAA
DATEcolumn iskeywordin Parquet andintegerin CSV, and empty CSV cells read as""rather than null. Check withLIMIT 0before comparing or joining.Parquet
MAP, nestedLIST, andVARIANTcolumns read as null;STRUCTfields flatten to dotted names.Datasets that span files default to
schema_resolution: union_by_namein 9.5 (9.6 defaults tofirst_file_wins).
Processing Commands
Processing commands transform the input table.
WHERE
Filters rows based on a boolean condition.
Syntax:
WHERE conditionExamples:
FROM employees
| WHERE salary > 50000
FROM logs-*
| WHERE status_code >= 400 AND status_code < 500
FROM events
| WHERE message LIKE "*error*"
FROM users
| WHERE name RLIKE "J.*n"
FROM data
| WHERE field IS NOT NULLWHERE IN / NOT IN with subqueries (preview 9.5; Stack and Serverless):
Filter rows by checking whether a field value exists in a reference dataset returned by another ES|QL expression. The
subquery must return exactly one column. Use it for allow lists and deny lists (known-good IPs, blocked users, exception
lists) when no columns from the reference set are needed; use LOOKUP JOIN when you also need its columns. Since 9.5.2,
when the subquery folds to an empty set (e.g. WHERE false), IN returns no rows and NOT IN keeps all rows.
// Exclude events from known-safe IPs
FROM logs-*
| WHERE source.ip NOT IN (FROM allowed_ips | KEEP ip)
| STATS count = COUNT(*) BY source.ip
| SORT count DESC
// Filter to users in an active user list
FROM events
| WHERE user.id IN (FROM active_users | KEEP user_id)Note:
WHERE field IN ("val1", "val2")with literal values continues to work as before (all versions). The subquery formWHERE field IN (FROM ... | KEEP ...)is new in 9.5.
NULL handling (three-valued logic): ES|QL follows SQL-style three-valued logic. Comparisons involving NULL
evaluate to unknown, not true or false. This means WHERE field != "value" silently excludes rows where field
is NULL (missing). This differs from DSL, KQL, EQL, and Splunk, where negation typically includes missing fields.
To include NULL rows in negation filters, add an explicit IS NULL check:
// WRONG: silently drops rows where user.name is NULL
FROM logs-*
| WHERE user.name != "admin"
// CORRECT: includes rows where user.name is missing
FROM logs-*
| WHERE user.name != "admin" OR user.name IS NULLEVAL
Adds or replaces columns with calculated values.
Syntax:
EVAL column1 = expression1 [, column2 = expression2, ...]Examples:
FROM employees
| EVAL annual_salary = monthly_salary * 12
FROM logs
| EVAL duration_ms = end_time - start_time
| EVAL duration_sec = duration_ms / 1000
FROM data
| EVAL full_name = CONCAT(first_name, " ", last_name)
| EVAL is_adult = age >= 18STATS ... BY
Aggregates data, optionally grouped by columns.
Syntax:
STATS aggregation1 [WHERE filter1] [, aggregation2 [WHERE filter2], ...] [BY grouping1, grouping2, ...]Examples:
// Simple count
FROM logs-*
| STATS count = COUNT(*)
// Multiple aggregations
FROM sales
| STATS
total = SUM(amount),
avg_amount = AVG(amount),
max_amount = MAX(amount)
// Grouped aggregation
FROM logs-*
| STATS count = COUNT(*) BY status_code
// Multiple groupings
FROM sales
| STATS total = SUM(amount) BY region, product_category
// Time-based grouping
FROM logs-*
| STATS count = COUNT(*) BY bucket = DATE_TRUNC(1 hour, @timestamp)
// Per-aggregation WHERE filters (8.16+) — conditional metrics in a single pass
FROM logs-*
| STATS
total = COUNT(*),
errors = COUNT(*) WHERE level == "error",
warnings = COUNT(*) WHERE level == "warning"
BY service.name
// Cluster semi-structured text into categories of similar format (requires Platinum license)
FROM logs-*
| STATS count = COUNT(*) BY category = CATEGORIZE(message)
// Control the clustering threshold: (1-100): Lower -> less clusters, default=70
FROM logs-*
| STATS count = COUNT(*) BY category = CATEGORIZE(message, {"similarity_threshold": 85})
// Use token output format and a custom analyzer
FROM logs-*
| STATS count = COUNT(*) BY category = CATEGORIZE(message, {"output_format": "tokens", "analyzer": "standard"})INLINE STATS ... BY
Aggregates data like STATS, but preserves all original columns and appends the aggregated values as new columns. The
output has the same number of rows as the input. Tech preview in 9.2, GA in 9.3.
Syntax:
INLINE STATS aggregation1 [WHERE filter1] [, aggregation2 [WHERE filter2], ...] [BY grouping1, grouping2, ...]Key differences from STATS:
STATSreplaces the input table with aggregation results (fewer rows)INLINE STATSkeeps every input row and adds the aggregated values as new columns
Examples:
// Add each employee's group max salary alongside their own salary
FROM employees
| KEEP emp_no, languages, salary
| INLINE STATS max_salary = MAX(salary) BY languages
// Add a global aggregation to every row (no BY clause)
FROM employees
| KEEP emp_no, salary
| INLINE STATS avg_salary = AVG(salary)
| WHERE salary > avg_salary
// Filter rows per aggregation with WHERE
FROM employees
| KEEP emp_no, salary
| INLINE STATS
avg_low = ROUND(AVG(salary)) WHERE salary < 50000,
avg_high = ROUND(AVG(salary)) WHERE salary >= 50000Use cases:
Compare individual values against group averages or totals
Calculate percentages of group totals without a separate query
Replaces some patterns that would require subqueries in SQL
Limitations:
Cannot use
FORKorLIMITbeforeINLINE STATSCATEGORIZEgrouping function is not supported
KEEP
Keeps only specified columns.
Syntax:
KEEP column1 [, column2, ...]Examples:
FROM employees
| KEEP first_name, last_name, salary
// With wildcards
FROM logs-*
| KEEP @timestamp, message, error.*DROP
Removes specified columns.
Syntax:
DROP column1 [, column2, ...]Examples:
FROM employees
| DROP internal_id, temp_field
// With wildcards
FROM data
| DROP temp_*, debug_*RENAME
Renames columns.
Syntax:
RENAME old_name AS new_name [, old_name2 AS new_name2, ...]Examples:
FROM employees
| RENAME emp_id AS employee_id
FROM data
| RENAME col1 AS column_one, col2 AS column_twoSORT
Sorts the table.
Syntax:
SORT column1 [ASC/DESC] [NULLS FIRST/LAST] [, column2 ...]Examples:
FROM employees
| SORT salary DESC
FROM logs-*
| SORT @timestamp DESC, severity ASC
FROM data
| SORT value ASC NULLS LASTLIMIT
Limits the number of rows returned. LIMIT ... BY (preview 9.4; Stack and Serverless) adds grouped top-N.
Syntax:
LIMIT number
LIMIT number BY fieldExamples:
FROM logs-*
| SORT @timestamp DESC
| LIMIT 100
// Grouped top-N: keep top 3 rows per service after sorting
FROM app_logs
| STATS cnt = COUNT(*) BY service, level
| SORT cnt DESC
| LIMIT 3 BY serviceNote: In
LIMIT n BY field, the number comes beforeBY.LIMIT BY field ndoes not parse.
DEDUP (preview; Serverless only)
Removes rows that are identical across all columns in scope, keeping one per combination (nulls compare equal). It
takes no arguments and is equivalent to LIMIT 1 BY <all columns>, so KEEP the columns that define a duplicate first.
Not available on Stack 9.5 (parse error): there, use LIMIT 1 BY cols (9.4+; SORT first to choose which row each
group keeps) or STATS BY cols (any version). DEDUP fails on aggregate_metric_double, counter, and date_range
columns, and full-text functions such as MATCH cannot follow it.
// Serverless: unique host/service pairs
FROM logs-test
| KEEP host.name, service.name
| DEDUP
// Stack 9.4+: newest row per host/service pair
FROM logs-test
| SORT @timestamp DESC
| LIMIT 1 BY host.name, service.name
| KEEP host.name, service.name, @timestamp
// Any version
FROM logs-test
| STATS BY host.name, service.nameDISSECT
Extracts structured fields from a string using a pattern.
Syntax:
DISSECT field "%{pattern}"Examples:
FROM logs
| DISSECT message "%{clientip} - - [%{timestamp}] \"%{method} %{path}\""
FROM apache_logs
| DISSECT message "%{ip} %{} %{} [%{timestamp}] \"%{request}\" %{status} %{bytes}"Cookbook — Common DISSECT Patterns:
// Extract email domain
FROM customers
| DISSECT email "%{local}@%{domain}"
| STATS count = COUNT(*) BY domain
// Parse HTTP method and path from log messages like "GET /api/users HTTP/1.1"
FROM logs
| DISSECT message "%{method} %{path} %{protocol}"
| WHERE method IS NOT NULL
| KEEP @timestamp, method, path
// Extract key-value pairs from structured strings like "user=admin action=login"
FROM audit_logs
| DISSECT message "%{key1}=%{val1} %{key2}=%{val2}"Limitations: DISSECT does not support
reference keys
(e.g., %{*key} / %{&key} for dynamic key-value extraction).
GROK
Extracts fields using grok patterns (regex-based).
Syntax:
GROK field "%{PATTERN:field_name}"Common Patterns:
%{IP:ip}- IP address%{NUMBER:num}- Number%{WORD:word}- Word%{DATA:data}- Any data (non-greedy)%{GREEDYDATA:text}- Any data (greedy)%{TIMESTAMP_ISO8601:ts}- ISO timestamp
Examples:
FROM logs
| GROK message "%{IP:client_ip} %{WORD:method} %{NUMBER:status:int}"
FROM web_logs
| GROK agent "%{WORD:browser}/%{NUMBER:version}"Limitations: ES|QL GROK does not support custom patterns or multiple pattern matching. Only built-in grok patterns are available.
LOOKUP JOIN
Joins data from a lookup index onto the current results. The preferred way to enrich query results with data from another index. GA in 8.19/9.1.
Syntax:
LOOKUP JOIN lookup_index ON join_fieldKey behavior:
Performs a LEFT OUTER JOIN — all rows from the source are preserved; unmatched rows get
NULLfor lookup fieldsThe lookup index must use
index.mode: lookup(single shard, max 2B docs)Supports multi-field joins (9.2+) and mixed numeric types
Updates to the lookup index are reflected immediately in subsequent queries
Name collisions: If a lookup field has the same name as an existing source column, the lookup value overwrites it. Use
RENAMEbefore the join to preserve the original column when needed.
Examples:
// Enrich logs with user metadata
FROM logs-*
| LOOKUP JOIN users ON user.id
// Add product details to order data
FROM orders
| LOOKUP JOIN products ON product_id
| STATS revenue = SUM(price * quantity) BY product_name
// Enrich security events with threat intelligence
FROM security-events
| LOOKUP JOIN threat_intel ON source.ip
| WHERE threat_level == "high"Multi-field joins (9.2+):
// Join on multiple fields — match service, environment, and version
FROM application_logs
| LOOKUP JOIN service_registry ON service_name, environment, versionComplex join predicates with expressions (9.2+ tech preview):
// Range-based join — find the SLA threshold for each service's response time
FROM app_metrics
| LOOKUP JOIN sla_thresholds ON service == service_name AND response_time_ms >= threshold_min
// Date-range join — find the pricing policy active at measurement time
FROM meter_readings
| LOOKUP JOIN customers ON customer_id
| LOOKUP JOIN pricing_policies ON region_id == region AND measurement_date >= valid_from AND measurement_date < valid_to
| EVAL due_amount = usage * price_per_unitLucene-pushable predicates in joins (9.3+ tech preview):
Full-text functions and other Lucene-pushable predicates (MATCH, QSTR, KQL, LIKE, STARTS_WITH) can be applied
to lookup index fields in the ON clause, enabling search-style joins.
// Full-text search against lookup index fields
FROM support_tickets
| LOOKUP JOIN knowledge_base ON MATCH(article_content, issue_description) AND product == product_name
// Combine text search with equality join
FROM error_logs
| LOOKUP JOIN runbooks ON QSTR("title:timeout OR title:connection") AND service == service_nameENRICH
Enriches data using a pre-configured enrich policy. On clusters with 8.18+, prefer LOOKUP JOIN — it requires no policy
setup and reflects changes immediately. On clusters before 8.18, ENRICH is the only option for data enrichment. If
no enrich policy exists, suggest the user create one (see Generation Tips
for setup steps).
Syntax:
ENRICH policy_name ON match_field [WITH new_field1, new_field2, ...]Examples:
FROM logs
| ENRICH geo_policy ON client_ip WITH country, city
FROM sales
| ENRICH products_policy ON product_id WITH product_name, categoryCHANGE_POINT
Detects spikes, dips, and change points in a metric. Requires a Platinum license. Tech preview in 8.19/9.1, GA in 9.2.
Syntax:
CHANGE_POINT value [ON key] [AS type_name, pvalue_name] [BY group1[, ..., groupN]]value-- the metric field to analyze for change pointskey-- the field to order by (typically a date or sequence); defaults to@timestampBY(GA 9.5) -- detect change points independently per group; the 22-value minimum and 1,000-value cap apply per grouptype_name-- output column for the type of change (step_change,distribution_change,trend_change,dip,spike,non_stationary,stationary,no_change)pvalue_name-- output column for the p-value (statistical significance)
Examples:
// Detect change points in error rates over time
FROM logs-*
| STATS error_count = COUNT(*) WHERE level == "error" BY hour = DATE_TRUNC(1 hour, @timestamp)
| SORT hour
| CHANGE_POINT error_count ON hour AS change_type, p_value
// Find significant changes in response times
FROM metrics
| STATS avg_latency = AVG(response_time) BY minute = DATE_TRUNC(1 minute, @timestamp)
| SORT minute
| CHANGE_POINT avg_latency ON minute
// Per-group detection (9.5+): one change point series per log level
FROM logs-*
| STATS c = COUNT(*) BY t = BUCKET(@timestamp, 1 hour), level
| SORT t
| CHANGE_POINT c ON t BY level
| WHERE type IS NOT NULLFORK
Creates multiple execution branches that operate on the same input data and combines results into a single output table.
A _fork column identifies which branch each row came from. Preview from 9.1 to 9.3, GA since 9.4.
Syntax:
FORK ( processing_commands ) ( processing_commands ) [... ( processing_commands )]Constraints:
Maximum 8 branches
Each branch defaults to
LIMIT 1000if no LIMIT is specifiedColumns with the same name must have the same type across branches; missing columns are filled with null
Cannot use remote cluster references with FORK
Only one FORK per query
Examples:
// Run different aggregations on the same data
FROM logs-*
| FORK
( WHERE level == "error" | STATS errors = COUNT(*) BY service.name )
( WHERE level == "warning" | STATS warnings = COUNT(*) BY service.name )
// Compare different time windows
FROM metrics
| FORK
( WHERE @timestamp > NOW() - 1 hour | STATS recent_avg = AVG(cpu) )
( WHERE @timestamp > NOW() - 24 hours | STATS daily_avg = AVG(cpu) )
| SORT _fork
// Search with multiple strategies — combine full-text and keyword matches
FROM articles METADATA _score
| FORK
( WHERE MATCH(content, "elasticsearch performance") | SORT _score DESC | LIMIT 10 )
( WHERE MATCH_PHRASE(title, "search optimization") | SORT _score DESC | LIMIT 10 )
( WHERE category == "guides" AND tags : "elasticsearch" | SORT published_date DESC | LIMIT 10 )
| KEEP _fork, title, _score, published_dateFUSE
Merges rows from multiple result sets (typically from FORK branches) and assigns new relevance scores. Preview from 9.2 to 9.4, GA since 9.5.
Syntax:
FUSE method SCORE BY score_column GROUP BY group_column KEY BY key_columns [WITH options]Backquote a column literally named score, key, or group when it follows SCORE BY, KEY BY, or GROUP BY
(SCORE BY `score`); the bare word is parsed as the clause keyword.
Methods:
rrf— Reciprocal Rank Fusion. Combines ranked lists by reciprocal rank; no score normalization needed.linear— Linear combination of scores. Supportsnormalizerand per-branchweights.
LINEAR options:
| Option | Type | Default | Description |
|---|---|---|---|
normalizer | keyword | — | Score normalization method; minmax maps scores to 0–1 |
weights | object | equal | Per-branch weights (e.g. {"fork1": 0.7, "fork2": 0.3}) |
Examples:
// RRF fusion (default)
FROM articles METADATA _score, _id
| FORK
( WHERE MATCH(content, "elasticsearch") | SORT _score DESC | LIMIT 50 )
( WHERE MATCH(title, "search guide") | SORT _score DESC | LIMIT 50 )
| FUSE rrf SCORE BY _score KEY BY _id
| LIMIT 10
// LINEAR fusion with minmax normalization and custom weights
FROM articles METADATA _id, _index, _score
| FORK
( WHERE MATCH(content, "elasticsearch") | SORT _score DESC | LIMIT 50 )
( WHERE semantic_content : "how does elasticsearch work" | SORT _score DESC | LIMIT 50 )
| FUSE linear WITH { "normalizer": "minmax", "weights": { "fork1": 0.7, "fork2": 0.3 } }
| SORT _score DESC
| LIMIT 10RERANK
Uses an inference model to re-score an initial set of documents. Preview 9.2, GA 9.4. Since 9.3, defaults to 1000 rows;
configurable via esql.command.rerank.limit and esql.command.rerank.enabled cluster settings.
Syntax:
RERANK [column =] query ON field [, field, ...] [WITH { "inference_id": "endpoint" [, "timeout": "1m"] }]"timeout" (9.5+) takes a duration string such as "500ms", "30s", or "1m" and bounds each inference request
RERANK sends (rows go out in batches of 10), not the whole query. It is the same limit as the _inference API's
timeout parameter and defaults to 30s, the inference default for the rerank task type. Exceeding it fails the query
with timeout_exception ("Request timed out after [...]"); on ML-node endpoints it also bounds the wait for the model
deployment to start (model_deployment_timeout_exception).
RERANK overwrites _score but does not re-sort rows, so add SORT _score DESC after it. The endpoint needs the
rerank task type; without WITH, the default is .rerank-v1-elasticsearch. Endpoints that run on ML nodes, like that
default, can fail the first call after idle with a "waiting for trained model deployment" timeout after 30s (or the
"timeout" value); retry.
Example:
FROM articles METADATA _score
| WHERE MATCH(content, "elasticsearch performance")
| SORT _score DESC
| LIMIT 100
| RERANK "how to improve elasticsearch performance" ON content WITH { "inference_id": "my-rerank-model" }
| SORT _score DESC
| LIMIT 10COMPLETION
Sends prompts and context to a Large Language Model (LLM) using a completion inference endpoint. Preview in 8.19/9.1,
GA since 9.3. Requires Platinum license.
Syntax:
COMPLETION [column =] prompt WITH { "inference_id": "endpoint" [, "timeout": "1m"] }column-- optional output column; defaults tocompletion"inference_id"-- endpoint with thecompletiontask type;chat_completionendpoints are rejected (for example, use.google-gemini-3.1-flash-lite-completion, not.google-gemini-3.1-flash-lite-chat_completion)"timeout"(9.5+) -- duration string such as"30s"or"1m", meant to bound each per-row inference request. As of 9.5.5 and Serverless the value is validated but not applied, so requests always use the default: 120s in 9.5, 30s in 9.3–9.4
Since 9.3, COMPLETION processes at most 100 rows by default (esql.command.completion.limit); every row is a separate
LLM call, so filter and LIMIT first. The positional form COMPLETION prompt WITH my_endpoint was 9.1 preview only and
is a parse error on current versions.
Example:
FROM support_tickets
| WHERE status == "open"
| LIMIT 20
| EVAL prompt = CONCAT("Summarize this ticket: ", description)
| COMPLETION summary = prompt WITH { "inference_id": "my_llm_endpoint" }
| KEEP ticket_id, summarySAMPLE
Samples a random fraction of rows from the input table. Preview from 8.19/9.1 to 9.4, GA since 9.5.
Syntax:
SAMPLE probabilityprobability-- value between 0 and 1 (exclusive), the chance each row is included
Use SAMPLE for "random sample" or "random subset" requests (add LIMIT n for a row count). ES|QL has no RANDOM()
function.
Example:
// Sample ~10% of rows for exploratory analysis
FROM logs-*
| SAMPLE 0.1
| STATS avg_duration = AVG(duration) BY service.nameSAMPLE can be used as a manual alternative to automatic query approximation when you need
control over sampling or an aggregation that approximation does not support (e.g. COUNT_DISTINCT). Unlike
SET approximation, it performs no extrapolation or confidence interval computation — accounting for sampling bias
is your responsibility.
MV_EXPAND
Expands multivalued fields into separate rows.
Syntax:
MV_EXPAND fieldExamples:
FROM data
| MV_EXPAND tags
| STATS count = COUNT(*) BY tagsIP_LOCATION (preview 9.5; Stack and Serverless)
Query-time GeoIP enrichment using MaxMind or IPinfo databases — the query-time equivalent of the GeoIP ingest processor.
A pipe command (like DISSECT/GROK), not a scalar function. The input is an ip or a string containing an IP.
Syntax:
IP_LOCATION prefix = ip_expression [WITH { option = value [, ...] }]Default output columns (default GeoLite2-City.mmdb database): prefix.country_iso_code, prefix.country_name,
prefix.continent_name, prefix.region_iso_code, prefix.region_name, prefix.city_name, prefix.location
(geo_point). There is no timezone column by default, and ASN fields (asn, organization_name, network) come
only from the ASN database. Unknown IPs produce null in every column.
WITH options:
database_file-- database to use (defaultGeoLite2-City.mmdb; alsoGeoLite2-Country.mmdb,GeoLite2-ASN.mmdb, GeoIP2/IPinfo variants, or a custom database registered via the IP geolocation database API)properties-- list of properties to return (non-empty); a subset reduces work, and non-default properties such astimezone,postal_code, orcontinent_codemust be requested herefirst_only--true(default) uses the first value of a multi-value input;falsereturnsnullwith a warning
Examples:
// Enrich source IPs with geographic information
FROM logs-*
| IP_LOCATION geo = source.ip
| KEEP source.ip, geo.country_name, geo.city_name, geo.location
// Count events by country, resolving only the needed property
FROM firewall_logs
| IP_LOCATION geo = source.ip WITH { "properties": ["country_name"] }
| STATS events = COUNT(*) BY geo.country_name
| SORT events DESC
// ASN / owning organization of each IP
FROM firewall_logs
| IP_LOCATION asn = source.ip WITH { "database_file": "GeoLite2-ASN.mmdb" }
| STATS events = COUNT(*) BY asn.organization_nameThe first IP_LOCATION query triggers a one-time database download; until it completes, keyword columns hold the
sentinel _ip_location_database_unavailable_<database_file>.
When to use:
IP_LOCATIONenriches at query time with no ingest pipeline configuration needed. It always uses the latest GeoIP database without re-indexing. Prefer ingest-time GeoIP enrichment when geographic data is needed frequently and query-time overhead matters.
METRICS_INFO
Returns one row per distinct metric available in the targeted time series data stream(s), with applicable dimensions and metadata. Use it to discover the metric catalogue without inspecting index mappings or calling the field capabilities API. GA since 9.4 (and on Elastic Cloud Serverless).
Syntax:
METRICS_INFOTakes no parameters.
Output columns (all keyword):
metric_name— the metric field name (single-valued)data_stream— data stream(s) containing this metric (multi-valued when several streams align on unit/metric_type/field_type)unit— declared unit from field mapping (e.g.,bytes,packets); may benullor multi-valuedmetric_type—counter,gauge, orhistogram(multi-valued when definitions differ across backing indices)field_type— Elasticsearch field type (e.g.,long,double,histogram,exponential_histogram,tdigest)dimension_fields— union of dimension field names across all time series for that metric
Restrictions:
Can only be used after a
TSsource command —FROM | METRICS_INFOis rejected.Must appear before pipeline-breaking commands (
STATS,SORT,LIMIT).The output replaces the original table — downstream commands operate on the metadata rows, not the raw documents.
Examples (TRANGE is omitted below for brevity — always add | WHERE TRANGE(...) after TS so discovery scans a
bounded window):
// List every metric in a TSDS, alphabetically
TS k8s
| METRICS_INFO
| SORT metric_name
// Narrow to metrics that have data matching a filter, then keep only key columns
TS k8s
| WHERE cluster == "prod"
| METRICS_INFO
| KEEP metric_name, metric_type
| SORT metric_name
// Count metrics by type
TS k8s
| METRICS_INFO
| STATS metric_count = COUNT(*) BY metric_type
| SORT metric_type
// Find metrics matching a name pattern
TS k8s
| METRICS_INFO
| WHERE metric_name LIKE "network.eth0*"
| SORT metric_nameTS_INFO
Returns one row per (metric, time series) combination in the targeted TSDS, including the dimension key/value pairs that identify each series. Use it to enumerate the actual time series — and their labels — that exist for each metric. GA since 9.4 (and on Elastic Cloud Serverless).
Syntax:
TS_INFOTakes no parameters.
Output columns (all keyword):
All columns from
METRICS_INFO(metric_name,data_stream,unit,metric_type,field_type,dimension_fields)dimensions— JSON-encoded object with the dimension key/value pairs identifying the time series, e.g.{"job":"elasticsearch","instance":"instance_1"}. Single-valued.
Restrictions:
Can only be used after a
TSsource command —FROM | TS_INFOis rejected.Must appear before pipeline-breaking commands (
STATS,SORT,LIMIT).The output replaces the original table — downstream commands operate on the metadata rows, not the raw documents.
Examples (same as METRICS_INFO: always precede with WHERE TRANGE(...) in real queries):
// Every (metric, time series) pair in a TSDS
TS k8s
| TS_INFO
| SORT metric_name, dimensions
// Restrict to series with data matching a filter, keep only key columns
TS k8s
| WHERE cluster == "prod"
| TS_INFO
| KEEP metric_name, dimensions
| SORT metric_name, dimensions
// Filter by metadata after TS_INFO
TS k8s
| TS_INFO
| WHERE metric_type == "gauge"
| SORT metric_name, dimensions
// Count distinct time series per metric
TS k8s
| TS_INFO
| STATS series_count = COUNT(*) BY metric_name
| SORT metric_name
// Count distinct metrics per time series — useful to spot under- or over-reporting series
TS k8s
| TS_INFO
| STATS metric_count = COUNT_DISTINCT(metric_name) BY dimensions
| SORT dimensions
METRICS_INFOvsTS_INFO:METRICS_INFOreturns one row per distinct metric;TS_INFOreturns one row per (metric, time series) combination and adds adimensionscolumn with the labels identifying each series. UseMETRICS_INFOto enumerate what is being measured, andTS_INFOto enumerate which time series exist.
URI_PARTS (preview 9.4)
Pipe command that parses a URI string into structured columns. A target prefix is required.
Syntax:
URI_PARTS target = fieldOutput columns: target.domain, target.path, target.scheme, target.extension, target.port, target.query,
target.fragment, target.user_info, target.username, target.password.
Example:
FROM web_logs
| WHERE http.response.status_code >= 400
| URI_PARTS parts = url.full
| STATS errors = COUNT(*) BY parts.domain, parts.path
| SORT errors DESCUSER_AGENT (preview 9.4)
Pipe command that parses a user agent string into structured columns. A target prefix is required.
Syntax:
USER_AGENT target = fieldOutput columns: target.name, target.version, target.os.name, target.os.version, target.os.full,
target.device.name.
Example:
FROM web_logs
| USER_AGENT ua = user_agent.original
| STATS cnt = COUNT(*) BY ua.name, ua.versionREGISTERED_DOMAIN (preview 9.4)
Pipe command that extracts the registered domain, top-level domain, and subdomain from a hostname. A target prefix is required.
Syntax:
REGISTERED_DOMAIN target = fieldOutput columns: target.domain (full input), target.registered_domain, target.top_level_domain,
target.subdomain.
Example:
FROM dns_logs
| REGISTERED_DOMAIN rd = dns.question.name
| STATS queries = COUNT(*) BY rd.registered_domain
| SORT queries DESCNote:
URI_PARTS,USER_AGENT, andREGISTERED_DOMAINare pipe commands (likeDISSECT/GROK), not scalar functions. The syntaxURI_PARTS(field)does not work — use| URI_PARTS target = field.
MMR (preview 9.4)
Maximal Marginal Relevance — diversifies search results by reducing redundancy among top hits. Requires a dense vector
field and a LIMIT before MMR to constrain the candidate set.
Syntax:
MMR query ON vector_field LIMIT nExample:
FROM articles
| WHERE MATCH(title, "elasticsearch tuning")
| LIMIT 100
| MMR "elasticsearch performance tuning" ON content_embedding LIMIT 10TS_COLLAPSE (preview 9.5)
Collapses the expanded per-step output of a PROMQL command into one row per time series. Metric and step columns
become positionally aligned multi-value fields; dimension columns stay single-valued. Must appear immediately after
PROMQL, and the PROMQL range must be bounded: pass both start and end (or run from Kibana, where the date picker
supplies them) — otherwise the query fails with TimeSeriesCollapse end must fold to a number.
Syntax:
PROMQL ... | TS_COLLAPSEExample:
PROMQL index=k8s step=1h start="2026-10-21T00:00:00Z" end="2026-10-22T00:00:00Z"
http_rate=(sum by (host) (rate(http_requests_total)))
| TS_COLLAPSE
| KEEP host, http_rate, stepAggregate Functions
Used with STATS command.
| Function | Description | Example |
|---|---|---|
COUNT(*) | Count all rows | STATS n = COUNT(*) |
COUNT(field) | Count non-null values | STATS n = COUNT(status) |
COUNT_DISTINCT(field) | Count unique values | STATS unique = COUNT_DISTINCT(user_id) |
SUM(field) | Sum of values | STATS total = SUM(amount) |
AVG(field) | Average | STATS avg_price = AVG(price) |
MIN(field) | Minimum value | STATS min_temp = MIN(temperature) |
MAX(field) | Maximum value | STATS max_score = MAX(score) |
MEDIAN(field) | Median value | STATS med = MEDIAN(response_time) |
PERCENTILE(field, p) | Percentile | STATS p95 = PERCENTILE(latency, 95) |
STD_DEV(field) | Standard deviation | STATS sd = STD_DEV(values) |
VARIANCE(field) | Variance | STATS var = VARIANCE(values) |
VALUES(field) | Collect all values (GA) | STATS all_tags = VALUES(tag) |
TOP(field, n, order) | Top N values | STATS top3 = TOP(score, 3, "desc") |
WEIGHTED_AVG(val, weight) | Weighted average | STATS wavg = WEIGHTED_AVG(score, weight) |
MEDIAN_ABSOLUTE_DEVIATION(field) | Robust variability measure | STATS mad = MEDIAN_ABSOLUTE_DEVIATION(latency) |
ABSENT(field) | True if no non-null values (9.2+) | STATS is_absent = ABSENT(error_code) |
PRESENT(field) | True if any non-null values (9.2+) | STATS has_data = PRESENT(metric) |
SAMPLE(field, n) | Collect n sample values (8.19/9.1+) | STATS examples = SAMPLE(message, 5) |
FIRST(field, sort_field) | Earliest value by sort field (9.4+) | STATS earliest = FIRST(message, @timestamp) |
LAST(field, sort_field) | Latest value by sort field (9.4+) | STATS latest = LAST(message, @timestamp) |
EARLIEST(field) | Min @timestamp shorthand (9.4+) | STATS e = EARLIEST(@timestamp) |
LATEST(field) | Max @timestamp shorthand (9.4+) | STATS l = LATEST(@timestamp) |
ST_CENTROID_AGG(field) | Spatial centroid of points | STATS center = ST_CENTROID_AGG(location) |
ST_EXTENT_AGG(field) | Bounding box of geometries (8.18/9.0+, preview) | STATS bbox = ST_EXTENT_AGG(location) |
SPARKLINE(agg, date, n, from, to) | Per-bucket values of agg (preview 9.5) | See example below |
SPARKLINE returns a multi-value of agg per date bucket (the y-axis of a sparkline), one row per group:
FROM logs-*
| STATS hourly = SPARKLINE(COUNT(*), @timestamp, 24, NOW() - 24 hours, NOW()) BY service.nameUse FIRST(value, @timestamp) / LAST(value, @timestamp) for "first/latest value per group" instead of
SORT ... | LIMIT 1 or INLINE STATS tricks.
Grouping Functions
Used in the BY clause of STATS and INLINE STATS to create dynamic groups.
| Function | Description | Example |
|---|---|---|
BUCKET(field, size) | Create fixed-size buckets for numbers or dates | STATS count = COUNT(*) BY b = BUCKET(price, 10) |
TBUCKET(interval) | Time-based bucketing (preview 9.2-9.3, GA in 9.4) | STATS SUM(RATE(reqs)) BY TBUCKET(1 hour) |
WITHOUT(dim, ...) | Group time series by every dimension except those listed (GA in 9.4) | STATS total = SUM(network.cost) BY WITHOUT(pod) |
CATEGORIZE(field) | Auto-categorize text values (8.18/9.0+, Platinum) | STATS count = COUNT(*) BY cat = CATEGORIZE(message) |
CATEGORIZE must be the first grouping key in the BY clause — any additional grouping must follow it. It is not
supported as an INLINE STATS grouping function.
CATEGORIZE options (9.2+):
| Option | Type | Default | Description |
|---|---|---|---|
similarity_threshold | integer | 70 | Clustering sensitivity (1–100); lower = fewer clusters |
output_format | keyword | regex | Output as regex patterns or space-separated tokens |
analyzer | keyword | field's | Override the analyzer used to tokenize text before categorization |
BUCKET examples:
// Numeric buckets — group prices into ranges of 50
FROM products
| STATS count = COUNT(*) BY price_range = BUCKET(price, 50)
| SORT price_range
// Date buckets — group events into 1-hour intervals
FROM logs-*
| WHERE @timestamp > NOW() - 24 hours
| STATS count = COUNT(*) BY hour = BUCKET(@timestamp, 1 hour)
| SORT hour
// Auto-sized buckets — let ES pick bucket size (target ~20 buckets)
FROM logs-*
| WHERE @timestamp > NOW() - 7 days
| STATS count = COUNT(*) BY bucket = BUCKET(@timestamp, 20, "2025-01-01", "2025-01-08")Time Series Aggregation Functions
Used with the STATS command after a TS source command. These functions evaluate per time series first, then
aggregate by group using an outer function (e.g., SUM, AVG). An optional second argument specifies a sliding time
window.
Availability: All time series aggregation functions are GA since 9.4 — both the 9.2-introduced set (RATE,
IRATE, INCREASE, DELTA, IDELTA, AVG_OVER_TIME, SUM_OVER_TIME, MIN_OVER_TIME, MAX_OVER_TIME,
FIRST_OVER_TIME, LAST_OVER_TIME, COUNT_OVER_TIME, COUNT_DISTINCT_OVER_TIME, PRESENT_OVER_TIME,
ABSENT_OVER_TIME) and the 9.3-introduced set (DERIV, PERCENTILE_OVER_TIME, STDDEV_OVER_TIME,
VARIANCE_OVER_TIME). On clusters in 9.2-9.3 these functions are still in tech preview.
Sliding window parameter (second argument): in 9.2-9.3 (preview) the window must be a multiple of the TBUCKET
interval; 9.4+ (GA) accepts arbitrary durations, with performance optimizations when the window is a multiple of the
bucket interval. Within a single query, you cannot mix windows smaller than the bucket interval for one metric with
windows larger than the bucket interval for another metric.
| Function | Description | Metric Types |
|---|---|---|
RATE(field [, window]) | Per-second rate of change | counter |
IRATE(field [, window]) | Instantaneous rate of change | counter |
INCREASE(field [, window]) | Total increase | counter |
AVG_OVER_TIME(field [, window]) | Average over time | gauge, counter |
SUM_OVER_TIME(field [, window]) | Sum over time | gauge |
MIN_OVER_TIME(field [, window]) | Minimum over time | gauge |
MAX_OVER_TIME(field [, window]) | Maximum over time | gauge |
LAST_OVER_TIME(field [, window]) | Last value over time | gauge, counter |
FIRST_OVER_TIME(field [, window]) | First value over time | gauge, counter |
COUNT_OVER_TIME(field [, window]) | Count of values over time | gauge, counter |
COUNT_DISTINCT_OVER_TIME(field) | Distinct count over time | gauge, counter |
PERCENTILE_OVER_TIME(field, p) | Percentile over time | gauge |
VARIANCE_OVER_TIME(field) | Variance over time | gauge |
STDDEV_OVER_TIME(field) | Standard deviation over time | gauge |
DELTA(field [, window]) | Change in value | gauge |
IDELTA(field [, window]) | Instantaneous change | gauge |
DERIV(field [, window]) | Rate of change for gauges | gauge |
PRESENT_OVER_TIME(field) | Whether time series has data | gauge, counter |
ABSENT_OVER_TIME(field) | Whether time series lacks data | gauge, counter |
Grouping helpers for time series:
TBUCKET(interval)— groups results into time buckets (used inBYclause)TRANGE(duration)— filters to a time range (used inWHEREclause)
Examples:
// Sum of per-time-series rates, grouped by host and hour
TS metrics
| WHERE @timestamp >= NOW() - 1 hour
| STATS SUM(RATE(search_requests)) BY TBUCKET(1 hour), host
// Average rate with a 10-minute sliding window, bucketed per minute
TS metrics
| WHERE TRANGE(1 hour)
| STATS AVG(RATE(requests, 10 minutes)) BY TBUCKET(1 minute), hostString Functions
| Function | Description | Example |
|---|---|---|
LENGTH(s) | String length | EVAL len = LENGTH(name) |
CONCAT(s1, s2, ...) | Concatenate strings | EVAL full = CONCAT(first, " ", last) |
SUBSTRING(s, start, len) | Extract substring | EVAL sub = SUBSTRING(text, 1, 10) |
LEFT(s, n) | Left n characters | EVAL l = LEFT(text, 5) |
RIGHT(s, n) | Right n characters | EVAL r = RIGHT(text, 5) |
TRIM(s) | Remove whitespace | EVAL clean = TRIM(input) |
LTRIM(s) | Trim left | EVAL clean = LTRIM(input) |
RTRIM(s) | Trim right | EVAL clean = RTRIM(input) |
TO_UPPER(s) | Uppercase | EVAL upper = TO_UPPER(name) |
TO_LOWER(s) | Lowercase | EVAL lower = TO_LOWER(name) |
REPLACE(s, old, new) | Replace text | EVAL fixed = REPLACE(msg, "err", "error") |
SPLIT(s, delim) | Split into array | EVAL parts = SPLIT(path, "/") |
STARTS_WITH(s, prefix) | Check prefix | WHERE STARTS_WITH(url, "https") |
ENDS_WITH(s, suffix) | Check suffix | WHERE ENDS_WITH(file, ".log") |
CONTAINS(s, substr) | Check contains | WHERE CONTAINS(message, "error") |
LOCATE(substr, s) | Find position | EVAL pos = LOCATE("@", email) |
REVERSE(s) | Reverse string | EVAL rev = REVERSE(text) |
REPEAT(s, n) | Repeat string | EVAL sep = REPEAT("-", 10) |
SPACE(n) | N spaces | EVAL spaces = SPACE(5) |
BIT_LENGTH(s) | Bit length (8.17+) | EVAL bits = BIT_LENGTH(name) |
BYTE_LENGTH(s) | Byte length (8.17+) | EVAL bytes = BYTE_LENGTH(name) |
CHUNK(field, settings) | Split text into chunks (9.3+, preview) | EVAL chunks = CHUNK(body, {"strategy":"word","max_chunk_size":50}) |
HASH(alg, s) | Hash string (8.18/9.0+) | EVAL h = HASH("SHA-256", msg) |
MD5(s) | MD5 hash (8.18/9.0+) | EVAL h = MD5(content) |
SHA1(s) | SHA-1 hash (8.18/9.0+) | EVAL h = SHA1(content) |
SHA256(s) | SHA-256 hash (8.18/9.0+) | EVAL h = SHA256(content) |
FROM_BASE64(s) | Decode base64 | EVAL decoded = FROM_BASE64(encoded) |
TO_BASE64(s) | Encode to base64 | EVAL encoded = TO_BASE64(data) |
URL_DECODE(s) | URL-decode (9.2+) | EVAL decoded = URL_DECODE(url) |
URL_ENCODE(s) | URL-encode (9.2+) | EVAL encoded = URL_ENCODE(text) |
URL_ENCODE_COMPONENT(s) | URL-encode for URI components (9.2+) | EVAL encoded = URL_ENCODE_COMPONENT(text) |
JSON_EXTRACT(field, path) | Extract value from JSON string (9.4+, GA in 9.5) | EVAL name = JSON_EXTRACT(raw, "$.user.name") |
FIELD_EXTRACT(field, key) | Flattened sub-field as keyword (preview 9.5) | EVAL val = FIELD_EXTRACT(flat_field, "sub.key") |
FIELD_EXTRACT (preview 9.5) is the only way to read a flattened sub-key (e.g. OTel resource.attributes) on 9.5+.
The root column has type flattened (an object of dotted keys); labels.env is an unknown column, and the root cannot
be sorted, compared, or passed to MATCH or JSON_EXTRACT.
Pass the root and the dotted key exactly as stored:
FIELD_EXTRACT(labels, "k8s.namespace")resolves both nested ({"k8s":{"namespace":…}}) and pre-dotted ({"k8s.namespace":…}) input. Keys are case-sensitive; brackets are rejected; a$.prefix or an object key ("k8s") silently returnsnull. Missing keys returnnull.The result is always
keyword. Cast before numeric comparisons or math:TO_INTEGER(FIELD_EXTRACT(labels, "n")) > 5. Comparing to an integer is a type error; comparing to a string (> "5") runs but compares lexicographically.Filter with
==,IN,LIKE/RLIKE, orMATCH(FIELD_EXTRACT(…), "…"). On Stack 9.5,MATCH_PHRASErejects it and:only works on a column assigned withEVALfirst; Serverless also acceptsMATCH_PHRASEand inline:.KQL/QSTRcan address sub-keys by path (KQL("labels.team: payments")), but their ranges are lexicographic too.
FROM k8s_events
| EVAL env = FIELD_EXTRACT(labels, "env"), retries = TO_INTEGER(FIELD_EXTRACT(labels, "retry_count"))
| WHERE env == "prod" AND retries > 5
| STATS events = COUNT(*), max_retries = MAX(retries) BY namespace = FIELD_EXTRACT(labels, "k8s.namespace")
| SORT events DESCOn versions before 9.5, or for fields that are not flattened, use JSON_EXTRACT on _source (below).
JSON_EXTRACT with _source — flattened field workaround:
On versions before 9.5, ES|QL does not natively access flattened field sub-keys. Use METADATA _source with
JSON_EXTRACT to reach inside flattened objects. _source can be passed directly to JSON_EXTRACT — do not wrap it
with TO_STRING(). In 9.5+, prefer FIELD_EXTRACT: the _source path follows the original JSON shape, so
$.labels.k8s.namespace misses documents that wrote the pre-dotted key "k8s.namespace".
FROM logs-* METADATA _source
| EVAL provider = JSON_EXTRACT(_source, "$.cloud.provider")
| STATS count = COUNT(*) BY providerFor a flattened field labels, read a sub-key with JSON_EXTRACT(_source, "$.labels.key"). Before 9.5 none of these
work: labels.key (unknown column), labels["key"] (parse error), and JSON_EXTRACT(labels, "$.key") (the flattened
column itself is unsupported). Cover pre-dotted keys with COALESCE:
FROM k8s_events METADATA _source
| EVAL ns = COALESCE(JSON_EXTRACT(_source, "$.labels.k8s.namespace"), JSON_EXTRACT(_source, "$.labels[\"k8s.namespace\"]"))
| STATS c = COUNT(*) BY nsThis also works for any field that exists in the raw document but has no explicit mapping.
Math Functions
| Function | Description | Example |
|---|---|---|
ABS(n) | Absolute value | EVAL abs_val = ABS(diff) |
ROUND(n, decimals) | Round | EVAL rounded = ROUND(price, 2) |
FLOOR(n) | Round down | EVAL floored = FLOOR(value) |
CEIL(n) | Round up | EVAL ceiled = CEIL(value) |
SQRT(n) | Square root | EVAL root = SQRT(area) |
POW(base, exp) | Power | EVAL squared = POW(x, 2) |
EXP(n) | e^n | EVAL e_power = EXP(x) |
LOG(n) | Natural log | EVAL ln = LOG(value) |
LOG10(n) | Log base 10 | EVAL log = LOG10(value) |
SIN(n), COS(n), TAN(n) | Trig functions | EVAL sine = SIN(angle) |
ASIN(n), ACOS(n), ATAN(n) | Inverse trig | EVAL angle = ASIN(ratio) |
PI() | Pi constant | EVAL circumference = 2 * PI() * radius |
E() | Euler's number | EVAL e = E() |
SIGNUM(n) | Sign (-1, 0, 1) | EVAL sign = SIGNUM(value) |
GREATEST(a, b, ...) | Maximum of values | EVAL max = GREATEST(a, b, c) |
LEAST(a, b, ...) | Minimum of values | EVAL min = LEAST(a, b, c) |
ATAN2(y, x) | Two-argument arctangent | EVAL angle = ATAN2(y, x) |
CBRT(n) | Cube root | EVAL root = CBRT(volume) |
COSH(n) | Hyperbolic cosine | EVAL ch = COSH(x) |
SINH(n) | Hyperbolic sine | EVAL sh = SINH(x) |
TANH(n) | Hyperbolic tangent | EVAL th = TANH(x) |
HYPOT(a, b) | Hypotenuse | EVAL h = HYPOT(x, y) |
TAU() | Tau (2*Pi) | EVAL t = TAU() |
COPY_SIGN(mag, sign) | Copy sign (8.19/9.1+) | EVAL v = COPY_SIGN(mag, sign) |
SCALB(d, scaleFactor) | Scale by power of 2 (8.19/9.1+) | EVAL v = SCALB(d, 3) |
ROUND_TO(n, v1, v2, ...) | Round to fixed points (8.19/9.1+) | EVAL r = ROUND_TO(val, 0, 10, 50, 100) |
Date/Time Functions
| Function | Description | Example |
|---|---|---|
NOW() | Current timestamp | WHERE @timestamp > NOW() - 1 hour |
DATE_TRUNC(interval, date) | Truncate to interval | EVAL hour = DATE_TRUNC(1 hour, @timestamp) |
DATE_EXTRACT(part, date) | Extract part | EVAL month = DATE_EXTRACT(month, date) |
DATE_FORMAT(pattern, date) | Format date | EVAL str = DATE_FORMAT("yyyy-MM-dd", date) |
DATE_PARSE(pattern, str) | Parse date string | EVAL dt = DATE_PARSE("yyyy-MM-dd", str) |
DATE_DIFF(unit, d1, d2) | Difference | EVAL days = DATE_DIFF("day", start, end) |
DAY_NAME(date) | Weekday name (9.2+) | EVAL day = DAY_NAME(@timestamp) |
MONTH_NAME(date) | Month name (9.2+) | EVAL month = MONTH_NAME(@timestamp) |
TRANGE(duration) | Time range filter (9.3+) | WHERE TRANGE(1 hour) |
Time units: millisecond, second, minute, hour, day, week, month, year
Timespan literals: 1 day, 2 hours, 30 minutes, 1 week
Type Conversion Functions
| Function | Description | Example |
|---|---|---|
TO_STRING(v) | Convert to string | EVAL str = TO_STRING(num) |
TO_INTEGER(v) | Convert to integer | EVAL int = TO_INTEGER(str) |
TO_LONG(v) | Convert to long | EVAL lng = TO_LONG(str) |
TO_DOUBLE(v) | Convert to double | EVAL dbl = TO_DOUBLE(str) |
TO_BOOLEAN(v) | Convert to boolean | EVAL bool = TO_BOOLEAN(str) |
TO_DATETIME(v) | Convert to datetime | EVAL dt = TO_DATETIME(str) |
TO_IP(v) | Convert to IP | EVAL ip = TO_IP(str) |
TO_VERSION(v) | Convert to version | EVAL ver = TO_VERSION(str) |
TO_UNSIGNED_LONG(v) | Convert to unsigned long | EVAL ul = TO_UNSIGNED_LONG(str) |
TO_DATEPERIOD(v) | Convert to date period (8.16+) | EVAL dp = TO_DATEPERIOD("1 day") |
TO_TIMEDURATION(v) | Convert to time duration (8.16+) | EVAL td = TO_TIMEDURATION("1h") |
TO_DATE_NANOS(v) | Convert to nanosecond date (8.18/9.0+) | EVAL ns = TO_DATE_NANOS(str) |
TO_DEGREES(n) | Radians to degrees | EVAL deg = TO_DEGREES(rad) |
TO_RADIANS(n) | Degrees to radians | EVAL rad = TO_RADIANS(deg) |
TO_GEOPOINT(v) | Convert to geo_point | EVAL pt = TO_GEOPOINT(str) |
TO_GEOSHAPE(v) | Convert to geo_shape | EVAL shape = TO_GEOSHAPE(wkt) |
TO_CARTESIANPOINT(v) | Convert to cartesian_point | EVAL pt = TO_CARTESIANPOINT(str) |
TO_CARTESIANSHAPE(v) | Convert to cartesian_shape | EVAL shape = TO_CARTESIANSHAPE(str) |
TO_AGGREGATE_METRIC_DOUBLE(v) | Convert to aggregate_metric_double (9.2+, preview) | EVAL amd = TO_AGGREGATE_METRIC_DOUBLE(val) |
TO_DENSE_VECTOR(v) | Convert to dense_vector (9.2+, preview) | EVAL vec = TO_DENSE_VECTOR(arr) |
TO_GEOHASH(v) | Convert to geohash (9.2+, preview) | EVAL hash = TO_GEOHASH(str) |
TO_GEOHEX(v) | Convert to geohex (9.2+, preview) | EVAL hex = TO_GEOHEX(str) |
TO_GEOTILE(v) | Convert to geotile (9.2+, preview) | EVAL tile = TO_GEOTILE(str) |
TO_TEXT(v [, {"analyzer": a}]) | Convert keyword to analyzed text (preview 9.5) | WHERE MATCH(TO_TEXT(note), "quota") |
TO_COUNTER(v) / v::counter | Mark numeric metric as counter (GA 9.5, TS only) | SUM(RATE(bytes::counter)) |
TO_GAUGE(v) / v::gauge | Mark metric as gauge (GA 9.5, TS only) | AVG(requests::gauge) |
TO_COUNTER / TO_GAUGE only change the type annotation, for metrics whose mapping has the wrong (or no)
time_series_metric. Use them inside TS ... | STATS; EVAL cannot return a counter type. See
time-series-queries.md.
IP Functions
| Function | Description | Example |
|---|---|---|
CIDR_MATCH(ip, block1, ...) | Test if IP is in one or more CIDRs | WHERE CIDR_MATCH(source.ip, "10.0.0.0/8") |
IP_PREFIX(ip, v4len, v6len) | Get the network prefix of an IP | EVAL prefix = IP_PREFIX(ip, 24, 64) |
TO_IP(v) | Convert to IP type | EVAL ip = TO_IP(ip_string) |
Examples:
// Filter to private network ranges
FROM logs-*
| WHERE CIDR_MATCH(source.ip, "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
// Group traffic by /24 subnet
FROM network_logs
| STATS bytes = SUM(bytes_transferred) BY subnet = IP_PREFIX(source.ip, 24, 64)
| SORT bytes DESCIP functions, IP comparisons, IN (FROM …) subqueries, and LOOKUP JOIN on an ip field reject keyword values
(must be [ip], found value [client_ip] type [keyword]). Check the mapping and convert with TO_IP(). An IN subquery
and a LOOKUP JOIN key need a column on the left, so assign it with EVAL first:
// IN (FROM …) subquery: preview 9.5+
FROM access_logs
| EVAL ip = TO_IP(client_ip)
| WHERE NOT CIDR_MATCH(ip, "10.0.0.0/8") AND ip NOT IN (FROM allowed_ips | KEEP ip)Spatial Functions
| Function | Description | Example |
|---|---|---|
ST_DISTANCE(p1, p2) | Distance between points | EVAL dist = ST_DISTANCE(loc, TO_GEOPOINT("POINT(0 0)")) |
ST_INTERSECTS(g1, g2) | Geometries intersect | WHERE ST_INTERSECTS(geo, boundary) |
ST_DISJOINT(g1, g2) | Geometries don't intersect | WHERE ST_DISJOINT(geo, zone) |
ST_CONTAINS(g1, g2) | g1 contains g2 | WHERE ST_CONTAINS(region, point) |
ST_WITHIN(g1, g2) | g1 within g2 | WHERE ST_WITHIN(point, region) |
ST_X(point) | X coordinate / longitude | EVAL lon = ST_X(location) |
ST_Y(point) | Y coordinate / latitude | EVAL lat = ST_Y(location) |
ST_ENVELOPE(geo) | Bounding box (8.18/9.0+) | EVAL bbox = ST_ENVELOPE(shape) |
ST_XMAX(geo) | Max X / longitude (8.18/9.0+) | EVAL max_lon = ST_XMAX(shape) |
ST_XMIN(geo) | Min X / longitude (8.18/9.0+) | EVAL min_lon = ST_XMIN(shape) |
ST_YMAX(geo) | Max Y / latitude (8.18/9.0+) | EVAL max_lat = ST_YMAX(shape) |
ST_YMIN(geo) | Min Y / latitude (8.18/9.0+) | EVAL min_lat = ST_YMIN(shape) |
ST_GEOHASH(point, prec) | Encode as geohash (9.2+) | EVAL hash = ST_GEOHASH(location, 5) |
ST_GEOHEX(point, prec) | Encode as geohex (9.2+) | EVAL hex = ST_GEOHEX(location, 5) |
ST_GEOTILE(point, prec) | Encode as geotile (9.2+) | EVAL tile = ST_GEOTILE(location, 10) |
ST_NPOINTS(geo) | Number of points | EVAL n = ST_NPOINTS(shape) |
ST_SIMPLIFY(geo, tol) | Simplify geometry | EVAL simple = ST_SIMPLIFY(shape, 100) |
ST_DIMENSION(geo) | Dimension (0/1/2) (9.4+) | EVAL dim = ST_DIMENSION(shape) |
ST_GEOMETRYTYPE(geo) | Geometry type string (9.4+) | EVAL gtype = ST_GEOMETRYTYPE(shape) |
ST_ISEMPTY(geo) | True if empty (9.4+) | WHERE NOT ST_ISEMPTY(shape) |
ST_BUFFER(geo, dist) | Buffer around geometry (9.4+) | EVAL area = ST_BUFFER(point, 1000) |
ST_SIMPLIFYPRESERVETOPOLOGY(geo, tol) | Simplify preserving topology (9.4+) | EVAL s = ST_SIMPLIFYPRESERVETOPOLOGY(shape, 100) |
ST_UNION(g1, g2) | Union of geometries (preview 9.5) | EVAL u = ST_UNION(zone_a, zone_b) |
ST_INTERSECTION(g1, g2) | Shared area (preview 9.5) | EVAL overlap = ST_INTERSECTION(zone_a, zone_b) |
ST_DIFFERENCE(g1, g2) | g1 minus g2 (preview 9.5) | EVAL rest = ST_DIFFERENCE(zone_a, zone_b) |
ST_SYMDIFFERENCE(g1, g2) | Area in exactly one (preview 9.5) | EVAL xor = ST_SYMDIFFERENCE(zone_a, zone_b) |
Dense Vector Functions
For vector search and similarity operations on dense_vector and semantic_text fields.
| Function | Description | Example |
|---|---|---|
KNN(field, query_vec [, options]) | K-nearest neighbor search (preview 9.2, GA 9.4) | WHERE KNN(embedding, query_vector, {"k": 10}) |
TEXT_EMBEDDING(text, inference_id) | Generate text embeddings (preview 9.3, GA 9.4) | EVAL vec = TEXT_EMBEDDING("query", "my-model") |
V_COSINE(v1, v2) | Cosine similarity (preview 9.3, GA 9.4) | EVAL sim = V_COSINE(vec1, vec2) |
V_DOT_PRODUCT(v1, v2) | Dot product (preview 9.3, GA 9.4) | EVAL dot = V_DOT_PRODUCT(vec1, vec2) |
V_L1_NORM(v1, v2) | L1 / Manhattan distance (preview 9.3, GA 9.4) | EVAL l1 = V_L1_NORM(vec1, vec2) |
V_L2_NORM(v1, v2) | L2 / Euclidean distance (preview 9.3, GA 9.4) | EVAL l2 = V_L2_NORM(vec1, vec2) |
V_HAMMING(v1, v2) | Hamming distance (preview 9.3, GA 9.4) | EVAL h = V_HAMMING(vec1, vec2) |
EMBEDDING(value, inference_id) | Multimodal embeddings (text, image, …) (preview 9.5) | EVAL vec = EMBEDDING("red running shoes", "my-mm") |
EMBEDDING needs an inference endpoint with the embedding task type (TEXT_EMBEDDING needs text_embedding). The
input must be a literal string; set its content type with the type option (e.g. a base64 data URI with
{"type": "image"}). Both accept {"timeout": "30s"} (9.5+) for a per-call inference timeout.
Multivalue Functions
For handling fields with multiple values.
| Function | Description | Example |
|---|---|---|
MV_COUNT(field) | Count values | EVAL n = MV_COUNT(tags) |
MV_FIRST(field) | First value | EVAL first_val = MV_FIRST(values) |
MV_LAST(field) | Last value | EVAL last_val = MV_LAST(values) |
MV_MIN(field) | Minimum | EVAL min = MV_MIN(scores) |
MV_MAX(field) | Maximum | EVAL max = MV_MAX(scores) |
MV_SUM(field) | Sum | EVAL total = MV_SUM(amounts) |
MV_AVG(field) | Average | EVAL avg = MV_AVG(scores) |
MV_MEDIAN(field) | Median | EVAL med = MV_MEDIAN(values) |
MV_CONCAT(field, delim) | Join to string | EVAL str = MV_CONCAT(tags, ", ") |
MV_DEDUPE(field) | Remove duplicates | EVAL unique = MV_DEDUPE(tags) |
MV_SORT(field) | Sort values | EVAL sorted = MV_SORT(values) |
MV_SLICE(field, start, end) | Slice array | EVAL slice = MV_SLICE(arr, 0, 3) |
MV_ZIP(f1, f2) | Zip arrays (both must be keyword/text) | EVAL zipped = MV_ZIP(keys, values) |
MV_APPEND(f1, f2) | Concatenate MVs | EVAL all = MV_APPEND(tags1, tags2) |
MV_CONTAINS(f1, f2) | All values in f2 present in f1 (9.2+) | EVAL has = MV_CONTAINS(perms, required) |
MV_INTERSECTION(f1, f2) | Values present in both (9.3+) | EVAL common = MV_INTERSECTION(a, b) |
MV_INTERSECTS(f1, f2) | Any value in f2 present in f1 (9.4+) | EVAL overlap = MV_INTERSECTS(a, b) |
MV_UNION(f1, f2) | Deduplicated union (9.4+) | EVAL merged = MV_UNION(a, b) |
MV_DIFFERENCE(f1, f2) | Values in f1 not in f2 (9.4+) | EVAL diff = MV_DIFFERENCE(a, b) |
MV_PERCENTILE(field, p) | Percentile of MV | EVAL p95 = MV_PERCENTILE(vals, 95) |
MV_PSERIES_WEIGHTED_SUM(field, p) | P-series weighted sum (both args must be double) | EVAL ws = MV_PSERIES_WEIGHTED_SUM(vals, 2.0) |
MV_MEDIAN_ABSOLUTE_DEVIATION(field) | MAD of MV | EVAL mad = MV_MEDIAN_ABSOLUTE_DEVIATION(vals) |
Conditional Functions
| Function | Description | Example |
|---|---|---|
CASE(cond1, val1, ..., default) | Conditional | EVAL level = CASE(score > 90, "A", score > 80, "B", "C") |
COALESCE(v1, v2, ...) | First non-null | EVAL name = COALESCE(nickname, full_name, "Unknown") |
field IS NULL | Check null | WHERE error IS NULL |
field IS NOT NULL | Check not null | WHERE response IS NOT NULL |
CLAMP(val, min, max) | Clamp to range (9.3+) | EVAL clamped = CLAMP(score, 0, 100) |
CLAMP_MIN(val, min) | Clamp lower bound (9.3+) | EVAL v = CLAMP_MIN(score, 0) |
CLAMP_MAX(val, max) | Clamp upper bound (9.3+) | EVAL v = CLAMP_MAX(score, 100) |
date_range Type and Functions (preview 9.5)
The date_range data type represents a time range with start and end bounds. Associated functions work with range
comparisons and bound extraction. date_range columns also work in COUNT, PRESENT, ABSENT, the MV_* functions,
and Arrow output.
| Function / Type | Description | Example |
|---|---|---|
TO_DATE_RANGE(string) | Parse "from..to" string into a date_range | EVAL dr = TO_DATE_RANGE("2026-01-01..2026-12-31") |
TO_RANGE(from, to) | Construct date_range from two dates | EVAL dr = TO_RANGE(start_date, end_date) |
RANGE_INTERSECTS(a, b) | Check if two ranges (or range + date) overlap | WHERE RANGE_INTERSECTS(event_window, @timestamp) |
RANGE_WITHIN(a, b) | Check if range a is within range b | WHERE RANGE_WITHIN(inner_range, outer_range) |
RANGE_CONTAINS(a, b) | Check if range a contains range b | WHERE RANGE_CONTAINS(event_window, sub_range) |
range_min(field) | Extract lower bound of a date_range | EVAL start = range_min(event_window) |
range_max(field) | Extract upper bound of a date_range | EVAL end = range_max(event_window) |
RANGE_INTERSECTS and RANGE_WITHIN support Lucene pushdown for efficient filtering.
Examples:
// Find events overlapping with the current timestamp
FROM events
| WHERE RANGE_INTERSECTS(event_window, @timestamp)
| EVAL start = range_min(event_window), end = range_max(event_window)
| KEEP event_name, start, end
// Construct a range and check containment
FROM maintenance_windows
| EVAL window = TO_RANGE(planned_start, planned_end)
| WHERE RANGE_CONTAINS(window, @timestamp)Full-Text Search Functions
For text search with analyzer support (available since 8.17+).
MATCH
Basic text search.
FROM articles
| WHERE MATCH(content, "elasticsearch query")
// With options
FROM docs
| WHERE MATCH(title, "search", {"operator": "AND"})MATCH (colon operator)
Shorthand for MATCH.
FROM logs
| WHERE message : "error"MATCH on expressions (preview 9.5):
MATCHand the:operator can search non-indexed expressions such asEVALcolumns, scanning row by row instead of using the inverted index. On akeywordexpression this is an exact-value match; wrap it inTO_TEXTfor analyzed search:EVAL t = TO_TEXT(CONCAT(level, " ", message)) | WHERE MATCH(t, "timeout"). On 9.5 Stack,MATCH/:are still rejected afterSTATS, theoptionsparameter is not supported, and expression matches score0. Serverless allows them afterSTATS(except on an indexed field, such as a plainBYkey) and withoptions; expression matches get an unweighted_score(about1.0per matched term), not BM25.
MATCH_PHRASE
Exact phrase matching. Returns documents where the field contains the exact phrase in order. GA in 8.19/9.1.
FROM articles
| WHERE MATCH_PHRASE(title, "quick brown fox")
// With slop to allow words between phrase terms
FROM articles
| WHERE MATCH_PHRASE(content, "elasticsearch query", slop=2)QSTR (Query String)
Complex queries using query string syntax.
FROM logs
| WHERE QSTR("status:error AND (type:critical OR type:warning)")KQL
Kibana Query Language support.
FROM logs
| WHERE KQL("message: error and host.name: server*")DECAY
Distance-based scoring that decays from an origin point. Works with numeric, date, and geo fields (preview 9.3).
FROM events METADATA _score
| EVAL decay_score = DECAY("gauss", @timestamp, origin=NOW(), scale="7 days")SCORE
Returns the relevance score for a row (9.3+).
FROM articles
| WHERE MATCH(content, "elasticsearch")
| EVAL relevance = SCORE()
| SORT relevance DESCTOP_SNIPPETS
Extracts best matching snippets from text fields (preview 9.3). Options: num_snippets, num_words; 9.5+ adds
highlight, pre_tag, post_tag, encoder (default/html), order (score/none), and analyzer.
FROM articles
| WHERE MATCH(content, "elasticsearch query")
| EVAL snippet = TOP_SNIPPETS(content, "elasticsearch query")
// 9.5+: highlight matched terms
FROM articles
| WHERE MATCH(content, "elasticsearch")
| EVAL snippet = TOP_SNIPPETS(content, "elasticsearch", {"num_snippets": 2, "highlight": true, "pre_tag": "<b>", "post_tag": "</b>"})Relevance Scoring
FROM articles METADATA _score
| WHERE MATCH(content, "elasticsearch")
| SORT _score DESC
| LIMIT 10Operators
Comparison Operators
==Equal!=Not equal<,<=,>,>=ComparisonIS NULL,IS NOT NULLNull checks
Logical Operators
ANDLogical ANDORLogical ORNOTLogical NOT
Pattern Matching
LIKEWildcard pattern (*zero or more chars,?single char)RLIKERegular expressionINValue in list
Examples:
WHERE name LIKE "John*"
WHERE email RLIKE ".*@example\\.com"
WHERE status IN ("active", "pending")
WHERE NOT (status == "deleted")Arithmetic Operators
+,-,*,/,%(modulo)
Syntax Details
Comments
// Single line comment
/* Multi-line
comment */
FROM logs // inline comment
| WHERE status == 200String Literals
// Standard strings — use backslash escapes
ROW msg = "line1\nline2", path = "C:\\Users\\data"
// Triple-quoted strings — no escaping needed, can contain single quotes
ROW pattern = """field "with quotes" and \backslashes"""Numeric Literals
// Integer, decimal, scientific notation
ROW a = 123, b = 0.23, c = 2E3, d = 1.2e-3Identifiers and Escaping
Identifiers must be enclosed in backticks if:
they don't start with a letter,
_, or@any other character is not a letter, number, or
_they clash with a reserved keyword, including when that word is a segment of a dotted name
A literal backtick inside a backtick-quoted identifier is escaped by doubling it. Quoting with backticks always works, even when it is not required.
// Backtick-quoted identifiers for special field names
FROM index | EVAL val = `1.field`
// Escaping backticks within identifiers
FROM index | EVAL val = `field``name`
// Reserved keyword as a column alias
FROM logs-* | STATS `first` = MIN(@timestamp) BY service.name
// Reserved keyword as a segment of a dotted field name (`in` is the IN operator)
FROM metrics-* | WHERE `host.network.interface.in.utilization` IS NOT NULLReserved keywords. ES|QL keywords are case-insensitive, so in, IN, and In are the same token. Most keywords
are treated as keywords only in the corresponding context; the docs' reserved list includes SCORE, KEY, GROUP,
SET, and SAMPLE on that basis.
Never bare in an expression:
FIRST,LAST,BY,IN,IS,ON,WITH,AND,OR,NOT,LIKE,RLIKE,NULL,TRUE,FALSE,ASC,DESC,NULLS. They fail asEVAL/STATSaliases, inBY,WHERE,SORT, function arguments, and as dotted segments. TheKEEP,DROP, andRENAMEsource name lists accept them.ASis a keyword only insideRENAME;METADATAonly insideFROM.SCORE,KEY, andGROUPfail bare only as the column right afterFUSE ... SCORE BY,KEY BY, orGROUP BY.Command names (
SET,SAMPLE,DEDUP,LIMIT,SORT,FUSE,FORK,RERANK,COMPLETION,CHANGE_POINT, …) are keywords only as the first word of a pipe segment and work bare as column names.
Backquoting any identifier always works; backquote a name that causes an unexplained parse error.
Timespan Literals
Supported units: millisecond (ms), second (s), minute (min), hour (h), day (d), week (w),
month (mo), quarter (q), year (yr). Plural s is always accepted. Whitespace between number and unit is
optional.
// Timespans are used in expressions, not as standalone values
FROM logs-*
| WHERE @timestamp > NOW() - 1 day
| STATS hourly = COUNT(*) BY bucket = DATE_TRUNC(30 minutes, @timestamp)Metadata Fields
Access document metadata with the METADATA directive on the FROM command. Once enabled, metadata fields behave like
regular index fields.
| Field | Type | Description |
|---|---|---|
_id | keyword | Unique document ID |
_index | keyword | Index name |
_version | long | Document version number |
_score | float | Query relevance score (updated by full-text search functions) |
_ignored | keyword | Fields that were ignored when the document was indexed |
_index_mode | keyword | Index mode (standard, lookup, logsdb, time_series etc.) |
_source | special | Original JSON document body. Use JSON_EXTRACT to access flattened or unmapped fields |
_size | integer | Document size in bytes (9.4+; requires mapper-size plugin) |
FROM logs METADATA _id, _index, _version
| KEEP _id, message
// Use _score for relevance-ranked search
FROM articles METADATA _score
| WHERE MATCH(content, "elasticsearch")
| SORT _score DESC
| LIMIT 10Best Practices
Always use LIMIT to avoid returning too many rows
Filter early with WHERE to reduce data processed
Use KEEP to select only needed columns
Use appropriate data types for comparisons
Use STATS for aggregations instead of returning all rows
Use DATE_TRUNC for time-based grouping
Leverage full-text functions (MATCH, QSTR) for text search - much faster than LIKE/RLIKE
Example Queries
Log Analysis
FROM logs-*
| WHERE @timestamp > NOW() - 24 hours
| WHERE status_code >= 400
| STATS error_count = COUNT(*) BY status_code, host.name
| SORT error_count DESC
| LIMIT 20User Activity
FROM user_events
| WHERE event_type == "login"
| EVAL hour = DATE_TRUNC(1 hour, @timestamp)
| STATS logins = COUNT(*), unique_users = COUNT_DISTINCT(user_id) BY hour
| SORT hour DESCPerformance Metrics
FROM metrics-*
| WHERE @timestamp > NOW() - 1 hour
| STATS
avg_response = AVG(response_time),
p95_response = PERCENTILE(response_time, 95),
max_response = MAX(response_time)
BY service.name
| SORT avg_response DESCTime series version (9.2+): For TSDS indices, use TS to access time series aggregation functions:
TS metrics-*
| WHERE @timestamp > NOW() - 1 hour
| STATS
SUM(RATE(request_count)) BY service.name, TBUCKET(5 minutes)
| SORT service.nameText Search with Scoring
FROM articles METADATA _score
| WHERE MATCH(content, "machine learning")
| KEEP title, author, _score
| SORT _score DESC
| LIMIT 10Data Transformation
FROM raw_logs
| GROK message "%{IP:client_ip} - %{WORD:method} %{URIPATHPARAM:path} %{NUMBER:status:int}"
| EVAL is_error = status >= 400
| STATS
total = COUNT(*),
errors = COUNT(CASE(is_error, 1, null))
BY client_ip
| EVAL error_rate = ROUND(errors * 100.0 / total, 2)
| SORT error_rate DESCRequirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
The author says: Elasticsearch 8.14 or later (ES|QL GA; introduced 8.11 as tech preview), self-managed, Elastic Cloud Hosted, or Elastic Cloud Serverless; individual ES|QL features are version-gated (see references/esql-version-history.md). Requires the `elastic` CLI ≥ 0.2 with `stack es` support.
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Created by
- elastic
- Publisher
- Elastic (Official)
- Source
- elastic/agent-skills · skills/elasticsearch/elasticsearch-esql · commit 09d879d
- Licence
- Apache License 2.0 (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
- Also distributed in
- elastic/agent-skills
AutoGineer is not affiliated with or endorsed by elastic.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download elasticsearch-esql.zip