Skip to content

Diagnostic Indexing

Products vanish mid reindex on delete then insert cycle

A support ticket says a product was on the site an hour ago and is gone now, but nobody touched it in the admin. The product is still enabled, still in stock, still sitting in the catalog. What changed is the price index. Magento's price indexer rebuilds its table by deleting a batch of rows and then inserting them again, and for a moment in between, that batch of products simply is not there. Here is why that gap exists and a small script that tells the harmless version of this apart from a real disappearance.

Python and Node.js Magento REST API Safe by default (dry run)
A server room aisle
Photo by Ismail Enes Ayhan on Unsplash
The short answer

The catalog_product_price indexer rebuilds catalog_product_index_price by paging through eligible products and issuing a delete then insert for each batch, either against the live table when the indexer runs on save, or against catalog_product_index_price_replica in Update by Schedule mode, which is later swapped in. With small batch sizes and a catalog carrying many disabled or out of stock SKUs, a product's row can be deleted in one batch and not reinserted until a later one, so a category page, layered navigation, or a search hitting the price index during that gap sees the product as absent even though it is still in the catalog. Because this is CLI and DB territory, not a writable REST resource, a script cannot detect it by reading the index table directly. Instead it polls GET /rest/V1/products for the enabled and visible SKU set before, during, and after a known reindex window, checks GET /rest/V1/indexer for catalog_product_price status, and reports which SKUs vanished and whether they came back. Full code, tests, and a dry run guard are below.

The problem in plain words

Magento does not compute a product's storefront price on the fly for every listing page. It keeps a flat table, catalog_product_index_price, that already holds the final price for every product across every customer group and website, so category pages and layered navigation can read one row instead of running the whole EAV and price rule pipeline each time. That table has to be rebuilt whenever prices, rules, or catalog data change, and that rebuild is the reindex.

The rebuild does not rewrite the whole table in one transaction. It pages through eligible products in batches, and for each batch it deletes the existing rows for those product ids and inserts the freshly computed ones, sometimes as a delete followed by an insert, sometimes as an insert or replace after a delete. If the indexer runs in the legacy on-save mode, or a forced full reindex runs against the live table on a split database or a high traffic setup, that delete and insert happens directly against the table storefront queries are reading from right now. For the short window between the delete and the matching insert, any query touching that batch of products, a category listing, layered nav, or a search or collection built on the price index, simply does not find them. The product has not been removed from the catalog. It has been removed from the index, for a moment, by the rebuild itself.

Reindex batch N a page of product ids DELETE rows from index table Product missing gap before INSERT lands Shopper's request category or search page Listing renders without the product INSERT lands later product reappears
The delete and the matching insert are two separate statements. Any read that lands in between sees the product as gone.

Why it happens

This is a well documented pattern rather than a one-off bug: it is filed against Magento 2 itself as products intermittently missing during a price reindex, and store operators have written up the same symptom while working on reindex performance. See the citations at the end for the exact reports.

The key insight

You cannot fix this from the REST API, because the root cause lives in the CLI-only reindex and cron pipeline, not in any endpoint you can call or write to. What you can do over REST is catch it in the act: pull the enabled and visible SKU set before a reindex window, again during it, and again after, and cross reference against the indexer's own reported status. If the SKUs that vanished all come back once the indexer finishes, that is the batching race working exactly as documented, self healing, and not something to chase as a bug. If they do not come back, that is a different problem entirely, a real removal or disabling, and it needs a human to look at the product record before anyone touches it.

The fix, as a flow

The script never reindexes or touches the database. It watches the catalog from the outside, across a reindex window, and sorts what it sees into two very different buckets: an expected timing gap that will resolve on its own, or a real loss that needs a person.

Record SKUs before, during, after Read indexer status catalog_product_price Missing SKUs back after? yes Flag transient gap expected, self healing no Flag permanent loss Report for a human
The script only reports. It never calls a mutating endpoint for a transient gap, and only ever suggests restoring a product after a person confirms it was wrongly disabled.

Build it step by step

1

Get an admin bearer token

Call the token endpoint once with an admin user, or use a long lived integration token. Keep the base URL and the token in environment variables, never in the file, and leave DRY_RUN on so nothing is ever written by mistake, since this script does not repair anything by itself anyway.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export DRY_RUN="true"   # this script only reports; kept for consistency
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export DRY_RUN="true"   // this script only reports; kept for consistency
2

