Diagnostic Inventory (MSI)
Listing page and product page disagree on stock status
A customer emails a screenshot: the category grid clearly says In Stock, but the product page they clicked through to says Out of Stock and will not let them add it to the cart. Nobody edited the product. Nothing is corrupted. A sale, or even just a pending unpaid order, quietly zeroed out the real time salable quantity the moment it was placed, while the grid keeps showing whatever the stock status index last calculated. Here is why those two numbers can disagree and a small script that finds every SKU where they do.
The category grid renders from the cataloginventory_stock_status flat index, rebuilt by the Category Products or Product indexers, usually on a schedule or cron. The product detail page and the add to cart flow instead call the live InventorySalesApi, specifically GetProductSalableQtyInterface and IsProductSalableInterface, which net source item quantities against active order reservations in real time. A sale, or a pending unshipped order, creates a reservation that zeroes the real time salable quantity immediately, but that does not synchronously update the stock status index, so the grid still says In Stock until the next reindex while the product page correctly blocks the purchase. A script can detect this by comparing is_in_stock from /V1/products against the live value from /V1/inventory/get-product-salable-quantity for the same SKU. Full code, tests, and a dry run guard are below.
The problem in plain words
Magento 2 and Adobe Commerce keep two different answers to the question "is this in stock," and they are computed by two different systems on two different schedules.
The category grid is built for speed. It reads from cataloginventory_stock_status, a flat table that stores a precomputed in stock flag and quantity per product per stock. That table is only as fresh as the last time the Category Products indexer or Product indexer ran, which on most stores is Update on Schedule, meaning a cron job catches up on a timer rather than the instant something changes.
The product page and the add to cart flow do not use that table at all. They call the MSI InventorySalesApi, which computes salable quantity live, on every request, by taking the source item quantities for a SKU and subtracting every active reservation against it. A reservation is created the moment an order is placed, even before it is paid or shipped, so the real time number can drop to zero within the same second a checkout completes. The stock status index has no way to know that happened until it is rebuilt.
Why it happens
- A source item quantity is fully reserved by a new order, dropping the live salable quantity to zero or below the moment checkout completes, well before that order is invoiced or shipped.
- The
cataloginventory_stock_statustable is maintained by the Category Products and Product indexers, which on most stores run in Update on Schedule mode, so the grid only reflects reality after the next cron tick. - A store that switched some indexers to Update on Save while leaving others on schedule sees the mismatch more often, since the two systems drift out of sync at different rates.
- Cancelled or expired reservations can also lag the other direction: a restock clears, live salable quantity goes positive, but the grid keeps reporting Out of Stock until it is reindexed too.
This is a long standing, widely reported MSI behavior rather than a one off bug in a single store. See the citations at the end for the exact GitHub issues and forum threads describing the same grid versus product page disagreement.
The two numbers are not lying to each other, they are answering different questions on different clocks. The grid answers "what did the last reindex compute," and the product page answers "what is salable right now, net of every reservation." A script cannot make the grid instantly accurate without triggering the same reindex Magento already schedules, so the safe move is to detect the disagreement, specifically is_in_stock == true on the indexed side while live salable quantity is zero or less, and report it, rather than guessing at a rewrite.
The fix, as a flow
We do not touch the live checkout or force product state. We add a job that reads both sides for the same SKUs, the indexed is_in_stock flag from /V1/products and the live salable quantity from /V1/inventory/get-product-salable-quantity, classifies the pair with one pure function, and reports every mismatch with its severity. Only under an explicit opt in does it also correct the one safe, reversible field over REST.
Build it step by step
Get an admin bearer token
The script authenticates like any other Magento REST client. Either call POST /rest/V1/integration/admin/token with an admin username and password, or create an integration and use its token directly. Keep the store URL and token in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export STOCK_ID="1"
export MIN_QTY_THRESHOLD="0"
export DRY_RUN="true" # start safe, change to false to allow the correction path
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export STOCK_ID="1"
export MIN_QTY_THRESHOLD="0"
export DRY_RUN="true" // start safe, change to false to allow the correction path
Talk to the Magento REST API
Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps GET and PUT and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def magento_put(path, payload):
r = requests.put(
f"{MAGENTO_URL}/rest/V1{path}",
json=payload,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function magentoPut(path, payload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
Read the grid side and the live side for the same SKUs
For each candidate SKU, read /V1/products filtered by SKU with an in condition to get extension_attributes.stock_item.is_in_stock and quantity, which mirrors what the grid's stock status index reflects. Then read /V1/inventory/get-product-salable-quantity/{sku}/{stockId} for the live salable quantity, and /V1/inventory/source-items filtered by SKU to corroborate the per source picture.
def products_by_sku(skus):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": ",".join(skus),
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[pageSize]": 200,
}
return magento_get("/products", params)["items"]
def salable_quantity(sku, stock_id):
data = magento_get(f"/inventory/get-product-salable-quantity/{sku}/{stock_id}")
return data if isinstance(data, (int, float)) else data.get("quantity", 0)
def source_items_for_sku(sku):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
}
return magento_get("/inventory/source-items", params)["items"]
async function productsBySku(skus) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": skus.join(","),
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[pageSize]": 200,
};
const data = await magentoGet("/products", params);
return data.items;
}
async function salableQuantity(sku, stockId) {
const data = await magentoGet(`/inventory/get-product-salable-quantity/${sku}/${stockId}`);
return typeof data === "number" ? data : data.quantity || 0;
}
async function sourceItemsForSku(sku) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
};
const data = await magentoGet("/inventory/source-items", params);
return data.items;
}
Decide, with one pure function
Keep the decision in its own function that takes the grid side flag and quantity plus the live salable quantity and returns whether it is a mismatch and how severe. A pure function like this is easy to read and easy to test, which we do later. When the live salable quantity is positive and the grid agrees it is in stock, that is consistent. When the live salable quantity is zero or less but the grid says in stock, that is the exact defect this script targets, and it is critical if the grid also shows a positive quantity, since that means the grid will let a shopper add to cart something the live check will immediately refuse. The mirror case, a restock the grid has not caught up to yet, is flagged too, at a lower severity.
def diagnose_stock_mismatch(sku, grid_in_stock, grid_qty, salable_qty, min_qty_threshold=0):
if salable_qty > min_qty_threshold and grid_in_stock:
return {"mismatched": False, "severity": "none", "reason": "consistent, both in stock"}
if salable_qty <= min_qty_threshold and grid_in_stock:
severity = "critical" if grid_qty > 0 else "stale_index"
return {
"mismatched": True,
"severity": severity,
"reason": "grid reports in-stock while live salable quantity is zero or "
"negative, stale stock_status index vs real-time reservation",
}
if salable_qty <= min_qty_threshold and not grid_in_stock:
return {"mismatched": False, "severity": "none", "reason": "both correctly out of stock"}
return {
"mismatched": True,
"severity": "stale_index",
"reason": "grid still reports out-of-stock after restock, index lag in the other direction",
}
export function diagnoseStockMismatch(sku, gridInStock, gridQty, salableQty, minQtyThreshold = 0) {
if (salableQty > minQtyThreshold && gridInStock) {
return { mismatched: false, severity: "none", reason: "consistent, both in stock" };
}
if (salableQty <= minQtyThreshold && gridInStock) {
const severity = gridQty > 0 ? "critical" : "stale_index";
return {
mismatched: true,
severity,
reason:
"grid reports in-stock while live salable quantity is zero or negative, " +
"stale stock_status index vs real-time reservation",
};
}
if (salableQty <= minQtyThreshold && !gridInStock) {
return { mismatched: false, severity: "none", reason: "both correctly out of stock" };
}
return {
mismatched: true,
severity: "stale_index",
reason: "grid still reports out-of-stock after restock, index lag in the other direction",
};
}
Report by default, correct only when gated
The default output is a structured record per mismatched SKU: the SKU, is_in_stock, the grid quantity, the live salable quantity, the stock id, and a timestamp, for an operator to act on or to hand to a reindex job. This is index staleness, not corrupt data, so the correct fix is bin/magento indexer:reindex cataloginventory_stock or cataloginventory_category_flat, a CLI concern outside a REST only token. The one safe, reversible REST correction, forcing stock_item.is_in_stock to false when salable quantity is zero or less and the grid says true, only runs when DRY_RUN is explicitly false, and it logs the prior value so it can be rolled back.
Always start with DRY_RUN=true. The real fix for this defect is a reindex, not a REST write, so treat the report as the thing to hand to whoever runs bin/magento indexer:reindex. Only flip DRY_RUN=false if you specifically want the narrow, logged is_in_stock correction as a stopgap.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, compares the grid side against the live side for each SKU, respects the dry run flag, and is safe to run again and again because by default it only reports.
"""Flag Magento 2 SKUs where the category grid and the product page disagree
on stock status, safely.
The grid renders from the cataloginventory_stock_status index, rebuilt by the
Category Products or Product indexers, typically on schedule or cron. The
product page and add to cart flow instead call the live InventorySalesApi
(GetProductSalableQtyInterface, IsProductSalableInterface), which nets source
item quantities against active reservations in real time. A sale or a
pending order zeroes the live salable quantity instantly, but the index only
catches up on the next reindex. This reports the mismatch by default and only
gates a narrow, reversible is_in_stock correction behind DRY_RUN=false. Run on
a schedule. Safe to run again and again.
"""
import os
import logging
import datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("diagnose_stock_mismatch")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
STOCK_ID = os.environ.get("STOCK_ID", "1")
MIN_QTY_THRESHOLD = float(os.environ.get("MIN_QTY_THRESHOLD", "0"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def magento_put(path, payload):
r = requests.put(
f"{MAGENTO_URL}/rest/V1{path}",
json=payload,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
def diagnose_stock_mismatch(sku, grid_in_stock, grid_qty, salable_qty, min_qty_threshold=0):
if salable_qty > min_qty_threshold and grid_in_stock:
return {"mismatched": False, "severity": "none", "reason": "consistent, both in stock"}
if salable_qty <= min_qty_threshold and grid_in_stock:
severity = "critical" if grid_qty > 0 else "stale_index"
return {
"mismatched": True,
"severity": severity,
"reason": "grid reports in-stock while live salable quantity is zero or "
"negative, stale stock_status index vs real-time reservation",
}
if salable_qty <= min_qty_threshold and not grid_in_stock:
return {"mismatched": False, "severity": "none", "reason": "both correctly out of stock"}
return {
"mismatched": True,
"severity": "stale_index",
"reason": "grid still reports out-of-stock after restock, index lag in the other direction",
}
def products_by_sku(skus):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": ",".join(skus),
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[pageSize]": 200,
}
return magento_get("/products", params)["items"]
def salable_quantity(sku, stock_id):
data = magento_get(f"/inventory/get-product-salable-quantity/{sku}/{stock_id}")
return data if isinstance(data, (int, float)) else data.get("quantity", 0)
def source_items_for_sku(sku):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
}
return magento_get("/inventory/source-items", params)["items"]
def force_out_of_stock(sku, prior_is_in_stock):
payload = {
"product": {
"sku": sku,
"extension_attributes": {
"stock_item": {"is_in_stock": False}
},
}
}
log.info("Correcting %s: is_in_stock %s -> false", sku, prior_is_in_stock)
return magento_put(f"/products/{sku}", payload)
def run(skus):
if not skus:
log.warning("No SKUs supplied. Nothing to check, exiting.")
return
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
flagged = 0
for product in products_by_sku(skus):
sku = product["sku"]
stock_item = (product.get("extension_attributes") or {}).get("stock_item") or {}
grid_in_stock = bool(stock_item.get("is_in_stock"))
grid_qty = float(stock_item.get("qty", stock_item.get("quantity", 0)) or 0)
salable = salable_quantity(sku, STOCK_ID)
result = diagnose_stock_mismatch(sku, grid_in_stock, grid_qty, salable, MIN_QTY_THRESHOLD)
if not result["mismatched"]:
continue
flagged += 1
log.warning(
"sku=%s is_in_stock=%s grid_qty=%s salable_qty=%s stock_id=%s severity=%s timestamp=%s reason=%s",
sku, grid_in_stock, grid_qty, salable, STOCK_ID, result["severity"], now, result["reason"],
)
if result["severity"] == "critical" and not DRY_RUN:
force_out_of_stock(sku, grid_in_stock)
log.info("Done. %d SKU(s) flagged.", flagged)
if __name__ == "__main__":
run(os.environ.get("CHECK_SKUS", "").split(",") if os.environ.get("CHECK_SKUS") else [])
/**
* Flag Magento 2 SKUs where the category grid and the product page disagree
* on stock status, safely.
*
* The grid renders from the cataloginventory_stock_status index, rebuilt by
* the Category Products or Product indexers, typically on schedule or cron.
* The product page and add to cart flow instead call the live
* InventorySalesApi (GetProductSalableQtyInterface, IsProductSalableInterface),
* which nets source item quantities against active reservations in real
* time. A sale or a pending order zeroes the live salable quantity
* instantly, but the index only catches up on the next reindex. This
* reports the mismatch by default and only gates a narrow, reversible
* is_in_stock correction behind DRY_RUN=false. Run on a schedule. Safe to
* run again and again.
*
* Guide: https://www.allanninal.dev/magento/listing-vs-detail-stock-status-mismatch/
*/
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 STOCK_ID = process.env.STOCK_ID || "1";
const MIN_QTY_THRESHOLD = Number(process.env.MIN_QTY_THRESHOLD || 0);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function diagnoseStockMismatch(sku, gridInStock, gridQty, salableQty, minQtyThreshold = 0) {
if (salableQty > minQtyThreshold && gridInStock) {
return { mismatched: false, severity: "none", reason: "consistent, both in stock" };
}
if (salableQty <= minQtyThreshold && gridInStock) {
const severity = gridQty > 0 ? "critical" : "stale_index";
return {
mismatched: true,
severity,
reason:
"grid reports in-stock while live salable quantity is zero or negative, " +
"stale stock_status index vs real-time reservation",
};
}
if (salableQty <= minQtyThreshold && !gridInStock) {
return { mismatched: false, severity: "none", reason: "both correctly out of stock" };
}
return {
mismatched: true,
severity: "stale_index",
reason: "grid still reports out-of-stock after restock, index lag in the other direction",
};
}
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function magentoPut(path, payload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function productsBySku(skus) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": skus.join(","),
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[pageSize]": 200,
};
const data = await magentoGet("/products", params);
return data.items;
}
async function salableQuantity(sku, stockId) {
const data = await magentoGet(`/inventory/get-product-salable-quantity/${sku}/${stockId}`);
return typeof data === "number" ? data : data.quantity || 0;
}
async function sourceItemsForSku(sku) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": sku,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
};
const data = await magentoGet("/inventory/source-items", params);
return data.items;
}
async function forceOutOfStock(sku, priorIsInStock) {
const payload = {
product: {
sku,
extension_attributes: { stock_item: { is_in_stock: false } },
},
};
console.log(`Correcting ${sku}: is_in_stock ${priorIsInStock} -> false`);
return magentoPut(`/products/${sku}`, payload);
}
export async function run(skus = []) {
if (!skus.length) {
console.warn("No SKUs supplied. Nothing to check, exiting.");
return;
}
const now = new Date().toISOString();
let flagged = 0;
const products = await productsBySku(skus);
for (const product of products) {
const sku = product.sku;
const stockItem = product.extension_attributes?.stock_item || {};
const gridInStock = Boolean(stockItem.is_in_stock);
const gridQty = Number(stockItem.qty ?? stockItem.quantity ?? 0);
const salable = await salableQuantity(sku, STOCK_ID);
const result = diagnoseStockMismatch(sku, gridInStock, gridQty, salable, MIN_QTY_THRESHOLD);
if (!result.mismatched) continue;
flagged++;
console.warn(
`sku=${sku} is_in_stock=${gridInStock} grid_qty=${gridQty} salable_qty=${salable} stock_id=${STOCK_ID} severity=${result.severity} timestamp=${now} reason=${result.reason}`
);
if (result.severity === "critical" && !DRY_RUN) {
await forceOutOfStock(sku, gridInStock);
}
}
console.log(`Done. ${flagged} SKU(s) flagged.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const skus = (process.env.CHECK_SKUS || "").split(",").filter(Boolean);
run(skus).catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The classification rule is the part most worth testing, because it decides whether a SKU gets flagged, and at what severity, or left alone. Because we kept diagnose_stock_mismatch pure, the test needs no network and no Magento store. It just feeds in plain values and checks the answer.
from diagnose_stock_mismatch import diagnose_stock_mismatch
def test_consistent_when_grid_in_stock_and_salable_positive():
result = diagnose_stock_mismatch("SKU1", True, 10, 5)
assert result["mismatched"] is False
assert result["severity"] == "none"
def test_consistent_when_grid_out_of_stock_and_salable_zero():
result = diagnose_stock_mismatch("SKU2", False, 0, 0)
assert result["mismatched"] is False
assert result["severity"] == "none"
def test_critical_when_grid_in_stock_positive_qty_but_salable_zero():
result = diagnose_stock_mismatch("SKU3", True, 8, 0)
assert result["mismatched"] is True
assert result["severity"] == "critical"
def test_stale_index_when_grid_in_stock_zero_qty_and_salable_zero():
result = diagnose_stock_mismatch("SKU4", True, 0, 0)
assert result["mismatched"] is True
assert result["severity"] == "stale_index"
def test_stale_index_when_grid_out_of_stock_after_restock():
result = diagnose_stock_mismatch("SKU5", False, 0, 12)
assert result["mismatched"] is True
assert result["severity"] == "stale_index"
def test_negative_salable_quantity_is_still_a_mismatch():
result = diagnose_stock_mismatch("SKU6", True, 3, -2)
assert result["mismatched"] is True
assert result["severity"] == "critical"
def test_respects_custom_min_qty_threshold():
result = diagnose_stock_mismatch("SKU7", True, 5, 2, min_qty_threshold=3)
assert result["mismatched"] is True
assert result["severity"] == "critical"
import { test } from "node:test";
import assert from "node:assert/strict";
import { diagnoseStockMismatch } from "./diagnose-stock-mismatch.js";
test("consistent when grid in stock and salable positive", () => {
const result = diagnoseStockMismatch("SKU1", true, 10, 5);
assert.equal(result.mismatched, false);
assert.equal(result.severity, "none");
});
test("consistent when grid out of stock and salable zero", () => {
const result = diagnoseStockMismatch("SKU2", false, 0, 0);
assert.equal(result.mismatched, false);
assert.equal(result.severity, "none");
});
test("critical when grid in stock positive qty but salable zero", () => {
const result = diagnoseStockMismatch("SKU3", true, 8, 0);
assert.equal(result.mismatched, true);
assert.equal(result.severity, "critical");
});
test("stale index when grid in stock zero qty and salable zero", () => {
const result = diagnoseStockMismatch("SKU4", true, 0, 0);
assert.equal(result.mismatched, true);
assert.equal(result.severity, "stale_index");
});
test("stale index when grid out of stock after restock", () => {
const result = diagnoseStockMismatch("SKU5", false, 0, 12);
assert.equal(result.mismatched, true);
assert.equal(result.severity, "stale_index");
});
test("negative salable quantity is still a mismatch", () => {
const result = diagnoseStockMismatch("SKU6", true, 3, -2);
assert.equal(result.mismatched, true);
assert.equal(result.severity, "critical");
});
test("respects custom min qty threshold", () => {
const result = diagnoseStockMismatch("SKU7", true, 5, 2, 3);
assert.equal(result.mismatched, true);
assert.equal(result.severity, "critical");
});
Case studies
The drop that oversold on the grid
A streetwear store ran a limited drop of 40 units. Within two minutes every unit was reserved by paid and unpaid orders alike, so the live salable quantity hit zero almost instantly. The category grid, driven by an hourly cron reindex, kept showing In Stock for another forty minutes, and dozens of shoppers clicked through only to hit a blocked add to cart button on the product page.
Running the detection script every five minutes during the drop caught the mismatch within the first cycle. The team used the report to trigger an on demand bin/magento indexer:reindex cataloginventory_stock instead of waiting for the next scheduled tick, closing the gap from forty minutes to under five.
The refill that the grid ignored
A supplements brand replenished a popular SKU after a stockout. Source item quantities updated immediately and the live salable quantity went positive right away, but the category grid, still built from a stale index, kept the product hidden as Out of Stock for hours, quietly losing sales the restock was meant to capture.
Adding this check surfaced the SKU as a stale_index mismatch in the other direction, distinct from the critical oversell case, and the report made it obvious that a reindex, not a data fix, was all that was needed.
After this runs on a schedule, a grid and product page disagreement is caught within one detection cycle instead of surviving until the next scheduled reindex. The report carries the SKU, both stock signals, the severity, and a timestamp, so whoever responds can trigger the right reindex fast, or apply the narrow logged correction if that is genuinely the right call. Keep the real fix a reindex, since that is what keeps both systems telling the same story.
FAQ
Why does the category page say In Stock while the product page says Out of Stock?
The category grid renders from the cataloginventory_stock_status index, which is rebuilt by the product and category indexers, typically on a schedule. The product page and add to cart flow instead call the live InventorySalesApi to compute salable quantity in real time, netting source item quantities against active reservations. A sale or a pending order creates a reservation that zeroes the real time salable quantity immediately, but the stock_status index only catches up on the next reindex, so the grid still says In Stock until then.
Is a mismatch between grid stock status and salable quantity a data corruption bug?
No, it is index staleness, not corrupted data. The underlying source item quantities and reservations are correct and the live salable quantity calculation is accurate. The stock_status index simply has not been rebuilt since the reservation was created. The fix is to reindex, not to rewrite product data.
Can a script safely fix this mismatch over the REST API?
The correct fix is triggering a reindex, such as bin/magento indexer:reindex cataloginventory_stock, which is a CLI concern outside a REST only token. A script can detect the mismatch safely by comparing is_in_stock against the live salable quantity and report affected SKUs. Only under an explicit DRY_RUN=false opt in should it write a stock_item update, and only as a reversible, logged correction, never as the default behavior.
Related field notes
Citations
On the problem:
- GitHub Issue: product with zero salable quantity shows in stock on the list page and out of stock on the view page. github.com/magento/inventory/issues/3062
- GitHub Issue: product shows in stock even when salable quantity is 0. github.com/magento/magento2/issues/31117
- Magento Forums: displaying Out of Stock when salable quantity is 0. community.magento.com display out of stock when salable quantity is 0
On the solution:
- Adobe Commerce: check salable quantities with the Inventory REST API. developer.adobe.com/commerce/webapi/rest/inventory/check-salable-quantity
- Adobe Commerce: Inventory Management API reference. developer.adobe.com/commerce/php/development/components/web-api/inventory-management
- Magento inventory wiki: salable quantity calculation and the mechanism of reservations. github.com/magento/inventory/wiki/Salable-Quantity-Calculation-and-Mechanism-of-Reservations
Stuck on a tricky one?
If you have a problem in Magento 2 or Adobe Commerce inventory, catalog data, orders, or indexing 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 clear your stock status confusion?
If this saved you a confusing support ticket or an oversold flash sale, 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