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://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/time-series-queries.md
ES|QL Time Series Queries
Query metrics data in Elasticsearch using the TS source command and time series aggregation functions. Requires
Elasticsearch 9.2+.
Status:
TS, all time series aggregation functions (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), theTBUCKETgrouping function, the newWITHOUT(...)grouping function, and the newMETRICS_INFOandTS_INFOdiscovery commands are GA since 9.4 (preview from 9.2 to 9.3 for the 9.2/9.3 features; new in 9.4 forWITHOUT,METRICS_INFO, andTS_INFO).TRANGEremains in preview. Looking for PromQL? Elasticsearch also exposes aPROMQLsource command (preview in 9.4, GA in 9.5+ and Serverless) for running Prometheus Query Language directly against TSDS indices. See promql-command.md. PreferPROMQLonly when the user explicitly thinks in PromQL or is migrating Prometheus dashboards/alerts; otherwise preferTSand the inner/outer aggregation paradigm described below.
Table of Contents
TS Source Command
Inner/Outer Aggregation Paradigm
Histogram Metrics
Time Series Aggregation Functions
TBUCKET Grouping Function
WITHOUT Grouping Function
Metric and Time Series Discovery
TRANGE Time Filter
CLAMP Functions
Kibana Time Filtering
Common Query Patterns
Guidelines
Counter and Gauge Casts (9.5)
References
TS Source Command
TS replaces FROM when querying
time series data streams (TSDS).
It enables time series aggregation functions (RATE, AVG_OVER_TIME, etc.) inside STATS.
Availability: Preview from 9.2 to 9.3, GA since 9.4. GA on Elastic Cloud Serverless.
Syntax:
TS index_pattern [METADATA fields]Key differences from FROM:
Targets only TSDS indices (
index.mode: time_series)Enables inner/outer aggregation paradigm in
STATSCannot combine with
FORKbeforeSTATSis appliedOptimized for processing time series data;
FROMmay produce unexpected results on TSDS indicesWhen there is no
STATScommand in the query,TSreturns rows sorted by@timestampdescending by default — useful for listing recent values across many time series.
Best practices:
Always use
TS(notFROM) for aggregations on time series indicesAdd a time range filter with
TRANGEto limit scan volumeAvoid aggregating multiple metrics with different dimensional cardinalities in the same query
Inner/Outer Aggregation Paradigm
The first STATS after a TS command uses a two-level aggregation model:
Inner function (time series aggregation) -- evaluated per individual time series (e.g.
RATE,AVG_OVER_TIME)Outer function (standard aggregation) -- aggregates inner results across groups (e.g.
SUM,AVG,MAX)
TS metrics
| STATS SUM(RATE(search_requests)) BY TBUCKET(1 hour), host
// ^^^ ^^^^ inner: RATE per time series
// outer: SUM across time series sharing the same host + bucketA single host can map to multiple underlying time series. The outer SUM combines the per-time-series rates into one
value per host per bucket. Use SUM as the outer function for counters (rates are additive). Use AVG or MAX for
gauges depending on intent.
If the inner function is omitted, the default inner aggregation depends on the field type:
Numeric and gauge fields —
LAST_OVER_TIME()is assumed implicitly.exponential_histogramandtdigestfields — distributions are merged across the time window (notLAST_OVER_TIME). Use standard aggregations likeSUM,AVG, andPERCENTILEdirectly.Plain
histogramfields — can't be directly processed; cast with::exponential_histogram(or::tdigest) first — see Histogram Metrics.
// Gauge — equivalent queries
TS metrics | STATS AVG(memory_usage)
TS metrics | STATS AVG(LAST_OVER_TIME(memory_usage))
// exponential_histogram / tdigest — merge + standard aggregation (no cast)
TS metrics-tsds | STATS SUM(jvm.gc.duration) BY TBUCKET(1 hour)
// plain histogram — cast required
TS metrics-tsds | STATS SUM(jvm.gc.duration::exponential_histogram) BY TBUCKET(1 hour)When to use *_OVER_TIME vs plain aggregations for gauges: For simple gauge queries (average CPU, max memory),
prefer AVG(cpu) over AVG(AVG_OVER_TIME(cpu)) — the implicit LAST_OVER_TIME is sufficient and produces cleaner
queries. Use explicit *_OVER_TIME only when you need a specific window behavior: AVG_OVER_TIME to average all
samples (not just the last), MIN_OVER_TIME/MAX_OVER_TIME to find extremes within each time series before
aggregating, or DELTA/DERIV to compute changes. When in doubt, omit the inner function. For histogram fields, do not
use *_OVER_TIME — see Histogram Metrics.
Since 9.3 (preview), use a time series function directly without an outer aggregation to get one value per time series
per bucket. The result is implicitly grouped by all dimensions of each time series and includes a _timeseries column
with the dimension key/value pairs — see WITHOUT Grouping Function for narrowing this
grouping (BY WITHOUT(dim, ...), GA since 9.4).
TS metrics
| WHERE TRANGE(1 day)
| STATS RATE(search_requests) BY TBUCKET(1 hour)Nesting two time series functions is not allowed and causes an error:
// INVALID -- nested time series functions
TS metrics | STATS AVG_OVER_TIME(RATE(memory_usage))Histogram Metrics
Distribution metrics use field types histogram, exponential_histogram, or tdigest (not plain numeric
gauge/counter). OpenTelemetry is a common source — OTLP often stores exponential_histogram; other ingestion paths
may store plain histogram or tdigest. For a candidate metric, use METRICS_INFO (9.4+) for metric_name,
data_stream, field_type, and metric_type — not mappings or field caps. The same metric name can appear on multiple
streams with different field_type values. See METRICS_INFO.
For exponential_histogram and tdigest, the default inner aggregation is histogram merge (it is an internal ES|QL
aggregation which cannot be referenced manually in queries) — use SUM / AVG / COUNT / MIN / MAX / MEDIAN /
PERCENTILE directly. Plain histogram is not merged automatically; cast first (see below). Do not use
*_OVER_TIME (plain histogram rejects it; exponential_histogram may accept it — still the wrong shape). Asking for
“average over time” or “avg duration” means AVG(field), not AVG_OVER_TIME.
When to cast: TS merge accepts exponential_histogram or tdigest, not plain histogram. Decide from
METRICS_INFO (or a type error):
METRICS_INFO.field_type for the streams you query | Cast? |
|---|---|
only exponential_histogram or only tdigest | Omit the cast |
any plain histogram (alone or mixed with other distribution types) | Required: ::exponential_histogram |
type / incompatible-types / ambiguities error mentioning histogram | Add ::exponential_histogram and retry |
Always cast when plain histogram is in play — including mixed streams. If the targeted streams are unambiguously
exponential_histogram or tdigest, no cast is needed. Default required cast is ::exponential_histogram (converts
plain histogram; no-op for exponential_histogram). Use ::tdigest instead when you know the metric actually stores
T-Digest data (for example, when values in a plain histogram field are mid-points rather than bucket boundaries — that
causes less precision loss).
// WRONG — *_OVER_TIME is the wrong shape (even if it succeeds)
TS metrics-* | STATS SUM(SUM_OVER_TIME(jvm.gc.duration)) BY TBUCKET(1 hour)
// CORRECT — pure exponential_histogram or tdigest on the stream from METRICS_INFO: 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)
// CORRECT — plain histogram present or mixed streams: cast
TS metrics-*
| STATS SUM(jvm.gc.duration::exponential_histogram) BY TBUCKET(1 hour), service.nameSUM totals the values the histograms were originally created from; COUNT counts the number of values the histograms
were created from — it does not count the number of histograms; use PERCENTILE/MEDIAN/AVG for latency. Prefer
the histogram metric from METRICS_INFO over a companion *.summary / aggregate_metric_double in the mapping unless
the user asks for it. Do not fall back to *_OVER_TIME or TO_STRING/REPLACE JSON parsing. For OTel ingestion
background, see OTel histogram metrics in ES|QL.
Availability: Histogram metric querying in TS is preview in 9.3 and GA since 9.4 (same as
exponential_histogram / tdigest field types).
Time Series Aggregation Functions
All functions below are available under TS ... | STATS. Each accepts a required field argument and an optional sliding
window (time_duration, 9.3+). The window must be a multiple of the TBUCKET interval. If omitted, the bucket interval
is used as the window.
Counter Functions
For fields with time_series_metric: counter (counter_double, counter_integer, counter_long).
| Function | Description | Since | Status |
|---|---|---|---|
RATE | Per-second average rate of increase; handles counter resets | 9.2 (preview) | GA (9.4) |
IRATE | Per-second rate between the last two data points; responsive to spikes | 9.2 (preview) | GA (9.4) |
INCREASE | Absolute increase of the counter in the time window; handles resets | 9.2 (preview) | GA (9.4) |
// Average rate per host per hour
// host is a dimension of the TSDS index metrics
TS metrics
| WHERE TRANGE(1 hour)
| STATS SUM(RATE(search_requests)) BY TBUCKET(1 hour), host
// Instant rate (last two points only) per cluster and 10 minutes
// k8s is an example of a TSDS index, to showcase that time series indexes do not have to be called metrics
// cluster is a dimension of the TSDS index k8s
TS k8s
| STATS SUM(IRATE(network.total_bytes_in)) BY cluster, TBUCKET(10 minute)
// Total counter increase
TS k8s
| STATS SUM(INCREASE(network.total_bytes_in)) BY cluster, TBUCKET(10 minute)Gauge / Numeric Functions
For gauge metrics and general numeric fields (double, integer, long, aggregate_metric_double).
| Function | Description | Since | Status |
|---|---|---|---|
AVG_OVER_TIME | Average value over the time window | 9.2 (preview) | GA (9.4) |
SUM_OVER_TIME | Sum of values over the time window | 9.2 (preview) | GA (9.4) |
MIN_OVER_TIME | Minimum value over the time window | 9.2 (preview) | GA (9.4) |
MAX_OVER_TIME | Maximum value over the time window | 9.2 (preview) | GA (9.4) |
FIRST_OVER_TIME | Earliest value by @timestamp | 9.2 (preview) | GA (9.4) |
LAST_OVER_TIME | Latest value by @timestamp (implicit default for numeric/gauge) | 9.2 (preview) | GA (9.4) |
COUNT_OVER_TIME | Count of values over the time window | 9.2 (preview) | GA (9.4) |
COUNT_DISTINCT_OVER_TIME | Count of distinct values over the time window | 9.2 (preview) | GA (9.4) |
PERCENTILE_OVER_TIME | Percentile of values; takes (field, percentile) | 9.3 (preview) | GA (9.4) |
STDDEV_OVER_TIME | Population standard deviation over the time window | 9.3 (preview) | GA (9.4) |
VARIANCE_OVER_TIME | Population variance over the time window | 9.3 (preview) | GA (9.4) |
DELTA | Absolute change of a gauge in the time window | 9.2 (preview) | GA (9.4) |
IDELTA | Change between the last two data points only | 9.2 (preview) | GA (9.4) |
DERIV | Derivative over time using linear regression | 9.3 (preview) | GA (9.4) |
// Average memory per cluster per 5 minutes (plain form — implicit LAST_OVER_TIME is sufficient for gauges)
// cluster is a dimension of the TSDS index metrics
TS metrics
| WHERE TRANGE(1 day)
| STATS AVG(memory_usage) BY cluster, TBUCKET(5 minute)
// P95 network cost per cluster per minute
// k8s is an example of a TSDS index, to showcase that time series indexes do not have to be called metrics
// cluster is a dimension of the TSDS index k8s
TS k8s
| STATS MAX(PERCENTILE_OVER_TIME(network.cost, 95)) BY cluster, TBUCKET(1 minute)
// Gauge delta (absolute change in bytes)
TS k8s
| STATS SUM(DELTA(network.bytes_in)) BY cluster, TBUCKET(10 minute)
// Derivative of cost per pod over time
// pod is a dimension of the TSDS index k8s
TS k8s
| STATS MAX(DERIV(network.cost)) BY pod, TBUCKET(5 minute)Presence / Absence Functions
Detect whether a field has data in a given time window. Return boolean.
| Function | Description | Since | Status |
|---|---|---|---|
PRESENT_OVER_TIME | true if field has values in the window | 9.2 (preview) | GA (9.4) |
ABSENT_OVER_TIME | true if field has no values in the window | 9.2 (preview) | GA (9.4) |
// Detect pods with missing data
TS k8s
| STATS missing = MAX(ABSENT_OVER_TIME(events_received)) BY pod, TBUCKET(2 minute)Sliding Window
Pass a time_duration as the second argument to any time series function to use a sliding window for the
per-time-series aggregation. The window is orthogonal to time bucketing of output results (TBUCKET). If the window is
omitted, the TBUCKET interval is used implicitly.
// Average rate per host over a 10-minute sliding window, bucketed by 1 minute
// host is a dimension of the TSDS index metrics
TS metrics
| WHERE TRANGE(1 hour)
| STATS AVG(RATE(requests, 10m)) BY TBUCKET(1m), hostVersion behavior:
9.2-9.3 (preview): the window must be a multiple of the
TBUCKETinterval in theBYclause (for example, withTBUCKET(1m)you may use1m,2m,10m;7mis rejected). If no window is specified, theTBUCKETinterval is used implicitly.9.4+ (GA): all window values are accepted, with performance optimizations when the window is a multiple of the
TBUCKETinterval. Restriction: within a single query you cannot mix windows that are smaller than the bucket interval for one metric with windows that are larger than the bucket interval for another metric.
TBUCKET Grouping Function
Creates time buckets from @timestamp. Use in the BY clause of STATS for time-based grouping.
Syntax:
STATS ... BY bucket = TBUCKET(interval)
STATS ... BY TBUCKET(interval)The interval is a time duration (1 hour, 5 minute, 30s) or date period (1 month). A string representation
("1 hour") also works.
TBUCKET is the preferred bucketing function for TS queries. It has a simpler signature than
DATE_TRUNC(interval, @timestamp) and is aware of time series semantics.
Availability: Preview from 9.2 to 9.3, GA since 9.4.
Always assign a column alias to TBUCKET so it can be referenced in SORT:
// 1-hour buckets — alias enables SORT
TS metrics
| STATS rate = SUM(RATE(requests)) BY bucket = TBUCKET(1 hour), host
| SORT bucket, host
// 5-minute buckets (plain form for gauge)
TS metrics
| STATS avg_cpu = AVG(cpu_percent) BY bucket = TBUCKET(5 minute), service
| SORT bucketWITHOUT Grouping Function
When the first STATS after TS uses a bare time series aggregation function (one not wrapped in an outer
aggregation such as AVG() or SUM()), rows are implicitly grouped by all dimensions of each time series. The
output includes a _timeseries keyword column containing a JSON-encoded object with the dimension key/value pairs
identifying each group. Only the dimensions that actually exist for a given time series appear in _timeseries — not
every dimension declared in the index mappings — so different rows in the result may carry different dimension keys.
WITHOUT(...) lets you make this grouping explicit, or narrow it to a subset of dimensions:
BY WITHOUT(dim1, dim2, ...)groups by all dimensions except those listed.BY WITHOUT()(no arguments) explicitly groups by every dimension; it is equivalent to the implicit "group by all" behavior.
When combining a bare time series function with other groupings, only grouping functions (TBUCKET, WITHOUT) are
allowed in the BY clause — bare dimension columns are rejected. For example:
// INVALID -- bare time series function with a bare dimension column in BY
TS k8s | STATS rate(network.total_bytes_in) BY hostUse BY TBUCKET(...) and/or BY WITHOUT(...), or wrap the time series function with an outer aggregation.
Availability: GA since 9.4. Can only be used in the first STATS command under a TS source — using it in
a FROM | STATS ... BY WITHOUT(...) query is rejected.
Examples:
// Group by every dimension implicitly — _timeseries column carries the dimension labels
TS k8s
| STATS avg = AVG_OVER_TIME(network.cost)
| SORT avg DESC
// Group by every dimension EXCEPT pod
TS k8s
| STATS total_cost = SUM(network.cost) BY WITHOUT(pod)
| SORT total_cost
// Combine WITHOUT with TBUCKET to add a time bucket to the surviving dimensions
TS k8s
| STATS total_cost = SUM(network.cost) BY WITHOUT(pod), tbucket = TBUCKET(1 hour)
| SORT total_cost
// Equivalent to implicit grouping (group by all dimensions)
TS k8s
| STATS avg = AVG_OVER_TIME(network.cost) BY WITHOUT()Metric and Time Series Discovery
Two processing commands introduced in 9.4 expose the metric catalogue of a TSDS so you can discover what to query
without inspecting index mappings or calling the field capabilities API. Both must follow a TS source command and must
appear before pipeline-breaking commands (STATS, SORT, LIMIT).
| Command | Granularity | Status | Adds beyond METRICS_INFO |
|---|---|---|---|
METRICS_INFO | One row per metric | GA since 9.4 | — |
TS_INFO | One row per (metric, time series) | GA since 9.4 | dimensions JSON column with the labels identifying each series |
METRICS_INFO
Returns one row per distinct metric in the targeted TSDS, with applicable dimensions and metadata. Useful for "what metrics exist in this stream?" and "what dimensions apply to metric X?".
Output columns (all keyword):
metric_name— single-valueddata_stream— multi-valued when several streams align on unit/metric_type/field_typeunit— declared unit; may benullor multi-valuedmetric_type—counter,gauge, orhistogramfield_type— Elasticsearch field type (long,double,histogram,exponential_histogram,tdigest, …)dimension_fields— union of dimension field names across the series for the metric
Always scope discovery with TRANGE (or another time filter) before METRICS_INFO / TS_INFO so the catalogue is
built from a bounded scan, not the full index history. Examples below omit TRANGE for brevity — add
| WHERE TRANGE(15m) (or another range) after TS in real queries.
// List every metric, alphabetically
TS k8s
| METRICS_INFO
| SORT metric_name
// Restrict to metrics that have data matching a filter
TS k8s
| WHERE cluster == "prod"
| METRICS_INFO
| SORT metric_name
// Filter by metric type, count by it
TS k8s
| METRICS_INFO
| STATS metric_count = COUNT(*) BY metric_type
| SORT metric_type
// Check field_type for a candidate metric (histogram vs numeric)
TS k8s
| METRICS_INFO
| WHERE metric_name == "jvm.gc.duration"
| KEEP metric_name, data_stream, field_type, metric_type
// Find metrics whose name matches a pattern
TS k8s
| METRICS_INFO
| WHERE metric_name LIKE "network.eth0*"
| SORT metric_nameTS_INFO
Returns one row per (metric, time series) combination, including the dimension key/value pairs that identify each series. Useful for "which time series report this metric?" and "what label combinations exist?".
TS_INFO includes all METRICS_INFO columns plus a dimensions column — a JSON-encoded object such as
{"job":"elasticsearch","instance":"instance_1"} (single-valued).
// Every (metric, time series) pair in the data stream
TS k8s
| TS_INFO
| SORT metric_name, dimensions
// Filter the underlying series before discovery
TS k8s
| WHERE cluster == "prod"
| TS_INFO
| KEEP metric_name, dimensions
| SORT metric_name, dimensions
// Filter by metadata after TS_INFO (gauges only)
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 — spot under- or over-reporting series
TS k8s
| TS_INFO
| STATS metric_count = COUNT_DISTINCT(metric_name) BY dimensions
| SORT dimensionsGuidelines
Use these for TSDS schema discovery before writing
RATE/AVG_OVER_TIMEqueries — they replace the older workflow of inspecting_settings,_mapping, or field capabilities for time series indices.Reach for
METRICS_INFOfirst to enumerate metrics; reach forTS_INFOonly when you need the exact dimension combinations (label sets) of individual series.Always time-filter before discovery with
WHERE TRANGE(...)(and optional dimension filters) soMETRICS_INFO/TS_INFOdo not scan the full index history.The output replaces the original table. Anything you
STATS/SORT/LIMITafterwards operates on metadata rows, not raw documents — there's no way to re-attach the data points afterMETRICS_INFOorTS_INFO.Both commands are TSDS-only.
FROM | METRICS_INFOandFROM | TS_INFOare rejected.
TRANGE Time Filter
Filter data by time range using @timestamp. Prefer TRANGE over manual WHERE @timestamp > NOW() - ... filters.
Syntax:
// Offset from now (last N time units)
WHERE TRANGE(offset)
// Explicit start and end
WHERE TRANGE(start, end)Examples:
// Last hour
TS metrics
| WHERE TRANGE(1 hour)
| STATS SUM(RATE(requests)) BY TBUCKET(1 minute), host
// Explicit time range
TS metrics
| WHERE TRANGE("2024-05-10T00:00:00Z", "2024-05-10T01:00:00Z")
| STATS SUM(RATE(requests)) BY TBUCKET(5 minute), host
// Epoch milliseconds
// k8s is an example of a TSDS index, to showcase that time series indexes do not have to be called metrics
FROM k8s
| WHERE TRANGE(1715300236000, 1715300282000)Supported parameter types: time_duration, date_period, date, date_nanos, keyword (date string), long
(epoch millis).
Availability: Preview since 9.3.
CLAMP Functions
Bound metric values to a range. Useful for capping outliers or enforcing value limits in time series analysis.
| Function | Description | Syntax |
|---|---|---|
CLAMP | Clamp values to [min, max] range | CLAMP(field, min, max) |
CLAMP_MIN | Set a lower bound; values below min become min | CLAMP_MIN(field, min) |
CLAMP_MAX | Set an upper bound; values above max become max | CLAMP_MAX(field, max) |
Availability: Preview since 9.3.
// Clamp network cost between 1 and 10
TS k8s
| EVAL clamped_cost = CLAMP(network.cost, 1, 10)
| STATS SUM(clamped_cost) BY TBUCKET(1 minute)
// Aggregate with clamped values
TS k8s
| STATS total = SUM(CLAMP_MAX(network.cost, 1)) BY TBUCKET(1 minute)Kibana Time Filtering
When writing ES|QL queries for Kibana (Discover, dashboards, alerting), do not add manual time range filters. Kibana
automatically applies @timestamp filtering based on the date picker.
Write Kibana queries without time filters:
// Kibana query -- no time filter needed
TS metrics
| STATS SUM(RATE(search_requests)) BY TBUCKET(1 hour), hostUse explicit time filters (TRANGE or WHERE @timestamp) only for:
Direct API queries (
POST /_query) run outside Kibana
Common Query Patterns
Rate of a Counter per Host per Hour
TS metrics
| WHERE TRANGE(1 hour)
| STATS SUM(RATE(search_requests)) BY TBUCKET(1 hour), hostTotal Rate per Host (No Time Bucketing)
TS metrics
| WHERE TRANGE(1 hour)
| STATS SUM(RATE(search_requests)) BY hostAverage Gauge per Cluster Over Time
// Plain form — preferred for gauge metrics
TS metrics
| WHERE TRANGE(1 day)
| STATS AVG(memory_usage) BY TBUCKET(5 minute), clusterPer-Time-Series Averages vs Global Average
// Average of last values per time series (default — preferred for most gauge queries)
TS metrics | STATS AVG(memory_usage)
// Average of ALL samples per time series (use AVG_OVER_TIME only when you need this distinction)
TS metrics | STATS AVG(AVG_OVER_TIME(memory_usage))Detect Missing Data
// k8s is an example of a TSDS index, to showcase that time series indexes do not have to be called metrics
// pod is a dimension of the TSDS index k8s
TS k8s
| WHERE TRANGE(1 hour)
| STATS missing = MAX(ABSENT_OVER_TIME(events_received)) BY pod, TBUCKET(2 minute)Counter Increase Totals
TS k8s
| WHERE TRANGE(1 hour)
| STATS SUM(INCREASE(network.total_bytes_in)) BY cluster, TBUCKET(10 minute)Instant Rate (Last Two Points)
TS k8s
| WHERE TRANGE(1 hour)
| STATS SUM(IRATE(network.total_bytes_in)) BY cluster, TBUCKET(10 minute)Sliding Window Rate
TS metrics
| WHERE TRANGE(1 hour)
| STATS AVG(RATE(requests, 10m)) BY TBUCKET(1m), hostIn 9.2-9.3 the window must be a multiple of the TBUCKET interval. In 9.4+ any window value is accepted (mixing windows
smaller and larger than the bucket interval for different metrics in the same query is not supported).
Guidelines
Use
TSfor all aggregations on TSDS indices.FROMis still available for listing raw documents, but useTSfor metrics aggregations.Use
SUMas the outer function for counters. Rates and increases are additive across time series that share a dimension (e.g. host). UseAVGorMAXfor gauges.Always add a time range filter with
TRANGE(orWHERE @timestamp) to limit scan volume, except in Kibana where the date picker handles this automatically. Don't add a range filter if the user explicitly asks not to add it.Version requirements:
TS,TBUCKET,WITHOUT,METRICS_INFO,TS_INFO, and all time series aggregation functions (the 9.2-introduced set —RATE,IRATE,INCREASE,DELTA,IDELTA, all*_OVER_TIMEfrom 9.2,PRESENT_OVER_TIME,ABSENT_OVER_TIME— and the 9.3-introduced set —DERIV,PERCENTILE_OVER_TIME,STDDEV_OVER_TIME,VARIANCE_OVER_TIME): GA since 9.4 (preview from 9.2-9.3 for the 9.2/9.3 functions; new in 9.4 forWITHOUT,METRICS_INFO,TS_INFO).TRANGE: 9.3+ (preview).Sliding window parameter (second argument to time series functions): introduced in 9.2-9.3 (preview, restricted to multiples of the
TBUCKETinterval); GA in 9.4 with arbitrary durations.CLAMP,CLAMP_MIN,CLAMP_MAX: 9.3+ (preview).
Do not nest time series functions.
AVG_OVER_TIME(RATE(field))is invalid. Use a standard aggregation as the outer function.Avoid mixing metrics with different dimensions in one query. If
fooandbarhave different dimension values,SUM(RATE(foo)) + SUM(RATE(bar))may produce nulls for mismatched dimensions.For histogram / exponential_histogram metrics, use standard aggregations (no
*_OVER_TIME); cast with::exponential_histogramwhen plainhistogramis present, types are mixed, or a type error occurs — see Histogram Metrics.
Counter and Gauge Casts (9.5)
Only needed when METRICS_INFO reports the wrong metric_type for a field. x::counter / TO_COUNTER(x) and
x::gauge / TO_GAUGE(x) (GA 9.5) change the type annotation only, inside TS ... | STATS (EVAL cannot return a
counter):
TS metrics-tsds
| STATS bytes_per_sec = SUM(RATE(network.bytes_in::counter)) BY hostTo get one row per time series from PROMQL, append | TS_COLLAPSE (preview 9.5; needs explicit start/end outside
Kibana) — see esql-reference.md.
References
ES|QL PROMQL Command — alternative source command using PromQL syntax (preview 9.4, GA 9.5)
promql-command.md — PROMQL command reference in this skill
OTel histogram metrics in ES|QL — common ingestion path for exponential histograms, casts, and query patterns
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 or Elastic.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download elasticsearch-esql.zip