<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anagha Biju</title>
    <description>The latest articles on DEV Community by Anagha Biju (@ab1109).</description>
    <link>https://dev.to/ab1109</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4108975%2F39c8ebf3-bfbc-4ee4-98b3-723e85cd00ec.png</url>
      <title>DEV Community: Anagha Biju</title>
      <link>https://dev.to/ab1109</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ab1109"/>
    <language>en</language>
    <item>
      <title>Your OpenSearch aggregation is lying to you: it returns 200 OK</title>
      <dc:creator>Anagha Biju</dc:creator>
      <pubDate>Fri, 04 Sep 2026 03:54:28 +0000</pubDate>
      <link>https://dev.to/ab1109/your-opensearch-aggregation-is-lying-to-you-it-returns-200-ok-4a2p</link>
      <guid>https://dev.to/ab1109/your-opensearch-aggregation-is-lying-to-you-it-returns-200-ok-4a2p</guid>
      <description>&lt;p&gt;A dashboard panel went blank in production. No error in the logs, no failed request, no alert. The panel just showed "no data", and the query had been fine for months.&lt;/p&gt;

&lt;p&gt;The cause: someone renamed an index field from &lt;code&gt;customer_country&lt;/code&gt; to &lt;code&gt;country&lt;/code&gt;, updated the mapping, and missed one saved aggregation that still referenced &lt;code&gt;customer_country&lt;/code&gt;. OpenSearch didn't complain. It returned an empty bucket list and a 200.&lt;/p&gt;

&lt;p&gt;Once I went looking, I found this wasn't one weird edge case. It's the default behaviour of the whole aggregation framework, and there are several distinct ways to hit it. Some of them don't even return an empty panel. They return a plausible one, which is much worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why aggregations fail quietly
&lt;/h2&gt;

&lt;p&gt;Here's the thing that makes all of this click, and it took me embarrassingly long to internalise:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aggregations don't validate. They count.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no step where the engine compares your &lt;code&gt;aggs&lt;/code&gt; block against the mapping and objects to what it finds. A &lt;code&gt;terms&lt;/code&gt; aggregation on &lt;code&gt;country&lt;/code&gt; walks the doc values for &lt;code&gt;country&lt;/code&gt; and buckets what's there. If nothing is there, the honest answer is zero buckets.&lt;/p&gt;

&lt;p&gt;And now the key part: from inside that loop, &lt;em&gt;a field that doesn't exist and a field that no document happens to have are indistinguishable.&lt;/em&gt; Both are "no values found". The second one is a completely legitimate result: an empty index, a filtered-out time range, a genuinely sparse optional field. If the engine threw an error on the first, it would have to throw on the second too, and half of all legitimate queries would start failing.&lt;/p&gt;

&lt;p&gt;So it returns &lt;code&gt;[]&lt;/code&gt; and a 200, and it is arguably right to. The problem is that "correct" and "useful to you at 2am" aren't the same thing.&lt;/p&gt;

&lt;p&gt;Everything below follows from that one design decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie #1: the field that isn't there
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aggs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"by_country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"terms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"custmer_country"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Typo, renamed field, field that only exists in the other index behind your alias. All of them produce the same silent empty result, and you find out when a human notices a blank chart, which might be next week.&lt;/p&gt;

&lt;p&gt;There's a nastier variant that catches people who did everything right. When OpenSearch dynamically maps a string, it creates a &lt;code&gt;text&lt;/code&gt; field with a &lt;code&gt;keyword&lt;/code&gt; sub-field, and that sub-field gets &lt;code&gt;ignore_above: 256&lt;/code&gt; by default. Values longer than 256 characters are not indexed into &lt;code&gt;.keyword&lt;/code&gt; at all.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;terms&lt;/code&gt; on &lt;code&gt;url.keyword&lt;/code&gt; will happily bucket your short URLs and silently drop every long one. No error. The field exists, the mapping is correct, the aggregation runs, and the answer is quietly incomplete in a way that correlates with exactly the values you were probably interested in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie #2: the field that's the wrong kind
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aggs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"by_city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"terms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"city"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;city&lt;/code&gt; is a &lt;code&gt;text&lt;/code&gt; field, this does not aggregate on city values.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;text&lt;/code&gt; fields go through an analyzer on the way in: lowercased, tokenized, sometimes stemmed. "New York" isn't stored as &lt;code&gt;New York&lt;/code&gt;, it's stored as two terms, &lt;code&gt;new&lt;/code&gt; and &lt;code&gt;york&lt;/code&gt;. An aggregation walks the terms in the index, so the buckets you get back are tokens rather than cities: &lt;code&gt;new&lt;/code&gt; (1 doc) and &lt;code&gt;york&lt;/code&gt; (1 doc).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;keyword&lt;/code&gt; fields skip the analyzer entirely. One document, one term, exactly the bytes you sent. That's why the fix is almost always a sub-field you forgot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aggs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"by_city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"terms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"city.keyword"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a second layer here worth knowing, because it explains an error message that otherwise looks arbitrary. Aggregations need to go from document to value, but an inverted index goes the other way, from term to documents. &lt;code&gt;keyword&lt;/code&gt; fields solve this with doc values: a columnar, on-disk structure written at index time, memory-mapped and cheap to scan.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;text&lt;/code&gt; fields don't get doc values, because building them for every token of every analyzed field would be enormous. The only way to aggregate a &lt;code&gt;text&lt;/code&gt; field is fielddata, which builds that same structure on the fly and holds it on the JVM heap. It has OOM'd real clusters, which is why it ships disabled and you get this instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fielddata is disabled on text fields by default. Set fielddata=true on [city]
in order to load fielddata in memory by uninverting the inverted index.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That message is the good outcome. If someone before you turned fielddata on to make an error go away, you don't get the message. You get a chart of plausible-looking word fragments and no indication that anything is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie #3: the interval that isn't the interval you meant
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aggs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"over_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date_histogram"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"created_at"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one 400s. &lt;code&gt;date_histogram&lt;/code&gt; requires &lt;code&gt;calendar_interval&lt;/code&gt; or &lt;code&gt;fixed_interval&lt;/code&gt;; the old &lt;code&gt;interval&lt;/code&gt; parameter was removed in Elasticsearch 7 / OpenSearch 1. If that query lives in a &lt;code&gt;.json&lt;/code&gt; template only exercised when one particular dashboard loads, production is where you'll meet it.&lt;/p&gt;

