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://mp-staging.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/esql-search.md

ES|QL Full-Text Search Reference

Full-text search in ES|QL uses analyzer-aware functions for fast, relevance-ranked text retrieval. Use these instead of LIKE/RLIKE for searching natural language content — they are significantly faster on large datasets.

Version: MATCH and QSTR were introduced in 8.17 (preview) and became GA in 8.19/9.1. KQL and scoring via METADATA _score were added in 8.18/9.0 (GA in 8.19/9.1). MATCH_PHRASE is 8.19/9.1+. See the version column in the Functions Overview table and esql-version-history.md for per-function availability.

Table of Contents

  • When to Use Full-Text Search

  • Functions Overview

  • MATCH

  • Colon Operator (:)

  • MATCH_PHRASE

  • QSTR (Query String)

  • KQL (Kibana Query Language)

  • Relevance Scoring

  • Semantic Search

  • FORK / FUSE (Hybrid Search)

  • LOOKUP JOIN

  • Parameters in ES|QL

  • Advanced Search Functions

  • Placement Rules

  • Common Patterns

  • Full-Text Search vs Pattern Matching

  • Interaction with Text Analyzers


Use full-text search functions (MATCH, QSTR, KQL, MATCH_PHRASE) when:

  • Searching natural-language text (log messages, descriptions, titles, comments)

  • Relevance ranking matters (most relevant results first)

  • You need analyzer features on text fields: case-insensitive matching, stemming, synonyms, fuzzy matching (note: analyzer features do not apply to semantic_text fields)

  • Searching multivalued text fields

  • Performance matters on large datasets

Use LIKE/RLIKE instead when:

  • Pattern-matching on exact (keyword) values: file paths, URLs, status codes

  • You need structural regex matching not covered by query string syntax

  • Working on small datasets where analyzer support is unnecessary


Functions Overview

FunctionUse CaseVersion (GA)
MATCH(field, query)Single-field text search9.1
field : "query"Shorthand for MATCH (no options)9.1
MATCH_PHRASE(field, phrase)Exact phrase matching (word order)9.1
QSTR(query_string)Multi-field search with Lucene syntax9.1
KQL(kql_string)Kibana Query Language queries9.1

MATCH

Single-field text search. Equivalent to the Query DSL match query.

Syntax
MATCH(field, query)
MATCH(field, query, {"option": value})

One field per MATCH: The first argument must be a single field from the index mapping (an identifier such as title or content), not a string literal and not a comma-separated list. MATCH("title,content", "q") is invalid — that is not a real field name. To search several text fields, use separate calls combined with OR (for example MATCH(title, "q") OR MATCH(body, "q")) or use QSTR / KQL for a multi-field query string. MATCH(title, body, "phrase") is invalid — the second argument is the query text; the optional third is the options map, not another field.

Basic Examples
// Simple text search
FROM logs-* METADATA _score
| WHERE MATCH(message, "connection timeout")
| SORT _score DESC
| LIMIT 100

// Search with AND operator (all terms must match)
FROM articles METADATA _score
| WHERE MATCH(title, "elasticsearch query language", {"operator": "AND"})
| SORT _score DESC
| LIMIT 20

// Fuzzy matching for typo tolerance
FROM docs METADATA _score
| WHERE MATCH(content, "authentcation error", {"fuzziness": "AUTO"})
| SORT _score DESC
| LIMIT 50
Named Parameters

All parameters are optional. Analyzer-related parameters (analyzer, fuzziness, auto_generate_synonyms_phrase_query) only apply to text fields — they have no effect on semantic_text fields.

