Skip to content

Diagnostic Inventory (MSI)

Product shows in stock while salable quantity is zero

The product detail page says In Stock, the Add to Cart button is live, and a shopper can drop it in their cart. But every unit is already spoken for. Salable quantity has been at zero for a while, and the shopper will only find out at checkout when Magento rejects the order. Here is why the in stock flag and the salable quantity number tell two different stories in MSI, and a small script that finds every SKU where they disagree.

Python and Node.js Inventory REST API Safe by default (report first)
A worker with a tablet in a warehouse
Photo by Rodrigo Rodrigues on Unsplash
The short answer

In Magento 2 and Adobe Commerce MSI, is_in_stock on the stock item is a slow-changing boolean that the cataloginventory and legacy stock indexers refresh, while salable quantity is computed on demand as the sum of source_items quantity minus active order and cart reservations. Checkout writes a reservation the moment an order is placed, but nothing in that same step forces is_in_stock to flip, so a product can sit at salable quantity zero while the flag still says true until a cron run or reindex catches up. A script cannot safely rewrite that flag blind, since the mismatch could be a pending reindex, backorders, or unmanaged stock. It can, however, pull every enabled SKU, compare is_in_stock against get-product-salable-quantity, and report the exact records that disagree. Full code, tests, and a dry run guard are below.

The problem in plain words

MSI split inventory into two systems that used to be one number. The stock item still carries an is_in_stock flag, the same boolean legacy Magento always had, and it drives the storefront's In Stock or Out of Stock label along with whether the Add to Cart button renders at all. Salable quantity is the newer, more accurate number. It is computed on the fly from how many units actually sit in your sources minus every reservation currently held against them, including orders that are mid-checkout and carts that have not converted yet.

Those two numbers are supposed to agree, and most of the time they do. But they are updated on different schedules. A reservation is written synchronously the instant an order is placed, so salable quantity can hit zero within the same request. The is_in_stock flag does not get that same synchronous treatment. It is refreshed by the cataloginventory and legacy stock indexers, and by out of stock threshold logic that runs on its own cadence. Between the reservation landing and the next reindex or qty-changing event, the flag keeps reporting true. The storefront, the product listing, and the raw REST response all agree the product is buyable, while InventorySalesApi already knows there is nothing left to sell.

Checkout completes reservation inserted Salable qty = 0 computed instantly is_in_stock not recomputed Flag stays true until cron or reindex Storefront still sells it Shopper adds phantom stock, rejected at checkout
Salable quantity is exact the moment a reservation is written. The is_in_stock flag is not, so it can keep reporting true after there is nothing left to sell.

Why it happens

This is a long-documented gap in MSI. Magento's own issue trackers describe products that show in stock even though salable quantity is already zero, and list pages disagreeing with detail pages on the very same SKU. See the citations at the end for the exact threads.

The key insight

This is a data consistency symptom of an indexer and reservation race, not something to patch by silently rewriting the stock flag. Flipping is_in_stock to false without knowing whether new stock is incoming, whether backorders are allowed, or whether a reindex is simply pending would itself misrepresent inventory. So the safe move is to detect and report every SKU where the flag says buyable and the salable quantity says otherwise, and leave the decision to a human, with a guarded, explicit override for the confirmed cases.

The fix, as a flow

We do not touch live stock data by default. We add a job that lists enabled products, reads each one's is_in_stock flag alongside its live salable quantity, and classifies it as a phantom in stock mismatch only when the flag is true, stock is managed, backorders are off, and salable quantity is at or below zero. Everything else is left alone and reported as fine.

Scheduled job runs on a timer List enabled products GET /V1/products Read flag and salable get-product-salable-quantity Phantom in stock? yes no, leave alone Report or correct operator reviews or overrides
The script only reports or corrects a SKU once it is a confirmed phantom in stock case. Everything with real salable stock, backorders on, or unmanaged stock is left alone.

Build it step by step

1

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.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export STOCK_ID="1"
export PAGE_SIZE="100"
export DRY_RUN="true"   # start safe, change to false to allow the repair path
setup (shell)
// 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 PAGE_SIZE="100"
export DRY_RUN="true"   // start safe, change to false to allow the repair path
2

