Skip to content

Diagnostic Indexing

Stale price index shows wrong or old prices

You changed a price in the admin, or a catalog price rule kicked in, and the admin agrees with you. But the storefront still shows the old number. Nothing is broken in the sense of an error message, the site just keeps quoting yesterday's price. Here is why Magento's price index falls behind and a small script that finds the SKUs where admin and storefront disagree.

Python and Node.js Adobe Commerce REST API Safe by default (report only)
A cable network
Photo by Taylor Vick on Unsplash
The short answer

Magento does not compute storefront prices on the fly from the EAV attribute tables. It precomputes them into flat index tables, catalog_product_price and catalog_product_index_price, and the storefront only ever reads from those tables. In Update by Schedule mode, a price edit is recorded as a pending row in a changelog table and only lands in the index tables when the price indexer cron actually runs. If cron is stalled or the indexer is stuck, the storefront keeps serving the last price that was successfully indexed. Run a small Python or Node.js script that pulls the admin price and the store scoped price for the same SKUs, diffs them, and reports the mismatches with the product's updated_at so you know whether it is a pending reindex or something worth a human's time. Full code, tests, and a dry run guard are below.

The problem in plain words

Every product price you see on a Magento or Adobe Commerce storefront comes from a table built ahead of time by the indexer, not from a live read of the price attribute. That table is catalog_product_price, later folded into the store and website specific catalog_product_index_price. The storefront queries that index, never the raw EAV price row.

When you edit a price in the admin, or a catalog price rule applies a new discount, that change lands in the source of truth tables immediately. But under Update by Schedule indexer mode, the change is only recorded as a pending row in a changelog table, a *_cl table, through an mview trigger. It waits there until indexer_reindex_all_invalid or indexer_update_all_views actually runs on cron. If cron is stalled, disabled, or an indexer is stuck in a working or invalid status, perhaps because a crashed prior run is still holding a lock, that changelog just keeps growing. The admin is right. The storefront is stuck on the last price that made it through.

Admin edits price or a rule applies EAV tables updated changelog row queued cron reindex stalled catalog_product _index_price still holds old price Storefront shows old price Storefront never queries EAV directly, it only reads the index tables above.
The admin price is correct the moment you save it. The storefront only catches up once the price indexer actually runs.

Why it happens

The gap is not a bug in any single save, it is a property of how the price indexer is designed to work at scale. A few concrete ways it shows up on real stores:

None of this raises an error anywhere a shopper or a merchant would see. The admin grid shows the new price because the admin grid reads the EAV tables directly. The storefront shows the old price because it reads the index. Two truths, same product, and nothing on screen tells you which one is stale. See the citations at the end for the exact threads that describe this behavior.

The key insight

Reindexing itself is a CLI and cron job, not something a script can safely trigger over REST. So the honest move is not to try to force a fix through the API. It is to detect the mismatch precisely, tell you whether it looks like a normal pending reindex or something stranger, and leave the actual bin/magento indexer:reindex catalog_product_price to an operator or a deployment job that has shell access.

The fix, as a flow

We do not touch the indexer tables directly and we do not run CLI commands from the script. We pull the admin truth price for recently edited SKUs, pull the store scoped price for the same SKUs, and diff them. A mismatch beyond a rounding epsilon gets classified using the product's updated_at against the last known reindex time, then reported. When an operator explicitly opts in, the only REST safe corrective nudge is a no-op re-save of the price attribute, which requeues the SKU in the changelog for the next cron run.

Fetch recent SKUs by updated_at Read admin price and store scoped price Diff the two beyond epsilon 0.01 Edited after last reindex? yes flag_reindex safe, just pending no flag_investigate human review only
The script only ever reports. It tells you whether a mismatch is a pending reindex, safe to hand to the normal reindex job, or an unexplained mismatch that needs a person.

Build it step by step

1

Get an admin token

