<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title><![CDATA[Query Languages - Elasticsearch Labs]]></title>
    <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Query Languages - Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/search-labs/blog/category/query-languages</link>
    </image>
    <link>https://www.elastic.co/search-labs/blog/category/query-languages</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/category/query-languages.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Sun, 13 Sep 2026 22:10:24 GMT</lastBuildDate>
  <item>
    <title><![CDATA[How we built PromQL into Elasticsearch]]></title>
    <description><![CDATA[PromQL runs on the same Elasticsearch compute engine as ES|QL, with no plugin and no separate process to operate. Getting there meant changing how the engine evaluates time windows and builds grouping keys.]]></description>
    <content:encoded><![CDATA[<p>More than 80% of the Prometheus Query Language (PromQL) queries in our real-world corpus run on Elasticsearch without modification. Elasticsearch 9.5 makes the PromQL and the Prometheus-compatible API generally available (GA), so you can ingest Prometheus metrics with<a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/tsds-ingest-prometheus-remote-write"> remote write</a> and query them through the<a href="https://www.elastic.co/docs/reference/query-languages/promql/promql-http-api"> Prometheus HTTP APIs</a> or the<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/promql"> PROMQL</a> command in Elasticsearch Query Language (ES|QL).</p><p>PromQL compiles to the same compute engine that runs ES|QL and inherits its planner and distributed execution, along with its release process. We didn’t build a second engine for this, and there’s no plugin to install.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9e310070ec244c5d/6aa3928e224d356c5e0dd18e/1.png" alt="PromQL compatibility in Elasticsearch rising from zero to 80% between 9.4 Tech Preview and 9.5 GA" /><p>This post is about how we built it.</p><p>Key takeaways:</p><ul><li><p><strong>One engine:</strong> The implementation combines Elasticsearch’s mature distributed planning, storage, and testing infrastructure with its newer compute engine, which provides a columnar execution runtime. This lets PromQL reuse proven Elasticsearch capabilities while executing through a modern, native vectorized pipeline rather than introducing a separate runtime.</p></li><li><p><strong>One server:</strong> Elasticsearch implements the Prometheus remote write and query APIs directly, so Prometheus-compatible ingest and queries run without any additional plugins.</p></li><li><p><strong>Engineered for efficiency:</strong> Supporting PromQL required new engine primitives for range-aligned evaluation grids, backward-looking windows, dynamic label grouping, pipeline result reshaping, and compact wide aggregation keys. These primitives allow PromQL queries to execute efficiently end to end, with the relevant semantics implemented directly in the compute engine rather than through external post-processing.</p></li><li><p><strong>Compatibility measured in real use:</strong> In addition to Prometheus compliance tests, we built a differential-testing and quality-control pipeline over 2,000 PromQL queries collected from public repositories. </p></li></ul><p>Read more:</p><ul><li><p><a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">Query Prometheus Metrics in Elasticsearch with PromQL</a></p></li><li><p><a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch">Ship Prometheus Metrics to Elasticsearch with Remote Write</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-native-prometheus-api">Bringing Fire to Elasticsearch: Adding Native Prometheus APIs</a></p></li></ul><h2><strong>Why run PromQL on Elasticsearch</strong></h2><p>Many teams already store logs and traces in Elasticsearch while running metrics in Prometheus or another dedicated metrics back end.</p><p>Prometheus and its ecosystem are strong and widely adopted, but large deployments can also bring operational sprawl and scaling challenges, along with limited retention. </p><p>So we set out to combine Elastic’s highly optimized time-series database (TSDB) with a best-in-class metrics ecosystem. The result is a smaller observability stack, with fewer systems to operate and metrics storage that scales horizontally and supports long-term retention.</p><h2><strong>One engine: PromQL and ES|QL share the same compute engine</strong></h2><p>We made an early architectural decision not to run a separate PromQL engine next to Elasticsearch.</p><p>PromQL is instead another front end to the Elasticsearch compute engine.</p><p>This puts PromQL in the normal Elasticsearch development lifecycle. It uses the same planner, distributed execution engine, testing infrastructure, and release process as Elasticsearch itself.</p><p>To learn more about Elasticsearch’s query engine, check <a href="https://www.elastic.co/search-labs/blog/elasticsearch-columnar-metrics-engine-30x-faster-prometheus">our blog</a>.</p><p>Like ES|QL’s time series queries that use the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> source command, PromQL is translated into a highly optimized query plan and executed across the cluster. Nodes process columnar batches through vectorized operators, while partial results move through exchanges until the final result is assembled. </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcf3a413e89209805/6aa392b38406d9813bcaa1d8/2.png" alt="PromQL and ES|QL frontends feed one shared Elasticsearch planner and columnar execution DAG across shards" /><p>This also means that PromQL and ES|QL operate over the same execution engine and time-series data. ES|QL can additionally extend a PromQL computation with post-processing that PromQL doesn’t support, such as lookup joins and inline aggregations.</p><p>For example, assume Prometheus request counters are stored in <code>metrics-*</code> and keyed by the <code>service</code> label. A lookup index, <code>service_registry</code>, maps each instance to its owning team and environment and to its service tier:</p><p></p><p></p><p>This architecture requires the execution engine to support PromQL semantics natively and efficiently rather than ES|QL syntax sugar. The following sections describe the changes and new execution primitives we introduced to achieve that.</p><h2><strong>One server: Prometheus remote write and HTTP API built into Elasticsearch</strong></h2><p>Query execution is only half of the story. The Prometheus ecosystem also expects familiar ingest and query APIs.</p><p>Prometheus protocols are the de facto standard for everything metrics in almost every team’s observability stack. So we built the HTTP API directly in the Elasticsearch server, which eliminated the need for a third component and tightened integration stability and performance.</p><p>On the ingest side, we <a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch-architecture">added</a> an endpoint for the <a href="https://prometheus.io/docs/specs/prw/remote_write_spec/">Prometheus remote write</a> protocol. It accepts Snappy-compressed Protocol Buffer messages, maps labels to TSDS dimensions, maps the metric name/value into metric fields, infers counter versus gauge mappings, and writes directly into TSDS. The built-in template is dynamic, so users don’t have to predeclare every Prometheus label or metric.</p><p>On the query side, Elasticsearch <a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch-architecture">exposes</a> Prometheus query APIs. A request enters through the Prometheus endpoint and is executed in the compute engine.</p><h2><strong>Running PromQL efficiently in a columnar engine</strong></h2><p>Sharing an execution engine doesn’t mean treating PromQL as syntax sugar over ES|QL. PromQL has different time, grouping, and response semantics, as well as workload characteristics that matter at scale. Supporting it efficiently required extending the compute engine rather than compensating in the API layer.</p><h3><strong>PromQL time grids: Aligning evaluation steps with TSTEP</strong></h3><p>Time-series query engines are optimized for grouping over time.</p><p>Elasticsearch normally groups timestamps with <code>TBUCKET(...)</code>, which truncates each timestamp to a fixed interval boundary. Truncation is cheap and produces deterministic bucket boundaries. It also makes intermediate results easier to reuse.</p><p>Prometheus defines evaluation points differently. For a range query, timestamps are laid out as fixed steps anchored to the query range, rather than derived by truncating each sample timestamp. Two queries with the same step but different range boundaries can therefore produce different evaluation grids.</p><p>To preserve these semantics, we introduced <code>TSTEP(...)</code>, which derives its grouping grid from the query range and step,  rather than truncating timestamps to globally aligned boundaries.</p><p>PromQL uses <code>TSTEP(...)</code> internally, preserving Prometheus timestamp semantics while still lowering the operation to a native Elasticsearch execution primitive.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt437155bca2029671/6aa3937927a5312436dcbf38/3.png" alt="TSTEP vs TBUCKET in Elasticsearch: PromQL step grid anchored to query start, TBUCKET to fixed boundaries" /><p>Some might see this as a simple problem, but the nuances matter. </p><p>Take, for example, a query that finds a 5m rolling average of a metric: </p><p></p><p></p><p>At evaluation time <code>T</code>, the result represents the average over the preceding five-minute range: </p><p><code>(T - 5m, T]</code></p><p>When the query is executed with a five-minute step, each output value is labeled with the upper end of its corresponding five-minute window.</p><p>Elasticsearch previously lacked this semantic and supported only forward-looking window aggregation functions:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt97aac2fa95cf657d/6aa393b71ade64390142ee2e/4.png" alt="Forward-looking window aggregation where each bucket covers the interval from timestamp T to T plus W" /><p>We rewrote the window-evaluation path so that both ES|QL and PromQL use a common backward-looking windowing implementation:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1a12ceb27d9ab95b/6aa393dcf08ee141fe856719/5.png" alt="Backward-looking PromQL window covering T minus W to T, the range used by rate and avg_over_time" /><p>Together, <code>TSTEP(...)</code> and backward-looking windows preserve the two time semantics that matter for PromQL range evaluation.</p><h3><strong>Dynamic label grouping: How PromQL </strong><strong><code>without()</code></strong><strong> resolves at runtime</strong></h3><p>Time grids determine when a PromQL expression is evaluated. Aggregation determines which input series are combined and which labels identify each output series.</p><p>For most analytical query engines, that identity is known when the query is planned. The planner can allocate grouping columns and choose an aggregation strategy. It also carries a fixed key through the execution pipeline.</p><p>That’s how ES|QL works:</p><p></p><p></p><p>The output series are grouped by an explicit key <code>(cluster, namespace)</code>.</p><p>PromQL can express the same operation in the opposite direction:</p><p></p><p></p><p>Now we know which dimensions <em>not</em> to use. We don’t necessarily know the full grouping key until the query is executed. This is a small language difference with significant execution consequences. </p><p>One possible implementation is to discover every label used by the metric, subtract <code>instance</code> and <code>pod</code>, and rewrite the expression into an ordinary <code>by(...)</code> aggregation. That adds a <a href="https://www.elastic.co/search-labs/blog/esql-metrics-info-ts-info-time-series-catalog">discovery phase</a> before planning. It also becomes inefficient for high-dimensional metrics where only a subset of all possible dimensions may have useful value in a particular series. Most queries need only a small subset of the available dimensions, so carrying the entire dimension universe as an aggregation key wastes memory and adds bookkeeping overhead.</p><p>We instead extended the time-series execution path with dynamic grouping columns. The engine loads dimensions as the series are read and applies the exclusions per time series. This avoids making the grouping schema a prerequisite for planning and avoids carrying a large sparse set of grouping columns through aggregation.</p><h3><strong>Dimension packing: Keeping wide PromQL grouping keys cheap</strong></h3><p>The <a href="https://prometheus.io/docs/practices/rules/#aggregation">idiomatic</a> way of writing PromQL aggregations involves heavy use of <code>without(...)</code> over <code>by(...)</code>:</p><p></p><p></p><p>Excluding labels rather than explicitly listing them makes dashboards and alerts resilient to schema evolution. If a new label is added to the metric, the query continues to preserve it unless it’s explicitly excluded.</p><p>The consequence for the engine is that effective grouping keys can be wide. Many of those labels often have low cardinality, yet each still participates in every aggregation stage.</p><p>In a columnar engine, each grouping column is normally represented as a separate vector. Ten grouping labels therefore mean 10 vectors flowing through every aggregation operator:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2f491d0d9fdb82f2/6aa3940027a53105ccdcbf3c/6.png" alt="Elasticsearch columnar page: rows split into typed blocks with delta and ordinal dictionary compression" /><p>Dimension fields are declared in the index mapping; the planner knows the schema up front, and grouping keys stay narrow and predictable.</p><p>In the columnar engine, each grouping label is carried as a separate vector or block. A key with 10 labels therefore requires 10 vectors to be read, hashed, compared, and retained by aggregation operators. As key width grows, so does the amount of data and bookkeeping that must move through the pipeline:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt13b25c686bbb46e0/6aa39429de2395f2e2e86d1d/7.png" alt="PromQL aggregation without packing: each grouping label hashed as a separate block into 64-byte keys" /><p>To avoid paying that per-column cost for every additional label, we introduced dimension packing. Before aggregation begins, the engine encodes the full grouping key into a single compact representation. Hash and comparison operations run on the packed key rather than on each block independently:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5a75855ef51c017b/6aa394468406d95667caa1de/8.png" alt="Dimension packing in Elasticsearch encodes PromQL grouping labels into one 16-byte key before hashing" /><p>Packing lets hashing and comparison operate on a single compact key rather than an increasing number of grouping blocks, making aggregation overhead less sensitive to key width. Because the engine is shared, ES|QL time-series queries will benefit from this optimization as well.</p><h3><strong>Building the Prometheus HTTP API response inside the pipeline</strong></h3><p>Unlike Elasticsearch's ES|QL column-oriented response format, the Prometheus response is row-oriented.  The Prometheus API returns one result row per time series, with its samples represented as timestamp-value pairs. </p><p>To support a compatible API layer, we had to regroup in the HTTP layer converting the columnar results into boxed row objects and accumulate them in map- and list-based structures until the complete Prometheus response could be produced. </p><p>We replaced this with the <code>TimeSeriesCollapse</code> compute operator. It groups rows by series and aligns samples to the query’s fixed step grid. It emits the reshaped result as ordinary columnar pages containing one row per series, with aligned multi-valued timestamp and value blocks. And it preserves compact, vectorized block representation throughout the pipeline: </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt99e2ee3137bc2fa6/6aa39496d29b4e02e81da7b6/9.png" alt="TimeSeriesCollapse operator reshapes five columnar rows into two Prometheus time series per output page" /><p>The HTTP layer can now serialize those blocks directly, avoiding the maps, lists, boxed objects, and associated allocations required by the earlier implementation.</p><h2><strong>Testing PromQL compatibility against 2,000 real queries</strong></h2><p>Prometheus <a href="https://github.com/prometheus/compliance">compliance tests</a> were our starting point.</p><p>Even though they gave us a strong baseline, they didn’t tell us how frequently individual PromQL features appear in real workloads. To complement that baseline, we built a second test corpus from over 2,000 PromQL queries collected from public repositories. </p><p>We then classified those queries by the language features and expression patterns they exercise:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf008821c47e46040/6aa394b68406d90c5bcaa1e2/10.png" alt="PromQL feature use across 2,000 real queries: aggregations 59.67%, selectors 57.55%, rate functions 45.21%" /><p>For each compatible query shape, we run the same query against Elasticsearch and Prometheus and compare the results. </p><p>In addition to that, we actively rely on <a href="https://en.wikipedia.org/wiki/Fuzzing">fuzz testing</a>, which catches issues that unit tests alone are unlikely to expose, including differences in timestamp alignment, label retention, aggregation behavior, range-vector evaluation, and response encoding.</p><h2><strong>Which PromQL functions and APIs are supported in 9.5</strong></h2><p>Since 9.4 (technical preview), PromQL support in Elasticsearch has expanded substantially. In Elasticsearch 9.5, both the<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/promql"> <code>PROMQL</code></a> command in ES|QL and the<a href="https://www.elastic.co/docs/reference/query-languages/promql/promql-http-api"> Prometheus HTTP APIs</a> are generally available (GA), with more than 80% of the PromQL workflows in our real-world corpus now running without modification:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6706c16409dccfd7/6aa394d327a5312acfdcbf41/1.png" alt="PromQL compatibility in Elasticsearch rising from zero to 80% between 9.4 Tech Preview and 9.5 GA" /><p>The main additions since technical preview are:</p><p><strong>Feature</strong></p><p><strong>Example</strong></p><p><strong>Status</strong></p><p>Prometheus remote write ingest</p><p><code>POST /_prometheus/api/v1/write</code></p><p>GA in 9.5</p><p>Range queries</p><p><code>/api/v1/query_range</code></p><p>GA in 9.5</p><p>Instant queries</p><p><code>/api/v1/query</code></p><p>GA in 9.5</p><p>Metric metadata and build info</p><p><code>/api/v1/metadata</code>, <code>/api/v1/status/buildinfo</code></p><p>GA in 9.5</p><p>Native histogram functions</p><p><code>histogram_quantile</code>, <code>histogram_count</code>, <code>histogram_sum</code></p><p>GA in 9.5</p><p>Per-selector offset modifiers</p><p><code>[5m] offset 1h</code></p><p>GA in 9.5</p><p>Top-level <code>or</code> operator</p><p><code>rate(a[5m])</code> or <code>rate(b[5m])</code></p><p>GA in 9.5, up to eight operands</p><h3><strong>Prometheus remote write ingest</strong></h3><p>Elasticsearch <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/tsds-ingest-prometheus-remote-write">accepts</a> Prometheus remote write (v1) messages directly:</p><p></p><p></p><p>Snappy-compressed Protocol Buffer messages are decoded, and labels are mapped to TSDS dimensions. Metric names and values are written into the time-series index. The built-in template is dynamic, so users don’t have to predeclare every Prometheus label or metric.</p><h3><strong>Range and instant queries through the Prometheus HTTP API</strong></h3><p>Both range and instant query endpoints are <a href="https://www.elastic.co/search-labs/blog/elasticsearch-native-prometheus-api">available</a>:</p><p></p><p></p><p></p><p></p><p>Range queries return matrices evaluated over a time window, and instant queries return vectors evaluated at a single timestamp. These endpoints can be used by Kibana, Grafana, or Prometheus-compatible alerting tools, and custom dashboards.</p><h3><strong>Metric metadata and build info endpoints</strong></h3><p>Elasticsearch <a href="https://www.elastic.co/docs/reference/query-languages/promql/promql-http-api#promql-http-api-metadata">exposes</a> metadata about available metrics and a build-info endpoint:</p><p></p><p></p><p></p><p></p><p>The metadata endpoint returns metric types and help text, and the build-info endpoint returns the Prometheus-compatible server version. Grafana and other tools use these endpoints for feature detection and UI behavior.</p><h3><strong>Native histogram functions: histogram_quantile, count, and sum</strong></h3><p>Elasticsearch supports the <a href="https://www.elastic.co/docs/reference/query-languages/promql/functions/histogram">main PromQL operations</a> over native histograms:</p><p></p><p></p><p></p><p></p><p>Native histograms adapt their bucket layout to the data, providing useful precision across a wide value range without requiring users to configure every bucket boundary in advance. Classic histograms continue to work alongside native histograms.</p><h3><strong>Per-selector offset modifiers in PromQL</strong></h3><p>Offset modifiers shift a selector’s time window backward:</p><p></p><p></p><p>This returns the request rate from one hour earlier. Per-selector offsets are commonly used to compare current traffic, latency, or resource usage with an earlier baseline, such as the same period one week ago.</p><h3><strong>Top-level </strong><strong><code>or</code></strong><strong> operator in PromQL</strong></h3><p>Elasticsearch supports the top-level PromQL <code>or</code> operator:</p><p></p><p></p><p>In PromQL, <code>or</code> isn’t a Boolean operation. It performs a union between two sets of time series. Results from the left side are retained; a series from the right side is added only when its label set doesn’t match a series already returned by the left side. This is useful during migrations where the same logical metric may exist under an old and a new name.</p><p>The implementation follows Prometheus’s left-side precedence rules and preserves the <code>__name__</code> label. Top-level chains of up to eight operands are supported.</p><h2><strong>PromQL features not yet supported in Elasticsearch</strong></h2><p>GA doesn’t mean complete PromQL compatibility. Some less common and more complex parts of PromQL remain unsupported. These gaps now define the next phase of the work: </p><p><strong>Feature</strong></p><p><strong>Example</strong></p><p><strong>Status</strong></p><p>Advanced vector matching</p><p><code>on(instance) group_left</code></p><p>Planned</p><p>Sorting and ranking</p><p><code>topk</code>, <code>bottomk</code>, <code>limitk</code>, <code>sort</code>, <code>sort_desc</code></p><p>Planned</p><p>Label manipulation</p><p><code>label_replace</code>, <code>label_join</code></p><p>Planned</p><p>Absolute time modifier</p><p><code>@ 1710000000</code></p><p>Planned</p><p>Mixed-offset compound expressions</p><p><code>rate(...) - rate(... offset 1h)</code></p><p>Planned</p><p>Alerting and target endpoints</p><p><code>/api/v1/alerts</code>, <code>/api/v1/targets</code></p><p>Out of scope</p><h3><strong>Advanced </strong><a href="https://prometheus.io/docs/prometheus/latest/querying/operators/#group-modifiers"><strong>vector matching</strong></a><strong> with </strong><strong><code>on()</code></strong><strong> and </strong><strong><code>group_left</code></strong></h3><p>Some binary operations that require Prometheus vector matching aren’t yet part of GA.</p><p>For example, this query divides per-instance request rates by a per-instance capacity metric:</p><p></p><p></p><p>The <code>on(instance)</code> clause specifies which labels identify matching series. <code>group_left</code> permits many request-rate series to match a single per-instance capacity series, while retaining the labels from the higher-cardinality left-hand side.</p><p>These expressions are common when joining a detailed metric with metadata or a lower-cardinality capacity metric. Basic binary expressions are supported where applicable, while the remaining vector-matching forms are planned work.</p><h3><strong>Sorting and ranking: </strong><strong><code>topk</code></strong><strong>, </strong><strong><code>bottomk</code></strong><strong>, and </strong><strong><code>sort</code></strong></h3><p>Prometheus sorting and ranking functions are also not yet part of GA:</p><p></p><p></p><p>This returns the 10 services with the highest request rate. Similar queries are widely used in “top offenders” dashboards for traffic, latency, errors, and resource consumption.</p><p>The remaining functions include:</p><p></p><p></p><p></p><p></p><p></p><p></p><h3><strong>Label manipulation with label_replace and label_join</strong></h3><p>PromQL can construct or rewrite labels during query evaluation. These functions are particularly useful when dashboard variables, naming conventions, or label schemas don’t match exactly:</p><p></p><p></p><p>This creates an <code>environment</code> label from the <code>cluster</code> label.</p><p>Another common example combines existing labels into a display-oriented label:</p><p></p><p></p><p>This produces a <code>target</code> label, such as <code>payments/api-7f6d9</code>. <code>label_replace(...)</code> and <code>label_join(...)</code> aren’t yet included in GA.</p><h3><strong>Advanced time modifiers: The </strong><strong><code>@</code></strong><strong> modifier and mixed offsets</strong></h3><p>Several advanced time modifiers and expression forms remain outside the GA scope.</p><p>For example, an absolute <code>@</code> modifier evaluates a selector at a fixed Unix timestamp rather than at the query’s normal evaluation time:</p><p></p><p></p><p>This is useful for comparisons against a fixed historical point.</p><p>PromQL also permits expressions in which the two sides use different offsets:</p><p></p><p></p><p>This compares current traffic with traffic one hour earlier. Per-selector <code>offset</code> is available in GA, but not every combination of offsets and compound expressions is part of GA yet.</p><h3><strong>Prometheus API endpoints not yet implemented</strong></h3><p>In addition, the Prometheus HTTP API surface isn’t yet fully complete. Notably:</p><p>Alerting metadata through:</p><p></p><p></p><p>used by tools that inspect active alert state.</p><p>Target discovery through:</p><p></p><p></p><p>used to inspect scrape targets, health, and labels.</p><p>These endpoints concern Prometheus server and scrape-target state rather than querying metrics stored in Elasticsearch.</p><p>For the full list of limitations, see the <a href="https://www.elastic.co/docs/reference/query-languages/promql/promql-limitations#promql-limitations-form-post">PromQL limitations</a> page. </p><h2><strong>Try PromQL in Elasticsearch 9.5</strong></h2><p>To query Prometheus metrics in Elasticsearch 9.5 or Serverless, see the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/promql">PromQL documentation</a> and the <a href="https://www.elastic.co/docs/reference/query-languages/promql/promql-http-api">Prometheus HTTP API reference</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/promql-elasticsearch-compute-engine</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/promql-elasticsearch-compute-engine</guid>
    <category><![CDATA[Query Languages]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Sergey Sidorov,Felix Barnsteiner]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf690e82ea51bbeec/6aa37da41ade6445cc42eded/unnamed.png" length="0" type="image/png"/>
    <pubDate>Fri, 11 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[One ES|QL query instead of two: WHERE IN subquery replaces the copy-paste loop in Elasticsearch]]></title>
    <description><![CDATA[ES|QL's WHERE clause can filter by another Elasticsearch subquery's results instead of a static ID list you copied by hand, with nested subqueries, NOT IN and compound conditions built in.]]></description>
    <content:encoded><![CDATA[<p>The <a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language (ES|QL)</a> <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/where"><code>WHERE</code></a> clause can now filter by the results of another query. If you've been running one query to find suspicious users or failing services, copying the IDs, then pasting them into a second query, you can stop. One ES|QL statement does the whole job: the subquery builds the filter list from live data, and it stays current every time you run it. The feature ships as a technical preview in Elasticsearch 9.5 and supports nesting, <code>NOT IN</code> and compound <code>AND</code><code>/</code><code>OR</code> conditions.</p>  The <code>WHERE IN</code> subquery may change or be removed in a future release. Elastic will work to fix any issues, but technical preview features aren’t subject to the support Service Level Agreement (SLA) of official general availability (GA) features.<h2>Static ID lists vs. dynamic filtering with ES|QL's WHERE clause</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt112e1ace022eb1a1/6a75c6d0b966e18d6563ef05/image1.png" alt="Infographic comparing manual ID copying across queries vs. ES|QL WHERE IN subquery dynamic filtering in a single pipeline" /><p></p><p><strong>Static ID list filtering</strong></p><p><strong>Dynamic filtering with WHERE IN subquery</strong></p><p>Run query A to find the IDs you care about</p><p>Outer query asks the main question</p><p>Copy the values by hand</p><p>Subquery builds the filter list from live data</p><p>Paste them into a static <code>WHERE IN</code> list</p><p><code>WHERE</code> field <code>IN</code> (subquery) applies the filter live</p><p>Run query B with the hardcoded list</p><p>Results stay current with the data</p><p>Repeat the whole process when the data changes</p><p>No copied list to maintain</p><p><em>Figure 1. The old copy-paste loop collapses into a single dynamic filter.</em></p><h2>How the WHERE IN subquery replaces static filter lists</h2><p>Traditional <code>IN</code> filtering is still useful when the list is small and static:</p>FROM logs-*
| WHERE status_code IN (401, 403, 429)<p>But many real investigations don’t start with a tidy list. They start with a question: <em>Which users are suspicious</em>, <em>which hosts are noisy</em>, <em>which services are failing</em>, or <em>which accounts crossed a threshold?</em></p><p>That’s where the <code>WHERE IN</code> subquery becomes useful. The list is produced by ES|QL rather than typed by hand.</p><h2>WHERE IN subquery example: filtering logs by suspicious users</h2>FROM logs-*
| WHERE user.name IN (
    FROM auth-logs-*
    | WHERE event.action == "login_failed"
    | STATS failed_attempts = COUNT(*) BY user.name
    | WHERE failed_attempts &gt;= 10
    | KEEP user.name
  )<p>Read it like this: <em>Show me log events for users who appear in the list of users with at least 10 failed login attempts.</em></p><p>The outer query asks the main question, and the subquery builds the dynamic filter list, eliminating the need to copy and paste.</p><p>The subquery can target a different index or index pattern from the outer query.</p><h2>Filtering without subqueries: the manual ID copy workflow</h2><p>Imagine that you want to inspect traffic for the top failing services. First you run:</p>FROM service-logs-*
| WHERE status_code &gt;= 500
| STATS failures = COUNT(*) BY service.name
| SORT failures DESC
| LIMIT 5<p>Then you copy the five service names and paste them into another query:</p>FROM service-logs-*
| WHERE service.name IN ("checkout", "payments", "search", "profile", "orders")<p>That’s fine once, but less fine when the top five change every hour.</p><h2>Dynamic filtering with a WHERE IN subquery</h2>FROM service-logs-*
| WHERE service.name IN (
    FROM service-logs-*
    | WHERE status_code &gt;= 500
      AND @timestamp &gt;= now() - 2 days
    | STATS failures = COUNT(*) BY service.name
    | SORT failures DESC
    | LIMIT 5
    | KEEP service.name
  )
  AND status_code &gt;= 500
  AND @timestamp &gt;= now() - 2 days
| KEEP @timestamp, service.name, status_code, message<p>The subquery finds the top failing services from the last two days, and the outer query returns the log events for those services. One query builds the full picture.</p><h2>Excluding values with NOT IN subqueries in ES|QL</h2><p>Sometimes the interesting question is about what doesn’t belong:</p>FROM access-logs-*
| WHERE user.name NOT IN (
    FROM known-users
    | WHERE user.name IS NOT NULL
    | KEEP user.name
  )<p>That pattern is useful for exclusion checks, gap analysis, and workflows that ask for the things outside an approved or expected set.</p><h2>Nested subquery chains in ES|QL's WHERE clause</h2><p>An <code>IN</code> subquery replaces the literal value list with a query in parentheses. The inner query runs first and returns a single column, and the outer <code>WHERE</code> filters against it. Because that inner query is a full pipeline, it can contain its own <code>IN</code> subquery, which lets you express a chain of lookups that would otherwise require three separate queries and two rounds of copy-paste.</p>FROM orders
| WHERE customer_id IN (
    FROM customers
    | WHERE region_id IN (
        FROM regions
        | WHERE tier == "priority"
        | KEEP region_id
      )
    | KEEP customer_id
  )
| STATS revenue = SUM(amount) BY customer_id<p>Read it inside out. The innermost query finds priority regions, and the middle query finds customers in those regions, while the outer query sums revenue for those customers. Each layer is a normal ES|QL pipeline, so each one can filter, aggregate, or sort on its own before handing a clean column up to the layer above.</p><h2>Combining WHERE IN subqueries with AND and OR conditions</h2><p>Because an <code>IN</code> subquery is a Boolean condition, it composes with <code>AND</code> and <code>OR</code> like any other predicate. You can require membership in two independent sets or accept membership in either:</p>FROM orders
| WHERE customer_id IN (FROM vip_customers | KEEP customer_id)
  AND product_id IN (FROM discontinued_products | KEEP product_id)
| KEEP order_id, customer_id, product_id, amount<p>The <code>AND</code> combination finds orders placed by VIP customers for products that are being discontinued. Swap <code>AND</code> for <code>OR</code>, and you get orders that match either condition. Each subquery runs its own pipeline, so the two sets are computed independently and then combined by the Boolean operator.</p><h2>Merging multiple indices into one WHERE IN subquery</h2><p>The query inside an <code>IN</code> subquery is a full pipeline, so its <code>FROM</code> command can reference more than one subquery. Each branch runs its own pipeline, and the <code>FROM</code> command merges the rows from all branches into one result set. <code>KEEP host_id</code> selects the single column that the outer filter needs. This is useful when the values you want to filter against live in several indices with different schemas. For more details on how subqueries in the <code>FROM</code> command handle indices with different schemas, see <a href="https://www.elastic.co/search-labs/blog/esql-subquery-from">Three indices walk into a FROM clause: ES|QL subqueries in Elasticsearch</a>.</p>FROM alerts
| WHERE host_id IN (
    FROM
      (FROM prod_hosts    | WHERE region == "us-east"),
      (FROM staging_hosts | WHERE region == "us-east"),
      (FROM edge_hosts    | WHERE region == "us-east")
    | KEEP host_id
  )
| STATS alert_count = COUNT(*) BY host_id<p>The <code>IN</code> subquery combines matching host IDs from three indices, prod, staging, and edge, into one value list. The outer query then counts alerts for any host in that combined set. Adding a fourth source means adding one more branch, with no change to the outer query.</p><h2>Using an Elasticsearch subquery inside each FROM branch</h2><p>A <code>FROM</code> subquery gives each index its own branch with its own <code>WHERE</code>, and that <code>WHERE</code> can use an <code>IN</code> subquery. This is how you apply the same dynamic filter across several indices that each have their own schema.</p>FROM
  (FROM orders  | WHERE customer_id IN (FROM vip_customers | KEEP customer_id)),
  (FROM refunds | WHERE customer_id IN (FROM vip_customers | KEEP customer_id))
| STATS total_events = COUNT(*) BY customer_id<p>Each branch filters its index down to VIP customers before the two branches combine, so the final aggregation runs over a single normalized set of rows.</p><h2>When to use ES|QL WHERE IN subqueries</h2><ul><li><p>Investigations that start by finding risky users, hosts, accounts, or services.</p></li><li><p>Operational dashboards where the interesting entities change over time.</p></li><li><p>Top-N follow-up queries, such as events for the five noisiest services.</p></li><li><p>Set comparison workflows, especially with <code>NOT IN</code>.</p></li><li><p>Queries that would otherwise need glue code just to pass values from one step to the next.</p></li></ul><h2>Requirements and constraints for WHERE IN subqueries</h2><ul><li><p>Return exactly one column from the <code>IN</code> subquery.</p></li><li><p>Use <code>KEEP</code> at the end of the subquery so the comparison field is obvious.</p></li><li><p>Make sure the outer field and the subquery field have compatible types.</p></li><li><p>If the subquery uses <code>SORT</code>, add an explicit <code>LIMIT</code>, as unbounded <code>SORT</code> isn’t supported in ES|QL yet.</p></li><li><p>Use this for membership filtering. If you need columns from both sides, a <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-lookup-join">JOIN`</a> may be the better tool.</p></li></ul><h2>Why ES|QL dynamic filtering replaces manual query workflows</h2><p>The <code>WHERE IN</code> subquery turns a manual workflow into a declarative one. You can let one query build the filter for another query directly inside the <code>WHERE</code> command, instead of asking ES|QL for a list, copying it somewhere else, and hoping it stays fresh. </p><p>Your <code>WHERE</code> clause now has a better way to handle <em>Filter this by whatever that query finds.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/dynamic-filtering-esql-where-in-subquery</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/dynamic-filtering-esql-where-in-subquery</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Query Languages]]></category>
    <dc:creator><![CDATA[Fang Xing]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8588803197e341c5/6a75c709f124644e306fd73e/good_oone.png" length="0" type="image/png"/>
    <pubDate>Fri, 07 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[One query, three data sources: ES|QL subqueries get FROM, TS and ROW]]></title>
    <description><![CDATA[Filter application logs by live metric behavior and combine indexed data with inline test values. Your filter lists pull from time-series data on the fly, so nothing is hard-coded.]]></description>
    <content:encoded><![CDATA[<p><a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language (ES|QL)</a> subqueries now support three source commands in Elasticsearch 9.5: <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/from"><code>FROM</code></a> for indexed data, <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> for time-series metrics, and <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/row"><code>ROW</code></a> for inline literal values. You can use them individually or combine all three in a single query, filtering log data by live metric behavior or mixing real and synthetic rows without any index setup.</p><p>If you've been exploring ES|QL, you might have noticed that a <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-subquery">subquery</a> used to feel like it had exactly one front door: <code>FROM</code>. And it made sense. Most of the time, queries start with a simple directive to go fetch documents from an index. In an earlier <a href="https://www.elastic.co/search-labs/blog/dynamic-filtering-esql-where-in-subquery">post</a>, we taught the <code>WHERE</code> command a new trick: <code>IN</code> subqueries. And before that, <a href="https://www.elastic.co/search-labs/blog/esql-subquery-from">subqueries showed up in the <code>FROM</code> command</a> to combine data sources. In both instances, every subquery started the same way, with <code>FROM</code>.</p><p>But not every useful query starts with a bulk document fetch. Sometimes you need to evaluate time-series semantics. Other times, you just need to whip up a tiny inline row for testing. Sometimes, the absolute best input to a filter is a dynamic query that builds the list for you on the fly, rather than a static, hard-coded list.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4453fe9ed8dad10b/6a75c5db7724f2073a045965/image3.png" alt="Diagram of three ES|QL subquery source commands with log filtering flow for FROM union and WHERE IN placements" /><p></p><p><strong>Source command</strong></p><p><strong>Reads from</strong></p><p><strong>Best for</strong></p><p><strong>Requirements</strong></p><p>FROM</p><p>Indexes, data streams, aliases, views</p><p>Stored document lookups, live filter lists</p><p>None (works with any index)</p><p>TS</p><p>Time-series data streams</p><p>Metric aggregations with counter-reset handling</p><p>TSDS with <code>index.mode: time_series</code></p><p>ROW</p><p>Inline literal values</p><p>Test cases, seed values, synthetic placeholders</p><p>None (no index needed)</p><p>The <code>FROM</code>, <code>TS</code> and <code>ROW</code> source commands are generally available (GA) in Elasticsearch 9.5, while the <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-in-subquery"><code>WHERE IN</code> subquery</a> remains in technical preview in 9.5.</p><h2>How ES|QL subquery source commands work</h2><p>Think of subqueries as having two specific placements and three different engines.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5542afed68fff9e7/6a75c5f6cd87c322296bcb39/image1.png" alt="ES|QL subquery source commands grid showing FROM, ROW and TS examples in both FROM union and WHERE IN filter placements" /><p></p><h3>Where subqueries go: FROM and WHERE placements</h3><ul><li><p><strong>Inside the </strong><strong><code>FROM</code></strong><strong> command:</strong> The subquery is an independent result source, contributing its rows to the outer query. Fields that exist in one source but not the other are gracefully filled with null values.</p></li><li><p><strong>Inside the </strong><strong><code>WHERE</code></strong><strong> command:</strong> The <code>IN</code> subquery contributes dynamic values to be used as a predicate or filter.</p></li></ul><h3>Three source commands for starting a subquery</h3><h4>Door #1: FROM (the classic door)</h4><p><code>FROM</code> is the familiar workhorse. You use it when the subquery should read stored data from indices, data streams, aliases, or views. One of its best use cases is the "stop-copy-pasting-IDs" pattern. Instead of running one query, manually copying the output values, and pasting them into the filter of another query, the subquery becomes your live filter list.</p>FROM employees
| WHERE emp_no IN (FROM high_value_accounts
                   | KEEP emp_no
                  )
| KEEP emp_no, first_name, last_name<h4>Door #2: ROW (the tiny door)</h4><p><code>ROW</code> is the lightweight option that requires absolutely no index setup. It allows you to build rows completely out of literal, inline values. This makes <code>ROW</code> useful for small seed values, test cases, allow/deny lists, or one-off "what if?" scenarios. In the query below, <code>ROW</code> is the perfect way to staple a synthetic sentinel row or placeholder directly onto real data.</p>FROM
(FROM access_logs
   | WHERE status == 500
| KEEP cluster, status),
  (ROW cluster = "synthetic", status = 0)
| SORT status
| KEEP cluster, status<h4>Door #3: TS (the time-series door)</h4><p>The <code>TS</code> command targets time-series data streams and enables time-series aggregation functions. Why not just use <code>FROM</code> for metrics? <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> is uniquely optimized for time-series data, and it natively handles tricky scenarios, like counter resets on process restarts and uneven metric publish intervals. Using TS as a subquery lets you filter your application logs by what your metrics are saying. For example, imagine asking ES|QL: <em>Show me log events only from clusters whose peak metric throughput crossed 800</em>:</p>FROM access_logs
| WHERE cluster IN (TS k8s_metrics
                    | STATS peak = MAX(bytes_in) BY cluster
                    | WHERE peak &gt; 800
| KEEP cluster
                   )
| SORT cluster, path
| KEEP cluster, status, path<p>In this scenario, the filter is reacting to live metric behavior rather than being hard-coded.</p><h2>Combining FROM, TS and ROW in one query</h2><p>Because subquery placements and source commands are independent, you can freely mix and match them. You can throw all three doors into a single <code>FROM</code> union to generate a cohesive table containing real logs, a live metrics summary, and a synthetic row.</p>FROM
(FROM access_logs
   | KEEP cluster, status),
  (TS k8s_metrics
   | STATS peak = MAX(bytes_in) BY cluster),
  (ROW cluster = "synthetic")
| STATS log_events = COUNT(status), peak = MAX(peak) BY cluster
| SORT cluster
| KEEP cluster, log_events, peak<h2>ES|QL subquery constraints</h2><p>A few constraints to keep in mind before using subqueries:</p><ul><li><p><strong><code>IN</code></strong><strong> subqueries demand one column:</strong> If a subquery feeds an <code>IN</code> operator, it must project exactly one column. Use the <code>KEEP</code> command to make that explicitly clear.</p></li><li><p><strong><code>TS</code></strong><strong> requires a time series data stream (TSDS):</strong> The <code>TS</code> command only works on data stored in a <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds">TSDS</a>, which uses <code>index.mode: time_series</code>.</p></li></ul><h2>The takeaway: when to use each source command</h2><p>Subqueries in ES|QL provide a structured way to compose queries by using the results of one query as the input to another. Choosing the appropriate source command (<code>FROM</code>, <code>ROW</code>, or <code>TS</code>) lets you combine data and generate inline values. It also lets you filter dynamically without duplicating query logic. For more details and additional examples, see the ES|QL subqueries documentation.</p><ul><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-subquery">Subquery in FROM command</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-in-subquery">Subquery in WHERE command</a></p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-subquery-source-commands</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-subquery-source-commands</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Query Languages]]></category>
    <dc:creator><![CDATA[Fang Xing]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt71cfd2ab01e3262e/6a75c5beaec90746295fd3b7/image2.png" length="0" type="image/png"/>
    <pubDate>Fri, 07 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch query logs: One coordinator-level line per query for ES|QL, DSL, SQL, and EQL]]></title>
    <description><![CDATA[Easily understand query impact on cluster performance with Elasticsearch query logs. One coordinator-level line records ES|QL, DSL, SQL, and EQL per request and provides full query text, tracing, optional user context, and CCS hints]]></description>
    <content:encoded><![CDATA[<p>Your dashboard times out and CPU spikes, but which query actually ran? Slow logs give you one line per shard; Elasticsearch query logs give you one JSON line per request, with the same end-to-end duration as the took you already trust from the API. That single line also captures full query text for ES|QL, DSL, SQL, and EQL, outcomes, tracing, optional user context, and cross-cluster hints when relevant.</p><p>They’re ECS-aligned, ready for Discover and out-of-the-box dashboards once you ship the log, no custom schema project. Below: why we built this, how it differs from slow logs, what each line contains, and how to turn it on.</p><h2>Why we built this (you asked, a lot!)</h2><p>Coordinator-level query logging has been a very popular request; we listened and delivered! The same pain kept showing up: You want the <em>response</em> duration for Service Level Objectives (SLOs) and dashboards. You want to know the execution time of queries executed in your cluster, and you want to be able to see the full query.</p><p>If using cross-cluster search, a search that fans out across clusters looks like one operation from the app or Kibana, but operationally it’s a chain of work: coordination, remote execution, merges, timeouts, and partial results. When something is slow or flaky, teams need to know not only how long the request took but also which clusters contributed and whether the outcome was success, partial, or a hard failure.</p><p><strong>What you get:</strong> One log stream, one entry per query! Every entry has the coordinator duration (the very same <code>took</code> time that actually matches your search API response), success or failure, and the full query text. Elastic Common Schema–compliant (ECS) JSON, optional duration threshold and user/audit fields, plus <code>X-Opaque-Id</code> that lets you <a href="https://www.elastic.co/docs/troubleshoot/kibana/trace-elasticsearch-query-to-the-origin-in-kibana">trace a hot query</a> back to the saved object it originates from, and the trace ID so you can correlate with Kibana or your own tooling.</p><p><strong>What’s more:</strong> Logs follow a stable, ECS-aligned schema, which means you don’t need to design your own ingestion pipelines or field mappings. This consistency enables out-of-the-box dashboards and analytics that work immediately once logs are shipped.</p><h2>Slow logs vs. query logs: The 30-second version</h2><p>Slow logs have been the go-to tool for years. They tell you which search operation is slow, but they emit <strong>one line per shard</strong> that took part, where each line reflects that shard’s piece of the work. This means that they don’t provide a single row that says how long the query execution took, from the client’s perspective. Query logs do exactly that: <strong>one line per query</strong>, with the <strong>end-to-end (wall clock)</strong> duration that lines up with the <code>took</code> time in the search API response. This makes them much better suited for understanding workload patterns and identifying problematic queries quickly.</p><p>Slow and query logs also differ in when they fire and what they cover. Slow logs only write when a shard’s slice breaches a duration threshold; that is, you’re optimized for “show me unusually slow shard work.” Query logs can record every query (or only those above a configurable threshold you set at the cluster level), so you can tune volume for analytics versus troubleshooting. Slow logs only support DSL queries, while query logs cover <strong>ES|QL, DSL, SQL, and EQL</strong>, which matches how you reason about “what ran on my cluster” in a modern stack. Both provide the same support in terms of correlation with headers, traces, and audit information (when you turn on user context).</p><p>The table below summarizes the main differences between the historical slow logs and the new query logs features.</p><p></p><p>Slow logs</p><p>Query logs</p><p>What they’re for</p><p>Finding hot shards / slow index operations on specific indices and classic performance tuning inside one cluster.</p><p>Understanding what query ran, how long the operation took end to end from the coordinator, and whether it succeeded, which is better for SLOs, analytics, and incident investigations.</p><p>Granularity</p><p>Per shard (and per phase) for searching slow logs: One user search can produce many lines across shards/replicas.</p><p>Per coordinator-level query: One query maps to one log event.</p><p>Scope of work</p><p>Query + indexing</p><p>Query only; indexing will come soon.</p><p>What you learn</p><p>“This shard on this index exceeded N ms in query/fetch phase.”</p><p>“This query (full text), this duration, this outcome, and (when relevant) federation/cross-cluster summary fields.”</p><p>Query types</p><p>DSL only</p><p>ES|QL, DSL, SQL, and EQL</p><p>Threshold model</p><p>Often tiered (for example, multiple time thresholds per log levels) and per index.</p><p>Single duration gate at the cluster level (for example, “log if duration ≥ 500ms”)</p><h2>What you get in each log line</h2><p>Every line is one JSON object (one request) in a dedicated file (for example, <code>*_querylog.json</code> under your Elasticsearch log directory). Below is what you can <em>do</em> with the data:</p><p><strong>Did it succeed, how long did it take, and what broke?</strong> Outcome (whether the request was successful or not), duration (<code>took / took_millis</code>, in line with the API), and a clear failure or timeout when something goes wrong. That’s the core signal for alerting, SLOs, and dashboards: “Are we green? If not, what’s the error?” You also get how many rows or hits came back (<code>result_count</code>), so you can separate “slow but empty” from “slow and huge.”</p><p><strong>What actually ran?</strong> Query type (<code>esql</code>, <code>dsl</code>, <code>sql</code>, <code>eql</code>) plus the <strong>full query text</strong>. That answers “Which dashboard rule, saved search, or client pattern is hammering us?” Mix it with duration and outcome to find the worst offenders to fix or throttle.</p><p><strong>Who asked for it</strong>, and how do I trace it end to end? <strong>X-Opaque-Id</strong> and <strong>trace ID</strong> tie a line back to Kibana or your own headers. Task and optional parent task IDs help follow work that was enqueued or chained (async or nested operations).</p><p><strong>Cross-cluster search: </strong>Who participated, and did anyone misbehave? When cross-cluster search (CCS) is in play, the log can carry <strong>remote cluster aliases</strong>, per-cluster duration, and status (successful, failed, partial, skipped). You can see at a glance whether a slow search was local or a specific remote dragging the response. DSL can also record that a search was served from a remote alias; ES|QL exposes the richer cluster map; EQL logs a lighter view (for example, which remotes and how many) when remotes are involved.</p><p><strong>Security (optional).</strong> With <code>elasticsearch.querylog.include.user</code>, you get the usual identity and realm fields (plus effective user when run-as applies), and API key metadata when applicable. Pair with query text and duration for governance and capacity conversations that use names, not only IPs.</p><p>There’s more available than we covered here, including additional execution details, shard-level outcomes, and optional profiling information depending on the query type. For every field path and setting, see the <a href="https://www.elastic.co/docs/deploy-manage/monitor/logging-configuration/query-logs">Elasticsearch documentation on query logs</a>.</p><h2>Where the logs live (and how to use them)</h2><p>Logs land in your Elasticsearch log directory as <code>*_querylog.json</code> (for example, <code>mycluster_querylog.json</code>) on the coordinating node. Ship them with the <code>querylog</code> fileset in the <a href="https://www.elastic.co/docs/reference/beats/filebeat/filebeat-module-elasticsearch#_querylog_log_fileset_settings">Filebeat Elasticsearch module</a>, so you can then inspect them in Discover (filter by <code>event.dataset: elasticsearch.querylog</code>). On Elastic Cloud, you need to enable Logs on your deployment, and the query logs are shipped as soon as you enable them.</p><p><strong>Two workflows.</strong> If you need a one-off look to find out who’s hammering the cluster, what the query mix is, or a quick audit, just turn logging on, set a duration threshold so you only log what matters (for example, ≥ 1 s or ≥ 5 min), and then turn it off when you’re done. If you want <strong>ongoing query analytics</strong>, simply enable logging, point Filebeat at the log, and open a dashboard on the monitoring cluster. Two very simple steps, enable + ship, and you’re done. One request per line, one duration per request, no custom pipeline.</p><p>The dashboard below builds upon the new query logs and is provided out of the box. On the top row, you can find the P95/P99 query latencies (with an optional “acceptable latency” bar), the query type breakdown, the success and failure ratio, the user and system queries ratio, and (for DSL) hits versus aggregations. Underneath that, the latency over time (avg, p50, p95, p99, max) with a reference line so you can spot regressions, query volume over time (stacked by type), and tables for top indices, top users, and top error types. Filtering for cluster, user, or index lets you zoom into exactly what you want to focus on.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcc2ae00bd7a37f99/6a170ae4c1e8a57104f882c4/f913f860b7a7235fea9d4eeff935bd2e2aa61c0f-1999x1406.png" alt="Dashboard showing Elasticsearch query performance metrics, including P95 and P99 latency values, pie charts for query type distribution, success versus failure, user versus system queries, and hits versus aggregations, line and bar charts for query latency and volume over time, and tables listing top indices, top users, and top error types." /><p><strong>Heads up.</strong> Logging of queries is asynchronous, so it doesn’t block the query execution. Use the duration threshold to cap volume. Also worth noting that at very high queries per second (QPS), we may drop some lines rather than slow your cluster down. For analytics, shipping to a separate monitoring cluster keeps the cluster you’re debugging from taking the extra load.</p><h2>Some configuration and code samples</h2><p>Query logging is <strong>off by default</strong>. Flip it on in <code>elasticsearch.yml</code> or via the cluster settings API. Here’s how.</p><h3>Enable query logging</h3><p>In <code>elasticsearch.yml</code>:</p>elasticsearch.querylog.enabled: true<p>Or dynamically via the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-update-settings.html">cluster settings API</a>:</p>PUT _cluster/settings
{
  "persistent": {
    "elasticsearch.querylog.enabled": "true"
  }
}<h3>Only log queries above a duration threshold</h3><p>If you don’t want to log every health check or tiny request, simply set a threshold so only queries that run at least this long get an entry. Duration is in <strong>time units</strong>:</p>PUT _cluster/settings
{
  "persistent": {
    "elasticsearch.querylog.enabled": "true",
    "elasticsearch.querylog.threshold": "1s"
  }
}<h3>Include user/audit information</h3><p>If you use the Security plugin and want to see <em>who</em> ran each query:</p>PUT _cluster/settings
{
  "persistent": {
    "elasticsearch.querylog.enabled": "true",
    "elasticsearch.querylog.include.user": "true"
  }
}<h3>Log DSL searches that hit only system indices</h3><p>By default, searches that target <em>only</em> system indices aren’t logged. To include them, enable query logging and set:</p>PUT _cluster/settings
{
  "persistent": {
    "elasticsearch.querylog.enabled": "true",
    "elasticsearch.querylog.include.system_indices": "true"
  }
}<h3>Example log entries</h3><p>One line = one JSON object = one request with the same shape for ES|QL, DSL, SQL, EQL. Below: a successful DSL search and afailed EQL query with timestamp, duration, query type, and full query. On success, you get result count and shard stats, on failure an <code>error</code> block. User-inclusion and X-Opaque-Id show up when you’ve enabled them.</p><p><strong>Success (DSL search):</strong></p>{
  "@timestamp": "2026-03-04T19:40:34.736Z",
  "log": {
    "level": "INFO",
    "logger": "elasticsearch.querylog"
  },
  "event": {
    "duration": 1000000,
    "outcome": "success"
  },
  "elasticsearch": {
    "querylog": {
      "type": "dsl",
      "query": "{\"size\":10,\"query\":{\"match_all\":{\"boost\":1.0}}}",
      "indices": ["query_log_test_index"],
      "result_count": 3,
      "search": { "total_count": 3 },
      "shards": { "successful": 1 },
      "took": 1000000,
      "took_millis": 1
    },
    "node": { "name": "node-1" },
    "cluster": { "name": "my-es-cluster" }
  },
  "http": {
    "request": {
      "headers": { "x_opaque_id": "opaque-1772653234" }
    }
  },
  "user": {
    "name": "elastic",
    "realm": "reserved"
  }
}<p><strong>Failure (EQL query):</strong></p>{
  "@timestamp": "2026-03-04T19:40:35.271Z",
  "log": {
    "level": "INFO",
    "logger": "elasticsearch.querylog"
  },
  "event": {
    "duration": 1326334,
    "outcome": "failure"
  },
  "elasticsearch": {
    "querylog": {
      "type": "eql",
      "query": "any where true",
      "indices": ["nonexistent_index_xyz"],
      "result_count": 0,
      "took_millis": 1
    },
    "node": { "name": "node-1" },
    "cluster": { "name": "my-es-cluster" }
  },
  "error": {
    "type": "org.elasticsearch.index.IndexNotFoundException",
    "message": "no such index [Unknown index [nonexistent_index_xyz]]"
  }
}<h2>Wrapping up</h2><p><strong>Elasticsearch query logs</strong> provide you with one single coordinator-level log for every query (ES|QL, DSL, SQL, EQL). One line per request, coordinator duration, full query, optional user and <code>X-Opaque-Id</code>. Enable it, set a duration threshold and user-inclusion if you want them, and you’re done. Logs live in your log dir (<code>*_querylog.json</code>), and when shipped with Filebeat, you can find them in Discover under the <code>elasticsearch.querylog</code> dataset.</p><p>Head to the <a href="https://www.elastic.co/docs/deploy-manage/monitor/logging-configuration/query-logs">Elasticsearch documentation on query logs</a> for the full list of configuration settings, and field references. Slow or broken queries can also be found in <a href="https://www.elastic.co/search-labs/blog/slow-search-elasticsearch-query-autoops">AutoOps</a>, which leverages the <code>X-Opaque-Id</code> to tie a long-running search back to its origin, such as a dashboard, a saved search, or an alerting rule.</p><p>Finally, it’s also worth noting that this new query log is an evolution of the <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-query-log">ES|QL-only query log</a> that we released in 9.2. We recommend adopting the new query log since it not only supports ES|QL queries, but also all your other queries.</p><p>Now, go see what’s actually running in your cluster.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-query-logs</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-query-logs</guid>
    <category><![CDATA[Inside Elastic]]></category>
    <category><![CDATA[Basics]]></category>
    <category><![CDATA[Query Languages]]></category>
    <dc:creator><![CDATA[Najwa Harif,Valentin Crettaz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4cb4463cec71b906/6a170ae6ab7f0834cfdb9e73/31f1d882d6c0b62bd5ba320c89bda5700434c25c-1672x941.png" length="0" type="image/png"/>
    <pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[From Elasticsearch runtime fields to ES|QL: Adapting legacy tools to current techniques]]></title>
    <description><![CDATA[Learn how to migrate five common Elasticsearch runtime field patterns to their ES|QL equivalents, with side-by-side code comparisons and guidance on when each approach makes sense.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch <a href="https://www.elastic.co/docs/manage-data/data-store/mapping/runtime-fields">runtime fields</a> solve the problem of computing values at query time without <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-reindex">reindexing</a>. But they come with <a href="https://www.elastic.co/docs/reference/scripting-languages/painless/painless">Painless scripting</a> complexity and performance costs that scale with document count. <a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language (ES|QL)</a> offers a more powerful alternative with a dedicated execution engine, pipeline processing, and no scripting required. In this article, you’ll learn how to map five common runtime field patterns to their ES|QL equivalents, so you can modernize your queries and understand when each approach makes sense.</p><h2>Prerequisites</h2><ul><li><p>Elasticsearch 8.15+ (for <code>::</code> cast operator support; core ES|QL features available from 8.11)</p></li></ul><h2>Runtime fields versus ES|QL</h2><p>Runtime fields were introduced in Elasticsearch 7.11 as a way to define fields at query time. Instead of reindexing data, you could write a Painless script that computes values on the fly:</p>PUT my-index/_mapping
{
  "runtime": {
    "full_address": {
      "type": "keyword",
      "script": {
        "source": "emit(doc['address'].value + ':' + doc['port'].value)"
      }
    }
  }
}<p>This works, but comes with trade-offs:</p><ul><li><p><strong>Painless scripting overhead:</strong> Every runtime field requires scripting knowledge, and the <a href="https://www.elastic.co/docs/reference/scripting-languages/painless/painless-language-specification">syntax</a> is Java-like, not query-like.</p></li><li><p><strong>Performance cost:</strong> Runtime fields evaluate per document at query time. Elasticsearch classifies them as "expensive queries" that <a href="https://www.elastic.co/docs/manage-data/data-store/mapping/runtime-fields#runtime-compromises">can be rejected</a> by cluster settings.</p></li><li><p><strong>Isolated computation:</strong> Each runtime field computes independently. There’s no way to chain transforms or use the output of one field in another within the same query.</p></li></ul><p>ES|QL changes the equation. It has its own execution engine (not translated to Query DSL), runs queries concurrently across nodes, and provides a complete toolkit for field computation: <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/eval"><code>EVAL</code></a>, <a href="http://elastic.co/docs/reference/query-languages/esql/commands/grok"><code>GROK</code></a>, <a href="http://elastic.co/docs/reference/query-languages/esql/commands/dissect"><code>DISSECT</code></a>, type casting, and pipeline chaining.</p><p>Let's see how each runtime field pattern maps to ES|QL.</p><h2>Setting up the example data</h2><p>All the code snippets in this article can be executed in the Kibana <a href="https://www.elastic.co/docs/explore-analyze/query-filter/tools/console">Dev Tools console</a>.</p><p>To follow along, create a sample index with data that exercises all five patterns. This simulates a server logs scenario with mixed field types, raw messages, and some intentional data quality issues:</p>PUT server-logs
{
  "mappings": {
    "properties": {
      "host": { "type": "keyword" },
      "port": { "type": "keyword" },
      "raw_message": { "type": "text" },
      "response_time": { "type": "keyword" },
      "status_code": { "type": "keyword" },
      "region": { "type": "keyword" }
    }
  }
}<p>Now index some sample documents:</p>POST _bulk
{ "index": { "_index": "server-logs" } }
{ "host": "web-01", "port": "8080", "raw_message": "2024-01-15 INFO user=alice action=login duration=230ms", "response_time": "145", "status_code": "200", "region": "us-east" }
{ "index": { "_index": "server-logs" } }
{ "host": "web-02", "port": "443", "raw_message": "2024-01-15 ERROR user=bob action=upload duration=1200ms", "response_time": "not_available", "status_code": "500", "region": "eu-west" }
{ "index": { "_index": "server-logs" } }
{ "host": "api-01", "port": "3000", "raw_message": "2024-01-15 WARN user=charlie action=query duration=890ms", "response_time": "890", "status_code": "200", "region": "us-east" }
{ "index": { "_index": "server-logs" } }
{ "host": "api-02", "port": "3000", "raw_message": "2024-01-16 INFO user=diana action=export duration=3400ms", "response_time": "3400", "status_code": "200", "region": "ap-south" }
{ "index": { "_index": "server-logs" } }
{ "host": "web-01", "port": "8080", "raw_message": "2024-01-16 ERROR user=eve action=login duration=50ms", "response_time": "50", "status_code": "401", "region": "US-EAST" }
<p>Notice that <code>response_time</code> is stored as a <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword">keyword</a> (a common real-world mistake), and the last document has <code>"US-EAST"</code> instead of <code>"us-east"</code> (a data quality issue we’ll fix later).</p><h2>Pattern 1: Field concatenation</h2><p>A common runtime field use case is combining two fields into one. For example, creating a <code>host:port</code> identifier.</p><h3>The runtime field approach</h3><p>You can define it inline at query time. Query-time approach avoids modifying the mapping, but you still need Painless scripting, scoping it to a single search request:</p>GET server-logs/_search
{
  "runtime_mappings": {
    "endpoint": {
      "type": "keyword",
      "script": {
        "source": "emit(doc['host'].value + ':' + doc['port'].value)"
      }
    }
  },
  "fields": ["endpoint"],
  "_source": false
}<h3>The ES|QL approach</h3><p>You can run ES|QL queries using the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-esql-query"><code>_query API</code></a> endpoint:</p>POST _query
{
  "query": """
    FROM server-logs
    | EVAL endpoint = CONCAT(host, ":", port)
    | KEEP host, port, endpoint
    | LIMIT 1
  """
}<p>Response:</p>{
  "columns": [
    { "name": "host", "type": "keyword" },
    { "name": "port", "type": "keyword" },
    { "name": "endpoint", "type": "keyword" }
  ],
  "values": [
    ["web-01", "8080", "web-01:8080"]
  ]
}<p><code>CONCAT</code> accepts two or more arguments and always returns a <code>keyword</code>.</p><p><em>Note: For brevity, the remaining ES|QL examples in this article show just the query. Wrap them in </em><em><code>POST _query { "query": "..." }</code></em><em> to run them in Kibana Dev Tools.</em></p><h4>When to use</h4><p>If you need <code>endpoint</code> to persist across all queries and be available in Kibana dashboards, use a mapping-level runtime field. If you need it for a single search request within Query DSL, use a query-time runtime field. If you need it for ad-hoc analysis or exploratory work, ES|QL is simpler.</p><h2>Pattern 2: Data extraction from unstructured text</h2><p>Extracting structured data from raw log messages is another classic runtime field pattern.</p><h3>The runtime field approach</h3><p>Painless uses Java's regex <a href="https://docs.oracle.com/javase/8/docs/api/java/util/regex/Matcher.html">Matcher</a> class:</p>GET server-logs/_search
{
  "runtime_mappings": {
    "log_user": {
      "type": "keyword",
      "script": {
        "source": "def matcher = /user=(\\w+)/.matcher(params._source['raw_message']); if (matcher.find()) { emit(matcher.group(1)); }"
      }
    }
  },
  "fields": ["log_user"],
  "_source": false
}<p>This is verbose. You need to know <a href="https://www.elastic.co/docs/explore-analyze/scripting/modules-scripting-regular-expressions-tutorial">Painless regex syntax</a>, handle the <code>Matcher</code> object, and call <code>emit()</code> correctly.</p><h3>The ES|QL approach: GROK</h3><p>ES|QL provides two purpose-built commands for text extraction. <code>GROK</code> uses regex-based patterns:</p><p>Response:</p>{
  "columns": [
    { "name": "user", "type": "keyword" },
    { "name": "log_level", "type": "keyword" },
    { "name": "action", "type": "keyword" },
    { "name": "duration", "type": "keyword" }
  ],
  "values": [
    ["alice", "INFO", "login", "230ms"], ...
  ]
}<p><code>GROK</code> uses the <code>%{SYNTAX:SEMANTIC}</code> pattern format. It extracts multiple fields in a single and readable command.</p><h3>The ES|QL approach: DISSECT</h3><p>For structured data with consistent delimiters, <code>DISSECT</code> is faster because it doesn’t use regular expressions:</p><p>The syntax is nearly identical to <code>GROK</code>, but <code>DISSECT</code> works by splitting on delimiters rather than matching regex patterns. This makes it faster for data that follows a consistent format.</p><h4>When to use GROK vs DISSECT</h4><p>Use <code>DISSECT</code> when your data has a predictable structure (same delimiters, same field order). Use <code>GROK</code> when you need regex flexibility, for example when fields may be optional or formats vary.</p><h2>Pattern 3: Dynamic type conversion</h2><p>When a field is mapped as <code>keyword</code> but contains numeric data (a surprisingly common scenario), runtime fields can cast it at query time.</p><h3>The runtime field approach</h3>GET server-logs/_search
{
  "runtime_mappings": {
    "response_time_long": {
      "type": "long",
      "script": {
        "source": """
          def val = doc['response_time'].value;
          if (val != 'not_available') {
            emit(Long.parseLong(val));
          }
        """
      }
    }
  },
  "fields": ["response_time_long"],
  "_source": false
}<p>You need to handle parsing exceptions manually. If <a href="https://docs.oracle.com/javase/8/docs/api/java/lang/Long.html#parseLong-java.lang.String-"><code>Long.parseLong</code></a> fails on an unexpected value, the script throws an error.</p><h3>The ES|QL approach</h3><p>ES|QL provides explicit conversion functions and a shorthand cast operator:</p><p>Or with the <code>::</code> cast operator (<a href="https://www.elastic.co/search-labs/blog/esql-timeline-of-improvements">available since 8.15</a>):</p><p>Response:</p>{
  "columns": [
    { "name": "host", "type": "keyword" },
    { "name": "response_time", "type": "keyword" },
    { "name": "response_ms", "type": "long" }
  ],
  "values": [
    ["web-01", "145", 145]
  ]
}<p>Both produce the same result. The key difference from Painless: <strong>Failed conversions return </strong><strong><code>null</code></strong><strong> instead of throwing exceptions</strong>. The document with <code>"not_available"</code> simply gets <code>null</code> for <code>response_ms</code>, and ES|QL emits a warning.</p><p>Common conversion functions include:</p><p>Function</p><p>Converts to</p><p>`TO_LONG()`</p><p>Long integer</p><p>`TO_INTEGER()`</p><p>Integer</p><p>`TO_DOUBLE()`</p><p>Double</p><p>`TO_DATETIME()`</p><p>Date</p><p>`TO_BOOLEAN()`</p><p>Boolean</p><p>`TO_IP()`</p><p>IP address</p><p>`TO_VERSION()`</p><p>Version</p><p>The <code>::</code> operator works with all these types (for example, <code>field::double</code>, <code>field::datetime</code>).</p><h4>When to use</h4><p>ES|QL's graceful null handling makes it safer for dirty data. Runtime fields with Painless give you fine-grained control over error handling but require more code. For type conversion specifically, ES|QL is almost always the better choice.</p><h2>Pattern 4: <a href="https://www.elastic.co/docs/manage-data/data-store/mapping/dynamic-field-mapping">Dynamic field</a> handling</h2><p>Runtime fields support <code>"dynamic": "runtime"</code> in mappings, which prevents <a href="https://www.elastic.co/docs/troubleshoot/elasticsearch/mapping-explosion">mapping explosion</a> by creating all new fields as runtime fields instead of indexed fields:</p>{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "timestamp": { "type": "date" }
    }
  }
}<p>Any new field sent to this index becomes a runtime field automatically. This is useful when you ingest semi-structured data with unpredictable field names.</p><h3>Where ES|QL fits</h3><p>ES|QL provides query-time flexibility, but it still needs fields to be visible in the mapping. This is where runtime fields and ES|QL complement each other rather than compete.</p><p>If a field exists in <code>_source</code> but isn’t mapped, ES|QL cannot access it directly. The current workaround is to define a runtime field to make the unmapped field visible:</p>PUT dynamic-logs/_mapping
{
  "runtime": {
    "custom_field": {
      "type": "keyword",
      "script": {
        "source": "emit(params._source['custom_field'])"
      }
    }
  }
}<p>Once defined, ES|QL can query it:</p><p>This is one scenario where runtime fields remain essential. They act as a bridge, making unmapped data accessible to ES|QL.</p><h2>Pattern 5: Field shadowing for error correction</h2><p>Runtime fields can shadow (override) indexed fields by defining a runtime field with the same name as an existing field. This is useful for correcting data without reindexing.</p><h3>The runtime field approach</h3><p>Remember our data quality issue, where <code>region</code> has inconsistent casing (<code>"US-EAST"</code> versus <code>"us-east"</code>)?</p>GET server-logs/_search
{
  "runtime_mappings": {
    "region": {
      "type": "keyword",
      "script": {
        "source": "emit(params._source['region'].toLowerCase())"
      }
    }
  },
  "fields": ["region"],
  "_source": false
}<p>This overrides the indexed <code>region</code> field for all queries. Every search, aggregation, and Kibana visualization will see the lowercase version.</p><p>When you use <code>EVAL</code> with an existing column name, ES|QL drops the original column and replaces it with the computed value. This is the exact equivalent of field shadowing, but scoped to the current query.</p><p>You can also chain multiple corrections in a pipeline:</p><h4>When to use</h4><p>If the correction should apply to all queries and <a href="https://www.elastic.co/kibana/kibana-dashboard">Kibana dashboards</a>, use runtime field shadowing. If you need to correct data for a specific analysis, ES|QL is more flexible since you can apply different transformations in different queries without modifying the mapping.</p><h2>The ES|QL pipeline advantage: Going beyond runtime fields</h2><p>This is where ES|QL fundamentally surpasses runtime fields. Runtime fields are isolated: each one computes independently, and you cannot use the output of one runtime field as input for another in the same query.</p><p>ES|QL pipelines chain transforms. Here’s a single query that combines multiple patterns:</p><p>This single query:</p><ul><li><p><strong>Extracts</strong> fields from raw text (<code>GROK</code>).</p></li><li><p><strong>Converts</strong> the duration to a number (<code>EVAL</code> with cast).</p></li><li><p><strong>Normalizes</strong> region casing (<code>EVAL</code> with <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/string-functions/to_lower"><code>TO_LOWER</code></a>).</p></li><li><p><strong>Filters</strong> for errors with high duration (<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/where"><code>WHERE</code></a>).</p></li><li><p><strong>Aggregates</strong> by region (<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/stats-by"><code>STATS</code></a>).</p></li></ul><p>To achieve the same result with runtime fields, you would need to define at least three separate runtime fields (for extraction, conversion, and normalization) and then write a Query DSL query with <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/filter-search-results">filters</a> and <a href="https://www.elastic.co/docs/explore-analyze/query-filter/aggregations">aggregations</a>. The ES|QL version is a single, readable pipeline.</p><p>You can even use expressions directly inside aggregations:</p><h2>Conclusion</h2><p>What we covered:</p><ul><li><p>ES|QL provides a full toolkit (<code>EVAL</code>, <code>GROK</code>, <code>DISSECT</code>, type casting with <code>::</code>) that replaces most runtime field patterns without any Painless scripting.</p></li><li><p>Failed type conversions in ES|QL return <code>null</code> instead of throwing exceptions, making it safer for real-world data.</p></li><li><p>Pipeline processing (chaining <code>GROK</code> into <code>EVAL</code> into <code>WHERE</code> into <code>STATS</code>) goes beyond what runtime fields can do in isolation.</p></li><li><p>Runtime fields remain valuable for persistent computed fields, field shadowing across all queries, and as a bridge for unmapped data in ES|QL.</p></li></ul><p>One important caveat: Both runtime fields and ES|QL compute values at query time, which means they pay the cost on every query. If you find yourself applying the same transformation repeatedly (type corrections, field extraction, data normalization), consider using <a href="https://www.elastic.co/docs/manage-data/ingest/transform-enrich/ingest-pipelines">ingest pipelines</a> to fix the data at index time instead. Ingest pipelines let you parse, enrich, and transform documents before they’re stored, so queries can work with clean, properly typed fields directly. Runtime fields and ES|QL are great for exploration and ad-hoc analysis, but for production workloads, indexing the right data from the start is almost always the better choice.</p><p><strong>The key takeaway: </strong>Runtime fields aren’t deprecated, and they aren’t going away. But for most query-time computation patterns, ES|QL offers a simpler, more powerful, and more performant approach. And when the transformation is known up front, an ingest pipeline is the most efficient option of all.</p><h2>Next steps</h2><ul><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL documentation</a></p></li><li><p><a href="https://www.elastic.co/docs/manage-data/data-store/mapping/runtime-fields">Runtime fields reference</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/esql-timeline-of-improvements">ES|QL timeline of improvements</a></p></li><li><p><a href="https://www.elastic.co/blog/getting-started-with-elasticsearch-runtime-fields">Getting started with runtime fields</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-process-data-with-dissect-grok">ES|QL processing data with DISSECT and GROK</a></p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-runtime-fields-to-esql</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-runtime-fields-to-esql</guid>
    <category><![CDATA[Query Languages]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt087dcbc58050f3f6/6a170a27964cea446908bb35/657ec44d182de78e6ddabb6632c6844b5a36774d-720x420.png" length="0" type="image/png"/>
    <pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>