<?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[Anton Persson - 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[Anton Persson - 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/anton-persson</link>
    </image>
    <link>https://www.elastic.co/search-labs/author/anton-persson</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/author/anton-persson.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Sun, 13 Sep 2026 07:18:07 GMT</lastBuildDate>
  <item>
    <title><![CDATA[How Elasticsearch cuts time-series storage by 34% with synthetic _id and bloom filters]]></title>
    <description><![CDATA[Learn how synthetic _id uses bloom filters to cut time-series storage by 34% while maintaining full API compatibility.]]></description>
    <content:encoded><![CDATA[<p>Synthetic <code>_id</code> reduces time-series index storage by up to 34% and eliminates 6% CPU overhead at ingest. Instead of building an inverted index for <code>_id</code>, Elasticsearch computes the document identifier on the fly from <code>_tsid</code> and <code>@timestamp</code>, using a bloom filter for deduplication. This optimization ships in Elasticsearch 9.4 and is already live on Elastic Cloud Serverless.</p><p>This post is a deep dive into the implementation. For context on how synthetic <code>_id</code> fits into the broader metrics performance story, see <a href="https://www.elastic.co/search-labs/blog/elasticsearch-columnar-metrics-engine-30x-faster-prometheus">How we rebuilt Elasticsearch as a leading columnar metrics datastore</a> to achieve up to 6.6x improvement in storage efficiency and 50% improvement in indexing throughput for OpenTelemetry metrics.</p><p>We'll start by explaining why the <code>_id</code> field is expensive for time-series workloads. We'll then describe how synthetic <code>_id</code> works and how it uses a bloom filter to optimize document deduplications instead of maintaining a traditional inverted index. Finally, we'll share the performance results from our benchmarks and serverless production deployments.</p><h2>The hidden cost of _id in time-series indices</h2><p>Time-series indices are a specialized index mode optimized for metrics, logs, traces, and other timestamped data. They store sequences of data points (like CPU usage, stock prices, or sensor readings) that track changes to specific entities over time. In Elasticsearch, each of these data points is indexed as a document with a unique identifier called <code>_id</code>. This identifier is used to look up, update, or delete specific documents. When a document is indexed in Elasticsearch, the system checks whether a document with the same <code>_id</code> already exists. Depending on the operation type (<code>op_type</code>), an existing document is either replaced (<code>index</code>) or the new document is rejected (<code>create</code>); the latter is the most common path for metrics ingestion.</p><p>To perform this lookup efficiently, Elasticsearch builds an <a href="https://en.wikipedia.org/wiki/Inverted_index">inverted index</a> for the <code>_id</code> field. This inverted index maps each <code>_id</code> value to its location in the index, enabling fast document lookups. Until version 8.11, the <code>_id</code> value was also stored separately in order to be returned in search results and other APIs. From 8.11 and onwards, we optimized Elasticsearch to only store this value temporarily for document replication purposes, the value being quickly merged away and reconstructed on demand.</p><p>For many use cases, building the inverted index and storing it is an acceptable overhead. But for time-series data, like metrics or traces, the cost can add up quickly. Our experiments showed that building the inverted index for the field <code>_id</code> adds 6% CPU overhead compared to indexing without it. In some extreme cases, we benchmarked that it could reduce indexing throughput by 25%.</p><p>This overhead is especially painful for time-series workloads where data points are typically small (often just a timestamp and a few numeric values) and compress extremely well. The <code>_id</code> field, however, doesn't benefit from the same compression. As a result, the inverted index for <code>_id</code> can represent a disproportionate share of the total storage. In our benchmarks with OpenTelemetry (OTel) metrics, the <code>_id</code> inverted index alone consumed around 5 bytes of the total 25 bytes per data point.</p><p>We considered several approaches to eliminate this overhead:</p><ul><li><p>Stop indexing <code>_id</code> and checking for duplicates: This would be the simplest solution, but without deduplication, duplicate data points could corrupt aggregations. A gauge average, for instance, would be skewed by repeated values.</p></li><li><p>Accept duplicates during indexing, deduplicate at query time: This preserves correctness but adds overhead to every query, degrading dashboard responsiveness.</p></li><li><p>Deduplicate during segment merges: Duplicates would eventually be removed, but queries on unmerged segments would still return results with duplicates.</p></li><li><p>Synthetic <code>_id</code>: Compute the document identifier on the fly from fields that already uniquely identify each data point, and use a lightweight bloom filter for deduplication instead of a full inverted index.</p></li></ul><p>We chose synthetic <code>_id</code> because it maintains correctness at ingest time while eliminating the storage and CPU overhead of the traditional approach. And we decided to implement it for time-series indices because they’re very well suited for this optimization.</p><p>In time-series indices, the <code>_id</code> isn’t arbitrary. Each document has a <strong>time series identifier</strong> (<code>_tsid</code>) and a <strong>timestamp</strong> (<code>@timestamp</code>). The <code>_tsid</code> is generated from the <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds#time-series-dimension">dimensions fields</a> of the document (like <code>host.name</code>, <code>pod.name</code>, or <code>sensor_id</code>), while the <code>@timestamp</code> marks the point in time of the document. Together, these two fields uniquely identify the document: There can only be one data point for a given time series at a given moment in time. This means we can derive the <code>_id</code> from the <code>_tsid</code> and <code>@timestamp</code> field values, rather than storing it separately.</p><h2>How does synthetic _id work in Elasticsearch?</h2><p>With synthetic <code>_id</code>, Elasticsearch computes the document identifier on the fly as the combination of the <code>_tsid</code> and <code>@timestamp</code> fields. This computed value is used wherever <code>_id</code> would normally be used: in API responses, for document lookups, and for deduplication. However, it’s never stored in an inverted index nor is it stored on disk for later retrieval.</p><p>The challenge is deduplication. When a new document arrives, Elasticsearch must verify that no document with the same <code>_id</code> already exists. Without an inverted index on <code>_id</code>, how can we perform this check efficiently?</p><h3>How synthetic _id simulates an inverted index without building one</h3><p>Our Elastic Lucene experts suggested a clever idea: Since <code>_tsid</code> and <code>@timestamp</code> are already stored as doc values, we could expose our own custom Lucene postings format that simulates an inverted index without actually building one.</p><p>This means that when Elasticsearch needs to look up a document by its <code>_id</code>, it uses the same code path as usual: It queries the underlying Lucene index to look up the <code>_id</code> term. But instead of hitting a real inverted index, our custom postings format intercepts the call, extracts the <code>_tsid</code> and <code>@timestamp</code> encoded in the synthetic <code>_id</code>, and uses their doc values to locate the document. Because time-series indices are sorted by these fields, documents belonging to the same time series are stored contiguously. This allows Elasticsearch to skip large subsets of nonmatching documents (sometimes entire segments) to find the target document(s) quickly.</p><p>While this process is efficient, it can involve several random-access reads: looking up the <code>_tsid</code> value, scanning for matching documents, and reading timestamps. For the common case in time-series indices where we don’t expect the document to already exist, we wanted to fail fast without touching doc values at all.</p><h3>Bloom filters for fast membership testing</h3><p>We solve this problem using a <a href="https://en.wikipedia.org/wiki/Bloom_filter"><strong>bloom filter</strong></a>, a probabilistic data structure that can quickly answer the question <em>Could this element be in the set?</em> with a small risk of false positives but no risk of false negatives. In other words, a bloom filter might occasionally say <em>yes</em> when the answer is actually <em>no</em>, but it will never say <em>no</em> when the answer is <em>yes</em>.</p><p>When a document is indexed, its synthetic <code>_id</code> is added to the bloom filter. When a new document arrives, we first check the bloom filter. If the bloom filter says <em>no</em>, we know for certain that no document with this <code>_id</code> exists and we can proceed with indexing immediately. If the bloom filter says <em>maybe yes</em>, we fall back to the more expensive verification using the <code>_tsid</code> and <code>@timestamp</code> doc values.</p><h3>Synthetic _id indexing workflow: step by step</h3><p>Let's walk through what happens when a document is indexed into a time-series index with synthetic <code>_id</code> enabled:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt387a8573301f120e/6a3e41e975bd4076e6a77b5d/61ef279f09c5447009d3695f154129fba6fe510d-1048x1462.png" alt="Flowchart on a dark background showing document indexing steps using synthetic IDs, bloom filters, and duplicate handling paths." /><ol><li><p><strong>Compute the synthetic </strong><strong><code>_id</code></strong>: Elasticsearch calculates <code>_id</code> as a combination of <code>_tsid || @timestamp</code>.</p></li><li><p><strong>Check the live version map</strong>: Like today, we first check an in-memory map of recently indexed documents. If the document is present in this map, we can handle the duplicate immediately.</p></li><li><p><strong>Filter segments by timestamp</strong>: Time-series indices are sorted by <code>_tsid</code> and <code>@timestamp</code>. We can skip any segment whose timestamp range does not overlap with the incoming document's timestamp.</p></li><li><p><strong>Check the bloom filter</strong>: For each candidate segment, we test whether the <code>_id</code> might exist using the bloom filter.</p></li><li><p><strong>Verify if needed</strong>: If the bloom filter returns a positive result, we look up the document using the <code>_tsid</code> and <code>@timestamp</code> doc values. Since documents are sorted by these fields, this lookup is efficient.</p></li><li><p><strong>Index the document</strong>: If no existing version is found, the document is indexed. The <code>_id</code> is added to the segment's bloom filter, but no inverted index is built and the field value is never stored.</p></li></ol><p>In the common case where new data arrives with recent timestamps, step 3 eliminates most segments from consideration, and step 4 quickly confirms that the document is new. The expensive verification in step 5 only happens on bloom filter false positives, which are expected to be rare.</p><h3>Bloom filter false positive rate: how Elasticsearch keeps it low</h3><p>One challenge with bloom-filter-based deduplication is controlling the false positive rate without sacrificing the storage efficiency we were after. To size bloom filters effectively, we consider the number of data points in each segment and target both a low false positive rate and a bit set saturation below 50%.</p><p>The saturation target serves a specific purpose: When segments are merged, we OR the bit sets rather than rebuilding bloom filters from scratch. This makes merges fast but means the false positive rate converges toward 100% as segments are merged repeatedly. Keeping saturation below 50% before merging buys headroom, delaying that convergence.</p><p>The low false positive rate target is justified by access patterns: Recent segments are checked far more often than older ones, since we prune the search space based on data point timestamps. Older, heavily merged segments with degraded bloom filters are unlikely to be checked.</p><h2>Synthetic _id performance benchmarks: indexing and storage</h2><p>We ran extensive benchmarks to validate our implementation.</p><h3>Indexing throughput</h3><p>A core goal of this effort was to match or improve on existing indexing throughput. In principle, the new approach does less work: Building an inverted index for <code>_id</code> requires hashing each value, building and maintaining complex data structures in memory, and flushing them to disk. These structures must also be reconstructed during segment merges, adding CPU and I/O overhead in high-throughput use cases.</p><p>Building a bloom filter isn't free (we still hash each value), but the memory footprint is smaller and there are no complex data structures to maintain or flush. The bloom filter is also cheap to merge: When possible, we simply OR the bit sets together rather than rebuilding from scratch.</p><p>The main cost of synthetic <code>_id</code> comes from verifying potential duplicates using doc values. However, this cost is mitigated by two factors: First, bloom filter false positives are rare, so most documents skip this step entirely. Second, time-series indices are sorted by <code>_tsid</code> and <code>@timestamp</code>, which means doc value lookups can skip large blocks of nonmatching documents efficiently.</p><p>In practice, that's exactly what we observed. Even accounting for the extra seeks needed to verify matches against the tsid and timestamp when a bloom filter returns a positive, throughput came out comparable or better than before. The savings from not building and merging the inverted index outweigh the occasional cost of a false positive check, as confirmed by our <a href="https://elasticsearch-benchmark-analytics.elastic.co/app/dashboards#/view/f7e091a0-1db1-11ed-920a-3b1141502d24?_g=(refreshInterval:(pause:!t,value:60000),time:(from:'2026-03-16T00:00:00.000Z',to:'2026-03-19T23:30:00.000Z'))&amp;_a=(viewMode:view)">nightly benchmarks</a>:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte73bd65d8545658d/6a3e41eca48ab9170737c817/32b5b068ef6dd7cd512f12dc9d50d8769f2930d4-1999x509.png" alt="Line graph titled “nightly‑tsdb‑indexing‑throughput,” showing nightly benchmark results for document indexing rates in docs per second over three days, with four colored lines representing different indexing operations." /><h3>Storage savings</h3><p>In our benchmarks with OTel metrics, synthetic <code>_id</code> reduced storage by approximately 5 bytes per data point. For a dataset where documents average 25 bytes per data point, this represents a 20% reduction in storage from this single optimization alone.</p><p>These results were soon confirmed by our <a href="https://elasticsearch-benchmarks.elastic.co/#tracks/tsdb/nightly/default/90d">nightly benchmarks</a>.The chart below shows the storage footprint reduction over time as we enabled the synthetic <code>_id</code> feature on March 19, 2026:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3cb1cf931ecebba9/6a3e41efb8c8ed6dec833e9d/0c0ac1f2c9716a6f99b6e055535db0def77bf930-1898x956.png" alt="Line graph titled “Disk usage” showing TSDB and downsampling data from March 15 to March 23, 2026, with disk usage measured in gigabytes per 24 hours. A teal line for TSDB drops near March 19 and stabilizes around 1.9 GB, while a brown line for downsampling decreases to 2.3 GB after the same date." /><p>Our standard time series database (TSDB) benchmark showed a reduction from 2.5 GiB to 1.9 GiB (24%). Similarly the time-series downsampling benchmark showed a comparable reduction from 3 GiB to 2.3 GiB (23%).</p><p>Another benchmark, more focused on metrics, <a href="https://elasticsearch-benchmark-analytics.elastic.co/app/dashboards#/view/37270832-cd2d-4ea7-8222-e61e8ad742a3?_g=(refreshInterval:(pause:!t,value:60000),time:(from:'2026-03-16T00:00:00.000Z',to:'2026-03-19T23:30:00.000Z'))&amp;_a=(viewMode:view)">showed an even better reduction</a>, from 3.0 GiB to 2.0 GiB (34%):</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd66d7617467e01c0/6a3e41f2809064d8e0e00d7f/2a243db0f3ad95e761fb334ffd6fea47f7787eee-1999x447.png" alt="Line graph titled “Dataset size,” showing two turquoise lines that decline from March 16 to 19, 2026, each representing a different TSDB metric. The x‑axis marks daily timestamps, and the y‑axis shows dataset size decreasing." /><h2>API compatibility</h2><p>An important design goal was maintaining compatibility with existing Elasticsearch APIs. With synthetic <code>_id</code>, all document APIs continue to work as expected: Bulk, Get, Update, Delete, Reindex, and Update/Delete by Query. This compatibility layer also limited the blast radius of the change, ensuring any issues would be contained to the internal implementation.</p><p>When the <code>_id</code> isn’t provided in an API request, Elasticsearch computes it from the <code>_tsid</code> and <code>@timestamp</code> fields. To check if the document already exists, it first queries the bloom filter and, if needed, falls back to doc values. The <code>_id</code> is also synthesized on demand from doc values when returning documents in search results or API responses.</p><p>One case that requires special handling is searching or filtering by <code>_id</code> prefix or pattern. Such queries require scanning many documents to find matching documents, and while this works correctly, it incurs a performance penalty compared to a direct <code>_id</code> lookup. We don’t expect this use case to be common for time-series indices though.</p><h2>Elasticsearch 9.4 and Elastic Cloud Serverless availability</h2><p>The synthetic <code>_id</code> feature will be released in Elasticsearch 9.4.0 and is already available on <a href="https://www.elastic.co/cloud/serverless">Elastic Cloud Serverless</a>.</p><p>No configuration is required: The feature is enabled by default, and newly created time-series indices (including those created on datastream rollover) will automatically benefit from this optimization. Existing time-series indices created before 9.4 will continue to create inverted indices for the <code>_id</code> field.</p><p>We expect synthetic <code>_id</code> to perform well across all time-series use cases. However, in some very specific, update-heavy use cases, if you encounter performance issues, the feature can be disabled by setting <code>index.mapping.synthetic_id</code> to <code>false</code> for new indices.</p><h2>Summary: synthetic _id storage and performance gains</h2><p>In this article, we’ve presented how synthetic <code>_id</code> eliminates the storage and compute overhead of document identifiers in time-series indices. By computing <code>_id</code> on the fly from <code>_tsid</code> and <code>@timestamp</code>, and using a bloom filter for deduplication, we achieve comparable or better indexing performance with up to 34% reduction in storage footprint while maintaining full API compatibility. For users running large-scale time-series workloads, this translates directly into lower infrastructure costs.</p><h2>Roadmap: what comes after synthetic _id</h2><p>Synthetic <code>_id</code> is part of a broader effort to reduce storage overhead in Elasticsearch.</p><ul><li><p><strong>Sequence number trimming:</strong> Every document carries a sequence number for replication and concurrency control. For append-only time-series data, these become redundant after segments are merged. Elasticsearch 9.4 now trims them during merges to reclaim even more storage: We'll cover this optimization in detail in an upcoming blog post.</p></li><li><p><strong>Synthetic _id beyond time-series:</strong> We’re exploring how to bring synthetic <code>_id</code> to regular indices by letting users declare which fields uniquely identify their documents and configuring index sorting on those fields to enable efficient lookups.</p></li></ul><p>Stay tuned!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-synthetic-id-time-series-storage</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-synthetic-id-time-series-storage</guid>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Tanguy Leroux,Francisco Fernández Castaño,Anton Persson]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbe7de1872f1527e3/6a17de303e9e452974ba1374/a70c5403064d5bbceff66a17373332362227f13c-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 28 May 2026 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>