ParameterTypeDefaultDescription
operatorkeyword"OR"Boolean logic between terms: "OR" or "AND"
fuzzinessvariesnoneEdit distance: "AUTO", 0, 1, 2
analyzerkeywordfield'sOverride the query-time analyzer (text fields only)
boostfloat1.0Relevance score multiplier
minimum_should_matchvariesnoneMin terms that must match (number or percentage)
fuzzy_transpositionsbooleantrueAllow ab→ba swaps in fuzzy matching
max_expansionsinteger—Max terms for fuzzy/prefix expansion
fuzzy_rewritekeyword—Rewrite method for fuzzy queries
prefix_lengthinteger—Leading chars unchanged in fuzzy matching
lenientbooleanfalseIgnore format errors (text query on numeric field)
zero_terms_querykeyword—Behavior when analyzer removes all tokens
auto_generate_synonyms_phrase_querybooleantrueAuto-create phrase queries for multi-term synonyms
Supported Field Types

text, semantic_text, keyword, boolean, date, date_nanos, double, integer, long, unsigned_long, ip, version.

Semantic search: MATCH is required for searching semantic_text fields — it automatically performs semantic (vector) search instead of lexical search. No syntax change needed. Other full-text functions (QSTR, KQL, MATCH_PHRASE) do not support semantic_text.


Colon Operator (:)

Shorthand for MATCH() with default parameters. Use for concise, simple searches.

Syntax
field : "query"
Examples
// Simple search
FROM logs-*
| WHERE message : "error"

// With scoring
FROM articles METADATA _score
| WHERE content : "machine learning"
| SORT _score DESC
| LIMIT 10

// Semantic search on semantic_text field
FROM knowledge_base METADATA _score
| WHERE semantic_content : "how to configure authentication"
| SORT _score DESC
| LIMIT 5

Limitation: The colon operator does not support named parameters. Use MATCH() when you need fuzziness, operator, analyzer, or other options.


MATCH_PHRASE

Matches documents where words appear in exact order. Equivalent to the Query DSL match_phrase query.

Syntax
MATCH_PHRASE(field, phrase)
MATCH_PHRASE(field, phrase, {"option": value})
Examples
// Exact phrase match
FROM articles METADATA _score
| WHERE MATCH_PHRASE(content, "machine learning pipeline")
| SORT _score DESC
| LIMIT 20

// With slop (allow N positions between words)
FROM docs METADATA _score
| WHERE MATCH_PHRASE(body, "connection refused", {"slop": 1})
| SORT _score DESC
| LIMIT 50
Named Parameters
ParameterTypeDefaultDescription
slopinteger0Max positions allowed between matching tokens
analyzerkeywordfield'sOverride the query-time analyzer
boostfloat1.0Relevance score multiplier
zero_terms_querykeyword—Behavior when analyzer removes all tokens
Supported Field Types

text, keyword. Does not support semantic_text or numeric types.

MATCH vs MATCH_PHRASE
Query"machine learning pipeline""learning machine""machine and learning"
MATCH(f, "machine learning")YesYesYes
MATCH(f, "machine learning", {"operator":"AND"})YesYesYes
MATCH_PHRASE(f, "machine learning")YesNoNo
MATCH_PHRASE(f, "machine learning", {"slop":1})YesNoYes

QSTR (Query String)

Multi-field search using Lucene query string syntax. Equivalent to the Query DSL query_string query. Use when you need complex boolean logic, wildcards, or field-specific searches in a single expression.

Syntax
QSTR(query_string)
QSTR(query_string, {"option": value})
Query String Mini-Language
SyntaxMeaningExample
termMatch single termerror
"phrase"Exact phrase"connection refused"
field:termSearch specific fieldstatus:error
field:"phrase"Phrase on specific fieldmessage:"disk full"
term1 AND term2Both must matcherror AND timeout
term1 OR term2Either matcheswarning OR error
-termExclude termerror -test
term*Wildcard prefixconnect*
term~NFuzzy match (edit distance N)errror~1
"phrase"~NProximity (words within N positions)"connection error"~3
(group)Grouping(error OR warning) AND production
field:(term1 OR term2)Multi-value on one fieldlevel:(error OR critical)
Examples
// Multi-field boolean search
FROM logs-* METADATA _score
| WHERE QSTR("message:timeout AND level:error AND NOT host.name:test*")
| SORT _score DESC
| LIMIT 100

