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.
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.
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:
- The indexer is set to Update by Schedule, and the cron job that drains the changelog,
indexer_reindex_all_invalidorindexer_update_all_views, is disabled, crashed, or simply not scheduled often enough for the volume of edits. - A previous reindex crashed mid run and left the indexer stuck in a working or invalid status, holding a lock so the next scheduled run cannot proceed cleanly.
- A bulk price import or a catalog price rule touches thousands of SKUs at once, and the changelog backs up faster than cron can drain it.
- Every product save re-triggers a price reindex even for unrelated attribute changes, which multiplies the load on the indexer and makes a slow or stuck cron even more visible.
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.
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.
Build it step by step
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.
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
// 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
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.
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()
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();
}
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.
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"]
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;
}
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.
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"))
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" };
}
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.
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()
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();
}
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.
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.
"""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 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.
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
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
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.
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.
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:
- Product Price Reindexes On Every Product Save. github.com/magento/magento2/issues/30598
- Partial Catalog Product Price Indexing not removing old price data. github.com/magento/magento2/issues/31752
- catalog_product_price index getting stuck. github.com/magento/magento2/issues/36471
On the solution:
- Indexing, Commerce PHP Extensions, Adobe Developer. developer.adobe.com/commerce/php/development/components/indexing
- Manage the indexers, Adobe Commerce Operations. experienceleague.adobe.com commerce-operations manage-indexers
- 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.
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