&lt;p&gt;But the 400 is the lucky outcome, because the reason &lt;code&gt;interval&lt;/code&gt; was removed is the real lesson. It accepted both kinds of value and guessed which you meant, and the two are genuinely different things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;calendar_interval&lt;/code&gt; follows the calendar. &lt;code&gt;1d&lt;/code&gt; is a real day, which is 23 or 25 hours across a DST boundary. &lt;code&gt;1M&lt;/code&gt; is a real month, so 28, 30, or 31 days. It only accepts a single unit, which makes &lt;code&gt;1M&lt;/code&gt; valid and &lt;code&gt;2M&lt;/code&gt; invalid.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fixed_interval&lt;/code&gt; is exact multiples of a fixed duration. &lt;code&gt;30d&lt;/code&gt; is always exactly 720 hours, regardless of what the calendar or DST thinks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;"calendar_interval": "1M"&lt;/code&gt; and &lt;code&gt;"fixed_interval": "30d"&lt;/code&gt; are not two ways of saying the same thing. Month-over-month revenue bucketed on &lt;code&gt;30d&lt;/code&gt; drifts against the calendar and slowly stops matching the finance team's numbers. And that query returns 200 every single time.&lt;/p&gt;

&lt;p&gt;Rule of thumb: humans read calendar intervals, machines read fixed ones. If a person is going to look at the bucket label, you want &lt;code&gt;calendar_interval&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie #4: the top 10 that isn't the top 10
&lt;/h2&gt;

&lt;p&gt;This is the one I'd most want you to take away, because it produces confidently wrong numbers from a completely valid query.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aggs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"top_products"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"terms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"product_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing wrong with it. On a multi-shard index it can still be wrong.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;terms&lt;/code&gt; aggregation is distributed. The coordinating node asks each shard for its own top terms, then merges what comes back. It never sees the full term list. That's the whole point: materialising every term from every shard is exactly the cost you're trying to avoid.&lt;/p&gt;

&lt;p&gt;Now picture a product that ranks #11 on all five of your shards. It's in nobody's top 10, so no shard mentions it, so it doesn't appear in your results at all, even though summed across all five it might outrank everything that did come back. And a product that made the cut on three shards but not the other two returns a &lt;code&gt;doc_count&lt;/code&gt; that's simply missing two shards' worth of documents. The number looks fine. It's just too low.&lt;/p&gt;

&lt;p&gt;OpenSearch mitigates this by fetching more than you asked for from each shard. That's &lt;code&gt;shard_size&lt;/code&gt;, which defaults to &lt;code&gt;size * 1.5 + 10&lt;/code&gt;. And it's honest about the remaining risk, in two response fields most people never look at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"top_products"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"doc_count_error_upper_bound"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sum_other_doc_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;84213&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"buckets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sum_other_doc_count&lt;/code&gt;: documents that fell outside your top &lt;code&gt;size&lt;/code&gt;. If it's large relative to your buckets, your "top 10" is a thin slice of reality. It's also the honest source for an "everything else" segment in a pie chart.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;doc_count_error_upper_bound&lt;/code&gt;: the worst case amount by which a returned &lt;code&gt;doc_count&lt;/code&gt; might be understated. Zero is your green light. Anything above zero means at least one number on that dashboard could be too low. Set &lt;code&gt;show_term_doc_count_error: true&lt;/code&gt; for the per-bucket breakdown.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Go and look at these two fields on your own dashboard queries. If you've never checked, the odds are good that at least one is nonzero.&lt;/p&gt;

