<?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[David Pilato - 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[David Pilato - 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/david-pilato</link>
    </image>
    <link>https://www.elastic.co/search-labs/author/david-pilato</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/author/david-pilato.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Tue, 15 Sep 2026 08:56:15 GMT</lastBuildDate>
  <item>
    <title><![CDATA[How to compare two Elasticsearch indices and find missing documents]]></title>
    <description><![CDATA[Exploring approaches for comparing two Elasticsearch indices and finding missing documents.]]></description>
    <content:encoded><![CDATA[<p>When managing Elasticsearch indices, you may need to verify that all documents present in one index also exist in another, such as after a reindex operation, a migration, or a data pipeline. Elasticsearch doesn't provide a built-in "diff" command for this, but the right approach depends on one key question: <strong>Are your document IDs stable between the two indices?</strong></p><h2>The problem</h2><p>Imagine you have two indices, <code>index-a</code> (source) and <code>index-b</code> (target), and you want to find all documents that exist in <code>index-a</code> but are missing from <code>index-b</code>.</p><p>A naive approach, querying both indices and comparing results in memory, won't scale. Elasticsearch is designed to handle millions of documents, and loading them all at once isn’t practical.</p><p>There are two scenarios:</p><ol><li><p><strong>IDs are stable</strong>: Both indices use the same <code>_id</code> for the same document (for example, <code>emp_no</code> as the document ID). This is the easy case.</p></li><li><p><strong>IDs are generated</strong>: Documents were ingested through different pipelines that assigned random or sequential IDs. You can't compare by <code>_id</code>; you need to match on content.</p></li></ol><p>Let's walk through both.</p><h2>Step 0 — A lighter CLI for Elasticsearch</h2><p>All the examples in this post use <a href="https://github.com/Anaethelion/escli-rs">escli</a>, a small Rust command line interface (CLI) that wraps the Elasticsearch REST API. It reads your cluster URL and credentials from environment variables, so you don’t have to repeat authentication headers on every command.</p><p>To see why that matters, here's a typical <code>_search</code> call with raw <code>curl</code>:</p>curl -X GET \
  -H "Authorization: ApiKey $ELASTIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":{"term":{"user.id":"kimchy"}}}' \
  "$ELASTICSEARCH_URL/my-index-000001/_search"<p>With <code>escli</code>, the same request becomes:</p>./escli search --index my-index-000001 &lt;&lt;&lt; '{"query":{"term":{"user.id":"kimchy"}}}'<p>The credentials live in a <code>.env</code> file that escli sources automatically — no <code>-H "Authorization: ..."</code> on every call, no risk of leaking secrets in shell history. The request body is passed via stdin (<code>&lt;&lt;&lt;</code>), which makes it easy to pipe in multi-line JSON built dynamically with <code>jq</code>.</p><h2>Step 1 — Count documents in both indices</h2><p>Before doing a full scan, get a quick count of each index. If the counts match, the indices are likely in sync, and there’s no need to scan at all.</p>./escli count --index index-a
./escli count --index index-b<p>The <code>_count</code> API returns:</p>{ "count": 1000000 }<p>If the counts differ, proceed to the full comparison.</p><h2>Step 2 — When IDs mean something: Use op_type=create</h2><p>If both indices use the same <code>_id</code> for the same document, for example, because you indexed documents using a functional business key like <code>emp_no</code> rather than a generated UUID, you can find and fix missing documents in a single <code>_reindex</code> call.</p><h3>Why functional IDs matter</h3><p>Using a meaningful field as <code>_id</code> (instead of a random UUID) is a best practice when the data has a natural key. It means:</p><ul><li><p>The same document always gets the same <code>_id</code>, regardless of which pipeline ingested it.</p></li><li><p>You can easily update or delete documents by ID.</p></li><li><p>You can use <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-index#operation-index-op_type"><code>op_type=create</code></a> to skip documents that already exist in the target.</p></li><li><p>No client-side scanning or comparison is needed.</p></li></ul><h3>The op_type=create trick</h3><p><code>_reindex</code> with <code>op_type=create</code> tries to create each document from the source in the target. If a document with the same <code>_id</code> already exists, Elasticsearch reports it as a <code>version_conflict</code> and moves on. It <strong>doesn’t</strong> overwrite the existing document. Setting <code>conflicts=proceed</code> tells the API to continue instead of aborting on the first conflict.</p>./escli reindex &lt;&lt;&lt; '{
  "source": { "index": "index-a" },
  "dest":   { "index": "index-b", "op_type": "create" },
  "conflicts": "proceed"
}'<p>The response tells you exactly what happened:</p>{
  "total": 1000000,
  "created": 49594,
  "version_conflicts": 950406,
  "failures": []
}<ul><li><p><code>created</code>: Documents that were missing from <code>index-b</code> and have now been added.</p></li><li><p><code>version_conflicts</code>: Documents that already existed in <code>index-b</code> and were left untouched.</p></li></ul><p><strong>No scanning, no client-side comparison, no intermediate file.</strong> Everything happens server-side in about six seconds on a 1M-document dataset.</p><h2>Step 3 — When IDs are not stable: Business-key comparison</h2><p>Sometimes you can't rely on <code>_id</code>. A document pipeline that generates IDs at ingestion time will assign a different <code>_id</code> each time the same record is processed. If <code>index-a</code> and <code>index-b</code> were populated by two such pipelines, the same employee record might have <code>_id: "abc123"</code> in one index and <code>_id: "xyz789"</code> in the other, even though the underlying data is identical.</p><p>In this case, you need to match documents by content rather than by ID. The key is to identify a set of fields that together form a unique business key.</p><p>For an employee dataset, a reasonable business key is <code>(first_name, last_name, birth_date)</code>. A document in <code>index-a</code> is "missing" from <code>index-b</code> if no document in <code>index-b</code> has the same combination of those three fields.</p><h3>3a — Scan the source with PIT + search_after</h3><p>Open a <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-open-point-in-time">point in time (PIT)</a> on the source index to get a consistent snapshot, and then paginate through it, fetching only the business-key fields:</p>./escli open_point_in_time index-a 5m
# → { "id": "46ToAwMDaWR..." }./escli search &lt;&lt;&lt; '{
  "size": 10000,
  "_source": ["first_name", "last_name", "birth_date"],
  "pit": { "id": "46ToAwMDaWR...", "keep_alive": "5m" },
  "sort": [{ "_shard_doc": "asc" }]
}'<p>The sort key <code>_shard_doc</code> is the most efficient sort for full-index pagination: it uses the internal Lucene document order with no overhead. Repeat with <code>search_after</code> until the response contains zero hits. Always close the PIT when done:</p>./escli close_point_in_time &lt;&lt;&lt; '{"id": "46ToAwMDaWR..."}'<h3>3b — Check each page against the target via _msearch</h3><p>For each page of source documents, build one <code>_msearch</code> request with one subquery per document. Each subquery uses a <code>bool/must</code> on the three business-key fields and requests <code>size: 0</code>; we only need to know whether a match exists, we don’t need to retrieve the document itself.</p>./escli msearch &lt;&lt; 'EOF'
{"index": "index-b"}
{"size":0,"query":{"bool":{"must":[{"term":{"first_name.keyword":"Alice1"}},{"term":{"last_name.keyword":"Smith"}},{"term":{"birth_date":"1985-03-12"}}]}}}
{"index": "index-b"}
{"size":0,"query":{"bool":{"must":[{"term":{"first_name.keyword":"Bob2"}},{"term":{"last_name.keyword":"Jones"}},{"term":{"birth_date":"1990-07-24"}}]}}}
EOF<p>The response contains one entry per subquery, in the same order:</p>{
  "responses": [
    { "hits": { "total": { "value": 1 } } },
    { "hits": { "total": { "value": 0 } } }
  ]
}<p><code>total.value == 0</code> means no document in <code>index-b</code> matches that business key; the document is missing. Collect the corresponding <code>_id</code> from the source page.</p><strong>Note on</strong> <strong><code>.keyword</code></strong> <strong>subfields</strong>: <code>term</code> queries require exact (keyword) matching. The <code>first_name</code> and <code>last_name</code> fields must have a <code>.keyword</code> subfield in the index mapping. The demo's <code>mapping.json</code> includes this.<h3>3c — Speed it up with split-by-date</h3><p>If the business key includes a date field, you can partition the source into date slices and run each slice as an independent job. Each slice opens its own PIT with a <code>range</code> filter on <code>birth_date</code>, runs its own msearch loop, and writes its results to a separate file. The parent script launches all slices in parallel and aggregates the results when they’re all done.</p><p>But depending on your use case, you might want to partition by a different field; for example, if you have a <code>team</code> field, you could run one slice per team. The key is to find a field that allows you to split the data into reasonably even chunks that can be processed in parallel.</p>[compare] Launching 5 slices in parallel...

  → Slice 1: 1960-01-01 → 1969-12-31 ✅ — 244408 checked, 12207 missing
  → Slice 2: 1970-01-01 → 1979-12-31 ✅ — 243624 checked, 12212 missing
  → Slice 3: 1980-01-01 → 1989-12-31 ✅ — 243551 checked, 11921 missing
  → Slice 4: 1990-01-01 → 1999-12-31 ✅ — 243895 checked, 11991 missing
  → Slice 5: 2000-01-01 → 2009-12-31 ✅ — 24522 checked, 1263 missing<h2>Performance on a 1M dataset</h2><p>To validate the approaches, the demo generates 1,000,000 documents in <code>index-a</code> and deliberately skips ~5% in <code>index-b</code> (49,594 missing documents), and then runs the full compare → reindex cycle.</p><p>Results on a MacBook M3 Pro:</p><p><strong>Comparison</strong> (<code>compare-indices.sh</code>):</p><p>Strategy</p><p>Compare</p><p>Reindex</p><p>Total</p><p>How it works</p><p>op_type</p><p></p><p>6s</p><p>6s</p><p>Full _reindex server-side, skips existing</p><p>business-key</p><p>1m 38s</p><p>4s</p><p>1m 42s</p><p>PIT scan + _msearch by business key</p><p>split-by-date</p><p>32s</p><p>4s</p><p>36s</p><p>Same as business-key, 5 slices in parallel</p><p>The <code>op_type=create</code> approach is fastest because everything is server-side and requires no client-side scanning. The <code>split-by-date</code> strategy cuts the <code>business-key</code> duration from 1m 38s down to 36s through parallelism: not bad for a comparison across two 1M-document indices.</p><h2>Decision tree</h2>Are _id values stable between both indices?
├── Yes → _reindex with op_type=create          (6s, server-side)
└── No  → Do you have a reliable business key?
          ├── Yes, simple scan is fast enough → business-key   (1m 42s)
          └── Yes, and you need more speed    → split-by-date  (36s, parallel)<h2>Conclusion</h2><p>Elasticsearch doesn't offer a native index diff command, but the right strategy depends on your data model:</p><ul><li><p><strong>Use functional</strong> <strong><code>_id</code></strong><strong>s</strong> (a natural business key like <code>emp_no</code>) whenever possible. It unlocks the simplest and fastest approach: <code>_reindex</code> with <code>op_type=create</code> finds and fills gaps in one server-side call.</p></li><li><p><strong>When IDs are unstable</strong>, match by business key using PIT + <code>_msearch</code>. Partition by a field and run slices in parallel to recover most of the performance. If you find yourself doing this regularly, consider computing a hash of your business key fields and using it as <code>_id</code> at ingestion time. You get the best of both worlds: stable IDs and efficient lookups.</p></li></ul><p>The complete demo, including dataset generation, comparison scripts, and reindex scripts, is available at <a href="https://github.com/dadoonet/blog-compare-indices/">https://github.com/dadoonet/blog-compare-indices/</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-index-comparison</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-index-comparison</guid>
    <category><![CDATA[Basics]]></category>
    <dc:creator><![CDATA[David Pilato]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt87c19debc74944e3/6a170e09286714495393e3ae/099abf465250360ab741a5aa13931fa8884ded34-1376x768.png" length="0" type="image/png"/>
    <pubDate>Mon, 06 Apr 2026 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>