Generated banner for Elasticsearch Esql
Generated logo for Elasticsearch Esql

Elasticsearch Esql

ElasticOfficialCreated by elastic

  • Skill
  • Tool integration
  • Data and AI
  • Apache-2.0
  • No issues found

From the author

Execute ES|QL (Elasticsearch Query Language) queries, use when the user wants to query Elasticsearch data, analyze logs, aggregate metrics, explore data, or create charts and dashboards from ES|QL results.
— the description by elastic

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

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/elastic/elasticsearch-esql/download
  2. Unzip 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 _source to be enabled on queried indices. Indices with _source disabled (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+), and INLINE STATS (9.2+) were added in later versions. On pre-8.18 clusters, use ENRICH as a fallback for LOOKUP JOIN (see generation tips). INLINE STATS and counter-field RATE() 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.number tracks the stack line under active development (next minor from main), so clients that only semver-compare may treat Serverless as “latest.” Do not use version.number to gate features: if build_flavor is "serverless", assume all GA and preview ES|QL features are available.

  • build_flavor: "default" — Stack (self-managed or Cloud-hosted). Use version.number for feature availability.

  • Snapshot builds have version.number like 9.4.0-SNAPSHOT. Strip the -SNAPSHOT suffix 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

  1. Verify the connection and detect the deployment type. Call GET / first. This confirms connectivity and detects whether the deployment is a Serverless project (all features available) or a versioned cluster (features depend on version). The build_flavor field is the authoritative signal — if it equals "serverless", ignore the reported version number and use all ES|QL features freely. If the call fails, stop and point the user at the CLI configuration instructions rather than guessing endpoints or credentials.

  2. Discover the schema (required — never guess index or field names). List candidate indices with GET /_cat/indices (pass a pattern to narrow), then fetch field types for the chosen index with GET /{index}/_mapping.

    Always run schema discovery before generating queries. Index names and field names vary across deployments and cannot be reliably guessed. Even common-sounding data (e.g., "logs") may live in indices named logs-test, logs-app-*, or application_logs. Field names may use ECS dotted notation (source.ip, service.name) or flat custom names — the only way to know is to check.

    Prefer simplicity: Query a single index unless the user explicitly asks for data across multiple sources. Do not combine indices with different schemas using COALESCE unless specifically requested — pick the single most relevant index for the question. When multiple indices contain similar data, prefer the one with the most complete schema for the task at hand.

    Detect time series indices. Check the index mode with GET /{index}/_settings/index.mode. If it is time_series, use TS <data-stream> (not FROM), TBUCKET(interval) (not DATE_TRUNC), and wrap counter fields with SUM(RATE(...)). Read the full TS section in Generation Tips before writing any time series query. For TSDS indices on 9.4+, prefer the in-language discovery commands METRICS_INFO and TS_INFO (both GA) over inspecting mappings — they enumerate the metric catalogue and the dimension labels of each time series directly, and are run as ES|QL queries via POST /_query. Treat METRICS_INFO as authoritative for metric_type (counter/gauge/histogram) and field_type (histogram, tdigest, exponential_histogram for distribution metrics). Both must follow TS and must precede STATS/SORT/LIMIT. See Time Series Queries:

    TS metrics-tsds | METRICS_INFO | SORT metric_name
    TS metrics-tsds | TS_INFO | KEEP metric_name, dimensions | SORT metric_name
  3. Choose the right ES|QL feature for the task. Version gate (mandatory on Stack): before writing any query, compare version.number to 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) or TS for TSDB

    • "PromQL", "Prometheus query/dashboard/alert", sum by (instance) (...), label matchers like {cluster="prod"} → PROMQL source command (GA in 9.5+/Serverless, preview in 9.4); see PROMQL Command. Prefer TS for 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" → STATS with aggregation functions

    • "approximate," "estimate," "rough numbers," "fast/cheap stats on huge data" → SET approximation=true; before a STATS query (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), then LIMIT n if a row count is asked; there is no RANDOM() function

    • "latest/first value per group," "most recent status per host" → STATS LAST(field, @timestamp) BY group (or FIRST), not SORT + LIMIT 1

    • allow list, deny list, "exclude known IPs/users" from another index → WHERE field [NOT] IN (FROM idx | KEEP col) (preview 9.5); use LOOKUP JOIN only 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 with TO_IP() before CIDR_MATCH, IP comparisons, IN (FROM …), or LOOKUP JOIN on an ip field; for the last two EVAL ip = TO_IP(src) first (the left side must be a column)

    • "country/city of an IP," "geolocate IPs," ASN → | IP_LOCATION geo = ip_field pipe command (preview 9.5)

    • parse URLs (domain, path, query) → | URI_PARTS parts = url_field pipe command (preview 9.4), not DISSECT/GROK

    • "in my local time," "per day in New York" → SET time_zone = "America/New_York"; (9.4+), not manual offsets

    • flattened field sub-keys (e.g. OTel resource.attributes) → FIELD_EXTRACT(field, "host.name") (preview 9.5), not field.host.name; it returns keyword, so cast (TO_INTEGER(...)) before numeric comparisons

    • embed text/images at query time for vector search → EMBEDDING(value, inference_id) (preview 9.5) or TEXT_EMBEDDING(text, inference_id) inside KNN

    • query 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 featureFallback on Stack without the feature
    DEDUP (Serverless preview only)LIMIT 1 BY cols (9.4+; SORT first picks the kept row) or STATS BY cols
    WHERE x NOT IN (FROM idx | KEEP c)LOOKUP JOIN idx ON x == c | WHERE c IS NULL (IN: IS NOT NULL); ON expressions need 9.2+, else RENAME x AS c first
    IP_LOCATIONNo query-time GeoIP: ENRICH with an IP-range enrich policy, or the ingest geoip processor
    FIELD_EXTRACT (or an unmapped key)FROM idx METADATA _source | EVAL v = JSON_EXTRACT(_source, "$.labels.key") — not labels.key, labels["key"], or JSON_EXTRACT(labels, …); COALESCE with "$.labels[\"k.sub\"]" for pre-dotted keys
  4. Read 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 PROMQL vs TS decision 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)

  5. Generate the query following ES|QL syntax. Prefer the simplest query that answers the question — do not add extra indices, fields, or transformations unless the user asks for them. Only include fields in KEEP that directly answer the question. Do not add extra filter conditions beyond what the user specified (e.g., don't add OR level == "ERROR" when the user just said "errors").

    • Start with FROM index-pattern (or TS index-pattern for time series indices)

    • Add WHERE for filtering (use TRANGE for time ranges on 9.3+)

    • Use EVAL for computed fields

    • Use STATS ... BY for aggregations

    • For time series metrics: TS with SUM(RATE(...)) for counters, AVG(...) for gauges, standard aggregations (SUM, AVG, PERCENTILE, … — not *_OVER_TIME) for histogram metrics, and TBUCKET(interval) for time bucketing — see the TS section in Generation Tips and Histogram Metrics

    • For detecting spikes, dips, or anomalies, use CHANGE_POINT after time-bucketed aggregation

    • Add SORT and LIMIT as needed

    • Re-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 no DEDUP — rewrite with the fallback.

  6. 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 n
Common Patterns

Filter and limit:

FROM logs-*
| WHERE @timestamp > NOW() - 24 hours AND level == "error"
| SORT @timestamp DESC
| LIMIT 100

Aggregate 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 DESC

Top N with count:

FROM web-logs
| STATS count = COUNT(*) BY response.status_code
| SORT count DESC
| LIMIT 10

Text search (8.17+): Use MATCH as the default for full-text search instead of LIKE/RLIKE — it is significantly faster and supports relevance scoring. MATCH on a text field is usually sufficient on its own — do not add redundant keyword equality filters (e.g., category == "X") alongside MATCH unless the user explicitly requests filtering. Use QSTR only when you need advanced boolean logic, wildcards, or multi-field searches in a single expression. The first argument to MATCH must be one real field name — not a string listing several fields (e.g. "title,content") and not multiple field arguments; combine fields with MATCH(a, "q") OR MATCH(b, "q"). KQL is available from 8.18/9.0+. For content/document search use cases, follow the ES|QL Search Strategy. See ES|QL Search Reference for the full function guide.

FROM documents METADATA _score
| WHERE MATCH(content, "search terms")
| SORT _score DESC
| LIMIT 20

String extraction: Use DISSECT for structured delimiter-based patterns (preferred — produces named fields) and GROK for regex-based extraction. For simple cases, SUBSTRING(s, start, len) for fixed-position extraction, SPLIT(s, delim) to split into a multivalue, LOCATE(substr, s) to find a character position. SPLIT returns a multivalue — use MV_FIRST, MV_LAST, or MV_SLICE to pick elements. INSTR and STRPOS do not exist — use LOCATE. REGEXP_EXTRACT does not exist — use GROK.

// 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 domain

Log categorization (Platinum license): Use CATEGORIZE to auto-cluster log messages into pattern groups. Prefer this over running multiple STATS ... BY field queries when exploring or finding patterns in unstructured text. 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 20

Change point detection (Platinum license): Use CHANGE_POINT to detect spikes, dips, and trend shifts in a metric series. Prefer this over manual inspection of time-bucketed counts.

FROM logs-*
| STATS c = COUNT(*) BY t = BUCKET(@timestamp, 30 seconds)
| SORT t
| CHANGE_POINT c ON t
| WHERE type IS NOT NULL

Time series metrics: With TS, use TRANGE for time filtering (9.3+) or omit it entirely — do not add a redundant WHERE @timestamp > NOW() - ... alongside TBUCKET. The TBUCKET duration defines the aggregation window.

// 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.name

Time 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 cluster

Data enrichment with LOOKUP JOIN: The basic ON clause matches fields by name in both indices (LOOKUP JOIN idx ON field_name). When the join key has a different name in the source, use RENAME first to align names. 9.2+ tech preview also supports expression predicates (ON expr == expr); see ES|QL Complete Reference for details. After LOOKUP JOIN, lookup columns are available by their original field names — do not table-qualify them (e.g., write threat_level, not threat_intel.threat_level). Ordering tip: when the question asks for top-N results, SORT and LIMIT before LOOKUP JOIN to reduce enrichment cost. For general listings or full enrichment, place LOOKUP JOIN right after FROM/WHERE.

// 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_team

Multivalue 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 DESC

Change 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, pvalue

Approximate STATS (GA in 9.5+/Serverless, preview in 9.4): Prepend SET approximation=true; to a STATS query to get fast estimates via sampling and extrapolation on large datasets when exact values are not required. The result adds _approximation_confidence_interval(col) and _approximation_certified(col) columns per estimated quantity — report those bounds, do not present estimates as exact. COUNT_DISTINCT, MIN, MAX, FIRST, LAST, TOP (and a few others) are not supported and fall back to exact execution; use the SAMPLE command for those. Pipelines with 2+ STATS, or using the TS/PROMQL source command, also fall back. 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 5

Full Reference

For complete ES|QL syntax including all commands, functions, and operators, read:

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 (or and, 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. Inside FUSE, backquote a column named score, key or group after SCORE 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() not STDDEV(), MEDIAN_ABSOLUTE_DEVIATION() not MAD(). Use CONCAT() for strings, not +. Use CASE(cond, val, ...) not CASE WHEN...THEN...END.

  • Wrong date part → DATE_EXTRACT uses ES|QL part names: "hour_of_day" not "hour", "day_of_month" not "day", "month_of_year" not "month". Use DATE_DIFF("day", start, end) for date arithmetic, not subtraction.

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 ASC

Guidelines

  • 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 WHERE before STATS so 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 as LOOKUP JOIN or INLINE 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/indiceselastic es cat indices --index '<pattern>'
GET /{index}/_mappingelastic es indices get-mapping --index '<index>'
GET /{index}/_settings/index.modeelastic es indices get-settings --index '<index>' --name index.mode
GET /_resolve/index/{name}elastic es indices resolve-index --name '<name>'
POST /_queryelastic es esql query --format tsv --query "<esql>"

Files

references/dsl-to-esql-migration.md

Query DSL to ES|QL Migration Guide

This guide helps you migrate from Elasticsearch Query DSL (JSON-based queries) to ES|QL (piped query language).

Versions: Statuses below reflect Elastic Stack 9.5 and Elastic Cloud Serverless. Serverless includes every Stack 9.5 feature plus a few that have not reached Stack yet (for example DEDUP). See esql-version-history.md for per-version availability before using a preview feature.

Table of Contents

  • Overview: Key Differences

  • Basic Query Structure

  • Match All Query

  • Term Query (Exact Match)

  • Match Query (Full-Text Search)

  • Match Phrase Query

  • Multi-Match Query

  • Query String Query

  • Range Query

  • Bool Query

  • Exists Query

  • Wildcard / Prefix Query

  • Regexp Query

  • Flattened Field Queries

  • Aggregations

  • Sampling and Approximate Aggregations

  • Sorting

  • Field Selection (_source)

  • Pagination

  • Field Collapsing and Top Hits per Group

  • Script Fields and Runtime Fields

  • LOOKUP JOIN (Replaces Enrichment Patterns)

  • GeoIP Enrichment

  • Filters Aggregation (Per-Aggregation WHERE)

  • Pipeline Aggregations

  • Highlighting

  • Vector and Semantic Search

  • Rescoring and Hybrid Ranking

  • ES|QL Limitations (vs Query DSL)

  • Migration Checklist

  • Performance Considerations

  • Quick Reference Table

Overview: Key Differences

AspectQuery DSLES|QL
FormatJSONPiped text
ExecutionTranslated to LuceneNative execution engine
Default results101,000
Max results10,000 (configurable)10,000 (configurable)
AggregationsNested JSON structureSTATS ... BY command
Full-text searchmatch, query_stringMATCH(), QSTR(), KQL()
ScoringAutomatic with queriesExplicit with METADATA _score
When to Use ES|QL vs Query DSL

Use ES|QL for:

  • Log exploration and ad-hoc analysis

  • Time-series data analysis

  • Simple to moderate aggregations

  • Data transformation pipelines

  • Interactive troubleshooting

Use Query DSL for:

  • Complex nested aggregations

  • Advanced scoring and boosting

  • Nested/parent-child document queries

  • Features not yet in ES|QL (see Limitations)


Basic Query Structure

Query DSL
POST /my-index/_search
{
  "query": { ... },
  "aggs": { ... },
  "sort": [ ... ],
  "size": 100,
  "_source": ["field1", "field2"]
}
ES|QL
FROM my-index
| WHERE <conditions>
| STATS <aggregations> BY <groupings>
| SORT <field> DESC
| KEEP field1, field2
| LIMIT 100

Match All Query

Query DSL
{
  "query": {
    "match_all": {}
  },
  "size": 100
}
ES|QL
FROM my-index
| LIMIT 100

Term Query (Exact Match)

Query DSL
{
  "query": {
    "term": {
      "status": "published"
    }
  }
}
ES|QL
FROM my-index
| WHERE status == "published"
Multiple Terms (terms query)
Query DSL
{
  "query": {
    "terms": {
      "status": ["published", "draft"]
    }
  }
}
ES|QL
FROM my-index
| WHERE status IN ("published", "draft")
Terms Lookup (terms from another index)
Query DSL
{
  "query": {
    "terms": {
      "source.ip": { "index": "allowed_ips", "id": "office", "path": "ips" }
    }
  }
}
ES|QL (9.5+ preview)

Use a WHERE ... IN subquery. The subquery must return exactly one column. It can read any index (not only lookup indices) and filter it with WHERE.

FROM web_logs
| WHERE source.ip IN (FROM allowed_ips | KEEP ip)
| STATS hits = COUNT(*) BY source.ip

NOT IN (FROM ...) works the same way for exclusion lists. A DSL terms lookup reads the values of one field in one document. The ES|QL subquery collects a column across all rows the subquery returns, so store one value per row or narrow the subquery with WHERE.

Before 9.5, join against a lookup index (index.mode: lookup) and keep the matched rows (IS NULL for NOT IN):

FROM web_logs
| LOOKUP JOIN allowed_ips ON source.ip == ip
| WHERE ip IS NOT NULL
| STATS hits = COUNT(*) BY source.ip

The LOOKUP JOIN fallback duplicates a row when several lookup rows match it. The ON a == b form is 9.2+; on 8.18/9.0–9.1, RENAME the source field to the lookup key name and use ON ip.


Query DSL
{
  "query": {
    "match": {
      "title": "elasticsearch guide"
    }
  }
}
ES|QL (8.17+)
FROM my-index
| WHERE MATCH(title, "elasticsearch guide")

Or using the match operator:

FROM my-index
| WHERE title : "elasticsearch guide"
With Relevance Scoring
FROM my-index METADATA _score
| WHERE MATCH(title, "elasticsearch guide")
| SORT _score DESC
| LIMIT 10

Match Phrase Query

Query DSL
{
  "query": {
    "match_phrase": {
      "title": "quick brown fox"
    }
  }
}
ES|QL (8.19+)
FROM my-index
| WHERE MATCH_PHRASE(title, "quick brown fox")

Multi-Match Query

Query DSL
{
  "query": {
    "multi_match": {
      "query": "elasticsearch",
      "fields": ["title", "content", "tags"]
    }
  }
}
ES|QL
FROM my-index
| WHERE MATCH(title, "elasticsearch")
    OR MATCH(content, "elasticsearch")
    OR MATCH(tags, "elasticsearch")

Or use QSTR for more flexibility:

FROM my-index
| WHERE QSTR("title:elasticsearch OR content:elasticsearch OR tags:elasticsearch")

Query String Query

Query DSL
{
  "query": {
    "query_string": {
      "query": "status:active AND (type:blog OR type:article)"
    }
  }
}
ES|QL
FROM my-index
| WHERE QSTR("status:active AND (type:blog OR type:article)")

Range Query

Query DSL
{
  "query": {
    "range": {
      "price": {
        "gte": 10,
        "lte": 100
      }
    }
  }
}
ES|QL
FROM my-index
| WHERE price >= 10 AND price <= 100
Date Range
Query DSL
{
  "query": {
    "range": {
      "@timestamp": {
        "gte": "now-24h",
        "lte": "now"
      }
    }
  }
}
ES|QL
FROM my-index
| WHERE @timestamp >= NOW() - 24 hours AND @timestamp <= NOW()

Or simply:

FROM my-index
| WHERE @timestamp > NOW() - 24 hours

Bool Query

The bool query is one of the most complex DSL structures to migrate.

Query DSL
{
  "query": {
    "bool": {
      "must": [{ "match": { "title": "elasticsearch" } }],
      "filter": [{ "term": { "status": "published" } }, { "range": { "date": { "gte": "2024-01-01" } } }],
      "should": [{ "term": { "featured": true } }],
      "must_not": [{ "term": { "draft": true } }]
    }
  }
}
ES|QL

Note: ES|QL handles must, filter, and must_not directly with WHERE conditions. A should clause that only adds optional score boosting on top of must has no direct equivalent: only full-text functions contribute to _score, and every WHERE condition filters.

FROM my-index METADATA _score
| WHERE MATCH(title, "elasticsearch")           // must
    AND status == "published"                   // filter
    AND date >= "2024-01-01"                    // filter
    AND (draft != true OR draft IS NULL)        // must_not
| EVAL featured_boost = CASE(featured == true, 100.0, 0.0)
| EVAL combined_score = _score + featured_boost // approximate should boost
| SORT combined_score DESC

Three-valued logic warning: draft != true alone excludes rows where draft is NULL. In Query DSL, must_not: { term: { draft: true } } keeps documents where draft is missing. To match that behavior in ES|QL, add OR draft IS NULL.

Should clause: ES|QL cannot natively replicate optional should boosting. The EVAL approach above is a rough approximation. If precise relevance scoring is critical, consider using Query DSL instead.

When a bool query has only should clauses over full-text queries (match any, rank by how well each matches), use OR between full-text functions. Their scores add up, and the boost option weights a clause:

FROM articles METADATA _score
| WHERE MATCH(title, "elasticsearch", {"boost": 2.0}) OR MATCH(content, "elasticsearch")
| SORT _score DESC
| LIMIT 10

Exists Query

Query DSL
{
  "query": {
    "exists": {
      "field": "user"
    }
  }
}
ES|QL
FROM my-index
| WHERE user IS NOT NULL
Does Not Exist
Query DSL
{
  "query": {
    "bool": {
      "must_not": {
        "exists": { "field": "user" }
      }
    }
  }
}
ES|QL
FROM my-index
| WHERE user IS NULL

Wildcard / Prefix Query

Query DSL
{
  "query": {
    "wildcard": {
      "name": "john*"
    }
  }
}
ES|QL
FROM my-index
| WHERE name LIKE "john*"

Or using STARTS_WITH:

FROM my-index
| WHERE STARTS_WITH(name, "john")

Regexp Query

Query DSL
{
  "query": {
    "regexp": {
      "name": "joh?n.*"
    }
  }
}
ES|QL
FROM my-index
| WHERE name RLIKE "joh?n.*"

Note: RLIKE uses the same Lucene regular expression syntax as the DSL regexp query, so patterns usually carry over unchanged (joh?n.* matches jon and john). The pattern must match the whole value, as in the DSL.


Flattened Field Queries

Query DSL
{
  "query": {
    "bool": {
      "filter": [{ "term": { "labels.env": "prod" } }, { "range": { "labels.retry_count": { "gte": "10" } } }]
    }
  }
}
ES|QL (9.5+ preview)

ES|QL loads a flattened field as a single root column, so labels.env is an unknown column. Read sub-keys with FIELD_EXTRACT(root, "dotted.key"):

FROM k8s_events
| WHERE FIELD_EXTRACT(labels, "env") == "prod"
    AND TO_INTEGER(FIELD_EXTRACT(labels, "retry_count")) >= 10
| KEEP service, reason

FIELD_EXTRACT always returns keyword. Cast it before numeric comparisons or math: comparing it to an integer is a type error, and comparing it to a string (>= "10") compares lexicographically, like a DSL range query on a flattened sub-field.

Before 9.5, the flattened column is unsupported. On 9.4, read sub-keys from _source with JSON_EXTRACT:

FROM k8s_events METADATA _source
| EVAL env = JSON_EXTRACT(_source, "$.labels.env")
| WHERE env == "prod"

See esql-reference.md for pre-dotted keys and other FIELD_EXTRACT rules.


Aggregations

Terms Aggregation (Group By Count)
Query DSL
{
  "size": 0,
  "aggs": {
    "status_counts": {
      "terms": {
        "field": "status",
        "size": 10
      }
    }
  }
}
ES|QL
FROM my-index
| STATS count = COUNT(*) BY status
| SORT count DESC
| LIMIT 10
Date Histogram Aggregation
Query DSL
{
  "size": 0,
  "aggs": {
    "events_over_time": {
      "date_histogram": {
        "field": "@timestamp",
        "calendar_interval": "day"
      }
    }
  }
}
ES|QL
FROM my-index
| STATS count = COUNT(*) BY day = DATE_TRUNC(1 day, @timestamp)
| SORT day
Date Histogram with Sub-Aggregation
Query DSL
{
  "size": 0,
  "aggs": {
    "events_over_time": {
      "date_histogram": {
        "field": "@timestamp",
        "calendar_interval": "hour"
      },
      "aggs": {
        "avg_response": {
          "avg": { "field": "response_time" }
        }
      }
    }
  }
}
ES|QL
FROM my-index
| STATS
    count = COUNT(*),
    avg_response = AVG(response_time)
  BY hour = DATE_TRUNC(1 hour, @timestamp)
| SORT hour
Multiple Metric Aggregations
Query DSL
{
  "size": 0,
  "aggs": {
    "price_stats": {
      "stats": { "field": "price" }
    }
  }
}
ES|QL
FROM my-index
| STATS
    count = COUNT(price),
    min_price = MIN(price),
    max_price = MAX(price),
    avg_price = AVG(price),
    sum_price = SUM(price)
Percentiles Aggregation
Query DSL
{
  "size": 0,
  "aggs": {
    "response_percentiles": {
      "percentiles": {
        "field": "response_time",
        "percents": [50, 95, 99]
      }
    }
  }
}
ES|QL
FROM my-index
| STATS
    p50 = PERCENTILE(response_time, 50),
    p95 = PERCENTILE(response_time, 95),
    p99 = PERCENTILE(response_time, 99)
Cardinality Aggregation (Distinct Count)
Query DSL
{
  "size": 0,
  "aggs": {
    "unique_users": {
      "cardinality": { "field": "user_id" }
    }
  }
}
ES|QL
FROM my-index
| STATS unique_users = COUNT_DISTINCT(user_id)
Filter Aggregation
Query DSL
{
  "size": 0,
  "aggs": {
    "errors": {
      "filter": { "term": { "level": "error" } },
      "aggs": {
        "count": { "value_count": { "field": "_id" } }
      }
    }
  }
}
ES|QL
FROM my-index
| WHERE level == "error"
| STATS count = COUNT(*)

Or to get both total and filtered in one query using CASE:

FROM my-index
| STATS
    total = COUNT(*),
    errors = COUNT(CASE(level == "error", 1, null))

With per-aggregation WHERE (8.16+), this is simpler:

FROM my-index
| STATS
    total = COUNT(*),
    errors = COUNT(*) WHERE level == "error"
Nested Aggregations (Multiple Group By)
Query DSL
{
  "size": 0,
  "aggs": {
    "by_country": {
      "terms": { "field": "country" },
      "aggs": {
        "by_city": {
          "terms": { "field": "city" }
        }
      }
    }
  }
}
ES|QL
FROM my-index
| STATS count = COUNT(*) BY country, city
| SORT country, count DESC
Top Hits per Bucket

A terms aggregation with a top_hits sub-aggregation maps to SORT + LIMIT n BY. See Field Collapsing and Top Hits per Group.


Sampling and Approximate Aggregations

random_sampler Aggregation
Query DSL
{
  "size": 0,
  "aggs": {
    "sampled": {
      "random_sampler": { "probability": 0.1 },
      "aggs": {
        "by_level": {
          "terms": { "field": "level" },
          "aggs": { "avg_duration": { "avg": { "field": "duration" } } }
        }
      }
    }
  }
}
ES|QL

Use SAMPLE (preview 8.19/9.1, GA 9.5) before STATS:

FROM logs-test
| SAMPLE 0.1
| STATS events = COUNT(*), avg_duration = AVG(duration) BY level

SAMPLE does no extrapolation: COUNT and SUM cover only the sampled rows, so divide them by the probability to estimate totals.

sampler Aggregation (top-scoring documents)

The DSL sampler aggregation is not random: it aggregates the top-scoring shard_size documents on each shard. The closest ES|QL pattern keeps the top-scoring rows overall, not per shard:

FROM articles METADATA _score
| WHERE MATCH(content, "elasticsearch")
| SORT _score DESC
| LIMIT 100
| STATS docs = COUNT(*) BY category
Approximate STATS

Query DSL has no automatic approximation. In ES|QL, prepend SET approximation = true; (preview 9.4, GA 9.5) to a large STATS query to get extrapolated estimates with confidence intervals:

SET approximation = true;
FROM logs-test
| STATS events = COUNT(*), avg_duration = AVG(duration) BY level

Some functions (for example COUNT_DISTINCT, MIN, MAX) and query shapes (TS, PROMQL, two or more STATS) fall back to exact execution. See query-approximation.md for the output columns, options, and when to use SAMPLE instead.


Sorting

Query DSL
{
  "sort": [{ "@timestamp": { "order": "desc" } }, { "name": { "order": "asc" } }]
}
ES|QL
FROM my-index
| SORT @timestamp DESC, name ASC

Field Selection (_source)

Query DSL
{
  "_source": ["title", "author", "date"]
}
ES|QL
FROM my-index
| KEEP title, author, date
Exclude Fields
Query DSL
{
  "_source": {
    "excludes": ["internal_*", "temp"]
  }
}
ES|QL
FROM my-index
| DROP internal_*, temp

Pagination

Query DSL
{
  "from": 20,
  "size": 10
}
ES|QL

ES|QL doesn't have direct from equivalent. Use filtering or time-based pagination:

FROM my-index
| SORT @timestamp DESC
| LIMIT 10

For subsequent pages, use the last seen value:

FROM my-index
| WHERE @timestamp < "2024-01-15T10:30:00Z"
| SORT @timestamp DESC
| LIMIT 10

Field Collapsing and Top Hits per Group

Query DSL
{
  "query": { "match": { "message": "error" } },
  "collapse": { "field": "service.name" },
  "sort": [{ "@timestamp": "desc" }]
}

The same shape as an aggregation:

{
  "size": 0,
  "aggs": {
    "by_service": {
      "terms": { "field": "service.name" },
      "aggs": {
        "latest": { "top_hits": { "size": 1, "sort": [{ "@timestamp": "desc" }], "_source": ["message"] } }
      }
    }
  }
}
ES|QL (9.4+ preview)

SORT to choose which row wins, then LIMIT n BY field keeps the first n rows per group (Stack and Serverless):

FROM logs-test
| WHERE MATCH(message, "error")
| SORT @timestamp DESC
| LIMIT 1 BY service.name
| KEEP service.name, @timestamp, message

Use LIMIT 3 BY service.name for the equivalent of inner_hits or top_hits with size: 3. Rows stay flat: there is no nested hits array per group. The number comes before BY (LIMIT BY service.name 3 does not parse). SORT ... DESC puts nulls first in ES|QL, so add NULLS LAST when the sort field can be missing.

When you only need one value per group, an aggregation is simpler: STATS latest_message = LAST(message, @timestamp) BY service.name (9.4+). Before 9.4, use INLINE STATS (9.2+ preview, GA 9.3):

FROM logs-test
| INLINE STATS latest = MAX(@timestamp) BY service.name
| WHERE @timestamp == latest

To drop rows that are identical across all kept columns, use DEDUP on Serverless (preview). It is not available on Stack 9.5 (parse error); use LIMIT 1 BY cols or STATS BY cols there:

// Serverless only
FROM logs-test
| KEEP host.name, service.name
| DEDUP

Script Fields and Runtime Fields

Query DSL
{
  "script_fields": {
    "price_with_tax": {
      "script": {
        "source": "doc['price'].value * 1.1"
      }
    }
  }
}
ES|QL
FROM my-index
| EVAL price_with_tax = price * 1.1

Search-time runtime_mappings also become EVAL columns, which later commands can filter, aggregate, and sort on:

FROM web_logs
| EVAL is_error = status_code >= 500, kb = bytes / 1024.0
| STATS errors = COUNT(*) WHERE is_error, avg_kb = AVG(kb)

Painless logic maps to ES|QL functions (CASE, CONCAT, DATE_EXTRACT, DISSECT, GROK, and so on). There is no Painless escape hatch for logic those functions cannot express.


LOOKUP JOIN (Replaces Enrichment Patterns)

Query DSL
{
  "query": { "match_all": {} },
  "runtime_mappings": {
    "region_name": {
      "type": "keyword",
      "script": "/* typically handled via enrich processor or application-side join */"
    }
  }
}
ES|QL

Use LOOKUP JOIN (8.18/9.0+) to join against a lookup index:

FROM orders
| LOOKUP JOIN customers_lookup ON customer_id
| KEEP order_id, customer_id, name, email, total

Note: The lookup index must use index.mode: lookup and is limited to a single shard. Prefer LOOKUP JOIN over ENRICH for new queries.


GeoIP Enrichment

Query DSL

The Search API has no query-time GeoIP lookup. Location fields are added at ingest time with the geoip processor:

PUT _ingest/pipeline/geoip-web
{
  "processors": [{ "geoip": { "field": "source.ip", "target_field": "geo" } }]
}
ES|QL (9.5+ preview)

IP_LOCATION resolves IPs at query time, with no pipeline or re-indexing (Stack and Serverless):

FROM web_logs
| IP_LOCATION geo = source.ip WITH { "properties": ["country_name"] }
| STATS hits = COUNT(*) BY geo.country_name

Private and unknown IPs return null. The first query on a cluster triggers a one-time database download. Before 9.5, read the fields written by the ingest geoip processor, or use ENRICH with a range policy over an IP-range source index.


Filters Aggregation (Per-Aggregation WHERE)

Query DSL
{
  "size": 0,
  "aggs": {
    "messages": {
      "filters": {
        "filters": {
          "errors": { "match": { "level": "error" } },
          "warnings": { "match": { "level": "warning" } }
        }
      }
    }
  }
}
ES|QL

With per-aggregation WHERE (8.16+):

FROM logs
| STATS
    errors = COUNT(*) WHERE level == "error",
    warnings = COUNT(*) WHERE level == "warning",
    total = COUNT(*)

Pipeline Aggregations

Query DSL
{
  "size": 0,
  "aggs": {
    "sales_per_month": {
      "date_histogram": { "field": "date", "calendar_interval": "month" },
      "aggs": {
        "total_sales": { "sum": { "field": "amount" } },
        "cumulative_sales": { "cumulative_sum": { "buckets_path": "total_sales" } }
      }
    }
  }
}
ES|QL

ES|QL has no buckets_path or window functions. cumulative_sum (the example above) has no equivalent, and neither do cumulative_cardinality, serial_diff, moving_fn, or derivative over arbitrary (non-time-series) buckets. Compute those client-side or keep them in Query DSL. Many other pipeline aggregations map to commands that run after STATS:

Pipeline aggregationES|QL equivalent
bucket_scriptEVAL after STATS
bucket_selectorWHERE after STATS
bucket_sortSORT + LIMIT after STATS
avg_bucket, max_bucket, sum_bucket, stats_bucketA second STATS over the first one's output
Ratio to the overall total (percent of total)INLINE STATS (preview 9.2, GA 9.3) after STATS, then EVAL
derivative / moving_avg on metricsTS with RATE, DERIV, AVG_OVER_TIME(field, window) (GA 9.4)
Change detection (change_point aggregation)CHANGE_POINT (preview 8.19/9.1, GA 9.2; BY GA 9.5); Platinum license
cumulative_sum, serial_diff, moving_fnNo equivalent

bucket_script and bucket_selector:

FROM sales
| STATS revenue = SUM(amount), orders = COUNT(*) BY month = DATE_TRUNC(1 month, date)
| EVAL avg_order = ROUND(revenue / orders, 2)
| WHERE revenue > 100
| SORT month

avg_bucket and max_bucket (sibling aggregations over monthly totals):

FROM sales
| STATS revenue = SUM(amount) BY month = DATE_TRUNC(1 month, date)
| STATS avg_monthly = AVG(revenue), max_monthly = MAX(revenue)

Percent of total with INLINE STATS, which adds an aggregate to every row without collapsing them:

FROM sales
| STATS revenue = SUM(amount) BY region
| INLINE STATS total = SUM(revenue)
| EVAL pct = ROUND(revenue / total * 100, 1)

Rate, derivative, and moving average on a time series data stream. The second argument of a time series function is a sliding window, which replaces moving_avg:

TS metrics-tsds
| STATS req_rate = SUM(RATE(requests)),
        cpu_trend = AVG(DERIV(cpu)),
        cpu_smoothed = AVG(AVG_OVER_TIME(cpu, 15 minutes))
  BY host, bucket = TBUCKET(5 minutes)

Per-series change detection after a date_histogram-style STATS:

FROM logs-test
| STATS c = COUNT(*) BY t = BUCKET(@timestamp, 1 hour), service.name
| SORT t
| CHANGE_POINT c ON t BY service.name
| WHERE type IS NOT NULL

See time-series-queries.md for the full set of time series functions.


Highlighting

Query DSL
{
  "query": { "match": { "content": "elasticsearch" } },
  "highlight": {
    "fields": { "content": {} }
  }
}
ES|QL (9.3+ preview)

Use TOP_SNIPPETS (preview since 9.3) to return the best-matching passages of a field. Its 9.5 options (preview) highlight, pre_tag, post_tag, encoder (default or html), and order (score or none) wrap matched terms in tags, like DSL highlighting:

FROM articles
| WHERE MATCH(content, "elasticsearch")
| EVAL snippet = TOP_SNIPPETS(content, "elasticsearch", {"num_snippets": 2, "num_words": 20, "highlight": true, "pre_tag": "<mark>", "post_tag": "</mark>", "encoder": "html"})
| KEEP title, snippet

On 9.3 and 9.4, only num_snippets and num_words exist: TOP_SNIPPETS returns passages without highlight tags.

Remaining gaps compared to DSL highlighting:

  • The query text is passed to TOP_SNIPPETS separately and does not reuse the WHERE query. Terms are matched individually: a quoted phrase such as "search engine" highlights search and engine wherever they occur.

  • One field per call; there is no fields: {"*": {}} wildcard or require_field_match.

  • A row with no matching terms returns null (no no_match_size fallback).

  • No highlighter choice (unified, plain, fvh), highlight_query, or fragment_size in characters. num_words controls snippet length in words.


These map one-to-one to ES|QL functions. See esql-search.md for full syntax and options.

Query DSLES|QL
knn query or top-level knn with a query vectorWHERE KNN(field, [0.1, 0.2, ...]) (preview 9.2, GA 9.4)
knn with query_vector_builder (text_embedding)WHERE KNN(field, TEXT_EMBEDDING("text", "endpoint-id")) (preview 9.3, GA 9.4)
semantic query or match on a semantic_text fieldWHERE MATCH(semantic_field, "text") or WHERE semantic_field : "text"
{
  "knn": {
    "field": "embedding",
    "query_vector_builder": {
      "text_embedding": { "model_id": "my-embedding-endpoint", "model_text": "how do I tune search performance" }
    },
    "k": 10,
    "num_candidates": 100
  }
}
FROM docs METADATA _score
| WHERE KNN(embedding, TEXT_EMBEDDING("how do I tune search performance", "my-embedding-endpoint"))
| SORT _score DESC
| LIMIT 10

KNN requires a dense_vector (or semantic_text) field, and TEXT_EMBEDDING requires a text_embedding inference endpoint.


Rescoring and Hybrid Ranking

Inference Reranking (text_similarity_reranker)
Query DSL
{
  "retriever": {
    "text_similarity_reranker": {
      "retriever": { "standard": { "query": { "match": { "content": "elasticsearch" } } } },
      "field": "content",
      "inference_id": "my-rerank-endpoint",
      "inference_text": "how do I tune search performance",
      "rank_window_size": 50
    }
  }
}
ES|QL

RERANK (preview 9.2, GA 9.4) re-scores the rows in front of it, so LIMIT first to set the window. It overwrites _score but does not re-sort:

FROM articles METADATA _score
| WHERE MATCH(content, "elasticsearch")
| SORT _score DESC
| LIMIT 50
| RERANK "how do I tune search performance" ON content WITH { "inference_id": "my-rerank-endpoint" }
| SORT _score DESC
| LIMIT 10

The endpoint needs the rerank task type. The DSL rescore element with a query rescorer (re-run a second query on the top N hits) has no direct equivalent; approximate it by combining scores in EVAL.

Reciprocal Rank Fusion and Linear Retrievers
Query DSL
{
  "retriever": {
    "rrf": {
      "retrievers": [
        { "standard": { "query": { "match": { "title": "elasticsearch" } } } },
        { "standard": { "query": { "match": { "content": "performance" } } } }
      ],
      "rank_window_size": 10
    }
  }
}
ES|QL

Run each retriever as a FORK branch (preview 8.19/9.1, GA 9.4) and merge them with FUSE (preview 9.2, GA 9.5). FUSE needs METADATA _id, _index, _score:

FROM articles METADATA _id, _index, _score
| FORK
    (WHERE MATCH(title, "elasticsearch") | SORT _score DESC | LIMIT 10)
    (WHERE MATCH(content, "performance") | SORT _score DESC | LIMIT 10)
| FUSE
| SORT _score DESC
| LIMIT 10

FUSE defaults to RRF. For a linear retriever, use FUSE linear WITH { "normalizer": "minmax", "weights": { "fork1": 0.7, "fork2": 0.3 } }. A branch can use KNN or semantic MATCH for hybrid search, and RERANK can follow FUSE.


ES|QL Limitations (vs Query DSL)

Feature gaps and partial equivalents as of Elastic Stack 9.5 (Serverless differences noted):

FeatureQuery DSLES|QL
Highlighting✅Partial: TOP_SNIPPETS (preview 9.3; highlight tags 9.5 preview)
Nested queries✅❌
Parent-child queries✅❌
Scroll/pagination beyond 10k✅❌
Percolate queries✅❌
Complex boosting✅Limited (boost option on full-text functions, EVAL on _score)
Geo distance sorting✅✅ EVAL d = ST_DISTANCE(...) then SORT d
Runtime fields✅Use EVAL
Suggest API✅❌
Collapse (field collapsing)✅✅ SORT + LIMIT 1 BY field (preview 9.4); DEDUP for whole-row duplicates (Serverless preview; Stack planned)
Inner hits✅Partial: LIMIT n BY for collapse groups (flat rows); ❌ for nested and parent-child
Terms lookup✅✅ WHERE x IN (FROM idx | KEEP col) (preview 9.5)
Query rescorer✅❌ (RERANK covers inference reranking, GA 9.4)
Cumulative / moving-window pipeline aggs✅❌ (time series windows via TS only)
Timezone support✅✅ SET time_zone (9.4+ GA; Serverless)
JOIN (non-lookup)N/A❌ (only LEFT JOIN on lookup index)
Subqueries / UNION ALLN/A✅ FROM subqueries (preview 9.4, GA 9.5); WHERE IN/NOT IN subqueries (preview 9.5)
Unsupported Field Types in ES|QL
  • nested (fields are omitted from results entirely)

  • join (parent-child)

  • binary

  • completion

  • flattened before 9.5 (on 9.4, use METADATA _source + JSON_EXTRACT to access sub-keys; 9.5+ preview: root column plus FIELD_EXTRACT, see Flattened Field Queries)

  • Range types other than date_range (integer_range, ip_range, etc.); date_range is supported as a preview type in 9.5 with RANGE_INTERSECTS, RANGE_WITHIN, and related functions

  • rank_feature, rank_features

  • search_as_you_type

  • histogram: only inside TS, cast with ::exponential_histogram or ::tdigest (preview 9.3, GA 9.4)


Migration Checklist

When migrating from Query DSL to ES|QL:

  1. Check field type support - Verify all fields use supported types

  2. Review aggregation complexity - Deeply nested aggregations may need restructuring

  3. Handle scoring requirements - Add METADATA _score if relevance sorting needed

  4. Adjust result limits - ES|QL defaults to 1000 rows, max 10000

  5. Test full-text search - Use MATCH(), QSTR(), or KQL() functions

  6. Validate time ranges - ES|QL time syntax differs from DSL

  7. Check for unsupported features - Nested and parent-child docs, cumulative pipeline aggregations, etc.

  8. Check version and deployment - Preview features such as LIMIT BY, TOP_SNIPPETS, FIELD_EXTRACT, and IN (subquery) need a recent Stack version; DEDUP is Serverless only


Performance Considerations

AspectQuery DSLES|QL
CachingFilter context cachedNo equivalent caching
Query planningBased on LuceneDedicated query engine
AggregationsCan be memory-intensiveBlock-based processing
Full-text searchNative LuceneUses same analyzers

ES|QL advantages:

  • Concurrent/parallel processing

  • Block-based execution (more efficient for large scans)

  • No query-to-DSL translation overhead

Query DSL advantages:

  • More mature caching

  • Better for complex scoring scenarios

  • More features available


Quick Reference Table

Query DSLES|QL Equivalent
match_allFROM index
termWHERE field == value
termsWHERE field IN (...)
matchWHERE MATCH(field, query)
match_phraseWHERE MATCH_PHRASE(field, query)
query_stringWHERE QSTR("...")
rangeWHERE field >= x AND field <= y
bool.mustWHERE cond1 AND cond2
bool.shouldWHERE cond1 OR cond2
bool.must_notWHERE (field != val OR field IS NULL) *
bool.filterWHERE cond (no scoring)
existsWHERE field IS NOT NULL
wildcardWHERE field LIKE "pattern*"
regexpWHERE field RLIKE "pattern"
prefixWHERE STARTS_WITH(field, "prefix")
terms aggSTATS count = COUNT(*) BY field
date_histogramSTATS ... BY DATE_TRUNC(interval, field)
avg, sum, min, maxSTATS AVG(f), SUM(f), MIN(f), MAX(f)
cardinalitySTATS COUNT_DISTINCT(field)
percentilesSTATS PERCENTILE(field, p)
filter aggCOUNT(*) WHERE cond (8.16+)
top_hits (per bucket)SORT field | LIMIT n BY group (9.4+)
collapseSORT field | LIMIT 1 BY field (9.4+)
terms lookupWHERE f IN (FROM idx | KEEP col) (9.5+)
term on flattened keyWHERE FIELD_EXTRACT(root, "key") == v
random_sampler aggSAMPLE p
bucket_scriptEVAL after STATS
bucket_selectorWHERE after STATS
highlightEVAL TOP_SNIPPETS(field, query, {...})
knnWHERE KNN(field, vector)
semanticWHERE MATCH(semantic_field, text)
text_similarity_rerankerRERANK query ON field WITH {...}
rrf / linear retrieverFORK (...) (...) | FUSE [linear]
runtime_mappingsEVAL
geoip ingest processorIP_LOCATION prefix = ip (9.5+)
_sourceKEEP field1, field2
sortSORT field DESC
sizeLIMIT n

* ES|QL uses three-valued logic. field != value excludes NULLs, unlike DSL must_not which keeps documents where the field is missing. Add OR field IS NULL to match DSL behavior.

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