Diagnostic Inventory and stock
Inventory not managed, item never sells out
Real stock hit zero days ago, but the storefront still lets people add the item to their cart and check out. Nothing in the Medusa logs looks broken. The variant simply has its inventory flag turned off, so Medusa never checks how much is actually left. Here is why that flag ends up off without anyone choosing it, and a small script that finds every variant at risk before it oversells again.
In Medusa v2 every ProductVariant has a boolean manage_inventory field. When it is false, the confirm-variant-inventory step used by add-to-cart and checkout workflows skips reservation entirely, so stocked_quantity and reserved_quantity never enter the picture and the variant can be sold forever. Run a small Python or Node.js script that lists every variant with manage_inventory not equal to true, or with manage_inventory true but zero inventory items or location levels, and reports it before writing anything. Full code, tests, and a dry run guard are below.
The problem in plain words
Medusa does not check real stock by default. It checks a flag first. Every variant carries manage_inventory, and only when that flag is true does Medusa bother to look at the Inventory Module at all, meaning the inventory item, its stock location levels, and the reservations against it.
When manage_inventory is false, the core commerce logic short-circuits before any of that. The cart and checkout availability check simply treats the variant as always available, the same way a digital download or a service would be. No stocked_quantity math runs, no reservation gets created, and nothing stops the same variant from being sold a thousand times over, whether the real count is one or zero.
Why it happens
Nobody sets out to leave inventory unmanaged. It is almost always a side effect of how the variant got created. A few common ways stores end up with the flag off, or on but empty:
- Variants created through a CSV or bulk product import, where the import template never set
manage_inventoryand the field was left at its default. - Data seeded or migrated from a Medusa v1 store, where inventory kits did not exist in the same shape and the flag came across false.
- Variants added quickly through the Admin API without also creating an inventory kit, so tracking was never wired up in the first place.
- A related but different trap:
manage_inventoryistrue, but the variant's inventory item has no stock location levels at all, a known gap from pre-upgrade data described in Medusa issue 3793. Tracking looks turned on, yet there is nothing to track against.
This is a common source of confusion because the product page and the admin list both look completely normal. The gap only shows up once real stock runs out and the item keeps selling anyway, and by then it looks like a bug in checkout rather than a flag nobody flipped. See the citations at the end for the exact docs and issue.
Medusa never infers that a variant should be tracked. It only tracks what manage_inventory tells it to. So this is not a bug to patch in Medusa, it is a data audit: some variants are unmanaged by mistake, and some, like digital goods, services, or gift cards, are unmanaged on purpose. The fix has to tell those two apart before it writes anything.
The fix, as a flow
We do not touch checkout logic. We add a job that lists every variant, checks its manage_inventory flag and its inventory items, classifies it, and reports every variant that is a real oversell risk. Flipping the flag and adding a stock count stays a separate, human-confirmed step, because a digital product or a gift card should stay untracked on purpose.
Build it step by step
Get an Admin API session
Medusa Admin auth is a JWT, not a long-lived static token. Exchange an admin email and password for a token at POST /auth/user/emailpass, then send it back as Authorization: Bearer <token> on every /admin/* call. Keep the backend URL, email, and password in environment variables.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" # start safe, report only
npm install @medusajs/js-sdk
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" // start safe, report only
List products with variants and their inventory
Expand variants, variants.manage_inventory, variants.sku, and the inventory chain down to location levels with the fields query param, then page through with limit and offset until the count is covered.
import os, requests
BASE = os.environ["MEDUSA_BACKEND_URL"]
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
PRODUCT_FIELDS = (
"id,title,status,*variants,variants.manage_inventory,variants.sku,"
"*variants.inventory_items,*variants.inventory_items.inventory,"
"*variants.inventory_items.inventory.location_levels"
)
def login():
r = requests.post(
f"{BASE}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_products(token):
offset = 0
limit = 100
while True:
r = requests.get(
f"{BASE}/admin/products",
params={"fields": PRODUCT_FIELDS, "limit": limit, "offset": offset},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
for product in body["products"]:
yield product
offset += limit
if offset >= body["count"]:
return
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({
baseUrl: process.env.MEDUSA_BACKEND_URL,
auth: { type: "jwt" },
});
const PRODUCT_FIELDS =
"id,title,status,*variants,variants.manage_inventory,variants.sku," +
"*variants.inventory_items,*variants.inventory_items.inventory," +
"*variants.inventory_items.inventory.location_levels";
async function login() {
await sdk.auth.login("user", "emailpass", {
email: process.env.MEDUSA_ADMIN_EMAIL,
password: process.env.MEDUSA_ADMIN_PASSWORD,
});
}
async function* listProducts() {
const limit = 100;
let offset = 0;
while (true) {
const body = await sdk.admin.product.list({
fields: PRODUCT_FIELDS,
limit,
offset,
});
for (const product of body.products) yield product;
offset += limit;
if (offset >= body.count) return;
}
}
Shape each variant into plain data
Pull the product's tags into a plain array and pair it with each variant's manage_inventory flag and inventory_items, so the classification step only ever deals with simple objects, never live API responses.
def variant_records(product):
tags = [t.get("value") for t in (product.get("tags") or []) if t.get("value")]
records = []
for variant in product.get("variants") or []:
records.append({
"id": variant.get("id"),
"sku": variant.get("sku"),
"manage_inventory": variant.get("manage_inventory"),
"inventory_items": variant.get("inventory_items") or [],
"product_tags": tags,
"product_id": product.get("id"),
"product_title": product.get("title"),
})
return records
function variantRecords(product) {
const tags = (product.tags || []).map((t) => t.value).filter(Boolean);
return (product.variants || []).map((variant) => ({
id: variant.id,
sku: variant.sku,
manage_inventory: variant.manage_inventory,
inventory_items: variant.inventory_items || [],
product_tags: tags,
product_id: product.id,
product_title: product.title,
}));
}
Classify, with one pure function
Keep the decision in its own function that takes a plain variant record and returns a classification string. A pure function like this is easy to read and easy to test, which we do later. Exempt tags such as digital, service, or gift-card win first. Otherwise, a flag that is not exactly true is an unmanaged risk. If the flag is true but there are no inventory items, or every item has no location level with a positive count, it is managed but untracked, a different but related problem. Anything else is fine.
DEFAULT_EXEMPT_TAGS = ("digital", "service", "gift-card")
def _has_stock(inventory_item):
inventory = inventory_item.get("inventory") or {}
levels = inventory.get("location_levels") or []
return any((lvl.get("stocked_quantity") or 0) > 0 for lvl in levels)
def classify_variant_inventory_risk(variant, exempt_tags=DEFAULT_EXEMPT_TAGS):
tags = variant.get("product_tags") or []
if any(tag in exempt_tags for tag in tags):
return "exempt"
if variant.get("manage_inventory") is not True:
return "unmanaged_risk"
items = variant.get("inventory_items") or []
if len(items) == 0 or not any(_has_stock(item) for item in items):
return "managed_but_untracked"
return "ok"
const DEFAULT_EXEMPT_TAGS = ["digital", "service", "gift-card"];
function hasStock(inventoryItem) {
const levels = inventoryItem.inventory?.location_levels || [];
return levels.some((lvl) => (lvl.stocked_quantity || 0) > 0);
}
export function classifyVariantInventoryRisk(variant, exemptTags = DEFAULT_EXEMPT_TAGS) {
const tags = variant.product_tags || [];
if (tags.some((tag) => exemptTags.includes(tag))) return "exempt";
if (variant.manage_inventory !== true) return "unmanaged_risk";
const items = variant.inventory_items || [];
if (items.length === 0 || !items.some(hasStock)) return "managed_but_untracked";
return "ok";
}
Repair only after a human confirms, never guessing a count
When a confirmed physical-goods variant needs to be fixed, POST /admin/products/{"{product_id}"}/variants/{"{variant_id}"} with {"{\"manage_inventory\": true}\"} flips the flag, which makes Medusa auto-create an inventory item if one is missing. Then check GET /admin/inventory-items/{"{inventory_item_id}"}/location-levels, and if it comes back empty, create one with POST /admin/inventory-items/{"{inventory_item_id}"}/location-levels using a real stock location id and the count a human supplied. The script never invents that number.
def enable_manage_inventory(token, product_id, variant_id):
"""Only called when DRY_RUN is false and a human confirmed this variant."""
r = requests.post(
f"{BASE}/admin/products/{product_id}/variants/{variant_id}",
json={"manage_inventory": True},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["variant"]
def ensure_location_level(token, inventory_item_id, location_id, stocked_quantity):
"""Only called when DRY_RUN is false and a human supplied stocked_quantity."""
r = requests.get(
f"{BASE}/admin/inventory-items/{inventory_item_id}/location-levels",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
levels = r.json().get("inventory_levels", [])
if levels:
return levels
r = requests.post(
f"{BASE}/admin/inventory-items/{inventory_item_id}/location-levels",
json={"location_id": location_id, "stocked_quantity": stocked_quantity},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
// Only called when DRY_RUN is false and a human confirmed this variant.
async function enableManageInventory(productId, variantId) {
return sdk.admin.product.updateVariant(productId, variantId, {
manage_inventory: true,
});
}
// Only called when DRY_RUN is false and a human supplied stockedQuantity.
async function ensureLocationLevel(inventoryItemId, locationId, stockedQuantity) {
const { inventory_levels: levels } = await sdk.admin.inventoryItem.listLocationLevels(
inventoryItemId
);
if (levels && levels.length) return levels;
return sdk.admin.inventoryItem.createLocationLevel(inventoryItemId, {
location_id: locationId,
stocked_quantity: stockedQuantity,
});
}
Wire it together with a dry run guard
The run loop logs in, pulls every product's variants, runs the pure classifier, and prints every variant classified as unmanaged_risk or managed_but_untracked with its product id, variant id, sku, flag value, and inventory item count, so the report is auditable before any write. Nothing is written unless DRY_RUN is explicitly false and a human has supplied the confirmed variant list and stock counts. Run it on a schedule, or right after any import, so a silent oversell risk gets caught before a customer does.
Leave DRY_RUN=true for reporting. Only turn it off once a human has reviewed the flagged list and separated genuine physical-goods gaps from intentionally untracked digital goods, services, or gift cards, and supplied a real stock count for each location level. The script must never invent a stocked_quantity.
The full code
Here is the complete script in one file for each language. It logs in, reads products and variants from the environment's Medusa backend, runs the pure classifier, and prints every variant at risk. The write path is present but only runs when DRY_RUN is false and a confirmed variant with a real stock count is supplied.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa v2 variants that can sell forever because inventory is not managed.
Every ProductVariant has a manage_inventory flag. When it is not exactly true,
Medusa's cart and checkout workflows skip the Inventory Module entirely, so the
variant is always treated as available no matter how much real stock exists.
This script lists every product's variants, classifies each one, and reports
every variant that is an oversell risk. It only reports by default. Flipping
manage_inventory on and setting a stock count is a separate, human-approved
step behind DRY_RUN, because some variants (digital goods, services, gift
cards) are deliberately left untracked.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("classify_inventory_risk")
BASE = os.environ["MEDUSA_BACKEND_URL"]
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
DEFAULT_EXEMPT_TAGS = ("digital", "service", "gift-card")
PRODUCT_FIELDS = (
"id,title,status,tags,*variants,variants.manage_inventory,variants.sku,"
"*variants.inventory_items,*variants.inventory_items.inventory,"
"*variants.inventory_items.inventory.location_levels"
)
def login():
r = requests.post(
f"{BASE}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_products(token):
offset = 0
limit = 100
while True:
r = requests.get(
f"{BASE}/admin/products",
params={"fields": PRODUCT_FIELDS, "limit": limit, "offset": offset},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
for product in body["products"]:
yield product
offset += limit
if offset >= body["count"]:
return
def variant_records(product):
tags = [t.get("value") for t in (product.get("tags") or []) if t.get("value")]
records = []
for variant in product.get("variants") or []:
records.append({
"id": variant.get("id"),
"sku": variant.get("sku"),
"manage_inventory": variant.get("manage_inventory"),
"inventory_items": variant.get("inventory_items") or [],
"product_tags": tags,
"product_id": product.get("id"),
"product_title": product.get("title"),
})
return records
def _has_stock(inventory_item):
inventory = inventory_item.get("inventory") or {}
levels = inventory.get("location_levels") or []
return any((lvl.get("stocked_quantity") or 0) > 0 for lvl in levels)
def classify_variant_inventory_risk(variant, exempt_tags=DEFAULT_EXEMPT_TAGS):
tags = variant.get("product_tags") or []
if any(tag in exempt_tags for tag in tags):
return "exempt"
if variant.get("manage_inventory") is not True:
return "unmanaged_risk"
items = variant.get("inventory_items") or []
if len(items) == 0 or not any(_has_stock(item) for item in items):
return "managed_but_untracked"
return "ok"
def enable_manage_inventory(token, product_id, variant_id):
"""Only called when DRY_RUN is false and a human confirmed this variant."""
r = requests.post(
f"{BASE}/admin/products/{product_id}/variants/{variant_id}",
json={"manage_inventory": True},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["variant"]
def ensure_location_level(token, inventory_item_id, location_id, stocked_quantity):
"""Only called when DRY_RUN is false and a human supplied stocked_quantity."""
r = requests.get(
f"{BASE}/admin/inventory-items/{inventory_item_id}/location-levels",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
levels = r.json().get("inventory_levels", [])
if levels:
return levels
r = requests.post(
f"{BASE}/admin/inventory-items/{inventory_item_id}/location-levels",
json={"location_id": location_id, "stocked_quantity": stocked_quantity},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def run():
token = login()
flagged = 0
for product in list_products(token):
for variant in variant_records(product):
risk = classify_variant_inventory_risk(variant)
if risk in ("ok", "exempt"):
continue
log.warning(
"Product %s variant %s (sku=%s) manage_inventory=%s inventory_items=%d risk=%s",
variant["product_title"], variant["id"], variant["sku"],
variant["manage_inventory"], len(variant["inventory_items"]), risk,
)
flagged += 1
log.info("Done. %d variant(s) %s.", flagged, "flagged" if DRY_RUN else "flagged (dry run off, no auto-fix wired in)")
if __name__ == "__main__":
run()
/**
* Find Medusa v2 variants that can sell forever because inventory is not managed.
*
* Every ProductVariant has a manage_inventory flag. When it is not exactly true,
* Medusa's cart and checkout workflows skip the Inventory Module entirely, so the
* variant is always treated as available no matter how much real stock exists.
* This script lists every product's variants, classifies each one, and reports
* every variant that is an oversell risk. It only reports by default. Flipping
* manage_inventory on and setting a stock count is a separate, human-approved
* step behind DRY_RUN, because some variants (digital goods, services, gift
* cards) are deliberately left untracked.
*
* Guide: https://www.allanninal.dev/medusa/inventory-not-managed-never-sells-out/
*/
import { pathToFileURL } from "node:url";
const BASE = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const DEFAULT_EXEMPT_TAGS = ["digital", "service", "gift-card"];
const PRODUCT_FIELDS =
"id,title,status,tags,*variants,variants.manage_inventory,variants.sku," +
"*variants.inventory_items,*variants.inventory_items.inventory," +
"*variants.inventory_items.inventory.location_levels";
function hasStock(inventoryItem) {
const levels = inventoryItem.inventory?.location_levels || [];
return levels.some((lvl) => (lvl.stocked_quantity || 0) > 0);
}
export function classifyVariantInventoryRisk(variant, exemptTags = DEFAULT_EXEMPT_TAGS) {
const tags = variant.product_tags || [];
if (tags.some((tag) => exemptTags.includes(tag))) return "exempt";
if (variant.manage_inventory !== true) return "unmanaged_risk";
const items = variant.inventory_items || [];
if (items.length === 0 || !items.some(hasStock)) return "managed_but_untracked";
return "ok";
}
function variantRecords(product) {
const tags = (product.tags || []).map((t) => t.value).filter(Boolean);
return (product.variants || []).map((variant) => ({
id: variant.id,
sku: variant.sku,
manage_inventory: variant.manage_inventory,
inventory_items: variant.inventory_items || [],
product_tags: tags,
product_id: product.id,
product_title: product.title,
}));
}
async function getSdk() {
const { default: Medusa } = await import("@medusajs/js-sdk");
const sdk = new Medusa({ baseUrl: BASE, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
async function* listProducts(sdk) {
const limit = 100;
let offset = 0;
while (true) {
const body = await sdk.admin.product.list({
fields: PRODUCT_FIELDS,
limit,
offset,
});
for (const product of body.products) yield product;
offset += limit;
if (offset >= body.count) return;
}
}
// Only called when DRY_RUN is false and a human confirmed this variant.
async function enableManageInventory(sdk, productId, variantId) {
return sdk.admin.product.updateVariant(productId, variantId, {
manage_inventory: true,
});
}
// Only called when DRY_RUN is false and a human supplied stockedQuantity.
async function ensureLocationLevel(sdk, inventoryItemId, locationId, stockedQuantity) {
const { inventory_levels: levels } = await sdk.admin.inventoryItem.listLocationLevels(
inventoryItemId
);
if (levels && levels.length) return levels;
return sdk.admin.inventoryItem.createLocationLevel(inventoryItemId, {
location_id: locationId,
stocked_quantity: stockedQuantity,
});
}
export async function run() {
const sdk = await getSdk();
let flagged = 0;
for await (const product of listProducts(sdk)) {
for (const variant of variantRecords(product)) {
const risk = classifyVariantInventoryRisk(variant);
if (risk === "ok" || risk === "exempt") continue;
console.warn(
`Product ${variant.product_title} variant ${variant.id} (sku=${variant.sku}) manage_inventory=${variant.manage_inventory} inventory_items=${variant.inventory_items.length} risk=${risk}`
);
flagged++;
}
}
console.log(`Done. ${flagged} variant(s) ${DRY_RUN ? "flagged" : "flagged (dry run off, no auto-fix wired in)"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The classifier is the part most worth testing, because it decides which variants get reported as a real oversell risk. Because classify_variant_inventory_risk is pure, the test needs no network and no Medusa backend. It just feeds in plain variant records and checks the classification that comes back.
from classify_inventory_risk import classify_variant_inventory_risk
def variant(**over):
base = {
"id": "variant_1",
"sku": "SKU-1",
"manage_inventory": True,
"inventory_items": [{"id": "iitem_1", "inventory": {"location_levels": [{"stocked_quantity": 5}]}}],
"product_tags": [],
}
base.update(over)
return base
def test_ok_when_managed_and_stocked():
assert classify_variant_inventory_risk(variant()) == "ok"
def test_unmanaged_risk_when_flag_false():
assert classify_variant_inventory_risk(variant(manage_inventory=False)) == "unmanaged_risk"
def test_unmanaged_risk_when_flag_missing():
assert classify_variant_inventory_risk(variant(manage_inventory=None)) == "unmanaged_risk"
def test_managed_but_untracked_when_no_inventory_items():
assert classify_variant_inventory_risk(variant(inventory_items=[])) == "managed_but_untracked"
def test_managed_but_untracked_when_no_location_levels_have_stock():
v = variant(inventory_items=[{"id": "iitem_1", "inventory": {"location_levels": [{"stocked_quantity": 0}]}}])
assert classify_variant_inventory_risk(v) == "managed_but_untracked"
def test_exempt_wins_even_when_unmanaged():
v = variant(manage_inventory=False, product_tags=["digital"])
assert classify_variant_inventory_risk(v) == "exempt"
def test_exempt_with_custom_tag_list():
v = variant(manage_inventory=False, product_tags=["subscription"])
assert classify_variant_inventory_risk(v, exempt_tags=("subscription",)) == "exempt"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyVariantInventoryRisk } from "./classify-inventory-risk.js";
const variant = (over = {}) => ({
id: "variant_1",
sku: "SKU-1",
manage_inventory: true,
inventory_items: [{ id: "iitem_1", inventory: { location_levels: [{ stocked_quantity: 5 }] } }],
product_tags: [],
...over,
});
test("ok when managed and stocked", () => {
assert.equal(classifyVariantInventoryRisk(variant()), "ok");
});
test("unmanaged risk when flag false", () => {
assert.equal(classifyVariantInventoryRisk(variant({ manage_inventory: false })), "unmanaged_risk");
});
test("unmanaged risk when flag missing", () => {
assert.equal(classifyVariantInventoryRisk(variant({ manage_inventory: null })), "unmanaged_risk");
});
test("managed but untracked when no inventory items", () => {
assert.equal(classifyVariantInventoryRisk(variant({ inventory_items: [] })), "managed_but_untracked");
});
test("managed but untracked when no location levels have stock", () => {
const v = variant({ inventory_items: [{ id: "iitem_1", inventory: { location_levels: [{ stocked_quantity: 0 }] } }] });
assert.equal(classifyVariantInventoryRisk(v), "managed_but_untracked");
});
test("exempt wins even when unmanaged", () => {
const v = variant({ manage_inventory: false, product_tags: ["digital"] });
assert.equal(classifyVariantInventoryRisk(v), "exempt");
});
test("exempt with custom tag list", () => {
const v = variant({ manage_inventory: false, product_tags: ["subscription"] });
assert.equal(classifyVariantInventoryRisk(v, ["subscription"]), "exempt");
});
Case studies
A restock import left half the catalog untracked
A homeware store bulk-imported a new product line through a CSV that never included a manage_inventory column. Every variant landed with the flag off by default. The products looked normal in the admin and sold fine for weeks, until a popular item sold out in the stockroom while the storefront kept accepting orders for it.
Running the classifier against the catalog turned up dozens of variants flagged unmanaged_risk, all from that one import. The team confirmed each was a physical item, flipped manage_inventory to true, and filled in real counts pulled from the warehouse system before re-enabling sales.
Migrated variants had tracking on but nothing to track
A store that migrated from Medusa v1 saw manage_inventory reading true across the board, so the team assumed inventory was safe. In reality, a subset of inventory items from the migration had no stock location levels at all, a gap matching Medusa issue 3793, so those variants also never blocked a sale.
The classifier caught these as managed_but_untracked instead of missing them entirely, since it checks for a positive stocked quantity, not just the flag. The team created the missing location levels with real counts and the storefront started respecting stock again.
After this runs on a schedule or right after any import, every variant that can silently oversell shows up in a report with its product id, variant id, sku, and the reason, instead of surfacing as an angry customer message about an order that cannot be fulfilled. Digital goods, services, and gift cards stay untracked on purpose, and every real physical-goods gap gets a human-confirmed stock count before the flag flips.
FAQ
Why does a Medusa product never sell out even at zero stock?
Every variant has a manage_inventory flag. When it is false, Medusa's cart and checkout workflows never look at the Inventory Module at all, so no stocked_quantity or reserved_quantity check ever runs. The variant is treated as always available no matter how many units actually exist.
Why is manage_inventory false on variants I never touched?
It commonly happens on variants created through a CSV import, seeded from a v1 migration, or added quickly through the Admin API without an inventory kit. Unless something explicitly sets manage_inventory to true, Medusa does not force it on, so the variant quietly skips reservation.
Is it safe to turn manage_inventory on for every variant automatically?
No. Some variants are deliberately untracked, such as digital goods, services, or gift cards, and flipping those on would wrongly block sales. The safe approach is to report every flagged variant first, exempt known intentional cases by tag, and only write once a human confirms which variants are physical goods that should be tracked.
Related field notes
Citations
On the problem:
- Product Variant Inventory, Medusa Documentation. docs.medusajs.com/resources/commerce-modules/product/variant-inventory
- Get Product Variant Inventory Quantity, Medusa Documentation. docs.medusajs.com/resources/commerce-modules/product/guides/variant-inventory
- Inventory quantity is missing after upgrading, cannot activate inventory module on products created before upgrade. Issue 3793. github.com/medusajs/medusa/issues/3793
On the solution:
- Inventory Module Concepts, Medusa Documentation. docs.medusajs.com/resources/commerce-modules/inventory/concepts
- Inventory Module in Medusa Flows, Medusa Documentation. docs.medusajs.com/resources/commerce-modules/inventory/inventory-in-flows
- Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
Stuck on a tricky one?
If you have a problem in Medusa pricing, regions, inventory, orders, promotions, or workflows 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 catch an oversell risk?
If this found a variant that was quietly selling past zero stock, 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