&lt;p&gt;Raising &lt;code&gt;shard_size&lt;/code&gt; is much cheaper than raising &lt;code&gt;size&lt;/code&gt;, since it costs transport and coordinating-node memory rather than deeper per-shard work, so it's the first knob to reach for. And if you genuinely need every term, &lt;code&gt;terms&lt;/code&gt; is the wrong aggregation; &lt;code&gt;composite&lt;/code&gt; is the one that paginates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest one
&lt;/h2&gt;

&lt;p&gt;Credit where it's due, one of these fails loudly and immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aggs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"revenue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"amount"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"aggs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"by_channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"terms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"channel"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instant 400. And once you have the mental model it's obvious forever: a bucket aggregation hands its children a set of documents. A metric aggregation hands you a number. You can't nest anything under a number.&lt;/p&gt;

&lt;p&gt;Which means revenue-per-channel requires inverting the nesting: bucket becomes the parent, metric becomes the child.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aggs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"by_channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"terms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"channel"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"aggs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"revenue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"amount"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you remember one sentence from this post, make it the one about documents and numbers. It answers most "why won't this nest" questions before you have to look anything up.&lt;/p&gt;

&lt;h2&gt;
  
  
  I got tired of remembering all of this
&lt;/h2&gt;

&lt;p&gt;Every check above is decidable from the query text and the mapping. You don't need a running cluster to know that &lt;code&gt;sum&lt;/code&gt; can't have children, that &lt;code&gt;date_histogram&lt;/code&gt; needs an interval, or that &lt;code&gt;city&lt;/code&gt; is a &lt;code&gt;text&lt;/code&gt; field. So I wrote &lt;a href="https://www.npmjs.com/package/opensearch-agg-inspector" rel="noopener noreferrer"&gt;opensearch-agg-inspector&lt;/a&gt; to do it statically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx opensearch-agg-inspector dashboard.json &lt;span class="nt"&gt;--mapping&lt;/span&gt; mapping.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✖ dashboard.json:3:5  error  invalid-date-histogram
  date_histogram is missing "calendar_interval" or "fixed_interval". This will throw a runtime error in OpenSearch 1.x+ and Elasticsearch 7.x+.
  → Add calendar_interval (e.g. "1d", "1M") or fixed_interval (e.g. "1h", "30m").

✖ dashboard.json:4:5  error  metric-sub-aggregation
  Aggregation at "aggs.revenue" is a metric aggregation ("sum") but has sub-aggregations. The cluster will reject this. Only bucket aggregations can have children.

⚠ dashboard.json:6:17  warning  unknown-field
  Field "chanel" does not exist in the provided mapping. The aggregation will silently return no data.
  → Check the field name spelling and confirm it exists in the index mapping.

2 errors, 1 warning across 3 aggregations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight rules today. Four are the lies above; the rest are performance footguns like a &lt;code&gt;terms&lt;/code&gt; &lt;code&gt;size&lt;/code&gt; in the thousands with no matching &lt;code&gt;shard_size&lt;/code&gt;. Output is &lt;code&gt;file:line:col&lt;/code&gt;, clickable in your editor, and it exits non-zero, so the useful home for it is a &lt;code&gt;lint:queries&lt;/code&gt; script in CI sitting next to your saved dashboard JSON. Export the mapping once and commit it alongside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:9200/orders/_mapping | jq &lt;span class="s1"&gt;'.orders.mappings.properties'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; mapping.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also an &lt;code&gt;inspect()&lt;/code&gt; function if you assemble aggregations at runtime from user input and would rather reject a broken one than return silent bad data.&lt;/p&gt;

&lt;p&gt;Being upfront about what it can't do: it reads the &lt;code&gt;aggs&lt;/code&gt; block only, not &lt;code&gt;query&lt;/code&gt;. Your mapping is its source of truth for "does this field exist", so a partial mapping means false positives on &lt;code&gt;unknown-field&lt;/code&gt;. Downgrade or disable that rule if that's your situation. It targets OpenSearch 1.x+ / Elasticsearch 7.x+ semantics and needs Node 20. And it can't do much about lie #4 beyond nagging you about &lt;code&gt;shard_size&lt;/code&gt;, because no static tool can tell you your term distribution is skewed. That one you have to go and read in the response.&lt;/p&gt;

&lt;p&gt;Zero runtime dependencies, ESM + CJS + TypeScript types, MIT. &lt;a href="https://github.com/ab1109/opensearch-agg-inspector" rel="noopener noreferrer"&gt;https://github.com/ab1109/opensearch-agg-inspector&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's v0.1.0, so the rule set is deliberately small. If there's a quiet aggregation bug that's bitten you and isn't in there, I'd like to hear about it in an issue.&lt;/p&gt;

</description>
      <category>opensearch</category>
      <category>elasticsearch</category>
      <category>javascript</category>
      <category>npm</category>
    </item>
  </channel>
</rss>