Talk to the Magento REST API

Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps the request and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.

step2.py
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()
step2.js
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();
}
3

List enabled products and read the stock item

Page through GET /rest/V1/products filtering on status equal to 1. Each item carries extension_attributes.stock_item, which holds is_in_stock, manage_stock, backorders, and the stock_id the salable quantity call needs.

step3.py
def enabled_products(page_size, current_page):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "status",
        "searchCriteria[filterGroups][0][filters][0][value]": "1",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    return magento_get("/products", params)["items"]


def stock_item_of(product):
    return (product.get("extension_attributes") or {}).get("stock_item") or {}
step3.js
async function enabledProducts(pageSize, currentPage) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "status",
    "searchCriteria[filterGroups][0][filters][0][value]": "1",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": currentPage,
  };
  const data = await magentoGet("/products", params);
  return data.items;
}

function stockItemOf(product) {
  return product.extension_attributes?.stock_item || {};
}
4

Read the live salable quantity

For each SKU, call GET /rest/V1/inventory/get-product-salable-quantity/{sku}/{stockId} and read the numeric quantity in the response body. Optionally cross check with GET /rest/V1/inventory/is-product-salable/{sku}/{stockId}, which should return false whenever the quantity is at or below zero, and with GET /rest/V1/inventory/source-items for the total physical quantity across sources.

step4.py
def salable_quantity(sku, stock_id):
    return magento_get(f"/inventory/get-product-salable-quantity/{sku}/{stock_id}")


def is_product_salable(sku, stock_id):
    return magento_get(f"/inventory/is-product-salable/{sku}/{stock_id}")


