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/generation-tips.md
ES|QL Query Generation Tips
Guidelines for generating accurate ES|QL queries from natural language.
Cluster detection: Check
build_flavorin theGET /response. For Serverless ("serverless"), do not version-gate:version.numbertracks the next minor from main (semver-only clients may see it as “latest”), but feature availability is not determined by that string — usebuild_flavoras the signal. For Stack ("default"), useversion.numberfor feature checks (strip-SNAPSHOTsuffix on pre-release builds).
Table of Contents
Critical Syntax Rules
Step-by-Step Generation Process
Field Name Conventions
Query Optimization Tips
Key Patterns
Common Query Templates
Handling Ambiguity
Output Formatting Suggestions
9.5 Additions
Critical Syntax Rules
String Literals Use Double Quotes Only
ES|QL uses double quotes for string literals — never single quotes. This is the most common source of
token recognition error at: ' failures. SQL habits lead models to write 'value' when ES|QL requires "value".
// WRONG — single quotes cause parse errors
| WHERE status == 'open'
| EVAL priority = CASE(status == 'open', 'high', 'low')
// CORRECT — always double quotes
| WHERE status == "open"
| EVAL priority = CASE(status == "open", "high", "low")This applies everywhere: WHERE, EVAL, CASE, STATS ... BY, function arguments, and string constants.
Reserved Words Need Backticks as Identifiers
A few ES|QL keywords cannot be used bare as column names, aliases, or as a segment of a dotted field name. first and
last are the most common traps because they read like natural column names for earliest/latest timestamps. Keywords
are case-insensitive, so first, FIRST, and First are the same token.
// WRONG — `first` is a reserved keyword
| STATS first = MIN(@timestamp) BY service.name
// CORRECT — rename the column (preferred)
| STATS first_seen = MIN(@timestamp) BY service.name
// CORRECT — or backtick-quote it
| STATS `first` = MIN(@timestamp) BY service.namePrefer renaming the output column over backticking it — a descriptive alias like first_seen or earliest reads
better and avoids the quoting entirely. Backticks are the only option when the clash is in a real field name you do not
control, including a reserved word buried in a dotted path:
// WRONG — `in` is the IN operator, so the dotted name fails to parse
| WHERE host.network.interface.in.utilization IS NOT NULL
// CORRECT — backtick-quote the whole identifier
| WHERE `host.network.interface.in.utilization` IS NOT NULLNever bare in an expression: FIRST, LAST, BY, IN, IS, ON, WITH, plus the operator and literal words
AND, OR, NOT, LIKE, RLIKE, NULL, TRUE, FALSE, ASC, DESC, NULLS. They fail in every expression
position: EVAL/STATS aliases, BY, WHERE, SORT, function arguments, and dotted segments (x.in.y). Only the
looser name lists of KEEP, DROP, and the RENAME source accept them bare.
Keywords only in one context:
ASfails bare only insideRENAME;METADATAonly insideFROM.SCORE,KEY, andGROUPfail bare only as the column right afterFUSE ... SCORE BY,KEY BY, orGROUP BY— writeFUSE linear SCORE BY `score`. Elsewhere they are ordinary column names.Command names (
SET,SAMPLE,DEDUP,LIMIT,SORT,FUSE,FORK,RERANK,COMPLETION,CHANGE_POINT, …) are keywords only as the first word of a pipe segment, soEVAL sample = 1orSTATS c = COUNT(*) BY scoreparse.
Backquoting any identifier always works. If a name causes an unexplained parse error, backquote it.
CASE Uses Condition-Value Pairs (Not SQL Syntax)
ES|QL CASE takes alternating condition-value pairs with an optional default — it does not support
CASE WHEN ... THEN ... ELSE ... END syntax.
// WRONG — SQL-style CASE
| EVAL grade = CASE WHEN score > 90 THEN "A" WHEN score > 80 THEN "B" ELSE "C" END
// CORRECT — ES|QL pairs: CASE(cond1, val1, cond2, val2, ..., default)
| EVAL grade = CASE(score > 90, "A", score > 80, "B", "C")Two-branch conditionals use three arguments (condition, true-value, false-value):
| EVAL priority = CASE(status == "open", "high", "low")Aggregation Function Names Differ from SQL
ES|QL function names use underscores where SQL does not. The most common mistake is STDDEV() — the correct ES|QL name
is STD_DEV().
| SQL Name | ES|QL Name |
|---|---|
| STDDEV | STD_DEV |
// WRONG — SQL function name
| STATS sd = STDDEV(total)
// CORRECT — ES|QL uses underscored name
| STATS sd = STD_DEV(total)String Concatenation Uses CONCAT (No + Operator)
ES|QL does not support the + operator for string concatenation. Use CONCAT() instead. ES|QL also does not have
SUBSTRING, STRPOS, SPLIT, or INSTR — use DISSECT or GROK for string extraction.
// WRONG — + operator does not work on strings
| EVAL full_name = first_name + " " + last_name
// CORRECT
| EVAL full_name = CONCAT(first_name, " ", last_name)DATE_EXTRACT Part Names Differ from SQL
DATE_EXTRACT(part, date) uses ES|QL-specific part name strings — not SQL keywords like HOUR or DAY. The part
string must be double-quoted and is case-insensitive.
| SQL Part | ES|QL Part Name |
|---|---|
| YEAR | "year" |
| QUARTER | "quarter" |
| MONTH | "month_of_year" |
| WEEK | "week" |
| DAY | "day_of_month" |
| DOW | "day_of_week" |
| DOY | "day_of_year" |
| HOUR | "hour_of_day" |
| MINUTE | "minute_of_hour" |
| SECOND | "second_of_minute" |
// WRONG — SQL-style part names or single quotes
| EVAL hour = DATE_EXTRACT("hour", @timestamp)
| EVAL hour = DATE_EXTRACT('HOUR_OF_DAY', @timestamp)
// CORRECT — ES|QL part name in double quotes
| EVAL hour = DATE_EXTRACT("hour_of_day", @timestamp)
| STATS count = COUNT(*) BY hour = DATE_EXTRACT("hour_of_day", @timestamp)Date Arithmetic Uses DATE_DIFF (No Subtraction)
ES|QL does not support the - operator between two date values. Use DATE_DIFF(unit, start, end) instead.
// WRONG — subtraction between dates is not supported
| EVAL days = end_date - start_date
// CORRECT — DATE_DIFF computes the difference in the given unit
| EVAL days = DATE_DIFF("day", start_date, end_date)Valid units: "year", "quarter", "month", "week", "day", "hour", "minute", "second", "millisecond".
Step-by-Step Generation Process
1. Identify the Data Source
Question: What index or data should be queried?
Look for index names, data types, or subject areas mentioned
Common patterns:
logs-*,metrics-*,events-*,apm-*If unclear, use wildcards or ask for clarification
FROM logs-* // Generic logs
FROM metrics-* // Metrics data
FROM my-index-2024.* // Dated indicesFor time series data streams (TSDS), use TS instead of FROM to enable time series aggregation functions like RATE,
AVG_OVER_TIME, etc. (preview from 9.2 to 9.3, GA since 9.4):
TS metrics-* // Time series source — enables RATE, AVG_OVER_TIME, etc.When the question asks about rates, throughput, CPU/memory trends, or metric comparisons, prefer a metrics-* or
TSDS index with TS over a general log index with FROM. Check the schema — if an index has Index mode: time_series,
always use TS.
2. Determine Time Range
Question: What time period should be covered?
| User Expression | ES|QL |
|---|---|
| "last hour" | @timestamp > NOW() - 1 hour |
| "last 24 hours" | @timestamp > NOW() - 24 hours |
| "last 7 days" | @timestamp > NOW() - 7 days |
| "today" | @timestamp >= DATE_TRUNC(1 day, NOW()) |
| "yesterday" | @timestamp >= DATE_TRUNC(1 day, NOW()) - 1 day AND @timestamp < DATE_TRUNC(1 day, NOW()) |
| "this week" | @timestamp >= DATE_TRUNC(1 week, NOW()) |
| "this month" | @timestamp >= DATE_TRUNC(1 month, NOW()) |
Default: If no time range is specified, add a reasonable default (e.g., last 24 hours) to avoid scanning too much data.
3. Identify Filters
Question: What conditions should narrow the results?
Look for:
Status/level: "errors", "warnings", "successful"
Environment: "production", "staging", "dev"
Source/host: specific servers, services, applications
Values: specific codes, IDs, names
// Multiple filters
| WHERE level == "error"
| WHERE environment == "production"
| WHERE service.name == "api-gateway"Or combined:
| WHERE level == "error" AND environment == "production" AND service.name == "api-gateway"Negation and NULL values: ES|QL uses three-valued logic. WHERE field != "value" silently excludes rows where the
field is NULL (missing). When generating negation filters, always add an IS NULL guard:
| WHERE environment != "test" OR environment IS NULL4. Determine Output Type
Question: Does the user want raw data or aggregated results?
| User Intent | Approach |
|---|---|
| "show me", "list", "find" | Raw data with KEEP, SORT, LIMIT |
| "count", "how many" | STATS with COUNT |
| "average", "total", "sum" | STATS with aggregation function |
| "by X", "per X", "grouped by" | STATS ... BY grouping |
| "top N", "most common" | STATS + SORT DESC + LIMIT |
| "distribution", "breakdown" | STATS COUNT BY category |
| "over time", "trend" | STATS BY DATE_TRUNC |
| "patterns", "categorize", "types of" | STATS ... BY CATEGORIZE(field) |
| "spike", "dip", "anomaly", "change" | CHANGE_POINT value ON key |
| "patterns over time" | CATEGORIZE + BUCKET |
When combining CATEGORIZE with a time bucket, CATEGORIZE must be the first BY key — see
CATEGORIZE Must Be the First BY Key. To find when a category's count shifted,
add CHANGE_POINT value ON bucket BY category — but per-group CHANGE_POINT (the BY clause) is GA only on 9.5+ /
Serverless. Without BY, CHANGE_POINT treats the whole input as one series, so differences between categories look
like temporal changes.
Prefer single advanced queries over multiple basic ones. When the user asks to "find patterns" or "analyze logs,"
use CATEGORIZE in one query rather than running several STATS ... BY field queries against different fields.
Similarly, use CHANGE_POINT to detect anomalies rather than producing hourly counts for the user to eyeball.
5. Select Fields
Question: What fields should be shown?
For raw data queries, use KEEP to select relevant fields:
| KEEP @timestamp, host.name, message, levelFor aggregations, the output fields are defined by STATS:
| STATS count = COUNT(*), avg_time = AVG(response_time) BY endpoint6. Apply Ordering and Limits
Question: How should results be ordered and limited?
Time-based:
SORT @timestamp DESCBy count/value:
SORT count DESCAlphabetical:
SORT name ASC
Always add LIMIT unless the user specifically wants all results:
| LIMIT 100 // Reasonable default
| LIMIT 1000 // Maximum before considering paginationField Name Conventions
When generating queries, use common field naming conventions:
Elastic Common Schema (ECS)
| Category | Common Fields |
|---|---|
| Timestamp | @timestamp |
| Message | message |
| Log level | log.level, level |
| Host | host.name, host.ip |
| Service | service.name, service.type |
| HTTP | http.request.method, http.response.status_code, url.path |
| User | user.name, user.id |
| Source | source.ip, source.port |
| Destination | destination.ip, destination.port |
| Error | error.message, error.type |
| Event | event.action, event.category, event.outcome |
Default to ECS Dotted Names
When schema discovery is not available and you must guess field names, always prefer ECS dotted notation over flat
names. Flat names like source_ip or service are common mistakes — most Elastic indices use the dotted ECS form.
| Prefer (ECS) | Avoid (flat) |
|---|---|
source.ip | source_ip |
service.name | service |
event.category | event |
event.outcome | outcome |
host.name | hostname |
Legacy/Custom Fields
Some indices may use non-ECS field names:
status_codeinstead ofhttp.response.status_codehostnameinstead ofhost.nametimestampinstead of@timestamp
Recommendation: Always discover actual field names with GET /{index}/_mapping, listing candidate indices first
with GET /_cat/indices, before generating queries (see the Operations table in SKILL.md for the elastic CLI
invocation). Never guess — index and field names vary across deployments.
Query Optimization Tips
1. Filter Early
Put WHERE clauses as early as possible:
// Good - filter first
FROM logs-*
| WHERE @timestamp > NOW() - 1 hour
| WHERE level == "error"
| STATS count = COUNT(*) BY host.name
// Less efficient - filtering after processing
FROM logs-*
| STATS count = COUNT(*) BY host.name, level
| WHERE level == "error"2. Use Appropriate Time Ranges
Smaller time ranges = faster queries:
// Specific range is faster
| WHERE @timestamp > NOW() - 1 hour
// Than scanning all data
// (no time filter)3. Limit Fields
Only keep fields you need:
// Good - specific fields
| KEEP @timestamp, message, host.name
// Less efficient - all fields
// (no KEEP command)4. Use LIMIT
Prevent returning excessive rows:
| LIMIT 100 // Always include for raw data queries5. Check for Pre-Existing Computed Fields
Before computing derived values (distances, durations, rates, etc.) with EVAL, check the schema for fields that were
already calculated at ingest time. Many indices pre-compute common values — using them is simpler and avoids
recomputation.
// Prefer: use the pre-computed field
FROM kibana_sample_data_flights
| STATS avg_distance = AVG(DistanceKilometers)
// Avoid: recomputing what already exists
FROM kibana_sample_data_flights
| EVAL distance_km = ST_DISTANCE(OriginLocation, DestLocation) / 1000
| STATS avg_distance = AVG(distance_km)Key Patterns
CATEGORIZE Must Be the First BY Key
CATEGORIZE(field) is only valid as the first grouping key in STATS ... BY. Listing any other grouping ahead of
it — a time bucket, a service name, a host — fails to parse. This bites most often when combining categorization with
time bucketing for a "patterns over time" question.
// WRONG — time bucket listed before CATEGORIZE
FROM logs-*
| STATS c = COUNT(*) BY bucket = BUCKET(@timestamp, 1 minute), category = CATEGORIZE(message)
// CORRECT — CATEGORIZE first, bucket second
FROM logs-*
| STATS c = COUNT(*) BY category = CATEGORIZE(message), bucket = BUCKET(@timestamp, 1 minute)
| SORT category, bucketCATEGORIZE is also unsupported as an INLINE STATS grouping function.
Per-Aggregation WHERE (8.16+)
Use COUNT(*) WHERE condition instead of CASE-based workarounds to compute conditional metrics in a single pass:
FROM logs-*
| STATS
total = COUNT(*),
errors = COUNT(*) WHERE level == "error",
warnings = COUNT(*) WHERE level == "warning"
BY service.name
| EVAL error_rate = ROUND(errors * 100.0 / total, 2)LOOKUP JOIN and ENRICH
LOOKUP JOIN (8.18+) is the preferred way to enrich query results from another index. On clusters before 8.18, fall
back to ENRICH — it provides similar enrichment capability but requires a pre-configured enrich policy.
If no enrich policy exists, suggest the user create one. Example setup:
# 1. Create the enrich policy
PUT /_enrich/policy/customers_policy
{
"match": {
"indices": "customers",
"match_field": "customer_id",
"enrich_fields": ["name", "region", "email"]
}
}
# 2. Execute the policy (builds the enrich index)
POST /_enrich/policy/customers_policy/_executeThen the query uses ENRICH instead of LOOKUP JOIN:
// 8.18+ — LOOKUP JOIN (preferred, no policy needed, easier to update)
FROM orders
| LOOKUP JOIN customers_lookup ON customer_id
| KEEP order_id, customer_id, name, region, total
// Pre-8.18 — ENRICH (requires policy setup above)
FROM orders
| ENRICH customers_policy ON customer_id WITH name, region
| KEEP order_id, customer_id, name, region, totalMulti-field joins (9.2+): Join on multiple fields when the lookup table has a composite key:
FROM application_logs
| LOOKUP JOIN service_registry ON service_name, environment
| KEEP service_name, environment, owner_team, response_time_msMulti-field joins have no ENRICH equivalent — ENRICH only supports a single match field.
Pre-join checklist: Before writing any LOOKUP JOIN, verify these two things:
Field name match: Does the join key have the same name in both the source and lookup index? If not, add
RENAMEbefore the join. This is a common source of silent failures.Composite key: Does the lookup table require multiple fields to uniquely identify a row? If so, list all key fields in the
ONclause (9.2+).
Field name mismatches: When the join key has a different name in the source vs the lookup table, use RENAME before
the join:
FROM support_tickets
| RENAME product AS product_name
| LOOKUP JOIN knowledge_base ON product_name
| KEEP ticket_id, description, resolutionTime Series (TS) Queries
When GET /{index}/_settings/index.mode reports time_series, use the TS source command instead of FROM. Critical
syntax rules:
1. Use the data stream name, not the resolved backing index:
// WRONG — resolved backing index
FROM .ds-metrics-tsds-2026.03.09-000001
// CORRECT — data stream name
TS metrics-tsdsRead the backing index's data_stream from GET /_resolve/index/{name} to find the data stream name. Do not derive it
from the backing index name.
2. TBUCKET takes only a duration — not @timestamp:
TBUCKET is not DATE_TRUNC. Do not pass @timestamp. Always assign a column alias so you can reference it in SORT:
// WRONG — DATE_TRUNC-style syntax
| STATS avg_cpu = AVG(cpu) BY bucket = TBUCKET(@timestamp, 5 minutes)
// WRONG — no alias makes SORT difficult
| STATS avg_cpu = AVG(cpu) BY TBUCKET(5 minutes)
// CORRECT — duration only with alias for SORT
| STATS avg_cpu = AVG(cpu) BY bucket = TBUCKET(5 minutes)
| SORT bucket3. Counter fields need RATE() wrapped in an outer aggregation:
RATE() computes per-time-series rates. When grouping by non-time dimensions (e.g., host), wrap it in SUM()
(counters are additive). Bare RATE() BY host fails:
// WRONG — bare RATE with non-time grouping
TS metrics-tsds
| STATS request_rate = RATE(requests) BY host
// CORRECT — SUM wraps RATE for non-time groupings
TS metrics-tsds
| STATS request_rate = SUM(RATE(requests)) BY TBUCKET(1 hour), hostFor gauge fields, use AVG() or MAX() as the outer function. Prefer the plain form — the inner LAST_OVER_TIME
is implicit and sufficient for most gauge queries. Use explicit AVG_OVER_TIME when you need the average of all samples
in the window (not just the last). Do not use AVG_OVER_TIME for histogram fields; see the histogram tip below:
// Preferred — plain aggregation (implicit LAST_OVER_TIME)
TS metrics-tsds
| STATS avg_cpu = AVG(cpu) BY TBUCKET(5 minutes), service.name
// Only use explicit *_OVER_TIME when you need specific window behavior
TS metrics-tsds
| STATS avg_cpu = AVG(AVG_OVER_TIME(cpu)) BY TBUCKET(5 minutes), service.nameSee Time Series Queries for the full inner/outer aggregation model.
4. Histogram fields: Check field_type with METRICS_INFO (histogram, exponential_histogram, or tdigest —
OTel is a common source; metric_type is histogram for all three). exponential_histogram and tdigest merge by
default as inner, per-series aggregation — use SUM/AVG/COUNT/PERCENTILE/…, not *_OVER_TIME. Plain
histogram is not usable without a cast. “Average over time” on a histogram means AVG(field), not AVG_OVER_TIME:
// WRONG — *_OVER_TIME is the wrong shape for histogram metrics
TS metrics-* | STATS SUM(SUM_OVER_TIME(jvm.gc.duration)) BY TBUCKET(1 hour)
// CORRECT — exponential_histogram / tdigest: merge + standard aggregation (no cast)
TS metrics-tsds
| WHERE TRANGE(1 hour)
| STATS count = COUNT(jvm.gc.duration),
avg = AVG(jvm.gc.duration),
p99 = PERCENTILE(jvm.gc.duration, 99)
BY jvm.gc.action, TBUCKET(5 minutes)Always cast with ::exponential_histogram when plain histogram is present (alone or mixed) or a type error requires
it; omit the cast when the targeted streams are unambiguously exponential_histogram or tdigest. Prefer ::tdigest
only when the metric actually stores T-Digest data. Cast table:
Histogram Metrics.
Version status: TS, TBUCKET, the new WITHOUT(...) grouping function, the new METRICS_INFO / TS_INFO
discovery commands, and all time series aggregation functions are GA since 9.4 — including the 9.2-introduced
set (RATE, IRATE, INCREASE, DELTA, IDELTA, all *_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 features are tech preview. TRANGE remains in preview.
Pre-9.2 limitation: The TS command, RATE(), TBUCKET(), and AVG_OVER_TIME() all require Elasticsearch
9.2+. On older clusters, counter fields (counter_long, counter_double) cannot be aggregated meaningfully —
standard aggregation functions like MAX(), SUM(), and AVG() reject counter field types. There is no workaround.
When the cluster is pre-9.2 and the question involves counter rates or time-series-specific aggregations, explain that
the TS command and RATE() are required (9.2+) and the query cannot be expressed on the current cluster version.
For gauge fields in time-series indices on pre-9.2 clusters, FROM with standard aggregations (AVG, MAX, MIN)
still works — only counter fields are affected.
Sliding window restriction (9.2-9.3): When the user wants a per-time-series aggregation window different from the
TBUCKET interval (RATE(field, 10m) BY TBUCKET(1m)), the window must be a multiple of the bucket interval on preview
clusters. 9.4+ (GA) accepts arbitrary windows.
INLINE STATS (9.2+)
INLINE STATS is available in 9.2+ only. It computes an aggregation and appends the result as a new column to every
row (like a SQL window function). Use cases that require comparing individual rows to group-level aggregates (e.g.,
"find values above the group average", "percentage of total") depend on INLINE STATS and cannot be expressed in
ES|QL before 9.2. There is no fallback.
When the cluster is pre-9.2 and the question requires per-row vs. aggregate comparison, explain that INLINE STATS is
needed and suggest the user either upgrade or perform the comparison client-side.
Pipe Commands: URI_PARTS, USER_AGENT, REGISTERED_DOMAIN (preview 9.4)
These are pipe commands (like DISSECT/GROK), not scalar functions. They must appear on their own pipeline stage
with target = expression syntax. A target prefix is mandatory.
// WRONG — function-call syntax does not work
| EVAL parts = URI_PARTS(url.full)
// CORRECT — pipe command syntax with target prefix
| URI_PARTS parts = url.full
| KEEP parts.domain, parts.path, parts.schemeWhen the user asks to "parse URLs", "extract domains", or "parse user agents", reach for these commands instead of
DISSECT/GROK:
| User Request | Command |
|---|---|
| Parse/decompose a URL | URI_PARTS |
| Parse a user agent string | USER_AGENT |
| Extract registered domain | REGISTERED_DOMAIN |
Grouped Top-N with LIMIT BY (preview 9.4)
LIMIT n BY field keeps the top N rows per group after sorting. The number comes before BY.
// Top 3 error-producing hosts per service
FROM logs-*
| WHERE level == "error"
| STATS cnt = COUNT(*) BY service.name, host.name
| SORT cnt DESC
| LIMIT 3 BY service.nameThis replaces the common INLINE STATS + rank-and-filter pattern for simple grouped top-N.
Unique Combinations (Deduplicating Rows)
For "unique combinations, no duplicates", produce one row per combination of the columns that define a duplicate:
Serverless:
| KEEP host.name, service.name | DEDUP(preview). Stack 9.5 and earlier rejectDEDUP.Stack 9.4+:
| LIMIT 1 BY host.name, service.name;SORTfirst to choose which row each group keeps.Any version:
| STATS BY host.name, service.name(addCOUNT(*)to count each combination).
Subqueries in FROM vs FORK
Subqueries (preview 9.4, GA 9.5) combine results from different data sources (UNION ALL semantics). FORK runs different analyses on the same data source.
| Scenario | Use |
|---|---|
| Combine errors from two index sets | Subqueries |
| Run multiple aggregations on one set | FORK |
| Compare time windows of the same data | FORK |
| Union independent pipelines | Subqueries |
// Subqueries — different sources
FROM
(FROM web_logs | WHERE status >= 500 | KEEP @timestamp, message, service.name),
(FROM app_logs | WHERE level == "error" | KEEP @timestamp, message, service.name)
| SORT @timestamp DESC
// FORK — same source, different analyses
FROM logs-*
| FORK
( WHERE level == "error" | STATS errors = COUNT(*) BY service.name )
( WHERE level == "warning" | STATS warnings = COUNT(*) BY service.name )External IPs — CIDR_MATCH with RFC 1918
When the user asks about "external IPs" or "public IPs", exclude private (RFC 1918) ranges with NOT CIDR_MATCH:
FROM security-events
| WHERE event.outcome == "failure"
AND NOT CIDR_MATCH(source.ip, "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")When the IP field is mapped as keyword, convert it first: EVAL ip = TO_IP(client_ip), then use ip in CIDR_MATCH,
comparisons, IN (FROM …), and LOOKUP JOIN (the last two reject an expression on the left).
Common Query Templates
Error Investigation
FROM logs-*
| WHERE @timestamp > NOW() - 1 hour
| WHERE level == "error"
| KEEP @timestamp, message, host.name, service.name, error.message
| SORT @timestamp DESC
| LIMIT 100Service Health Overview
FROM metrics-*
| WHERE @timestamp > NOW() - 15 minutes
| STATS
avg_cpu = AVG(system.cpu.percent),
avg_mem = AVG(system.memory.used.pct),
host_count = COUNT_DISTINCT(host.name)
BY service.name
| SORT avg_cpu DESCAPI Performance Analysis
FROM apm-*
| WHERE @timestamp > NOW() - 1 hour
| STATS
count = COUNT(*),
avg_duration = AVG(transaction.duration.us),
p95_duration = PERCENTILE(transaction.duration.us, 95),
error_count = COUNT(CASE(transaction.result != "success", 1, null))
BY transaction.name
| EVAL error_rate = ROUND(error_count * 100.0 / count, 2)
| SORT count DESC
| LIMIT 20Traffic Analysis
FROM web-logs
| WHERE @timestamp > NOW() - 24 hours
| STATS
requests = COUNT(*),
unique_ips = COUNT_DISTINCT(client.ip)
BY hour = DATE_TRUNC(1 hour, @timestamp)
| SORT hour DESCSecurity Event Review
FROM security-*
| WHERE @timestamp > NOW() - 24 hours
| WHERE event.category == "authentication"
| WHERE event.outcome == "failure"
| STATS
failures = COUNT(*)
BY user.name, source.ip
| WHERE failures > 5
| SORT failures DESCHandling Ambiguity
When the user request is ambiguous:
Missing Index
If no index specified, make a reasonable assumption:
"show errors" →
FROM logs-*"show CPU usage" →
FROM metrics-*"show requests" →
FROM web-logsorFROM access-*
Or output the query with a placeholder and note:
FROM <index-pattern> // Specify your index
| WHERE ...Missing Time Range
Add a sensible default:
| WHERE @timestamp > NOW() - 24 hours // Default: last 24 hoursUnclear Aggregation
When "show X" could mean list or count:
If followed by "by Y" → aggregation
If asking for specifics → raw data
If asking "how many" → count
Default to raw data with limit
Unknown Field Names
If field names are uncertain:
Use common ECS names as first guess
Suggest running schema discovery
Note the assumption in output
Output Formatting Suggestions
When presenting generated queries:
=== ES|QL Query ===
FROM logs-*
| WHERE @timestamp > NOW() - 1 hour
| WHERE level == "error"
| STATS count = COUNT(*) BY host.name
| SORT count DESC
| LIMIT 10
=== Explanation ===
- Queries all log indices
- Filters to the last hour
- Counts errors per host
- Returns top 10 hosts by error count
=== To Execute ===
POST /_query (see the Operations table in SKILL.md for the elastic CLI invocation)9.5 Additions
Use these on 9.5+ Stack and on Serverless (all preview unless noted). Full syntax is in esql-reference.md.
Allow/deny lists:
WHERE source.ip NOT IN (FROM allowed_ips | KEEP ip)— the subquery returns one column. UseLOOKUP JOINinstead when you also need columns from the reference index.GeoIP at query time:
| IP_LOCATION geo = source.ipis a pipe command; calling it as a function insideEVALfails. Default columns aregeo.country_name,geo.city_name,geo.location, and the ISO/region fields; useWITH {"database_file": "GeoLite2-ASN.mmdb"}for ASN/organization.Data Federation (external sources such as S3) is experimental and not for production use, and available on ECH and self-managed only (not Serverless) — do not generate it unless the user asks, and then warn about that status. Never create data sources or datasets to answer a question about data already in an index. When the user names a dataset, query it with
FROM <dataset>; it is not listed byGET /_cat/indicesorGET /{index}/_mapping, so read its columns withFROM <dataset> | LIMIT 0. Use it only as the source of aLOOKUP JOIN, never as the lookup target, and never withTS. See Data Federation.
Requirements 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