Authenticate against the admin token endpoint with an admin username and password, or use an integration access token if you already have one. Keep the base URL and credentials in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export STORE_CODE="default"
export SINCE="2026-07-01 00:00:00"
export DRY_RUN="true"   # start safe, change to false only for the no-op re-save nudge
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export STORE_CODE="default"
export SINCE="2026-07-01 00:00:00"
export DRY_RUN="true"   // start safe, change to false only for the no-op re-save nudge
2

Authenticate and list recently edited SKUs

POST to /rest/V1/integration/admin/token with the username and password to get a bearer token. Then use searchCriteria to list products whose updated_at is on or after your window, with the admin scoped price on each one.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")

def get_token(username, password):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/integration/admin/token",
        json={"username": username, "password": password},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def recent_products(token, since, page_size=200, page=1):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "updated_at",
        "searchCriteria[filterGroups][0][filters][0][value]": since,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
        "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()
    return r.json()
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");

async function getToken(username, password) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function recentProducts(token, since, pageSize = 200, page = 1) {
  const params = new URLSearchParams({
    "searchCriteria[filterGroups][0][filters][0][field]": "updated_at",
    "searchCriteria[filterGroups][0][filters][0][value]": since,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
    "searchCriteria[pageSize]": String(pageSize),
    "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}`);
  return res.json();
}
3

Read the store scoped price the index would serve

For each SKU, fetch the store view scoped product, /rest/{storeCode}/V1/products/{sku}, which applies website and store price overrides the same way the price index does. That is your storefront truth for the diff.

step3.py
def storefront_price(token, store_code, sku):
    r = requests.get(
        f"{MAGENTO_URL}/rest/{store_code}/V1/products/{sku}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["price"]
step3.js
async function storefrontPrice(token, storeCode, sku) {
  const res = await fetch(`${MAGENTO_URL}/rest/${storeCode}/V1/products/${sku}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  const body = await res.json();
  return body.price;
}
4

Decide, with one pure function

Keep the decision in its own function that takes only already fetched values, admin price, storefront price, the product's updated_at, and the last known reindex time, and returns a verdict. Since bin/magento indexer:status is CLI only and not REST reachable, lastReindexAt is whatever you already track from your own deploy or cron logs, or null if you do not track it, in which case any mismatch is treated as explained by a pending reindex.

decide.py
from datetime import datetime

def decide_price_index_action(admin_price, storefront_price, updated_at, last_reindex_at, epsilon=0.01):
    diff = abs(admin_price - storefront_price)
    if diff <= epsilon:
        return {"stale": False, "action": "none"}
    edited_after_reindex = (
        last_reindex_at is None
        or _parse(updated_at) > _parse(last_reindex_at)
    )
    if edited_after_reindex:
        return {"stale": True, "action": "flag_reindex"}
    return {"stale": True, "action": "flag_investigate"}


def _parse(value):
    return datetime.fromisoformat(value.replace("Z", "+00:00"))
decide.js
export function decidePriceIndexAction(adminPrice, storefrontPrice, updatedAt, lastReindexAt, epsilon = 0.01) {
  const diff = Math.abs(adminPrice - storefrontPrice);
  if (diff <= epsilon) return { stale: false, action: "none" };
  const editedAfterReindex = lastReindexAt === null || new Date(updatedAt) > new Date(lastReindexAt);
  if (editedAfterReindex) return { stale: true, action: "flag_reindex" };
  return { stale: true, action: "flag_investigate" };
}
5

The only REST safe corrective nudge

Reindexing itself cannot happen over REST. When DRY_RUN is off and an operator has explicitly opted in, the one safe write is a no-op re-save of the price attribute, which enqueues the SKU back into the changelog for the next scheduled or manual reindex to pick up. It does not force an immediate reindex.

nudge.py
def nudge_changelog(token, sku, admin_price):
    r = requests.put(
        f"{MAGENTO_URL}/rest/V1/products/{sku}",
        json={"product": {"sku": sku, "price": admin_price}},
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
nudge.js
async function nudgeChangelog(token, sku, adminPrice) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/products/${encodeURIComponent(sku)}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ product: { sku, price: adminPrice } }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
6

Wire it together with a dry run guard

The loop authenticates once, lists recently edited SKUs, reads the store scoped price for each, runs the pure decision function, and reports. Notice the dry run guard. Leave DRY_RUN on so the script only reports flagged SKUs. When you turn it off, it only performs the no-op re-save nudge for flag_reindex rows, never for flag_investigate rows, since those need a human first.

Run it safe

This script never calls bin/magento indexer:reindex and never touches cron. It reports. Treat flag_reindex as safe to hand to the normal reindex job or cron health check, and treat flag_investigate as a signal that a human should look at the catalog price rule or the product before anything else happens.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever performs the one REST safe corrective action, a no-op price re-save, when explicitly told to.

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.
flag_stale_price_index.py
"""Flag Magento 2 or Adobe Commerce SKUs where the storefront price index is stale.

Magento precomputes storefront prices into catalog_product_price and
catalog_product_index_price. Under Update by Schedule, an admin price edit or
catalog rule change sits as a pending changelog row until the price indexer
cron actually runs. If cron is stalled or an indexer is stuck, the storefront
keeps serving the last indexed price. This script diffs the admin price
against the store scoped price for recently edited SKUs and reports the
mismatches. It never runs a reindex or touches cron: that is CLI and operator
only. Safe to run again and again.
"""
import os
import csv
import logging
import requests

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

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
ADMIN_USERNAME = os.environ.get("MAGENTO_ADMIN_USERNAME")
ADMIN_PASSWORD = os.environ.get("MAGENTO_ADMIN_PASSWORD")
ADMIN_TOKEN = os.environ.get("MAGENTO_ADMIN_TOKEN")
STORE_CODE = os.environ.get("STORE_CODE", "default")
SINCE = os.environ.get("SINCE", "1970-01-01 00:00:00")
LAST_REINDEX_AT = os.environ.get("LAST_REINDEX_AT") or None
PRICE_EPSILON = float(os.environ.get("PRICE_EPSILON", "0.01"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OUTPUT_CSV = os.environ.get("OUTPUT_CSV", "stale_price_index.csv")
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "200"))


def get_token():
    if ADMIN_TOKEN:
        return ADMIN_TOKEN
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/integration/admin/token",
        json={"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def recent_products(token, since, page_size=PAGE_SIZE):
    page = 1
    while True:
        params = {
            "searchCriteria[filterGroups][0][filters][0][field]": "updated_at",
            "searchCriteria[filterGroups][0][filters][0][value]": since,
            "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
            "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", [])
        for item in items:
            yield item
        if len(items) < page_size:
            return
        page += 1


def storefront_price(token, store_code, sku):
    r = requests.get(
        f"{MAGENTO_URL}/rest/{store_code}/V1/products/{sku}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["price"]


def nudge_changelog(token, sku, admin_price):
    r = requests.put(
        f"{MAGENTO_URL}/rest/V1/products/{sku}",
        json={"product": {"sku": sku, "price": admin_price}},
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def _parse(value):
    import datetime
    return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))


def decide_price_index_action(admin_price, storefront_price_value, updated_at, last_reindex_at, epsilon=PRICE_EPSILON):
    diff = abs(admin_price - storefront_price_value)
    if diff <= epsilon:
        return {"stale": False, "action": "none"}
    edited_after_reindex = (
        last_reindex_at is None
        or _parse(updated_at) > _parse(last_reindex_at)
    )
    if edited_after_reindex:
        return {"stale": True, "action": "flag_reindex"}
    return {"stale": True, "action": "flag_investigate"}


def run():
    token = get_token()
    flagged = []
    for product in recent_products(token, SINCE):
        sku = product.get("sku")
        admin_price = product.get("price")
        updated_at = product.get("updated_at")
        if sku is None or admin_price is None:
            continue
        try:
            store_price = storefront_price(token, STORE_CODE, sku)
        except requests.HTTPError as exc:
            log.warning("Could not read storefront price for %s: %s", sku, exc)
            continue
        verdict = decide_price_index_action(admin_price, store_price, updated_at, LAST_REINDEX_AT)
        if not verdict["stale"]:
            continue
        row = {
            "sku": sku,
            "adminPrice": admin_price,
            "storefrontPrice": store_price,
            "diff": round(abs(admin_price - store_price), 2),
            "updated_at": updated_at,
            "action": verdict["action"],
        }
        flagged.append(row)
        log.info(
            "SKU %s: admin=%s storefront=%s diff=%s action=%s",
            row["sku"], row["adminPrice"], row["storefrontPrice"], row["diff"], row["action"],
        )
        if not DRY_RUN and verdict["action"] == "flag_reindex":
            nudge_changelog(token, sku, admin_price)
            log.info("Nudged %s back into the price changelog.", sku)

    if flagged:
        with open(OUTPUT_CSV, "w", newline="") as fh:
            writer = csv.DictWriter(fh, fieldnames=["sku", "adminPrice", "storefrontPrice", "diff", "updated_at", "action"])
            writer.writeheader()
            writer.writerows(flagged)

    log.info("Done. %d SKU(s) flagged, %s.", len(flagged), "dry run, nothing written" if DRY_RUN else "nudge applied where safe")


if __name__ == "__main__":
    run()
flag-stale-price-index.js
/**
 * Flag Magento 2 or Adobe Commerce SKUs where the storefront price index is stale.
 *
 * Magento precomputes storefront prices into catalog_product_price and
 * catalog_product_index_price. Under Update by Schedule, an admin price edit or
 * catalog rule change sits as a pending changelog row until the price indexer
 * cron actually runs. If cron is stalled or an indexer is stuck, the storefront
 * keeps serving the last indexed price. This script diffs the admin price
 * against the store scoped price for recently edited SKUs and reports the
 * mismatches. It never runs a reindex or touches cron: that is CLI and operator
 * only. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/stale-price-index-wrong-prices/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/$/, "");
const ADMIN_USERNAME = process.env.MAGENTO_ADMIN_USERNAME || "admin";
const ADMIN_PASSWORD = process.env.MAGENTO_ADMIN_PASSWORD || "change-me";
const ADMIN_TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "";
const STORE_CODE = process.env.STORE_CODE || "default";
const SINCE = process.env.SINCE || "1970-01-01 00:00:00";
const LAST_REINDEX_AT = process.env.LAST_REINDEX_AT || null;
const PRICE_EPSILON = Number(process.env.PRICE_EPSILON || 0.01);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 200);

export function decidePriceIndexAction(adminPrice, storefrontPrice, updatedAt, lastReindexAt, epsilon = PRICE_EPSILON) {
  const diff = Math.abs(adminPrice - storefrontPrice);
  if (diff <= epsilon) return { stale: false, action: "none" };
  const editedAfterReindex = lastReindexAt === null || new Date(updatedAt) > new Date(lastReindexAt);
  if (editedAfterReindex) return { stale: true, action: "flag_reindex" };
  return { stale: true, action: "flag_investigate" };
}

async function getToken() {
  if (ADMIN_TOKEN) return ADMIN_TOKEN;
  const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function* recentProducts(token, since, pageSize = PAGE_SIZE) {
  let page = 1;
  while (true) {
    const params = new URLSearchParams({
      "searchCriteria[filterGroups][0][filters][0][field]": "updated_at",
      "searchCriteria[filterGroups][0][filters][0][value]": since,
      "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
      "searchCriteria[pageSize]": String(pageSize),
      "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) yield item;
    if (items.length < pageSize) return;
    page++;
  }
}

async function storefrontPrice(token, storeCode, sku) {
  const res = await fetch(`${MAGENTO_URL}/rest/${storeCode}/V1/products/${encodeURIComponent(sku)}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  const body = await res.json();
  return body.price;
}

async function nudgeChangelog(token, sku, adminPrice) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/products/${encodeURIComponent(sku)}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ product: { sku, price: adminPrice } }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

export async function run() {
  const token = await getToken();
  const flagged = [];
  for await (const product of recentProducts(token, SINCE)) {
    const sku = product.sku;
    const adminPrice = product.price;
    const updatedAt = product.updated_at;
    if (sku == null || adminPrice == null) continue;
    let storePrice;
    try {
      storePrice = await storefrontPrice(token, STORE_CODE, sku);
    } catch (err) {
      console.warn(`Could not read storefront price for ${sku}: ${err.message}`);
      continue;
    }
    const verdict = decidePriceIndexAction(adminPrice, storePrice, updatedAt, LAST_REINDEX_AT);
    if (!verdict.stale) continue;
    const row = {
      sku,
      adminPrice,
      storefrontPrice: storePrice,
      diff: Math.round(Math.abs(adminPrice - storePrice) * 100) / 100,
      updated_at: updatedAt,
      action: verdict.action,
    };
    flagged.push(row);
    console.log(`SKU ${row.sku}: admin=${row.adminPrice} storefront=${row.storefrontPrice} diff=${row.diff} action=${row.action}`);
    if (!DRY_RUN && verdict.action === "flag_reindex") {
      await nudgeChangelog(token, sku, adminPrice);
      console.log(`Nudged ${sku} back into the price changelog.`);
    }
  }
  console.log(`Done. ${flagged.length} SKU(s) flagged, ${DRY_RUN ? "dry run, nothing written" : "nudge applied where safe"}.`);
  return flagged;
}

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 decides whether a mismatch is safe to hand to a normal reindex or needs a human first. Since decide_price_index_action and decidePriceIndexAction are pure, the tests need no network and no Magento instance. They just feed in plain values and check the verdict.

test_stale_price_index.py
from flag_stale_price_index import decide_price_index_action


def test_not_stale_within_epsilon():
    result = decide_price_index_action(19.99, 19.98, "2026-07-05T00:00:00Z", "2026-07-01T00:00:00Z")
    assert result == {"stale": False, "action": "none"}


def test_flag_reindex_when_edited_after_last_reindex():
    result = decide_price_index_action(24.00, 19.99, "2026-07-05T00:00:00Z", "2026-07-01T00:00:00Z")
    assert result == {"stale": True, "action": "flag_reindex"}


def test_flag_investigate_when_edited_before_last_reindex():
    result = decide_price_index_action(24.00, 19.99, "2026-06-20T00:00:00Z", "2026-07-01T00:00:00Z")
    assert result == {"stale": True, "action": "flag_investigate"}


def test_flag_reindex_when_no_known_last_reindex():
    result = decide_price_index_action(24.00, 19.99, "2026-06-20T00:00:00Z", None)
    assert result == {"stale": True, "action": "flag_reindex"}


def test_exactly_at_epsilon_is_not_stale():
    result = decide_price_index_action(20.00, 19.995, "2026-07-05T00:00:00Z", "2026-07-01T00:00:00Z", epsilon=0.01)
    assert result == {"stale": False, "action": "none"}


def test_just_over_epsilon_is_stale():
    result = decide_price_index_action(20.02, 20.00, "2026-07-05T00:00:00Z", "2026-07-01T00:00:00Z", epsilon=0.01)
    assert result["stale"] is True
price-index.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decidePriceIndexAction } from "./flag-stale-price-index.js";

test("not stale within epsilon", () => {
  const result = decidePriceIndexAction(19.99, 19.98, "2026-07-05T00:00:00Z", "2026-07-01T00:00:00Z");
  assert.deepEqual(result, { stale: false, action: "none" });
});

test("flag_reindex when edited after last reindex", () => {
  const result = decidePriceIndexAction(24.0, 19.99, "2026-07-05T00:00:00Z", "2026-07-01T00:00:00Z");
  assert.deepEqual(result, { stale: true, action: "flag_reindex" });
});

test("flag_investigate when edited before last reindex", () => {
  const result = decidePriceIndexAction(24.0, 19.99, "2026-06-20T00:00:00Z", "2026-07-01T00:00:00Z");
  assert.deepEqual(result, { stale: true, action: "flag_investigate" });
});

test("flag_reindex when no known last reindex", () => {
  const result = decidePriceIndexAction(24.0, 19.99, "2026-06-20T00:00:00Z", null);
  assert.deepEqual(result, { stale: true, action: "flag_reindex" });
});

test("exactly at epsilon is not stale", () => {
  const result = decidePriceIndexAction(20.0, 19.995, "2026-07-05T00:00:00Z", "2026-07-01T00:00:00Z", 0.01);
  assert.deepEqual(result, { stale: false, action: "none" });
});

test("just over epsilon is stale", () => {
  const result = decidePriceIndexAction(20.02, 20.0, "2026-07-05T00:00:00Z", "2026-07-01T00:00:00Z", 0.01);
  assert.equal(result.stale, true);
});

Case studies

Bulk price update

A seasonal repricing job that outran cron

A home goods store pushed a seasonal price update across 12,000 SKUs through a bulk import. The changelog for the price indexer filled up faster than the scheduled cron could drain it, and for most of a day the storefront kept quoting the previous season's prices while the admin grid showed the new ones.

Running the script against SKUs updated in the last 24 hours turned up the exact list, all classified flag_reindex because every edit happened after the last known reindex. The operator ran bin/magento indexer:reindex catalog_product_price once by hand and the mismatch disappeared for the whole batch.

Stuck indexer

A crashed deploy left the price indexer locked

A deployment was interrupted mid reindex, leaving the price indexer stuck in a working status that blocked the next scheduled run entirely. Days later, a handful of clearance items were still showing their pre sale price on the storefront even though the admin had moved on.

The script flagged those SKUs, and because their updated_at was well before the last time anyone remembered a successful reindex, several came back as flag_investigate instead of the usual flag_reindex. That distinction was the tell that the indexer itself, not just a slow cron, needed a look.

What good looks like

After running this on a schedule, stale prices stop being a mystery. You get a short, dated list of exactly which SKUs disagree between admin and storefront, and each one already tells you whether it is a normal pending reindex or something that needs a person to look at a catalog rule or the indexer's health. The actual reindex still belongs to an operator with CLI access, but they now know exactly what to run and why.

FAQ

Why does my Magento storefront still show the old price after I changed it in the admin?

Magento never computes storefront prices live. It precomputes them into index tables such as catalog_product_price and catalog_product_index_price, and the storefront only reads from those tables. In Update by Schedule mode your edit sits as a pending row in a changelog table until the price indexer cron actually runs. If cron is stalled or the indexer is stuck, the storefront keeps serving the last price that was successfully indexed.

Can a script fix a stale Magento price index through the REST API?

Not directly. Reindexing is a CLI and cron operation, bin/magento indexer:reindex catalog_product_price, and it is not exposed over REST. A script can detect the mismatch by comparing the admin price to the store scoped price, and it can nudge a single SKU into the changelog with a no-op price re-save over the Products REST endpoint, but only an operator or a deployment job can run the actual reindex or fix cron.

How do I tell a stale index apart from a genuine pricing rule mistake?

Compare the product's updated_at timestamp to the last known reindex time. If the product was edited after the last reindex, the mismatch is most likely just a pending reindex and is safe to flag for a normal catalog_product_price reindex. If the edit happened before the last reindex and the prices still disagree, that is not a stale index, it is an unexplained mismatch such as a misconfigured catalog rule, and it should be flagged for a human to investigate rather than auto corrected.

Related field notes

Citations

On the problem:

  1. Product Price Reindexes On Every Product Save. github.com/magento/magento2/issues/30598
  2. Partial Catalog Product Price Indexing not removing old price data. github.com/magento/magento2/issues/31752
  3. catalog_product_price index getting stuck. github.com/magento/magento2/issues/36471

On the solution:

  1. Indexing, Commerce PHP Extensions, Adobe Developer. developer.adobe.com/commerce/php/development/components/indexing
  2. Manage the indexers, Adobe Commerce Operations. experienceleague.adobe.com commerce-operations manage-indexers
  3. Products endpoint, Adobe Commerce Web API, searchCriteria and product PUT. developer.adobe.com/commerce/webapi/rest/quick-reference/search-criteria

Stuck on a tricky one?

If you have a problem in Magento indexing, cron, MSI stock, or order grid 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 untangle your price index?

If this saved you a confusing support ticket about a wrong price, 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