List enabled and visible SKUs

Call GET /rest/V1/products filtered to status equal to 1 and visibility matching one of catalog, search, or both, paging with searchCriteria[pageSize] and [currentPage] until every SKU is collected. This is the EAV source of truth for what should be visible, independent of the price index.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "100"))

def enabled_visible_skus():
    """Full pageSize iteration over enabled, visible products. Returns a list of SKUs."""
    skus = []
    page = 1
    while True:
        params = {
            "searchCriteria[filterGroups][0][filters][0][field]": "status",
            "searchCriteria[filterGroups][0][filters][0][value]": "1",
            "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
            "searchCriteria[filterGroups][1][filters][0][field]": "visibility",
            "searchCriteria[filterGroups][1][filters][0][value]": "2,3,4",
            "searchCriteria[filterGroups][1][filters][0][conditionType]": "in",
            "searchCriteria[pageSize]": PAGE_SIZE,
            "searchCriteria[currentPage]": page,
        }
        r = requests.get(
            f"{MAGENTO_URL}/rest/V1/products",
            params=params,
            headers={"Authorization": f"Bearer {TOKEN}"},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        items = body.get("items", [])
        skus.extend(item["sku"] for item in items)
        if len(items) < PAGE_SIZE:
            return skus
        page += 1
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 100);

async function enabledVisibleSkus() {
  // Full pageSize iteration over enabled, visible products. Returns an array of SKUs.
  const skus = [];
  let page = 1;
  while (true) {
    const params = new URLSearchParams({
      "searchCriteria[filterGroups][0][filters][0][field]": "status",
      "searchCriteria[filterGroups][0][filters][0][value]": "1",
      "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
      "searchCriteria[filterGroups][1][filters][0][field]": "visibility",
      "searchCriteria[filterGroups][1][filters][0][value]": "2,3,4",
      "searchCriteria[filterGroups][1][filters][0][conditionType]": "in",
      "searchCriteria[pageSize]": String(PAGE_SIZE),
      "searchCriteria[currentPage]": String(page),
    });
    const res = await fetch(`${MAGENTO_URL}/rest/V1/products?${params}`, {
      headers: { Authorization: `Bearer ${TOKEN}` },
    });
    if (!res.ok) throw new Error(`Magento ${res.status}`);
    const body = await res.json();
    const items = body.items || [];
    for (const item of items) skus.push(item.sku);
    if (items.length < PAGE_SIZE) return skus;
    page++;
  }
}
3

Read the indexer status

Call GET /rest/V1/indexer and pick out the entry whose indexer_id is catalog_product_price. Its status field reads valid, invalid, or processing, and that timestamped status is what lets the decision function tell a race apart from a real problem.

step3.py
def price_indexer_status():
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/indexer",
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    for row in r.json():
        if row.get("indexer_id") == "catalog_product_price":
            return {"code": row["indexer_id"], "status": row.get("status", "")}
    return {"code": "catalog_product_price", "status": "unknown"}
step3.js
async function priceIndexerStatus() {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/indexer`, {
    headers: { Authorization: `Bearer ${TOKEN}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  const rows = await res.json();
  const row = rows.find((r) => r.indexer_id === "catalog_product_price");
  return { code: "catalog_product_price", status: row ? row.status || "" : "unknown" };
}
4

Decide, with one pure function

Compute what went missing between the before and during snapshots, and what is still missing once the after snapshot is in. If the gap closed and the indexer was actively reindexing, that is the expected batching race. If SKUs never came back, that is a real loss and needs a human, not a script, to look at the product.

decide.py
def decide_reindex_anomaly(before_skus, during_skus, after_skus, indexer_status):
    before_set, during_set, after_set = set(before_skus), set(during_skus), set(after_skus)
    missing = before_set - during_set
    still_missing_after = missing - after_set

    if not missing:
        return {
            "isTransientDropDetected": False,
            "missingDuringWindow": [],
            "falsePositive": len(before_skus) != len(after_skus),
            "recommendation": "ok",
        }

    reindexing = indexer_status.get("code") == "catalog_product_price" and indexer_status.get("status") in ("processing", "invalid")

    if not still_missing_after and reindexing:
        return {
            "isTransientDropDetected": True,
            "missingDuringWindow": sorted(missing),
            "falsePositive": False,
            "recommendation": "flag_transient_index_gap",
        }

    if still_missing_after:
        return {
            "isTransientDropDetected": False,
            "missingDuringWindow": sorted(missing),
            "falsePositive": False,
            "recommendation": "flag_permanent_loss",
        }

    return {
        "isTransientDropDetected": False,
        "missingDuringWindow": sorted(missing),
        "falsePositive": True,
        "recommendation": "ok",
    }