// Wildcard and proximity
FROM docs METADATA _score
| WHERE QSTR("title:elast* AND description:\"query language\"~2")
| SORT _score DESC
| LIMIT 20

// With default field and lenient mode
FROM logs-*
| WHERE QSTR("connection lost", {"default_field": "message", "lenient": true})
| LIMIT 100
Named Parameters
ParameterTypeDefaultDescription
default_fieldkeyword—Default field when none specified in query string
allow_leading_wildcardbooleantrueAllow * or ? as first character
lenientbooleanfalseIgnore format-based errors
fuzzinessvaries—Edit distance for fuzzy matching
boostfloat1.0Relevance score multiplier

KQL (Kibana Query Language)

Run KQL queries within ES|QL. Useful for migrating existing Kibana search bar queries without rewriting them.

Syntax
KQL(kql_string)
KQL(kql_string, {"option": value})
Examples
// Basic KQL query
FROM logs-*
| WHERE KQL("message: error and host.name: server*")
| LIMIT 100

// KQL with multiple conditions
FROM web-logs METADATA _score
| WHERE KQL("http.request.method: GET and http.response.status_code >= 400")
| SORT _score DESC
| LIMIT 50

// Case-insensitive keyword matching (9.3+)
FROM logs-*
| WHERE KQL("level: Error", {"case_insensitive": true})
| LIMIT 100
Named Parameters (9.3+)
ParameterTypeDefaultDescription
boostfloat1.0Relevance score multiplier
time_zonekeyword—UTC offset or IANA time zone for date literals
case_insensitivebooleanfalseCase-insensitive matching for keyword fields
default_fieldkeyword—Default field when none provided

Relevance Scoring

Full-text functions produce relevance scores. For lexical search on text fields, scoring uses BM25. For semantic search on semantic_text fields, scoring is based on vector similarity. For hybrid search via FUSE, scores are combined using the selected fusion method (RRF or linear). Scoring must be explicitly requested.

Enabling Scores

Add METADATA _score to the FROM clause:

FROM index METADATA _score
| WHERE MATCH(field, "query")
| SORT _score DESC
| LIMIT 10

Without METADATA _score, full-text functions still filter documents but no ranking is applied.

Score Boosting

Boost specific fields or queries by combining multiple MATCH calls with different boost values:

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

Filter out low-relevance results:

FROM docs METADATA _score
| WHERE MATCH(content, "query optimization")
| WHERE _score > 2.0
| SORT _score DESC
| LIMIT 50
Custom Scoring

Combine _score with other fields in EVAL:

FROM products METADATA _score
| WHERE MATCH(description, "wireless headphones")
| EVAL custom_score = _score + rating / 5.0
| SORT custom_score DESC
| LIMIT 20

Search semantic_text fields using MATCH or : for vector-based semantic matching. No separate function is needed — MATCH automatically performs semantic search on semantic_text fields.

// Semantic search via colon operator
FROM knowledge_base METADATA _score
| WHERE semantic_content : "how do I reset my password"
| SORT _score DESC
| LIMIT 10

// Semantic search via MATCH
FROM knowledge_base METADATA _score
| WHERE MATCH(semantic_content, "configure two-factor authentication")
| SORT _score DESC
| LIMIT 10

Combines multiple search strategies in parallel and merges results with relevance scoring.

FROM index METADATA _id, _index, _score
| FORK
    (WHERE MATCH(text_field, "keyword query") | SORT _score DESC | EVAL branch = "lexical")
    (WHERE MATCH(semantic_field, "semantic query") | SORT _score DESC | EVAL branch = "semantic")
| FUSE
| SORT _score DESC
| LIMIT 25

