Reconciler Inventory (MSI)
Configurable parent stock status not derived from children
A shopper opens a configurable product, picks a size, and Magento says In Stock right up until they try to add it to the cart. Every single child is actually out of stock. Or the opposite happens: a configurable sits hidden as Out of Stock while two of its children are perfectly salable. Nobody touched the parent. An import updated a child's quantity, or a source-level edit landed through the API, and nothing told the parent to recompute its own cached stock flag from its children. Here is why that cache goes stale and a small script that finds every configurable where it has.
A configurable product's own is_in_stock flag lives in cataloginventory_stock_item, the same legacy per-stock row every simple product uses. In MSI, that flag is supposed to be the boolean OR of every child's salable status, true if at least one child is in stock and salable, false only if none are. But that recalculation only runs through the Magento\ConfigurableProduct stock-status plugin and indexer path when specific save events fire and the inventory indexers are caught up. Edit a child's quantity through an import, the API, or a source-level change without triggering the parent's own reindex, and the parent's cached flag simply keeps whatever value it last had. A script can detect this by pulling every child's is_in_stock and salable quantity from /V1/inventory/get-product-salable-quantity/{sku}/{stockId}, computing the expected parent status with a pure OR-of-salable-children rule, and comparing it against the parent's actual extension_attributes.stock_item.is_in_stock from /V1/products/{sku}. Full code, tests, and a dry run guard are below.
The problem in plain words
A configurable product does not carry its own inventory. Every unit that actually ships lives on one of its simple children, each with its own source item quantities and its own salable status per stock. The parent is really just a wrapper, and its stock status should always answer one question: is at least one of my children salable right now?
Magento does try to keep the parent's is_in_stock flag answering that question correctly, but the recalculation is event driven, not continuous. It runs when the Magento\ConfigurableProduct stock-status plugin fires on specific save events, and it depends on the cataloging and inventory indexers being up to date. When a child's quantity changes through a path that does not trigger that plugin, an import, a direct API call to a source item, or a source-level quantity edit on a non-default stock, the parent's cached flag is never told to recheck itself. It just sits there, correct until the moment it silently stops being correct.
Why it happens
- A child's source item quantity is edited directly through
/V1/inventory/source-items, an import, or a bulk API call, none of which necessarily trigger the configurable's own stock-status recalculation plugin. - A child is disabled or goes out of stock on a non-default stock while the parent's legacy
cataloginventory_stock_itemrow, tied to the default stock, still reports in stock, since MSI's per-stock model and the single legacy flag do not always move together. - Async or cron-deferred indexing means even a save that should trigger the recalculation only takes effect once the relevant indexer catches up, leaving a window where the parent's flag is stale by design, not by accident.
- Bulk operations on many children at once, such as a supplier feed that zeroes out an entire size run, can update every child correctly while updating the shared parent row zero times, since the batched write path was never designed to fan out a parent recheck per child touched.
This is a long standing, widely reported MSI gap rather than a one off bug in a single store. Some threads describe a configurable staying In Stock while every child is Out of Stock, others describe the reverse: a configurable flipping Out of Stock even though salable children exist. See the citations at the end for the exact GitHub issues describing both directions.
A configurable's stock status is not its own fact, it is a derived fact. It should always equal the boolean OR of "is this child in stock and salable" across every child, nothing more and nothing less. When Magento's own event driven recalculation misses a change, the derived fact goes stale, and it can stay stale indefinitely since nothing re-derives it on a timer. A script cannot make Magento recompute it live, but it can independently recompute the same OR-of-salable-children rule from the REST API and flag every parent where that answer disagrees with what is actually cached.
The fix, as a flow
We do not touch the live storefront or force a reindex. We add a job that lists configurables, reads each one's children and their true salable state, computes the expected parent status with one pure function, and reports every SKU where the parent's cached flag disagrees. Only under an explicit opt in does it also correct the one legacy 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 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 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();
}
List configurables and read each one's children
Page through /V1/products filtered by type_id equal to configurable. For each parent SKU, call /V1/configurable-products/{sku}/children to get the simple children, then for each child call /V1/inventory/get-product-salable-quantity/{sku}/{stockId} for the live salable quantity and read extension_attributes.stock_item.is_in_stock from /V1/products/{sku} for its cached in-stock flag.
def configurable_products(page_size=50):
page = 1
while True:
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "type_id",
"searchCriteria[filterGroups][0][filters][0][value]": "configurable",
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": page,
}
data = magento_get("/products", params)
items = data.get("items", [])
if not items:
return
for item in items:
yield item
if page * page_size >= data.get("total_count", 0):
return
page += 1
def children_for(sku):
return magento_get(f"/configurable-products/{sku}/children")
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 is_in_stock(sku):
product = magento_get(f"/products/{sku}")
stock_item = (product.get("extension_attributes") or {}).get("stock_item") or {}
return bool(stock_item.get("is_in_stock"))
async function* configurableProducts(pageSize = 50) {
let page = 1;
while (true) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "type_id",
"searchCriteria[filterGroups][0][filters][0][value]": "configurable",
"searchCriteria[pageSize]": pageSize,
"searchCriteria[currentPage]": page,
};
const data = await magentoGet("/products", params);
const items = data.items || [];
if (!items.length) return;
for (const item of items) yield item;
if (page * pageSize >= (data.total_count || 0)) return;
page++;
}
}
async function childrenFor(sku) {
return magentoGet(`/configurable-products/${sku}/children`);
}
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 isInStock(sku) {
const product = await magentoGet(`/products/${sku}`);
const stockItem = product.extension_attributes?.stock_item || {};
return Boolean(stockItem.is_in_stock);
}
Decide, with one pure function
Keep the decision in its own function that takes a plain array of children, each with a SKU, an isInStock flag, and a salableQty, and returns the expected parent status as a boolean. A pure function like this is easy to read and easy to test, which we do later. The rule is the OR-of-salable-children aggregation: true only if at least one child is both in stock and has salable quantity above zero, false if children is empty or every child fails that test.
def compute_expected_parent_stock_status(children):
if not children:
return False
return any(
bool(child.get("isInStock")) and float(child.get("salableQty", 0) or 0) > 0
for child in children
)
export function computeExpectedParentStockStatus(children) {
if (!children || children.length === 0) return false;
return children.some(
(child) => Boolean(child.isInStock) && Number(child.salableQty || 0) > 0
);
}
Report by default, correct only when gated
The default output is a structured record per mismatched parent: the SKU, the expected status, the actual status, the child count, and a timestamp, for an operator to act on or hand to a reindex job. Only under an explicit DRY_RUN=false opt in does the script issue a corrective PUT /V1/products/{sku} that sets extension_attributes.stock_item.is_in_stock to the expected value. That write only fixes the cached legacy flag, not the MSI salable quantity index itself, since a full reindex of cataloginventory_stock and the inventory indexers is a CLI concern the script cannot trigger over REST, so it logs the intended change and still recommends running bin/magento indexer:reindex afterward.
Always start with DRY_RUN=true. The corrective write only patches the cached is_in_stock flag, it does not rebuild the MSI salable quantity index, so treat bin/magento indexer:reindex as still required after any correction, not optional.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, compares each configurable's actual stock status against the true child aggregate, respects the dry run flag, and is safe to run again and again because by default it only reports.
"""Flag Magento 2 configurable products whose cached is_in_stock flag disagrees
with the true OR-of-salable-children aggregate, safely.
A configurable's own is_in_stock flag lives in cataloginventory_stock_item and
is only refreshed by the Magento\\ConfigurableProduct stock-status plugin and
indexer path when specific save events fire and the inventory indexers are
caught up. A child quantity edited through an import, the API, or a
source-level change without triggering that path leaves the parent's cached
flag stale. This reports the mismatch by default and only gates a narrow
corrective PUT behind DRY_RUN=false. That write only fixes the cached flag,
not the MSI index itself, so a full bin/magento indexer:reindex is still
recommended afterward. 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("configurable_stock_sync")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
STOCK_ID = os.environ.get("STOCK_ID", "1")
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 compute_expected_parent_stock_status(children):
if not children:
return False
return any(
bool(child.get("isInStock")) and float(child.get("salableQty", 0) or 0) > 0
for child in children
)
def configurable_products(page_size=50):
page = 1
while True:
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "type_id",
"searchCriteria[filterGroups][0][filters][0][value]": "configurable",
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": page,
}
data = magento_get("/products", params)
items = data.get("items", [])
if not items:
return
for item in items:
yield item
if page * page_size >= data.get("total_count", 0):
return
page += 1
def children_for(sku):
return magento_get(f"/configurable-products/{sku}/children")
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 child_stock_item(product):
stock_item = (product.get("extension_attributes") or {}).get("stock_item") or {}
return bool(stock_item.get("is_in_stock"))
def actual_parent_status(product):
return child_stock_item(product)
def build_child_snapshot(child_sku, stock_id):
child_product = magento_get(f"/products/{child_sku}")
return {
"sku": child_sku,
"isInStock": child_stock_item(child_product),
"salableQty": salable_quantity(child_sku, stock_id),
}
def correct_parent_status(sku, expected_status):
payload = {
"product": {
"sku": sku,
"extension_attributes": {
"stock_item": {"is_in_stock": expected_status, "manage_stock": True}
},
}
}
log.info("Correcting %s: is_in_stock -> %s (reindex still recommended)", sku, expected_status)
return magento_put(f"/products/{sku}", payload)
def run():
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
flagged = 0
for parent in configurable_products():
sku = parent["sku"]
children_raw = children_for(sku)
if not children_raw:
continue
children = [
build_child_snapshot(child["sku"], STOCK_ID) for child in children_raw
]
expected = compute_expected_parent_stock_status(children)
actual = actual_parent_status(parent)
if expected == actual:
continue
flagged += 1
log.warning(
"sku=%s expected_in_stock=%s actual_in_stock=%s child_count=%s stock_id=%s timestamp=%s",
sku, expected, actual, len(children), STOCK_ID, now,
)
if not DRY_RUN:
correct_parent_status(sku, expected)
log.info("Done. %d configurable(s) flagged.", flagged)
if __name__ == "__main__":
run()
/**
* Flag Magento 2 configurable products whose cached is_in_stock flag disagrees
* with the true OR-of-salable-children aggregate, safely.
*
* A configurable's own is_in_stock flag lives in cataloginventory_stock_item
* and is only refreshed by the Magento\ConfigurableProduct stock-status
* plugin and indexer path when specific save events fire and the inventory
* indexers are caught up. A child quantity edited through an import, the
* API, or a source-level change without triggering that path leaves the
* parent's cached flag stale. This reports the mismatch by default and only
* gates a narrow corrective PUT behind DRY_RUN=false. That write only fixes
* the cached flag, not the MSI index itself, so a full
* bin/magento indexer:reindex is still recommended afterward. Run on a
* schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/magento/configurable-parent-stock-status-not-synced/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function computeExpectedParentStockStatus(children) {
if (!children || children.length === 0) return false;
return children.some(
(child) => Boolean(child.isInStock) && Number(child.salableQty || 0) > 0
);
}
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* configurableProducts(pageSize = 50) {
let page = 1;
while (true) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "type_id",
"searchCriteria[filterGroups][0][filters][0][value]": "configurable",
"searchCriteria[pageSize]": pageSize,
"searchCriteria[currentPage]": page,
};
const data = await magentoGet("/products", params);
const items = data.items || [];
if (!items.length) return;
for (const item of items) yield item;
if (page * pageSize >= (data.total_count || 0)) return;
page++;
}
}
async function childrenFor(sku) {
return magentoGet(`/configurable-products/${sku}/children`);
}
async function salableQuantity(sku, stockId) {
const data = await magentoGet(`/inventory/get-product-salable-quantity/${sku}/${stockId}`);
return typeof data === "number" ? data : data.quantity || 0;
}
function childStockItem(product) {
const stockItem = product.extension_attributes?.stock_item || {};
return Boolean(stockItem.is_in_stock);
}
async function buildChildSnapshot(childSku, stockId) {
const childProduct = await magentoGet(`/products/${childSku}`);
return {
sku: childSku,
isInStock: childStockItem(childProduct),
salableQty: await salableQuantity(childSku, stockId),
};
}
async function correctParentStatus(sku, expectedStatus) {
const payload = {
product: {
sku,
extension_attributes: {
stock_item: { is_in_stock: expectedStatus, manage_stock: true },
},
},
};
console.log(`Correcting ${sku}: is_in_stock -> ${expectedStatus} (reindex still recommended)`);
return magentoPut(`/products/${sku}`, payload);
}
export async function run() {
const now = new Date().toISOString();
let flagged = 0;
for await (const parent of configurableProducts()) {
const sku = parent.sku;
const childrenRaw = await childrenFor(sku);
if (!childrenRaw || !childrenRaw.length) continue;
const children = [];
for (const child of childrenRaw) {
children.push(await buildChildSnapshot(child.sku, STOCK_ID));
}
const expected = computeExpectedParentStockStatus(children);
const actual = childStockItem(parent);
if (expected === actual) continue;
flagged++;
console.warn(
`sku=${sku} expected_in_stock=${expected} actual_in_stock=${actual} child_count=${children.length} stock_id=${STOCK_ID} timestamp=${now}`
);
if (!DRY_RUN) {
await correctParentStatus(sku, expected);
}
}
console.log(`Done. ${flagged} configurable(s) flagged.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The aggregation rule is the part most worth testing, because it decides whether a parent gets flagged or corrected at all. Because we kept compute_expected_parent_stock_status pure, the test needs no network and no Magento store. It just feeds in plain arrays of children and checks the answer.
from configurable_stock_sync import compute_expected_parent_stock_status
def child(**over):
base = {"sku": "CHILD-1", "isInStock": True, "salableQty": 5}
base.update(over)
return base
def test_true_when_one_child_in_stock_and_salable():
children = [child(isInStock=False, salableQty=0), child()]
assert compute_expected_parent_stock_status(children) is True
def test_false_when_all_children_out_of_stock():
children = [child(isInStock=False, salableQty=0), child(isInStock=False, salableQty=3)]
assert compute_expected_parent_stock_status(children) is False
def test_false_when_children_empty():
assert compute_expected_parent_stock_status([]) is False
def test_false_when_in_stock_flag_true_but_qty_zero():
children = [child(isInStock=True, salableQty=0)]
assert compute_expected_parent_stock_status(children) is False
def test_false_when_qty_positive_but_flag_false():
children = [child(isInStock=False, salableQty=10)]
assert compute_expected_parent_stock_status(children) is False
def test_true_with_floating_point_qty_edge_case():
children = [child(isInStock=True, salableQty=0.0001)]
assert compute_expected_parent_stock_status(children) is True
def test_true_when_multiple_children_and_only_last_is_salable():
children = [
child(isInStock=False, salableQty=0),
child(isInStock=True, salableQty=0),
child(isInStock=True, salableQty=2),
]
assert compute_expected_parent_stock_status(children) is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeExpectedParentStockStatus } from "./configurable-stock-sync.js";
const child = (over = {}) => ({ sku: "CHILD-1", isInStock: true, salableQty: 5, ...over });
test("true when one child in stock and salable", () => {
const children = [child({ isInStock: false, salableQty: 0 }), child()];
assert.equal(computeExpectedParentStockStatus(children), true);
});
test("false when all children out of stock", () => {
const children = [child({ isInStock: false, salableQty: 0 }), child({ isInStock: false, salableQty: 3 })];
assert.equal(computeExpectedParentStockStatus(children), false);
});
test("false when children empty", () => {
assert.equal(computeExpectedParentStockStatus([]), false);
});
test("false when in stock flag true but qty zero", () => {
const children = [child({ isInStock: true, salableQty: 0 })];
assert.equal(computeExpectedParentStockStatus(children), false);
});
test("false when qty positive but flag false", () => {
const children = [child({ isInStock: false, salableQty: 10 })];
assert.equal(computeExpectedParentStockStatus(children), false);
});
test("true with floating point qty edge case", () => {
const children = [child({ isInStock: true, salableQty: 0.0001 })];
assert.equal(computeExpectedParentStockStatus(children), true);
});
test("true when multiple children and only last is salable", () => {
const children = [
child({ isInStock: false, salableQty: 0 }),
child({ isInStock: true, salableQty: 0 }),
child({ isInStock: true, salableQty: 2 }),
];
assert.equal(computeExpectedParentStockStatus(children), true);
});
Case studies
The size run that zeroed out quietly
An apparel store synced inventory nightly from a supplier feed that wrote directly to source items through the API for every child SKU in a size run. One night the entire run sold through at the supplier's end and every child dropped to zero salable quantity. The children updated correctly, but the parent configurable, still holding whatever stock status it cached from days earlier, kept showing In Stock and letting shoppers reach checkout on a product with nothing left to ship.
Running the detection script after each feed run caught the configurable within one cycle, flagging expected false against a cached true. The team used the report to trigger a targeted reindex instead of discovering the problem from cancelled order tickets.
The warehouse that had stock the storefront denied
A home goods retailer ran a secondary warehouse on its own stock in MSI. A restock landed source items on that stock for two children of a configurable, both salable, but the parent's legacy stock item row, tied to the default stock, had been marked out of stock during an earlier outage and nothing recalculated it afterward.
The configurable stayed hidden as Out of Stock on category pages for a week, losing sales the restock was meant to enable. Adding this check flagged the parent as expected true against a cached false, and the report made it clear that a reindex, not a manual product edit, was what would keep it accurate going forward.
After this runs on a schedule, a configurable's cached stock status drifting from its true child aggregate is caught within one detection cycle instead of surviving until someone notices a bad order or a lost sale. The report carries the SKU, the expected and actual status, the child count, 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 full bin/magento indexer:reindex, since that is what keeps the cached flag and the MSI index telling the same story.
FAQ
Why does a Magento configurable product show In Stock when every child is Out of Stock?
The configurable product's own is_in_stock flag is stored in cataloginventory_stock_item and is only refreshed when a save event or reindex specifically recalculates it from the children. If a child's quantity changes through an import, an API call, or a source-level edit without triggering that recalculation, the parent keeps whatever stock status it last had, even after every child has gone out of stock.
Is this the same bug as the listing versus product page stock mismatch?
No, it is a related but distinct gap. The listing versus detail mismatch is about the cataloginventory_stock_status index lagging the live salable quantity calculation for a single product. This issue is about the configurable parent's own cached is_in_stock flag never being recomputed as the boolean OR of its children's salable status in the first place, which can happen even between reindexes if the recalculation path itself was not triggered.
Can a script safely fix a configurable parent's stock status over the REST API?
A script can safely detect the mismatch by computing the expected status from each child's is_in_stock and salable quantity, then comparing it to the parent's actual is_in_stock. Under an explicit DRY_RUN=false opt in it can PUT a corrected stock_item.is_in_stock, but that only fixes the cached legacy flag, not the MSI salable quantity index itself, so a full bin/magento indexer:reindex is still recommended afterward.
Related field notes
Citations
On the problem:
- GitHub Issue: configurable stock status is not correct when using custom stock (MSI). github.com/magento/magento2/issues/36154
- GitHub Issue: configurable product is In Stock even though all children are Out of Stock. github.com/magento/magento2/issues/14389
- GitHub Issue: configurable product gets Out of Stock status when its simple configurations are In Stock. github.com/magento/inventory/issues/397
On the solution:
- Adobe Commerce: Inventory Management API reference. developer.adobe.com/commerce/php/development/components/web-api/inventory-management
- Magento 2 Developer Documentation: check salable quantities. devdocs.magento.com check salable quantity
- Adobe Commerce Web API: REST API quick reference for products and configurable-products endpoints. developer.adobe.com/commerce/webapi/rest/quick-reference
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 configurable stock confusion?
If this saved you a confusing support ticket or a checkout that should not have gone through, 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