decide.js
export function decideReindexAnomaly(beforeSkus, duringSkus, afterSkus, indexerStatus) {
  const beforeSet = new Set(beforeSkus);
  const duringSet = new Set(duringSkus);
  const afterSet = new Set(afterSkus);
  const missing = [...beforeSet].filter((sku) => !duringSet.has(sku));
  const stillMissingAfter = missing.filter((sku) => !afterSet.has(sku));

  if (missing.length === 0) {
    return {
      isTransientDropDetected: false,
      missingDuringWindow: [],
      falsePositive: beforeSkus.length !== afterSkus.length,
      recommendation: "ok",
    };
  }

  const reindexing =
    indexerStatus.code === "catalog_product_price" &&
    (indexerStatus.status === "processing" || indexerStatus.status === "invalid");

  if (stillMissingAfter.length === 0 && reindexing) {
    return {
      isTransientDropDetected: true,
      missingDuringWindow: missing.sort(),
      falsePositive: false,
      recommendation: "flag_transient_index_gap",
    };
  }

  if (stillMissingAfter.length > 0) {
    return {
      isTransientDropDetected: false,
      missingDuringWindow: missing.sort(),
      falsePositive: false,
      recommendation: "flag_permanent_loss",
    };
  }

  return {
    isTransientDropDetected: false,
    missingDuringWindow: missing.sort(),
    falsePositive: true,
    recommendation: "ok",
  };
}
5

Poll a reindex window and report

Take a before snapshot, wait for the window to include a reindex, take a during snapshot and the indexer status, take an after snapshot, and hand all four to the decision function. Log the recommendation. The script never calls a mutating endpoint for a transient gap, and only ever suggests a human-confirmed PUT /rest/V1/products/{sku} when the loss is permanent.

Run it safe

