npx skills add ...
npx skills add elastic/agent-skills --skill elasticsearch-esql
Execute ES|QL (Elasticsearch Query Language) queries, use when the user wants to query Elasticsearch data, analyze logs, aggregate metrics, explore data, or create charts and dashboards from ES|QL results.
npx skills add elastic/agent-skills --skill elasticsearch-esql
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.
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.
ES|QL (Elasticsearch Query Language) is a piped query language for Elasticsearch. It is NOT the same as:
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.
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). The build_flavor field 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 with
GET /{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-*, or
application_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 COALESCE unless 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 is
time_series, use TS <data-stream> (not FROM), TBUCKET(interval) (not DATE_TRUNC), and wrap counter fields
with SUM(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 commands METRICS_INFO and
TS_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 via POST /_query. Treat METRICS_INFO as authoritative for
metric_type (counter/gauge/histogram) and field_type (histogram, tdigest, exponential_histogram for
distribution metrics). Both must follow TS and must precede STATS/SORT/LIMIT. See
Time Series Queries:
Choose the right ES|QL feature for the task. Before writing queries, match the user's intent to the most appropriate ES|QL feature. Prefer a single advanced query over multiple basic ones.
CATEGORIZE(field)CHANGE_POINT value ON keySTATS ... BY BUCKET(@timestamp, interval) or TS for TSDBsum by (instance) (...), label matchers like {cluster="prod"} →
PROMQL source command (9.4+ preview); see PROMQL Command. Prefer TS for native
ES|QL phrasing.MATCH (default), QSTR (advanced boolean), KQL (Kibana migration). For
content/document relevance search, follow the ES|QL Search StrategySTATS with aggregation functionsSET approximation=true; before a
STATS query (GA in 9.5+/Serverless, preview in 9.4); see Query ApproximationRead the references before generating queries:
PROMQL vs TS decision matrix (9.4+ preview)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 KEEP that directly
answer the question. Do not add extra filter conditions beyond what the user specified (e.g., don't add
OR level == "ERROR" when the user just said "errors").
FROM index-pattern (or TS index-pattern for time series indices)WHERE for filtering (use TRANGE for time ranges on 9.3+)EVAL for computed fieldsSTATS ... BY for aggregationsTS with SUM(RATE(...)) for counters, AVG(...) for gauges, standard aggregations
(SUM, AVG, PERCENTILE, … — not *_OVER_TIME) for histogram metrics, and TBUCKET(interval) for time
bucketing — see the TS section in Generation Tips and
Histogram MetricsCHANGE_POINT after time-bucketed aggregationSORT and LIMIT as neededExecute the query with POST /_query. Request tabular (TSV) output for clean, decoration-free results that are
easy to read and post-process.
Version availability: This section omits version annotations for readability. Check ES|QL Version History for feature availability by Elasticsearch version.
Filter and limit:
Aggregate by time: For time series (TSDS) indices, prefer TS with TRANGE and TBUCKET over FROM +
DATE_TRUNC (see the time series section below).
Top N with count:
Text 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.
String 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.
Log 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.
Change 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.
Time 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.
Time series with PromQL syntax (9.4+ preview): 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. The PROMQL command accepts standard PromQL with optional index, step,
buckets, start, end, and scrape_interval options, and produces a table that the rest of the ES|QL pipeline can
process. Range selectors are optional — when omitted, the window is max(step, scrape_interval). Otherwise prefer TS
(GA in 9.4). PROMQL does not support group modifiers, set operators (or/and/unless), or functions like
histogram_quantile, predict_linear, and label_join — fall back to TS for those. See
PROMQL Command for the full reference.
Data 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.
Multivalue field filtering: Use MV_CONTAINS to check if a multivalue field contains a specific value. Use
MV_COUNT to count values.
Change point detection (alternate example): Use when the user asks about spikes, dips, or anomalies. Requires
time-bucketed aggregation, SORT, then CHANGE_POINT.
Approximate 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. See
Query Approximation.
For complete ES|QL syntax including all commands, functions, and operators, read:
When query execution fails, read the error message from Elasticsearch and correct the query. Common issues:
GET /{index}/_mapping) and list indices (GET /_cat/indices)
before writing a query. Never guess field or index names — they vary across deployments.STD_DEV() not STDDEV(), MEDIAN_ABSOLUTE_DEVIATION() not
MAD(). Use CONCAT() for strings, not +. Use CASE(cond, val, ...) not CASE WHEN...THEN...END.DATE_EXTRACT uses ES|QL part names: "hour_of_day" not "hour", "day_of_month" not "day",
"month_of_year" not "month". Use DATE_DIFF("day", start, end) for date arithmetic, not subtraction.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:
"Average response time per service, only for 5xx responses" — filter to errors before aggregating:
"Error count per day for the last week" — bucket by day with DATE_TRUNC:
GET /{index}/_mapping) and list indices (GET /_cat/indices) before
writing a query — never guess field or index names.WHERE before STATS so aggregation runs over the smallest row set.LIMIT.GET / (build_flavor, version.number) and
references/esql-version-history.md before using newer commands such as LOOKUP JOIN or INLINE STATS.| 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 |
POST /_query | elastic es esql query --format tsv --query "<esql>" |