719-286-0751 [email protected]

Fixing the “_product_0” Missing Elasticsearch Index Error in Magento 2

One of the nastier search bugs in a Magento 2 codebase leaves no error at all. A custom indexer pushes documents into an Elasticsearch index named <prefix>_product_0 — an index your storefront never reads — while the live <prefix>_product_1 index quietly goes stale. Sometimes you'll catch a no such index [<prefix>_product_0] or an index_not_found_exception in the logs, but more often Elasticsearch auto-creates the phantom index on the first bulk write and reports success. The result: customers see outdated prices, stock, and listings, and nothing in your monitoring flags it. Below we cover why the _0 suffix appears, how to confirm it, and how to point writes back at the index the storefront actually queries.

To be clear up front: this is not a defect in stock Magento 2. Stock indexers receive $storeId as an explicit framework argument and build the index name through Magento\Elasticsearch\Model\Adapter\Index\IndexNameResolver::getIndexName($storeId, ...) — they never call getStore(). The _product_0 trap shows up in custom indexing code that resolves the store itself.

Need help fixing your Magento 2 store?
If this error is impacting live revenue, our senior Magento team can help you debug and deploy a fix safely.
Contact us →

Why a custom indexer writes to the wrong index

Per-store Elasticsearch indices follow the pattern <prefix>_product_<storeId>. The bug is almost always one line: a custom service builds that name from $storeManager->getStore()->getId() instead of an explicit store view. The trap is specific to the admin (adminhtml) request scope. In an admin controller or an admin save observer, the area binds the "current" store to the admin store, whose store id is 0. Real store views start at 1, so the index name becomes a dead <prefix>_product_0.

CLI, cron, and queue consumers behave differently, and this is the part teams get wrong. There, Magento\Store\Model\StoreManagerInterface::getStore() does not resolve to admin — the default StoreResolver::getCurrentStoreId() falls through to the default storefront store (id 1) when no store code is present in the request. So on a single-store site, an indexer run from CLI or cron usually lands on the right index by accident. On a multi-store site it lands on a wrong-but-valid store view, which is its own quiet data bug. The dead _product_0 symptom is the admin-context signature.

  • Using getStore() to name a store-scoped ES index inside an admin controller or admin save observer — the admin area resolves the current store to the admin store (id 0).
  • Indexing triggered from an admin save flow where store emulation is never set. (From pure CLI/cron/queue, getStore() instead returns the default store id 1 — so the failure mode there is a wrong store on multi-store sites, not a dead _product_0.)
  • Treating store_id 0 as a storefront scope. In Magento, 0 is the admin/config scope — valid for reading configuration, never for store-scoped catalog indices.
  • Elasticsearch auto-creating the missing index on first write, so the wrong index "works" while the live one rots.

1. Confirm a stray admin-scope index exists

Start at the index list. A _product_0 index should not exist at all.

curl -s 'http://localhost:9200/_cat/indices?v' | grep -nE '_product_(0|1)'
echo '--- doc counts ---'
curl -s 'http://localhost:9200/<prefix>_product_0/_count'
curl -s 'http://localhost:9200/<prefix>_product_1/_count'

You should see a stray <prefix>_product_0, and/or a <prefix>_product_1 whose doc count never moves after the indexer runs. Either signal confirms writes are landing in the admin-scope index instead of the storefront one.

One thing to know before you reach for DELETE later: on a stock Magento install the physical product index is versioned<prefix>_product_1_v{N} (e.g. magento2_product_1_v3) — and <prefix>_product_1 is only an alias pointing at the current version. Your _count and search queries resolve through that alias. A custom indexer that writes to the bare name (<prefix>_product_0) skips versioning entirely, which is why the orphan is a plain index you can safely drop. Just don't blindly DELETE the storefront name on a stock deployment — you may be deleting an alias mid-rotation.

2. Find every store-id resolution in custom indexing code

Grep for the leak. We've seen this most often when indexing logic was lifted from a controller — where getStore() happened to return a real store view — into an admin save observer, where the admin area pins it to store 0.

grep -rn 'storeManager->getStore()' app/code | grep -iE 'index|elastic|search'

Each hit is a place where the ambient "current store" leaks into the index name. Those are the lines to change.

3. Resolve a real storefront store view

Swap the current-store call for one that can never return admin.

// Replace:
// $storeId = $storeId ?? (int) $storeManager->getStore()->getId();
// With a guaranteed storefront store view (never admin / id 0):
$storeId = $storeId ?? (int) ($storeManager->getDefaultStoreView()?->getId() ?? 1);

getDefaultStoreView() returns the default storefront view (id >= 1) and never the admin store, so the index name always points at something the storefront reads. The ?? 1 guards the rare unconfigured case. For per-store indexing, iterate $storeManager->getStores() — it excludes admin — rather than trusting getStore().

Need help fixing your Magento 2 store?
If this error is impacting live revenue, our senior Magento team can help you debug and deploy a fix safely.
Contact us →

4. Reindex into the correct index and drop the orphan

With the store id fixed, rebuild and clean up.

bin/magento indexer:reindex catalogsearch_fulltext
curl -s -X DELETE 'http://localhost:9200/<prefix>_product_0'

The <prefix>_product_1 count should rise to your full catalog total and the stray _product_0 should be gone. Note the DELETE above targets the bare _product_0 name — safe because the orphan from a custom indexer is an unversioned physical index. Don't run DELETE against the live storefront name on a stock install, where it may be an alias; in a blue-green or multi-store setup, reindex per active store view and re-point aliases rather than deleting a live index.

5. Verify the storefront index stays fresh

Re-run the indexer from CLI, then check the count holds.

curl -s 'http://localhost:9200/<prefix>_product_1/_count'
curl -s 'http://localhost:9200/_cat/indices?v' | grep -E '_product_0$' || echo 'no _product_0 index — good'

A live count that matches the catalog, plus no _product_0, confirms documents now land in the storefront index. Trigger an admin save (the path that originally produced the orphan) and re-check that the count moves and no _product_0 reappears — that's the real regression test.

When this points to a deeper problem

Deleting the orphan index fixes today's symptom. The pattern that produced it usually runs deeper:

  • Conflating config/admin scope (store_id 0) with a real store view — 0 is never a valid target for store-scoped catalog indices.
  • Indexing logic that depends on ambient request state instead of taking an explicit, validated store id as a parameter, so behavior silently differs between an admin save, web, CLI, and cron. (This is exactly why stock Magento passes storeId into IndexNameResolver::getIndexName() instead of reading it from the store manager.)
  • A silent-failure surface: Elasticsearch auto-creates missing indices and the indexer logs success, so a wrong-index bug surfaces only as "stale search." On multi-store sites the CLI/cron variant — writing to the wrong real store (id 1 instead of the intended view) — is even quieter, because every index involved is valid.

The durable fix is an audit. Grep the whole codebase for getStore()->getId() and getStoreId() feeding any ES index name, and ask of each: can this run from an admin controller, an admin save observer, CLI, cron, or a queue? If yes, resolve the store explicitly. Add a guard rejecting store_id 0 for store-scoped writes, and a health check comparing the live index doc count against the catalog so a regression is caught immediately instead of as a customer complaint. (Magento's own history with ambiguous getStore() resolution — see GitHub issues magento/magento2#6068 and #4371 — is a good reminder that "current store" is context-dependent.)

You now have everything you need to find the leak, point writes back at <prefix>_product_1, and keep them there. One grep and one line of code usually close it out for that execution path.

Install our webapp on your iPhone! Tap and then Add to homescreen.
Share This