Rules:

  1. METADATA _id, _index, _score must be in the FROM clause — FUSE requires all three. The same METADATA _index declaration is required before you filter on _index inside a branch (for example WHERE _index LIKE "logs-*"). Without METADATA _index, _index is not a valid column.

  2. Each branch uses WHERE MATCH(...) inside parentheses — not a bare MATCH command

  3. Each branch should SORT _score DESC to feed ranked results into FUSE

  4. FUSE supports two methods: rrf (Reciprocal Rank Fusion, default) and linear (weighted linear combination with optional minmax score normalization and per-branch weights)

  5. The _fork column in results indicates which branch(es) matched each document

  6. A maximum of 8 forks are allowed

Examples
// Hybrid lexical + semantic search (RRF, default)
FROM articles METADATA _id, _index, _score
| FORK
    (WHERE MATCH(title, "elasticsearch performance") | SORT _score DESC | LIMIT 10)
    (WHERE semantic_content : "how to make elasticsearch faster" | SORT _score DESC | LIMIT 10)
| FUSE
| SORT _score DESC
| LIMIT 10

// LINEAR fusion with minmax normalization and custom weights
FROM articles METADATA _id, _index, _score
| FORK
    (WHERE MATCH(title, "elasticsearch performance") | SORT _score DESC | LIMIT 50)
    (WHERE semantic_content : "how to make elasticsearch faster" | SORT _score DESC | LIMIT 50)
| FUSE linear WITH { "normalizer": "minmax", "weights": { "fork1": 0.6, "fork2": 0.4 } }
| SORT _score DESC
| LIMIT 10

// Three-way hybrid: lexical, semantic, and KNN
FROM docs METADATA _id, _index, _score
| FORK
    (WHERE MATCH(content, "query optimization") | SORT _score DESC | LIMIT 20)
    (WHERE semantic_content : "how to speed up database queries" | SORT _score DESC | LIMIT 20)
    (WHERE KNN(embedding, TEXT_EMBEDDING("query optimization", "my-endpoint")) | SORT _score DESC | LIMIT 20)
| DROP embedding
| FUSE
| SORT _score DESC
| LIMIT 10

LOOKUP JOIN

Joins search results with a pre-built lookup index to enrich them with additional fields.

FROM source-index
| STATS count = COUNT(*) BY join_key_field
| LOOKUP JOIN lookup-index-name ON join_key_field
| KEEP join_key_field, count, enriched_field_from_lookup
| LIMIT 10

Constraints:

  • Target must be a separate, pre-built lookup index — self-joins are not valid

  • The lookup index must be in lookup mode (index.mode: lookup)

  • After STATS, only aggregated columns exist — original source fields are gone

  • Parameters (?param) cannot be used as field names

Examples
// Enrich search results with category labels
FROM logs-* METADATA _score
| WHERE MATCH(message, "authentication error")
| STATS error_count = COUNT(*) BY host.name
| LOOKUP JOIN host-metadata ON host.name
| KEEP host.name, error_count, environment, team
| SORT error_count DESC
| LIMIT 20

// Enrich aggregated results with user info
FROM audit-logs
| WHERE MATCH(message, "permission denied")
| STATS denied_count = COUNT(*) BY user.id
| LOOKUP JOIN users-lookup ON user.id
| KEEP user.id, denied_count, user.name, department
| SORT denied_count DESC
| LIMIT 10

Parameters in ES|QL

Use ?param_name syntax for agent-controlled dynamic values:

FROM orders-*
| WHERE region == ?region AND @timestamp >= NOW() - ?days::integer * 1d
| STATS total = SUM(amount) BY product_category
| SORT total DESC
| LIMIT ?limit

Parameter types: keyword, text, integer, long, double, boolean, date

Gotchas:

  • Parameters are values only — ?field_name cannot be used as a dynamic column reference

  • Duration syntax (30d) cannot be a parameter directly — use ?days::integer * 1d instead

  • Optional parameters should have defaults to prevent null-breaking query syntax