def source_items_for(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"]
step4.js
async function salableQuantity(sku, stockId) {
  return magentoGet(`/inventory/get-product-salable-quantity/${sku}/${stockId}`);
}

async function isProductSalable(sku, stockId) {
  return magentoGet(`/inventory/is-product-salable/${sku}/${stockId}`);
}

async function sourceItemsFor(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;
}
5

Decide, with one pure function

Keep the decision in its own function that takes the stock item, the salable quantity, and whether backorders are allowed, and returns true or false. A pure function like this is easy to read and easy to test, which we do later. It only calls a product phantom in stock when the flag is true, stock is managed, salable quantity is at or below zero, and backorders are off. Unmanaged stock and backorders both make a low or negative salable quantity expected and valid, so those are never flagged.

decide.py
def is_phantom_in_stock(stock_item, salable_qty, backorders_allowed):
    if not stock_item.get("is_in_stock"):
        return False
    if not stock_item.get("manage_stock"):
        return False
    if backorders_allowed:
        return False
    return salable_qty <= 0
decide.js
export function isPhantomInStock(stockItem, salableQty, backordersAllowed) {
  if (!stockItem.is_in_stock) return false;
  if (!stockItem.manage_stock) return false;
  if (backordersAllowed) return false;
  return salableQty <= 0;
}
6

Report by default, correct only when gated

The default output is a report row per mismatch: sku, stock_id, is_in_stock, salable_qty, and the source items total, for merchandising or ops to review. Only under an explicit DRY_RUN=false operator override does the script send a guarded PUT /rest/V1/products/{sku} with extension_attributes.stock_item.is_in_stock set to false for confirmed zero-salable-qty items. It never sets the flag to true automatically, and it logs a reminder that bin/magento indexer:reindex cataloginventory_stock inventory or bin/magento cron:run is the CLI step that normally reconciles the flag going forward.

Run it safe

Always start with DRY_RUN=true. The correction path only ever writes is_in_stock=false, and only for SKUs the script itself confirmed at zero salable quantity, never the reverse. Treat the report as a lead for merchandising or ops, not an automatic silent rewrite.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through enabled products, compares the flag against live salable quantity, respects the dry run flag, and is safe to run again and again because by default it only reports.

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_phantom_in_stock.py
"""Flag Magento 2 products where is_in_stock disagrees with zero salable quantity.

MSI keeps is_in_stock as a slow-changing flag refreshed by the cataloginventory
and legacy stock indexers, while salable quantity is computed on demand from
source_items minus active reservations. A checkout reservation lands
synchronously, so salable quantity can hit zero immediately while is_in_stock
keeps reporting true until a cron run or reindex catches up. This is a data
consistency symptom, not something safe to silently rewrite, so it reports by
default and only gates a real correction behind DRY_RUN=false. Run on a
schedule. Safe to run again and again.
"""
import os
import logging
import requests

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

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
STOCK_ID = os.environ.get("STOCK_ID", "1")
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "100"))
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, body):
    r = requests.put(
        f"{MAGENTO_URL}/rest/V1{path}",
        json=body,
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def is_phantom_in_stock(stock_item, salable_qty, backorders_allowed):
    if not stock_item.get("is_in_stock"):
        return False
    if not stock_item.get("manage_stock"):
        return False
    if backorders_allowed:
        return False
    return salable_qty <= 0


def stock_item_of(product):
    return (product.get("extension_attributes") or {}).get("stock_item") or {}


def enabled_products(page_size, current_page):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "status",
        "searchCriteria[filterGroups][0][filters][0][value]": "1",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    return magento_get("/products", params)["items"]


def all_enabled_products(page_size):
    page = 1
    while True:
        items = enabled_products(page_size, page)
        if not items:
            return
        for item in items:
            yield item
        if len(items) < page_size:
            return
        page += 1


def salable_quantity(sku, stock_id):
    return magento_get(f"/inventory/get-product-salable-quantity/{sku}/{stock_id}")


def source_items_total(sku):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "sku",
        "searchCriteria[filterGroups][0][filters][0][value]": sku,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    items = magento_get("/inventory/source-items", params)["items"]
    return sum(item.get("quantity", 0) for item in items)


def correct_flag(sku):
    body = {"product": {"sku": sku, "extension_attributes": {"stock_item": {"is_in_stock": False}}}}
    return magento_put(f"/products/{sku}", body)


def run():
    flagged = 0
    for product in all_enabled_products(PAGE_SIZE):
        sku = product.get("sku")
        stock_item = stock_item_of(product)
        stock_id = stock_item.get("stock_id", STOCK_ID)
        backorders_allowed = bool(stock_item.get("backorders"))

        qty_response = salable_quantity(sku, stock_id)
        salable_qty = qty_response[0] if isinstance(qty_response, list) else qty_response

        if not is_phantom_in_stock(stock_item, salable_qty, backorders_allowed):
            continue

        total_qty = source_items_total(sku)
        log.warning(
            "Mismatch: sku=%s stock_id=%s is_in_stock=%s salable_qty=%s source_items_total=%s. %s",
            sku, stock_id, stock_item.get("is_in_stock"), salable_qty, total_qty,
            "would correct" if DRY_RUN else "correcting",
        )
        if not DRY_RUN:
            correct_flag(sku)
            log.info("Corrected %s. Run bin/magento indexer:reindex cataloginventory_stock inventory or bin/magento cron:run to reconcile.", sku)
        flagged += 1

    log.info("Done. %d mismatched SKU(s) %s.", flagged, "to review" if DRY_RUN else "corrected")


if __name__ == "__main__":
    run()
flag-phantom-in-stock.js
/**
 * Flag Magento 2 products where is_in_stock disagrees with zero salable quantity.
 *
 * MSI keeps is_in_stock as a slow-changing flag refreshed by the cataloginventory
 * and legacy stock indexers, while salable quantity is computed on demand from
 * source_items minus active reservations. A checkout reservation lands
 * synchronously, so salable quantity can hit zero immediately while is_in_stock
 * keeps reporting true until a cron run or reindex catches up. This is a data
 * consistency symptom, not something safe to silently rewrite, so it reports
 * by default and only gates a real correction behind DRY_RUN=false. Run on a
 * schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/in-stock-flag-disagrees-with-zero-salable-qty/
 */
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 PAGE_SIZE = Number(process.env.PAGE_SIZE || 100);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function isPhantomInStock(stockItem, salableQty, backordersAllowed) {
  if (!stockItem.is_in_stock) return false;
  if (!stockItem.manage_stock) return false;
  if (backordersAllowed) return false;
  return salableQty <= 0;
}

function stockItemOf(product) {
  return product.extension_attributes?.stock_item || {};
}

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, body) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function enabledProducts(pageSize, currentPage) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "status",
    "searchCriteria[filterGroups][0][filters][0][value]": "1",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": currentPage,
  };
  const data = await magentoGet("/products", params);
  return data.items;
}

