<?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[Alexander Spies - 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[Alexander Spies - 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/author/alexander-spies</link>
    </image>
    <link>https://www.elastic.co/search-labs/author/alexander-spies</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/author/alexander-spies.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Wed, 16 Sep 2026 03:15:13 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Taming PUNKs: How ES|QL queries Elasticsearch fields it was never told about]]></title>
    <description><![CDATA[In Elasticsearch 9.5, ES|QL can query unmapped fields. It reads them from _source or returns nulls, so a query keeps working when a field drops out of the mapping and you avoid a reindex that takes hours.]]></description>
    <content:encoded><![CDATA[<p>How do you make an analytical query engine use data that it cannot know exists? You “just” read the query, since everything that the user asks for is right there. Right?</p><p>In Elasticsearch 9.5, <a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language (ES|QL)</a> queries no longer fail when a field isn't in the mapping. The new <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-unmapped-fields"><code>unmapped_fields</code> setting</a> lets queries load values from <code>_source</code> or fill with <code>nulls</code>, so queries keep working even when a backing index changes and a field goes missing, and can use unmapped data without reindexing. Here’s how we built that: the design choices and the edge cases (including a class of fields we nicknamed PUNKs), along with the testing strategies that gave us the confidence to ship it in general availability (GA).</p><h2>Why ES|QL queries fail when a field is unmapped</h2><p>You built a visualization using an ES|QL query. You refined it, and the query grew. You’re at 15 chained commands and counting, but it does <em>just</em> the right thing. It works, and your dashboard is <em>useful</em>.</p><p>Your query uses an index from a remote cluster, say <code>my-remote:logs-foo</code>. But actually, <code>logs-foo</code> is an alias, and at some point, the remote cluster makes it point to a different backing index. The new index is missing a field that’s used in your query, and your query and visualization break.</p><p>Or maybe you have an already fairly large index, and while building ES|QL queries on top of it, you realize that you’d like to use a field in the indexed documents that unfortunately never made it into the index mapping. You could reindex the data, but that would take hours.</p><p>ES|QL’s <code>unmapped_fields</code> setting is meant to deal with these types of situations.</p><p>If your query looks like this:</p><p>and <code>some_field</code> is unmapped, ES|QL’s default behavior is to fail with a verification exception.</p><p>You can use the <code>unmapped_fields</code> setting to instead either fill <code>some_field</code> with <code>null</code>s or read it from the document’s <code>_source</code>, like so:</p><h2>How ES|QL resolves queries with field caps</h2><p>Before we jump into the inner workings of <code>unmapped_fields</code>, we have to look into how ES|QL resolves queries regularly. Let’s consider the above query:</p><p>We said that if <code>some_field</code> isn’t in the mapping for <code>index</code>, ES|QL will reject the query. How does it make that decision?</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7ba9af4b4b550018/6a8ee83fe41d7fea88654d42/image4.png" alt="ES|QL query resolution flow: analyzer checks index mappings, unresolved fields fail with Unknown column error" /><h3>How field caps tells ES|QL which fields exist</h3><p>In a typical schema-on-write fashion, Elasticsearch clusters maintain mappings with their respective indices. As a first step, ES|QL makes an internal request to the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-field-caps">field caps endpoint</a> to determine which fields the <code>index</code> has. It then passes the query, together with the field caps response, to the query planner, which consists essentially of the query analyzer (unrelated to analyzers of text fields) and query optimizer. The analyzer makes sense of raw names, like <code>some_field</code>, and notices that they correspond to index fields (or not). If all went well, the query is then passed on to the optimizer, which rewrites the query for efficiency, before it’s handed to the compute engine for execution.</p><h3>How the analyzer resolves field names in the query plan</h3><p>Let’s zoom in to the analyzer. The parsed query is represented in a tree structure, and the analyzer partially rewrites it, one command at a time, until it either has resolved all references or not.</p><p>For illustration, let’s use a somewhat more complex query and see how the analyzer would resolve it:</p><p>The parsed tree is actually a chain here, and it looks something like this:</p><p></p><p>The analyzer then moves up through the query tree to try and resolve the field names used in every command.</p><p>This is a simplified version of how we represent parse trees in tests and when debugging. The bottom of the chain corresponds to the <code>FROM</code> command and contains a list of all mapped fields that we know about, obtained from the field caps endpoint. (The <code>{f}</code> suffix marks an actually mapped field for better distinction later.)</p><p>The two <code>EVAL</code> nodes on top of it correspond to the remaining commands, and their fields are still unresolved, expressed by the question mark <code>?</code> in front of the name. At this point, the analyzer still has to check whether they correspond to existing index fields.</p><p>For the <code>EVAL</code> that defines <code>uppercased_mapped</code>, it can see that the previous command outputs <code>mapped_field</code>, so the unresolved <code>?mapped_field</code> marker can be replaced by a real field reference:</p><p>Next, it encounters the topmost <code>EVAL</code>, which defines <code>uppercased_unmapped</code>. The previous tree nodes produce only two fields: <code>[mapped_field, uppercased_mapped]</code>. The reference <code>?unmapped_field</code> thus has to remain unresolved. We bail here and emit the verification exception to the user.</p><h2>How unmapped_fields LOAD and NULLIFY work</h2><h3>Adding unmapped fields to the query plan</h3><p>When using <code>unmapped_fields=”NULLIFY”</code> or <code>”LOAD”</code>, we do something else; we act as if the field was actually in the index. The analyzer adds <code>unmapped_field</code> to the <code>From</code> node and marks it as unmapped to signal to the compute engine that this has to be read from <code>_source</code> or filled with <code>null</code>s. Let’s express this with a <code>{u}</code> (for <strong>u</strong>nmapped):</p><p>After amending the <code>From</code>, the analyzer can continue trying to resolve the topmost <code>Eval</code> node. It sees that the upstream nodes produce the fields <code>[mapped_field, unmapped_field, uppercased_mapped]</code> and thus <code>unmapped_field</code> can be correctly resolved:</p><p></p><p>The query plan is now fully resolved and can be passed down the regular optimization-execution pipeline. Other than the actual value extraction mechanism, everything stays the same. Schematically, the workflow looks like this:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt59bf2d2da201d00a/6a8ee8df8658b77c28469356/image2.png" alt="ES|QL unmapped fields flow: analyzer retries with NULLIFY or LOAD instead of failing on an unresolved field" /><h3>Example: enabling unmapped fields with the SET directive</h3><p>To give an example, let’s fire up a cluster and create an index with non-dynamic mappings.</p>PUT /index
{                                 
  "mappings": {
    "dynamic": false,
    "properties": {
      "mapped_field": {"type": "keyword"}
    }
  }
}

POST /index/_doc?refresh
{
  "mapped_field":"foo"
  "unmapped_field": "bar"
}<p>We can run the example query, above:</p>POST /_query
{
  "query": """
           FROM index
           | EVAL uppercased_mapped = TO_UPPER(mapped_field)
           | EVAL uppercased_unmapped = TO_UPPER(unmapped_field)
           """
}<p>This should result in the error message:</p><p><code>Unknown column [unmapped_field], did you mean [mapped_field]?</code></p><p>To make things work, we can prepend <code>SET unmapped_fields=”...”;</code> with <code>LOAD</code> or <code>NULLIFY</code>:</p>POST /_query
{
  "query": """
           SET unmapped_fields="LOAD";
           FROM index
           | EVAL uppercased_mapped = TO_UPPER(mapped_field)
           | EVAL uppercased_unmapped = TO_UPPER(unmapped_field)
           """
}

 mapped_field  |unmapped_field |uppercased_mapped|uppercased_unmapped
---------------+---------------+-----------------+-------------------
foo            |bar            |FOO              |BAR<h3>Inspecting the analyzer's rewrite steps</h3><p>If you want to see what the query analyzer is doing to the parse tree, you can log the query rewrite steps, like so:</p>PUT /_cluster/settings"
{
  "transient" : {
    "logger.org.elasticsearch.xpack.esql.analysis.Analyzer.changes": "TRACE"
  }
}<p>This will log a line containing <code>Rule rules.ResolveUnmapped applied with change…</code> You’ll see that <code>unmapped_field</code> is added to the bottom of the parse tree as described above.</p><h2>Why we have to infer the schema</h2><p>Of course, this isn’t the only possible method to deal with unmapped fields. Here are some alternatives:</p><ol><li><p>We could also scan or probe the documents in <code>index</code> to determine that their <code>_source</code> actually has the <code>unmapped_field</code>.</p></li><li><p>We could disable verifications in the analyzer and make the compute engine blindly pass unmapped fields through individual computation steps.</p></li></ol><p>The first alternative front-loads more work to understand the <em>actual</em> schema of an index and thus generally increases latency. It doesn’t scale to large, highly distributed datasets. The second alternative isn’t viable since it means a large-scale change to how ES|QL’s compute engine is built, because it passes around streams of data with fixed columns from one operator to another.</p><p>In contrast, the approach we chose is neatly compatible with ES|QL’s existing optimization pipeline.</p><p>The trade-off is that the analyzer has to correctly <em>infer</em> a schema based on the actual index mappings (obtained from the field caps endpoint) and additional fields used inside the query. </p><p>This isn’t always straightforward. There were two main challenges:</p><ol><li><p>There are many different query shapes and commands that can be used. The mechanism needs to detect unmapped fields, update the proper <code>FROM</code> command, and pass the new field through the halfway resolved plan correctly in all cases.</p></li><li><p>There are many different mappings we have to deal with, and we specifically need to make our feature work correctly when mappings <em>change over time</em> on top of that.</p></li></ol><p>In the following, we’ll focus on <code>LOAD</code>, although some problems (generally many fewer) also apply to <code>NULLIFY</code>.</p><h3>Which index to load unmapped fields from for LOOKUP JOIN and FORK</h3><p>To briefly illustrate the first problem, here are some choices we needed to make:</p><ul><li><p>Which index do we load from when using <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/lookup-join">lookup joins</a>? This one?</p><p>The <code>unmapped_field</code> cannot be attributed to both indices. We chose <code>index</code> since this is where we expect mappings to change more often than in lookup indices.</p></li><li><p>Similarly, how do we deal with subqueries and views or the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fork"><code>FORK</code> command</a>? In the following:</p><p>one fork branch triggers loading of an unmapped field. Is it also present in the other fork branch? (Yes, it should be, but it’s not obvious and is specifically not true if the two <code>FORK</code>s are replaced by independent subqueries.)</p></li></ul><h3>Two principles to keep queries working</h3><p>The second problem, diversity of mappings and their evolution over time, is a far bigger driver for complexity. We strove for two basic usability principles:</p><ul><li><p>Queries that work in the default mode should generally still work when using <code>unmapped_fields=”NULLIFY”</code> and <code>”LOAD”</code>.</p></li></ul><ul><li><p>Queries that work when all fields are mapped should generally still work with <code>NULLIFY</code> and <code>LOAD</code> when a field becomes unmapped and vice versa.</p></li></ul><h3>The type of unmapped fields and inadvertent type conflicts</h3><p>Let’s talk about data types to see where this leads to complexity. First, when using <code>unmapped_fields=”LOAD”</code>, we need to assume a data type for unmapped fields. We chose <code>KEYWORD</code>, which allows us to avoid type conflicts when reading from <code>_source</code>. One document can contain <code>”unmapped_field”: “foo”</code>, and another can contain <code>”unmapped_field”: 123.4</code>. It’s fine because we treat both as strings.</p><p>However, this is a violation of the second principle when a non-<code>KEYWORD</code> field happens to go unmapped. Consider this query:</p><p>If <code>some_field</code> becomes unmapped, we’ll have to assume that the <code>KEYWORD</code> type and the query will fail with a type conflict.</p><p>Type conflicts <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-multi-index#esql-multi-index-invalid-mapping">aren’t new</a> and can be dealt with by using explicit casts in the query, like so:</p><p>It would be great if ES|QL just inferred a useful type to cast to, but this is something for the future.</p><h3>Type conflicts with partially unmapped fields, or: making PUNKs well behaved</h3><p>In addition to fully unmapped fields, <em>partially unmapped</em> fields are everywhere and should also work with <code>LOAD</code>. Let’s look at a query that uses multiple indices.</p><p>Let’s say that there are indices <code>index</code> and <code>index_without_some_field</code>, containing just the following documents.</p>// index1
{
  "some_field": "foo"
}

// index2
{
  "some_field": "bar"
}<p>Now let’s consider the query:</p>FROM index, index_without_some_field<p>and assume that <code>some_field</code> is unmapped in <code>index_without_some_field</code>. This will return:</p>some_field
-------------
 foo
 null<p>because ES|QL doesn’t load unmapped fields per default.</p><p>Of course, when setting <code>unmapped_fields=”LOAD”</code>, we want to load from <code>_source</code> for <code>index_without_some_field</code>:</p>SET unmapped_fields="LOAD";
FROM index, index_without_some_field

 some_field
-------------
 foo
 bar           // loaded from _source<p>As with fully unmapped fields, the case is simple when <code>some_field</code> is mapped as <code>KEYWORD</code> in <code>index</code>. When loading from <code>_source</code> for <code>index_without_some_field</code>,  we treat the field as <code>KEYWORD</code> as well, so there’s no conflict.</p><h3>What makes a field a PUNK</h3><p>The case is less clear when <code>some_field</code>is partially unmapped and the mapped leg is of a type other than <code>KEYWORD</code>. Such fields caused a lot of trouble until we found the best solution, which makes their acronym quite fitting: <strong>p</strong>artially <strong>u</strong>nmapped <strong>n</strong>on-<strong>k</strong>eyword fields, or PUNKs.</p><p>Unfortunately, PUNKs are far from being esoteric. For instance, it’s very natural to filter on a PUNK:</p><p>If <code>some_field</code> is mapped as <code>INTEGER</code> in <code>index</code>, the type conflict looks like this:</p><ul><li><p>Mapped as an <code>INTEGER</code> in <code>index</code>.</p></li><li><p>Unmapped in <code>index_without_some_field</code> and thus treated as <code>KEYWORD</code>.</p></li></ul><p>This can again be resolved manually by providing an explicit cast:</p><p>But this is far from acceptable. Even queries that work fine without <code>NULLIFY</code> and <code>LOAD</code> typically have <em>some</em> PUNKs; the unmapped leg is simply treated as <code>null</code> then. Both guiding principles are violated if <code>LOAD</code> requires an explicit cast here.</p><h3>Casting implicitly to the mapped type</h3><p>The solution is to introduce an implicit cast to the mapped type. In this case, we know that <code>some_field</code> is an <code>INTEGER</code> in <code>index</code>, and thus we treat it essentially as if the user wrote:</p><p>This means that queries that work without <code>LOAD</code> keep working. (ES|QL may even give you more data because we load the unmapped leg of PUNKs from <code>_source</code>.) Queries that used to work when a field is fully mapped also keep working when it goes unmapped in some (but not all) of its indices without having to alter the query in any way.</p><p><strong>Behavior</strong></p><p><strong>Default</strong></p><p><strong><code>NULLIFY</code></strong></p><p><strong><code>LOAD</code></strong></p><p>Unmapped field in query</p><p>Query fails</p><p>Query runs</p><p>Query runs</p><p>Values returned</p><p>None</p><p><code>null</code></p><p>Read from <code>_source</code> </p><p>Assumed type</p><p>n/a</p><p><code>NULL</code></p><p><code>KEYWORD</code></p><p>Partially unmapped field (PUNK)</p><p>Unmapped leg is <code>null</code></p><p>Unmapped leg is <code>null</code></p><p>Cast to the mapped type</p><p>Pushdown optimization</p><p>Full</p><p>Full</p><p>Per-node where fully mapped</p><h2>Don't throw it all away: Keeping ES|QL query optimization with unmapped fields</h2><p>There's one more thing to get right; that is, to make sure that optimizations still work correctly with <code>LOAD</code>. Consider the previous query:</p><p>ES|QL’s optimizer aggressively pushes down such <code>WHERE</code> filters and turns them into Lucene queries, so the compute engine doesn’t perform unnecessary work.</p><p>For this query, evaluating the filter in the compute engine would require fetching each and every document from the index; meaning, a full scan, very slow. If <code>some_field</code> was mapped as an <code>INTEGER</code> in both indices, we would instead perform a Lucene query, which looks like this:</p>{
  "range": {
    "some_field": {
      "gt" : 10,
      "boost" : 0.0
    }
  }
}<p>The compute engine then doesn’t have to load each document separately and check whether it matches the filter. Documents with <code>some_field &lt;= 10</code> are never fetched from the Lucene index, which is very efficient at this kind of filtering. Nice.</p><h3>Why filter pushdown is unsafe for unmapped fields</h3><p>If <code>some_field</code> is unmapped in <code>index_without_some_field</code>, however, it’s wrong to narrow the documents down using the same Lucene query, as Lucene interprets an unmapped <code>some_field</code> as <code>null</code> and thus no documents from <code>index_without_some_field</code> will ever match. This edge case is easy to miss, and it doesn’t help that there are several flavors of similar pushdowns. For instance, in the query:</p><p>the compute engine pushes even the counting to Lucene. Again, this is only correct if <code>some_field</code> is fully mapped.</p><p>This means that such optimizations can't apply to unmapped fields. It would be disappointing if a query used hundreds of indices and only one of them happened to not map <code>some_field</code>, causing the whole query to run unoptimized.</p><h3>How the local optimizer recovers the fast path</h3><p>Luckily, this problem has a solution, too. ES|QL actually has multiple optimizer runs:</p><ol><li><p>First, a preliminary optimizer run on the node handling the <code>_query</code> request.</p></li><li><p>Then, a second, local optimizer run on every node we fan out to because we need to fetch documents from its shards.</p></li></ol><p>The workflow after the initial optimization looks more like this:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb2c907ea31960f96/6a8eeabe36492afa7685fa36/image1.png" alt="" /><p>If the current node happens to map <code>some_field</code> in all shards, the local optimizer detects this situation and treats <code>some_field</code> like any other fully mapped field, including performing Lucene queries to greatly narrow down the dataset to be processed. In fact, data nodes process <code>LIMIT</code> queries like:</p><p>in batches of shards (to avoid loading too much data too eagerly), which includes a full local optimizer run per batch. This makes it even more likely to encounter batches where <code>some_field</code> is fully mapped, allowing ES|QL to run a fast Lucene query.</p><h2>Is it working now? Testing unmapped_fields across every ES|QL query shape</h2><p>As we have seen from the optimizer issues above, problems can hide in plain sight, even for very simple queries. Because <code>unmapped_fields=”LOAD”</code> can affect each and every kind of query, the surface area for bugs is essentially all of ES|QL.</p><p>Accordingly, getting good test coverage was tricky and challenged us to refine our testing strategies.</p><h3>Reusing spec tests with unmapped_fields</h3><p>Conveniently, ES|QL has an extensive corpus of test queries, together with expected result sets; we call them <em>spec tests</em> because they’re written using a simple text specification language, which looks roughly like this:</p>simpleEval
row a = 1 | eval b = 2
;

a:integer | b:integer
1         | 2
;<p>This lets us create new tests out of the existing ones by introducing slight variations. For instance, any existing test that runs without <code>SET unmapped_fields=”...”</code> should produce the exact same results when run with <code>SET unmapped_fields=”NULLIFY”</code>.</p><p>It also helped find major issues early in the development process, especially for <code>NULLIFY</code>. The <code>LOAD</code> setting changes the meaning of queries much more dramatically, limiting the usefulness of this approach. However, ES|QL also uses what we call <em>generative testing</em>; that is, we string together random commands, run the query, and then check whether the server reports a bug. This approach cannot confirm the correctness of results, but it still helped greatly with finding query types that didn’t work properly and resulted in some kind of error. (Property-based tests would be a refinement in the future by running the queries against a reference implementation. This way, correctness of results can also be checked.)</p><h3>Testing type conflicts across different mappings</h3><p>In the end, one of the most important testing dimensions was using different indices with various mappings in the same query. (Recall how, above, we had to deal with type conflicts to come up with a solid approach for PUNKs? It doesn’t end there; all kinds of type conflicts are more complex with <code>LOAD</code>.) Since we couldn’t automatically generate correct expected results, ES|QL’s test suite had to grow by adding more than 10,000 lines of CSV spec tests. Fortunately, adding such tests is a well-suited task for an AI agent, which has cut down the effort dramatically. (Of course, the test results were still reviewed by humans.)</p><p>All testing strategies together provided us with good confidence for the GA release of <code>unmapped_fields</code> with Elasticsearch 9.5.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-unmapped-fields-deep-dive</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-unmapped-fields-deep-dive</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Mappings]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Alexander Spies]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9f8079c053533ad8/6a8ee79f8658b748a0469342/image4.png" length="0" type="image/png"/>
    <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>