This script is a detector, not a repair tool. It never writes to a product from a transient finding. If it reports flag_permanent_loss, treat that as a lead for a person to check the product's status and visibility by hand, then decide whether to restore it, not as an instruction to auto-enable anything.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, and never mutates a product from this detection path, only reporting the SKUs, the timestamps, and the indexer status so a human can act on a genuine loss.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
reindex_anomaly.py
"""Detect the transient product-vanishing gap in Magento's catalog_product_price reindex.

Reindex/cron and direct index-table access are CLI/DB-only, so this detects the symptom
over REST: it records the enabled and visible SKU set before, during, and after a known
reindex window, cross references indexer status, and reports whether a drop is the
expected self-healing batching race or a genuine, still-missing product. It never calls
a mutating endpoint for a transient gap. Safe to run again and again.
"""
import os
import time
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reindex_anomaly")

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "100"))
POLL_INTERVAL_SECONDS = float(os.environ.get("POLL_INTERVAL_SECONDS", "5"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def enabled_visible_skus():
    """Full pageSize iteration over enabled, visible products. Returns a list of SKUs."""
    skus = []
    page = 1
    while True:
        params = {
            "searchCriteria[filterGroups][0][filters][0][field]": "status",
            "searchCriteria[filterGroups][0][filters][0][value]": "1",
            "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
            "searchCriteria[filterGroups][1][filters][0][field]": "visibility",
            "searchCriteria[filterGroups][1][filters][0][value]": "2,3,4",
            "searchCriteria[filterGroups][1][filters][0][conditionType]": "in",
            "searchCriteria[pageSize]": PAGE_SIZE,
            "searchCriteria[currentPage]": page,
        }
        r = requests.get(
            f"{MAGENTO_URL}/rest/V1/products",
            params=params,
            headers={"Authorization": f"Bearer {TOKEN}"},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        items = body.get("items", [])
        skus.extend(item["sku"] for item in items)
        if len(items) < PAGE_SIZE:
            return skus
        page += 1


def price_indexer_status():
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/indexer",
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    for row in r.json():
        if row.get("indexer_id") == "catalog_product_price":
            return {"code": row["indexer_id"], "status": row.get("status", "")}
    return {"code": "catalog_product_price", "status": "unknown"}


def decide_reindex_anomaly(before_skus, during_skus, after_skus, indexer_status):
    before_set, during_set, after_set = set(before_skus), set(during_skus), set(after_skus)
    missing = before_set - during_set
    still_missing_after = missing - after_set

    if not missing:
        return {
            "isTransientDropDetected": False,
            "missingDuringWindow": [],
            "falsePositive": len(before_skus) != len(after_skus),
            "recommendation": "ok",
        }

    reindexing = indexer_status.get("code") == "catalog_product_price" and indexer_status.get("status") in ("processing", "invalid")

    if not still_missing_after and reindexing:
        return {
            "isTransientDropDetected": True,
            "missingDuringWindow": sorted(missing),
            "falsePositive": False,
            "recommendation": "flag_transient_index_gap",
        }

    if still_missing_after:
        return {
            "isTransientDropDetected": False,
            "missingDuringWindow": sorted(missing),
            "falsePositive": False,
            "recommendation": "flag_permanent_loss",
        }

    return {
        "isTransientDropDetected": False,
        "missingDuringWindow": sorted(missing),
        "falsePositive": True,
        "recommendation": "ok",
    }


def run():
    log.info("Recording before snapshot.")
    before_skus = enabled_visible_skus()

    log.info("Waiting %ss to bracket the reindex window.", POLL_INTERVAL_SECONDS)
    time.sleep(POLL_INTERVAL_SECONDS)

    log.info("Recording during snapshot and indexer status.")
    during_skus = enabled_visible_skus()
    indexer_status = price_indexer_status()

    log.info("Waiting %ss for the reindex to finish before the after snapshot.", POLL_INTERVAL_SECONDS)
    time.sleep(POLL_INTERVAL_SECONDS)

    log.info("Recording after snapshot.")
    after_skus = enabled_visible_skus()

    result = decide_reindex_anomaly(before_skus, during_skus, after_skus, indexer_status)

    if result["recommendation"] == "ok":
        log.info("No anomaly detected. %d before, %d after.", len(before_skus), len(after_skus))
    elif result["recommendation"] == "flag_transient_index_gap":
        log.warning(
            "Transient index gap detected during catalog_product_price reindex. %d SKU(s) dipped and returned: %s",
            len(result["missingDuringWindow"]), result["missingDuringWindow"],
        )
        log.warning("This is expected, self healing batching behavior. No write performed. DRY_RUN=%s", DRY_RUN)
    elif result["recommendation"] == "flag_permanent_loss":
        log.error(
            "%d SKU(s) missing during the window and still missing after it finished: %s",
            len(result["missingDuringWindow"]), result["missingDuringWindow"],
        )
        log.error("This looks like a real removal, not a reindex race. A human should confirm before any product is re-enabled. No write performed.")

    return result


if __name__ == "__main__":
    run()
reindex-anomaly.js
/**
 * Detect the transient product-vanishing gap in Magento's catalog_product_price reindex.
 *
 * Reindex/cron and direct index-table access are CLI/DB-only, so this detects the
 * symptom over REST: it records the enabled and visible SKU set before, during, and
 * after a known reindex window, cross references indexer status, and reports whether
 * a drop is the expected self-healing batching race or a genuine, still-missing product.
 * It never calls a mutating endpoint for a transient gap. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/products-vanish-during-price-reindex/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 100);
const POLL_INTERVAL_SECONDS = Number(process.env.POLL_INTERVAL_SECONDS || 5);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function decideReindexAnomaly(beforeSkus, duringSkus, afterSkus, indexerStatus) {
  const beforeSet = new Set(beforeSkus);
  const duringSet = new Set(duringSkus);
  const afterSet = new Set(afterSkus);
  const missing = [...beforeSet].filter((sku) => !duringSet.has(sku));
  const stillMissingAfter = missing.filter((sku) => !afterSet.has(sku));

  if (missing.length === 0) {
    return {
      isTransientDropDetected: false,
      missingDuringWindow: [],
      falsePositive: beforeSkus.length !== afterSkus.length,
      recommendation: "ok",
    };
  }

  const reindexing =
    indexerStatus.code === "catalog_product_price" &&
    (indexerStatus.status === "processing" || indexerStatus.status === "invalid");

  if (stillMissingAfter.length === 0 && reindexing) {
    return {
      isTransientDropDetected: true,
      missingDuringWindow: missing.sort(),
      falsePositive: false,
      recommendation: "flag_transient_index_gap",
    };
  }

  if (stillMissingAfter.length > 0) {
    return {
      isTransientDropDetected: false,
      missingDuringWindow: missing.sort(),
      falsePositive: false,
      recommendation: "flag_permanent_loss",
    };
  }

  return {
    isTransientDropDetected: false,
    missingDuringWindow: missing.sort(),
    falsePositive: true,
    recommendation: "ok",
  };
}

async function enabledVisibleSkus() {
  const skus = [];
  let page = 1;
  while (true) {
    const params = new URLSearchParams({
      "searchCriteria[filterGroups][0][filters][0][field]": "status",
      "searchCriteria[filterGroups][0][filters][0][value]": "1",
      "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
      "searchCriteria[filterGroups][1][filters][0][field]": "visibility",
      "searchCriteria[filterGroups][1][filters][0][value]": "2,3,4",
      "searchCriteria[filterGroups][1][filters][0][conditionType]": "in",
      "searchCriteria[pageSize]": String(PAGE_SIZE),
      "searchCriteria[currentPage]": String(page),
    });
    const res = await fetch(`${MAGENTO_URL}/rest/V1/products?${params}`, {
      headers: { Authorization: `Bearer ${TOKEN}` },
    });
    if (!res.ok) throw new Error(`Magento ${res.status}`);
    const body = await res.json();
    const items = body.items || [];
    for (const item of items) skus.push(item.sku);
    if (items.length < PAGE_SIZE) return skus;
    page++;
  }
}

async function priceIndexerStatus() {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/indexer`, {
    headers: { Authorization: `Bearer ${TOKEN}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  const rows = await res.json();
  const row = rows.find((r) => r.indexer_id === "catalog_product_price");
  return { code: "catalog_product_price", status: row ? row.status || "" : "unknown" };
}

function sleep(seconds) {
  return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}

export async function run() {
  console.log("Recording before snapshot.");
  const beforeSkus = await enabledVisibleSkus();

  console.log(`Waiting ${POLL_INTERVAL_SECONDS}s to bracket the reindex window.`);
  await sleep(POLL_INTERVAL_SECONDS);

  console.log("Recording during snapshot and indexer status.");
  const duringSkus = await enabledVisibleSkus();
  const indexerStatus = await priceIndexerStatus();

  console.log(`Waiting ${POLL_INTERVAL_SECONDS}s for the reindex to finish before the after snapshot.`);
  await sleep(POLL_INTERVAL_SECONDS);

  console.log("Recording after snapshot.");
  const afterSkus = await enabledVisibleSkus();

  const result = decideReindexAnomaly(beforeSkus, duringSkus, afterSkus, indexerStatus);

  if (result.recommendation === "ok") {
    console.log(`No anomaly detected. ${beforeSkus.length} before, ${afterSkus.length} after.`);
  } else if (result.recommendation === "flag_transient_index_gap") {
    console.warn(
      `Transient index gap detected during catalog_product_price reindex. ${result.missingDuringWindow.length} SKU(s) dipped and returned: ${result.missingDuringWindow.join(", ")}`
    );
    console.warn(`This is expected, self healing batching behavior. No write performed. DRY_RUN=${DRY_RUN}`);
  } else if (result.recommendation === "flag_permanent_loss") {
    console.error(
      `${result.missingDuringWindow.length} SKU(s) missing during the window and still missing after it finished: ${result.missingDuringWindow.join(", ")}`
    );
    console.error("This looks like a real removal, not a reindex race. A human should confirm before any product is re-enabled. No write performed.");
  }

  return result;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it is what tells an ops team whether to shrug or to escalate. Because decide_reindex_anomaly is pure, taking plain arrays and a status object, the test needs no Magento store and no network.

test_vanish_decision.py
from reindex_anomaly import decide_reindex_anomaly

PROCESSING = {"code": "catalog_product_price", "status": "processing"}
INVALID = {"code": "catalog_product_price", "status": "invalid"}
VALID = {"code": "catalog_product_price", "status": "valid"}


def test_transient_gap_when_missing_returns_and_indexer_processing():
    before = ["sku-1", "sku-2", "sku-3"]
    during = ["sku-1", "sku-3"]
    after = ["sku-1", "sku-2", "sku-3"]
    result = decide_reindex_anomaly(before, during, after, PROCESSING)
    assert result["isTransientDropDetected"] is True
    assert result["missingDuringWindow"] == ["sku-2"]
    assert result["recommendation"] == "flag_transient_index_gap"


def test_permanent_loss_when_sku_never_returns():
    before = ["sku-1", "sku-2"]
    during = ["sku-1"]
    after = ["sku-1"]
    result = decide_reindex_anomaly(before, during, after, PROCESSING)
    assert result["isTransientDropDetected"] is False
    assert result["missingDuringWindow"] == ["sku-2"]
    assert result["recommendation"] == "flag_permanent_loss"


def test_permanent_loss_even_if_indexer_says_valid():
    before = ["sku-1", "sku-2"]
    during = ["sku-1"]
    after = ["sku-1"]
    result = decide_reindex_anomaly(before, during, after, VALID)
    assert result["recommendation"] == "flag_permanent_loss"


def test_ok_when_nothing_missing_and_counts_match():
    before = ["sku-1", "sku-2"]
    result = decide_reindex_anomaly(before, before, before, VALID)
    assert result["recommendation"] == "ok"
    assert result["falsePositive"] is False


def test_false_positive_when_nothing_missing_but_counts_differ():
    before = ["sku-1", "sku-2"]
    after = ["sku-1", "sku-2", "sku-3"]
    result = decide_reindex_anomaly(before, before, after, VALID)
    assert result["recommendation"] == "ok"
    assert result["falsePositive"] is True


def test_transient_gap_detected_when_indexer_status_invalid():
    before = ["sku-1", "sku-2"]
    during = ["sku-1"]
    after = ["sku-1", "sku-2"]
    result = decide_reindex_anomaly(before, during, after, INVALID)
    assert result["recommendation"] == "flag_transient_index_gap"
reindex-anomaly.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideReindexAnomaly } from "./reindex-anomaly.js";

const PROCESSING = { code: "catalog_product_price", status: "processing" };
const INVALID = { code: "catalog_product_price", status: "invalid" };
const VALID = { code: "catalog_product_price", status: "valid" };

test("transient gap when missing returns and indexer processing", () => {
  const before = ["sku-1", "sku-2", "sku-3"];
  const during = ["sku-1", "sku-3"];
  const after = ["sku-1", "sku-2", "sku-3"];
  const result = decideReindexAnomaly(before, during, after, PROCESSING);
  assert.equal(result.isTransientDropDetected, true);
  assert.deepEqual(result.missingDuringWindow, ["sku-2"]);
  assert.equal(result.recommendation, "flag_transient_index_gap");
});

test("permanent loss when sku never returns", () => {
  const before = ["sku-1", "sku-2"];
  const during = ["sku-1"];
  const after = ["sku-1"];
  const result = decideReindexAnomaly(before, during, after, PROCESSING);
  assert.equal(result.isTransientDropDetected, false);
  assert.deepEqual(result.missingDuringWindow, ["sku-2"]);
  assert.equal(result.recommendation, "flag_permanent_loss");
});

test("permanent loss even if indexer says valid", () => {
  const before = ["sku-1", "sku-2"];
  const during = ["sku-1"];
  const after = ["sku-1"];
  const result = decideReindexAnomaly(before, during, after, VALID);
  assert.equal(result.recommendation, "flag_permanent_loss");
});

test("ok when nothing missing and counts match", () => {
  const before = ["sku-1", "sku-2"];
  const result = decideReindexAnomaly(before, before, before, VALID);
  assert.equal(result.recommendation, "ok");
  assert.equal(result.falsePositive, false);
});

test("false positive when nothing missing but counts differ", () => {
  const before = ["sku-1", "sku-2"];
  const after = ["sku-1", "sku-2", "sku-3"];
  const result = decideReindexAnomaly(before, before, after, VALID);
  assert.equal(result.recommendation, "ok");
  assert.equal(result.falsePositive, true);
});

test("transient gap detected when indexer status invalid", () => {
  const before = ["sku-1", "sku-2"];
  const during = ["sku-1"];
  const after = ["sku-1", "sku-2"];
  const result = decideReindexAnomaly(before, during, after, INVALID);
  assert.equal(result.recommendation, "flag_transient_index_gap");
});

Case studies

Large catalog, on-save mode

The furniture importer with thousands of discontinued SKUs

A furniture importer kept years of discontinued SKUs disabled rather than deleted, for historical order references. Every price rule edit triggered an on-save reindex that had to page past thousands of disabled rows to reach the active ones, stretching every batch's delete-to-insert gap, and support kept getting tickets that a product had vanished for no reason.

Running the detector across a rule edit showed the same handful of SKUs dipping and returning within seconds, every time, with the indexer status reading processing throughout. That confirmed it was the batching race, not a data problem, and the team switched the price indexer to Update by Schedule so shoppers stopped seeing it at all.

Small batch size

The store that tuned batch size down for a slow host

A store on constrained hosting had turned the indexer batch size down to ease database load during reindex. That fixed the load problem but multiplied the number of delete and insert cycles for the same catalog, and a few products started disappearing from category pages right after every price update, always for a few seconds.

The team ran the detector during a scheduled price update and got flag_transient_index_gap every time, with the same SKUs returning within the after snapshot. Knowing it was self healing, they raised the batch size back up on a beefier reindex window instead of chasing a phantom bug, and the dips got shorter.

What good looks like

After this runs across a reindex window, a support ticket about a vanishing product stops being a mystery. Either the SKU dipped and returned while the indexer was processing, which is the expected batching race and needs no code change, or it is still missing afterward, which is flagged clearly for a human to check the product's real status before anyone touches it. No product gets auto re-enabled from a guess.

FAQ

Why do products disappear from my Magento store during a reindex?

The catalog_product_price indexer rebuilds catalog_product_index_price in batches, and each batch deletes the rows for the products in that batch before it inserts their fresh rows. When a batch is small and the catalog has many disabled or out of stock SKUs to skip over, a product can be deleted from the index and not reinserted until a later batch. Any storefront query that reads the price index, such as a category listing or layered navigation, can hit that gap and show the product as gone even though it still exists in the catalog data.

Does switching to Update by Schedule fix the vanishing products problem?

It removes the worst version of the problem. In Update by Schedule mode Magento builds the new index into catalog_product_index_price_replica and only swaps the table names in when the build finishes, so storefront queries keep reading the old, complete table for the whole rebuild. The same delete then insert batching still happens inside the replica build, but shoppers never see it because they are not reading the replica table while it is being written.

How can I tell a transient reindex gap apart from a product that is actually gone?

Record the set of enabled and visible SKUs before a reindex window, again during it, and again after it finishes, using GET /rest/V1/products with pageSize iteration so you get exact SKU lists rather than just counts. If SKUs that were missing during the window are back afterward, and the indexer status for catalog_product_price was processing or invalid at the time, that is the expected, self healing race and needs no fix. If SKUs are still missing after the window closes, that is a real data problem, most likely a disabled or hidden product, and needs a human to confirm before anyone changes its status.

Related field notes

Citations

On the problem:

  1. Magento 2 GitHub Issues: catalog_product_index_price reindexing occasionally resulting in missing products on site. github.com/magento/magento2/issues/35616
  2. How we reduced the Magento 2 price reindex process from hours to minutes, including the products and prices disappearing on frontend symptom. royandre.medium.com
  3. Magento Forums: Products not showing and re-indexing. community.magento.com products not showing and re-indexing

On the solution:

  1. Adobe Commerce: Manage the indexers, including Update by Schedule and indexer:set-mode. experienceleague.adobe.com manage-indexers
  2. Adobe Commerce Admin: Index management. experienceleague.adobe.com index-management
  3. Adobe Commerce developer docs: Indexing, Commerce PHP Extensions. developer.adobe.com commerce indexing

Stuck on a tricky one?

If you have a problem in Magento or Adobe Commerce indexing, catalog, orders, or inventory that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this save you a false alarm?

If this helped you tell a harmless reindex race apart from a real problem, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Magento field notes