async function* allEnabledProducts(pageSize) {
  let page = 1;
  while (true) {
    const items = await enabledProducts(pageSize, page);
    if (!items.length) return;
    for (const item of items) yield item;
    if (items.length < pageSize) return;
    page++;
  }
}

async function salableQuantity(sku, stockId) {
  return magentoGet(`/inventory/get-product-salable-quantity/${sku}/${stockId}`);
}

async function sourceItemsTotal(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.reduce((sum, item) => sum + (item.quantity || 0), 0);
}

async function correctFlag(sku) {
  const body = { product: { sku, extension_attributes: { stock_item: { is_in_stock: false } } } };
  return magentoPut(`/products/${sku}`, body);
}

export async function run() {
  let flagged = 0;
  for await (const product of allEnabledProducts(PAGE_SIZE)) {
    const sku = product.sku;
    const stockItem = stockItemOf(product);
    const stockId = stockItem.stock_id || STOCK_ID;
    const backordersAllowed = Boolean(stockItem.backorders);

    const qtyResponse = await salableQuantity(sku, stockId);
    const salableQty = Array.isArray(qtyResponse) ? qtyResponse[0] : qtyResponse;

    if (!isPhantomInStock(stockItem, salableQty, backordersAllowed)) continue;

    const totalQty = await sourceItemsTotal(sku);
    console.warn(
      `Mismatch: sku=${sku} stock_id=${stockId} is_in_stock=${stockItem.is_in_stock} salable_qty=${salableQty} source_items_total=${totalQty}. ${
        DRY_RUN ? "would correct" : "correcting"
      }`
    );
    if (!DRY_RUN) {
      await correctFlag(sku);
      console.log(`Corrected ${sku}. Run bin/magento indexer:reindex cataloginventory_stock inventory or bin/magento cron:run to reconcile.`);
    }
    flagged++;
  }
  console.log(`Done. ${flagged} mismatched SKU(s) ${DRY_RUN ? "to review" : "corrected"}.`);
}

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 which SKUs get reported or corrected. Because we kept is_phantom_in_stock pure, the test needs no network, no database, and no Magento store. It just feeds in plain objects and checks the answer.

test_phantom_stock.py
from flag_phantom_in_stock import is_phantom_in_stock


def stock_item(**over):
    base = {"is_in_stock": True, "manage_stock": True}
    base.update(over)
    return base


def test_phantom_when_in_stock_managed_zero_qty_no_backorders():
    assert is_phantom_in_stock(stock_item(), 0, False) is True


def test_phantom_when_salable_qty_negative():
    assert is_phantom_in_stock(stock_item(), -2, False) is True


def test_not_phantom_when_salable_qty_positive():
    assert is_phantom_in_stock(stock_item(), 5, False) is False


def test_not_phantom_when_already_out_of_stock():
    assert is_phantom_in_stock(stock_item(is_in_stock=False), 0, False) is False


def test_not_phantom_when_stock_unmanaged():
    assert is_phantom_in_stock(stock_item(manage_stock=False), 0, False) is False


def test_not_phantom_when_backorders_allowed():
    assert is_phantom_in_stock(stock_item(), 0, True) is False
phantom-stock.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isPhantomInStock } from "./flag-phantom-in-stock.js";

const stockItem = (over = {}) => ({ is_in_stock: true, manage_stock: true, ...over });

test("phantom when in stock, managed, zero qty, no backorders", () => {
  assert.equal(isPhantomInStock(stockItem(), 0, false), true);
});

test("phantom when salable qty negative", () => {
  assert.equal(isPhantomInStock(stockItem(), -2, false), true);
});

test("not phantom when salable qty positive", () => {
  assert.equal(isPhantomInStock(stockItem(), 5, false), false);
});

test("not phantom when already out of stock", () => {
  assert.equal(isPhantomInStock(stockItem({ is_in_stock: false }), 0, false), false);
});