Advanced Search Functions

These functions were added in recent versions. KNN is GA since 9.4; TOP_SNIPPETS and DECAY are still preview.

KNN — Dense Vector Search (preview 9.2, GA 9.4)
FROM index METADATA _score
| WHERE KNN(vector_field, [0.5, 0.8, 0.3])
| SORT _score DESC
| LIMIT 10

// With text embedding
FROM index METADATA _score
| WHERE KNN(embedding_field, TEXT_EMBEDDING("search query", "my-inference-endpoint"))
| SORT _score DESC
| LIMIT 10
TOP_SNIPPETS — Search Result Highlights (9.3+, preview)

Extract the best-matching text snippets from a field:

FROM articles METADATA _score
| WHERE MATCH(content, "elasticsearch performance")
| EVAL snippets = TOP_SNIPPETS(content, "elasticsearch performance", {"num_snippets": 3, "num_words": 50})
| KEEP title, snippets, _score
| SORT _score DESC
| LIMIT 10

Options: num_snippets (number of snippets to return), num_words (max words per snippet; 0 disables chunking, 9.4+).

9.5+ options (preview): highlight (boolean, wrap matched terms in tags), pre_tag / post_tag (defaults <em> / </em>), encoder (default or html), order (score default, or none for original text order), and analyzer (registered analyzer for scoring and highlighting, default standard):

FROM articles
| WHERE MATCH(content, "elasticsearch")
| EVAL snippets = TOP_SNIPPETS(content, "elasticsearch", {"num_snippets": 2, "highlight": true, "pre_tag": "<b>", "post_tag": "</b>", "encoder": "html"})
| KEEP title, snippets
DECAY — Distance-Based Scoring (9.3+, preview)

Score based on distance from an origin (date, number, or geo point):

FROM events METADATA _score
| WHERE MATCH(title, "conference")
| EVAL recency_score = DECAY("gauss", @timestamp, NOW(), "7d", "30d")
| SORT recency_score DESC
| LIMIT 20

Placement Rules

Full-text search functions on indexed fields use the inverted index, so they must appear in a WHERE close to FROM. How close depends on the function:

  • QSTR and KQL — only after FROM, WHERE, or SORT. They fail after EVAL, GROK, DISSECT, KEEP, DROP, RENAME, MV_EXPAND, STATS, and LIMIT (unchanged in 9.5).

  • MATCH, :, and MATCH_PHRASE on an indexed field — can also follow EVAL, GROK, DISSECT, KEEP, DROP, and RENAME (a renamed field still counts as indexed), but fail after MV_EXPAND, STATS, and LIMIT.

  • All of them can appear in per-aggregation STATS ... WHERE filters.

Before 9.5, full-text functions accept only index-mapped fields: a column created by EVAL (even EVAL m = message) or ROW fails with cannot operate on [m], which is not a field from an index mapping.

From 9.5 (MATCH on expressions, preview), MATCH and : also accept expressions such as EVAL columns, scanning row by row. On 9.5 Stack this is still rejected after STATS (and LIMIT), takes no options, and scores 0; MATCH_PHRASE, QSTR, and KQL still require indexed fields. Serverless relaxes these limits — see MATCH on expressions.

// CORRECT — MATCH on an indexed field after EVAL
FROM logs-* METADATA _score
| EVAL lower_msg = TO_LOWER(message)
| WHERE MATCH(message, "timeout")
| SORT _score DESC
| LIMIT 50

// WRONG — QSTR after EVAL fails
FROM logs-*
| EVAL lower_msg = TO_LOWER(message)
| WHERE QSTR("message:timeout")

// WRONG — MATCH on an indexed field after STATS fails
FROM logs-*
| STATS c = COUNT(*) BY message
| WHERE MATCH(message, "timeout")