test("not phantom when stock unmanaged", () => {
  assert.equal(isPhantomInStock(stockItem({ manage_stock: false }), 0, false), false);
});

test("not phantom when backorders allowed", () => {
  assert.equal(isPhantomInStock(stockItem(), 0, true), false);
});

Case studies

Flash sale

The drop that oversold in the last minute

A limited run sneaker drop sold out in under ninety seconds. The last few units were reserved almost simultaneously, so salable quantity hit zero well before the next scheduled cataloginventory reindex. For roughly ten minutes, the product page still showed In Stock and let shoppers add it to cart, and every one of those orders failed at payment with a stock error.

The team added the detection job on a five minute schedule during launches. It caught the exact SKU as a mismatch inside the first cycle after sellout, giving support a heads up before the wave of failed checkout tickets arrived, and merchandising could swap in a Sold Out banner manually while waiting on the next reindex.

Multi source

The warehouse transfer that looked like real stock

A retailer ran two sources feeding one stock. A transfer between sources briefly left the aggregate source_items quantity at a stale higher number while reservations from a weekend sales spike had already consumed all of it. The is_in_stock flag, last refreshed before the spike, kept reporting true for a full day.

Running the script nightly surfaced the SKU with its source_items total next to the zero salable quantity, which made the timing obvious: nothing was wrong with the sources, the flag was just behind. Ops filed it as a known reindex lag rather than chasing a false inventory bug.

What good looks like

After this runs on a schedule, a phantom in stock product is caught within one detection cycle instead of quietly costing failed checkouts. The report carries the SKU, the stock id, both numbers, and the source items total, so merchandising or ops can decide fast whether it is a pending reindex or something worth investigating. Keep the correction path gated behind an explicit override, since that is what keeps the script from ever claiming a product is out of stock when it truly is not.

FAQ

Why does Magento show a product In Stock when salable quantity is 0?

MSI keeps two loosely coupled pieces of state. The is_in_stock flag on the stock item is a slow-changing boolean refreshed by the cataloginventory and legacy stock indexers, while salable quantity is computed on demand from source_items quantity minus active reservations. Checkout inserts a reservation the instant an order is placed, but nothing in that same transaction forces is_in_stock to recompute, so the flag can keep reporting true after salable quantity has already reached zero.

How do I detect this mismatch through the REST API?

Pull enabled products from GET /rest/V1/products and read extension_attributes.stock_item.is_in_stock and stock_id. For each SKU call GET /rest/V1/inventory/get-product-salable-quantity/{sku}/{stockId} to get the numeric salable quantity, and optionally GET /rest/V1/inventory/is-product-salable/{sku}/{stockId} as a cross check. A record where is_in_stock is true and salable quantity is at or below zero is the mismatch.

Is it safe to flip is_in_stock to false automatically?

Not by default. The mismatch can be a legitimate pending reindex, a product with backorders allowed, or an unmanaged stock item, and flipping the flag blind would misrepresent inventory in the other direction. The safe pattern is to report the affected SKUs for review, and only under an explicit operator override write is_in_stock false for confirmed zero-salable-qty items, never write it true automatically.

Related field notes

Citations

On the problem:

  1. GitHub Issue: Product show in stock even if the salable quantity is 0. github.com/magento/magento2/issues/31117
  2. GitHub Issue: Product with Salable Qty of 0 shows In Stock on product page. github.com/magento/magento2/issues/35319
  3. GitHub Issue: Product having zero salable quantity results in stock in product list page and out of stock in view page. github.com/magento/inventory/issues/3062

On the solution:

  1. Adobe Commerce: Inventory Management API Reference. developer.adobe.com/commerce/php/development/components/web-api/inventory-management
  2. Adobe Commerce: Check salable quantities, Inventory REST API. developer.adobe.com/commerce/webapi/rest/inventory/check-salable-quantity
  3. Adobe Commerce: Source algorithms and reservations. experienceleague.adobe.com selection and 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.

Contact me on LinkedIn

Did this clear up your phantom stock?

If this saved you a wave of failed checkouts or a confusing inventory report, 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