// 9.5+ (preview) — MATCH on an EVAL column; wrap keyword expressions in TO_TEXT for analyzed search
FROM logs-*
| EVAL t = TO_TEXT(CONCAT(level, " ", message))
| WHERE MATCH(t, "timeout")

Common Patterns

Search Logs for Error Messages
FROM logs-* METADATA _score
| WHERE @timestamp > NOW() - 1 hour
| WHERE MATCH(message, "connection refused timeout")
| KEEP @timestamp, host.name, message, _score
| SORT _score DESC
| LIMIT 100
Find Documents by Exact Phrase
FROM docs METADATA _score
| WHERE MATCH_PHRASE(content, "null pointer exception")
| KEEP title, content, _score
| SORT _score DESC
| LIMIT 20
Multi-Criteria Search with QSTR
FROM logs-* METADATA _score
| WHERE @timestamp > NOW() - 24 hours
| WHERE QSTR("message:(timeout OR refused) AND level:error AND NOT host.name:staging*")
| KEEP @timestamp, host.name, level, message, _score
| SORT _score DESC
| LIMIT 100
Aggregate Search Results
FROM logs-*
| WHERE MATCH(message, "authentication failure")
| WHERE @timestamp > NOW() - 24 hours
| STATS failure_count = COUNT(*) BY host.name
| SORT failure_count DESC
| LIMIT 20
// Original KQL in Kibana: message: error and host.name: prod-*
FROM logs-*
| WHERE KQL("message: error and host.name: prod-*")
| SORT @timestamp DESC
| LIMIT 100
Combine Text Search with Aggregation
FROM logs-*
| WHERE MATCH(message, "disk space")
| WHERE @timestamp > NOW() - 7 days
| STATS
    count = COUNT(*),
    hosts_affected = COUNT_DISTINCT(host.name)
  BY day = DATE_TRUNC(1 day, @timestamp)
| SORT day DESC

Full-Text Search vs Pattern Matching

CapabilityFull-Text (MATCH, QSTR)Pattern (LIKE, RLIKE)
Uses inverted indexYes (fast)No (scans values)
Analyzer supportYes (stemming, synonyms)No
Relevance scoringYes (_score)No
Case-insensitiveYes (via analyzer)Manual (TO_LOWER + match)
Fuzzy matchingYes (fuzziness option)Manual (complex regex)
Wildcard patternsLimited (* in QSTR)Full regex support
Works on keyword fieldsYesYes
Works on computed valuesNo (index fields only)Yes
Performance on large dataExcellentPoor

Interaction with Text Analyzers

Full-text search functions automatically use the field's configured analyzer at both index time and query time. These analyzer features apply to text fields only — they do not apply to semantic_text fields, which use vector-based similarity instead of token analysis.

Analyzer-powered capabilities (text fields only):

  • Case-insensitive matching — analyzers typically lowercase tokens

  • Stemming — "running" matches "run", "runs", "ran"

  • Stopword removal — common words like "the", "a" are excluded

  • Synonyms — configured synonym mappings are applied

  • ASCII folding — "café" matches "cafe"

Override the analyzer at query time using the analyzer parameter (text fields only):

FROM logs-*
| WHERE MATCH(message, "query text", {"analyzer": "my_custom_analyzer"})

Contrast with LIKE/RLIKE: These operators work on exact stored values and bypass all analyzer processing.

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

The author says: Elasticsearch 8.14 or later (ES|QL GA; introduced 8.11 as tech preview), self-managed, Elastic Cloud Hosted, or Elastic Cloud Serverless; individual ES|QL features are version-gated (see references/esql-version-history.md). Requires the `elastic` CLI ≥ 0.2 with `stack es` support.

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
elastic
Publisher
Elastic (Official)
Source
elastic/agent-skills · skills/elasticsearch/elasticsearch-esql · commit 09d879d
Licence
Apache License 2.0 (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced
Also distributed in
elastic/agent-skills

AutoGineer is not affiliated with or endorsed by elastic.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download elasticsearch-esql.zip