Repair Inventory and stock
Oversold variant goes negative
A support ticket says a customer got an order confirmation for something that was not actually in the warehouse. You open the inventory item in the Medusa admin and the available quantity reads a negative number. Nothing crashed. No error was logged. The stock just went underwater. Here is why Medusa lets reserved quantity outrun stocked quantity under concurrent checkout traffic, and a script that finds every oversold location level and helps you repair it safely.
Medusa computes available stock as stocked_quantity minus reserved_quantity on an inventory level, and that check runs inside the reserve-inventory workflow step. Under concurrent checkout traffic, retried webhooks, or a direct external write to stock levels, the read then write on that level is not always serialized, so reserved_quantity can end up higher than stocked_quantity. Medusa does not enforce a non-negative constraint on stocked_quantity, so the row just sits there with negative available stock. Run a small Python or Node.js script that lists inventory items with their location levels, flags any level where available is negative or reservations exceed stock (skipping variants with allow_backorder on, since that is expected there), cross-checks against live reservations, and in dry run only logs the recount a human should approve before a --confirm write resets the level.
The problem in plain words
In Medusa v2, every inventory item can sit at more than one stock location, and each location has its own level with a stocked_quantity and a reserved_quantity. Available stock for that level is simply stocked_quantity - reserved_quantity, and the reserve-inventory workflow step compares that against the requested quantity, honoring allow_backorder when it is set on the variant.
That check is only as good as the write that follows it. When two carts complete checkout close together, when a webhook gets retried after a timeout and reprocesses the same reservation, or when a custom integration or ERP sync writes stocked_quantity directly outside the reservation flow, the read-then-write is not always serialized. One reservation gets counted twice, or the stock gets decremented after a reservation already accounted for it, and reserved_quantity ends up bigger than stocked_quantity. There is no database constraint stopping stocked_quantity from going negative or stopping reserved_quantity from exceeding it, so the row just persists that way. Nothing errors. Nothing rolls back. The number quietly goes negative and stays negative until a human notices and fixes it by hand.
Why it happens
Medusa's availability check is correct as written, but it depends on the write happening exactly once per reservation and on stock levels only changing through the reservation flow. A few concrete ways that breaks down:
- Two carts complete checkout near-simultaneously against the same low-stock variant, and the reserve step's read of
stocked_quantityminusreserved_quantityis not serialized against the concurrent write, so both reservations succeed against stock that could only cover one. - A webhook or checkout completion event gets retried after a timeout or a transient failure, and the retry re-runs the reservation instead of recognizing it already happened, effectively double counting one sale.
- A custom integration or an external ERP sync writes
stocked_quantitydirectly on a location level, outside the reservation workflow, without first checking how much is already reserved against it. - A manual admin edit lowers
stocked_quantitybelow what is already reserved, or a canceled order's restock logic double-counts a location level that was already corrected. - The variant has
allow_backorderdisabled, so a negative or oversold level there is a genuine bug, not an intentional backorder allowance.
This is a common source of confusion because Medusa's admin UI will show the negative number plainly, but nothing proactively tells you it happened or why. There is also a known issue where cancelling a fulfillment does not behave correctly once a variant's stock is already negative, which compounds the mess. See the citations at the end for the exact docs and issue.
A negative available quantity is a symptom, not something to paper over with a guess. Writing an arbitrary stocked_quantity like zero can mask a real fulfillment or accounting problem: maybe those units really were sold and need restocking, maybe a reservation is stuck and should be removed instead. The safe pattern is to recompute a real count from data Medusa already has, primarily the sum of legitimate open reservations tied to unfulfilled orders, log that recommendation for a human to review, and only write it after an explicit confirmation. Backorder-enabled variants are excluded entirely, because going negative there is by design.
The fix, as a flow
We do not touch checkout and we do not silently rewrite stock. We list every inventory item's location levels, flag the ones where reserved exceeds stocked or available is negative on a variant that does not allow backorder, cross-check against the live reservations for that item and location to rule out a stuck duplicate, and in dry run only log the proposed real count. A write only happens with an explicit human confirmation.
Build it step by step
Get an admin session and the base URL
Point the script at your Medusa backend and an admin user with rights to read inventory items, reservations, and products. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Keep everything in environment variables, never hardcoded.
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, a write also needs --confirm
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, a write also needs --confirm
Authenticate against the Admin API
Both languages exchange credentials for a token the same way. The Python version talks to the REST route directly with requests. The Node version uses the official @medusajs/js-sdk, which wraps the same login call.
import os, requests
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
import Medusa from "@medusajs/js-sdk";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
async function login() {
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
List inventory items with their location levels
Ask for every inventory item's SKU and expand location_levels plus the location on each one, paging with limit and offset so a large catalog is fully covered. Each level carries the raw stocked_quantity and reserved_quantity we need for the decision.
def list_inventory_items(token):
headers = {"Authorization": f"Bearer {token}"}
fields = "id,sku,*location_levels,*location_levels.location"
out, offset, limit = [], 0, 200
while True:
r = requests.get(
f"{BASE_URL}/admin/inventory-items",
params={"fields": fields, "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(body["inventory_items"])
offset += limit
if offset >= body["count"]:
return out
async function listInventoryItems(sdk) {
const fields = "id,sku,*location_levels,*location_levels.location";
const out = [];
let offset = 0;
const limit = 200;
while (true) {
const body = await sdk.admin.inventoryItem.list({ fields, limit, offset });
out.push(...body.inventory_items);
offset += limit;
if (offset >= body.count) return out;
}
}
Decide, with one pure function
Keep the decision in a function with no network calls, so it is easy to read and easy to test. Available is stocked_quantity minus reserved_quantity. A level is oversold only when available is negative or reserved exceeds stocked, and the variant does not allow backorder, since a backorder variant is expected to go negative by design. When flagged, the proposed count is the larger of the current stocked quantity and the open reservations total, so the fix never drops below what live orders have already reserved.
def decide_inventory_repair(level, open_reservations_total):
"""Pure: level is {inventoryItemId, locationId, stockedQuantity,
reservedQuantity, allowBackorder}. Returns the repair decision."""
stocked = level["stockedQuantity"]
reserved = level["reservedQuantity"]
available = stocked - reserved
is_oversold = (available < 0 or reserved > stocked) and not level["allowBackorder"]
if not is_oversold:
return {
"isOversold": False,
"available": available,
"reason": "ok",
"proposedStockedQuantity": None,
}
reason = "reserved_exceeds_stock" if reserved > stocked else "negative_available"
proposed = max(stocked, open_reservations_total)
return {
"isOversold": True,
"available": available,
"reason": reason,
"proposedStockedQuantity": proposed,
}
export function decideInventoryRepair(level, openReservationsTotal) {
// Pure: level is { inventoryItemId, locationId, stockedQuantity,
// reservedQuantity, allowBackorder }. Returns the repair decision.
const { stockedQuantity: stocked, reservedQuantity: reserved, allowBackorder } = level;
const available = stocked - reserved;
const isOversold = (available < 0 || reserved > stocked) && !allowBackorder;
if (!isOversold) {
return { isOversold: false, available, reason: "ok", proposedStockedQuantity: null };
}
const reason = reserved > stocked ? "reserved_exceeds_stock" : "negative_available";
const proposedStockedQuantity = Math.max(stocked, openReservationsTotal);
return { isOversold: true, available, reason, proposedStockedQuantity };
}
Cross-check against live reservations
For every flagged level, pull the open reservations for that inventory item and location, and sum their quantity. If the total does not match reserved_quantity, the level's reserved number is likely stuck or duplicated rather than a true oversell, and that sum is exactly the open_reservations_total the decision function needs.
def open_reservations_total(token, inventory_item_id, location_id):
headers = {"Authorization": f"Bearer {token}"}
out, offset, limit = [], 0, 200
while True:
r = requests.get(
f"{BASE_URL}/admin/reservations",
params={
"location_id": location_id,
"inventory_item_id": inventory_item_id,
"limit": limit,
"offset": offset,
},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(body["reservations"])
offset += limit
if offset >= body["count"]:
break
return sum(res["quantity"] for res in out)
async function openReservationsTotal(sdk, inventoryItemId, locationId) {
const out = [];
let offset = 0;
const limit = 200;
while (true) {
const body = await sdk.admin.reservation.list({
location_id: locationId,
inventory_item_id: inventoryItemId,
limit,
offset,
});
out.push(...body.reservations);
offset += limit;
if (offset >= body.count) break;
}
return out.reduce((sum, res) => sum + res.quantity, 0);
}
Log the proposed recount, write only with --confirm
In dry run, only log {inventory_item_id, location_id, current stocked_quantity, current reserved_quantity, computed available, proposed realCount} for a human to review. Only when DRY_RUN is false and the run was passed --confirm, call POST /admin/inventory-items/{inventory_item_id}/location-levels/{location_id} with {"stocked_quantity": realCount} to reset the level to the reconciled count. This is a direct correction, not a delta adjustment, and it never runs without that explicit confirmation.
Always start with DRY_RUN=true and review the logged recounts before anything writes. Writing an arbitrary stocked_quantity can mask a real fulfillment or accounting problem, so the script requires an explicit --confirm flag before it issues the location-level write, and it never bulk-deletes reservations without matching each one to a live, unfulfilled order first.
The full code
Here is the complete script in one file for each language. It authenticates, lists inventory items and their location levels, computes the repair decision with a pure function, cross-checks live reservations, and logs or writes the recount depending on DRY_RUN and --confirm.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa inventory levels where reserved quantity exceeds stocked
quantity (an oversold variant with negative available stock).
Never writes a location level without --confirm. Safe to run again and again.
"""
import os
import sys
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repair_oversold_inventory")
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ITEM_FIELDS = "id,sku,*location_levels,*location_levels.location"
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_inventory_items(token):
headers = {"Authorization": f"Bearer {token}"}
out, offset, limit = [], 0, 200
while True:
r = requests.get(
f"{BASE_URL}/admin/inventory-items",
params={"fields": ITEM_FIELDS, "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(body["inventory_items"])
offset += limit
if offset >= body["count"]:
return out
def open_reservations_total(token, inventory_item_id, location_id):
headers = {"Authorization": f"Bearer {token}"}
out, offset, limit = [], 0, 200
while True:
r = requests.get(
f"{BASE_URL}/admin/reservations",
params={
"location_id": location_id,
"inventory_item_id": inventory_item_id,
"limit": limit,
"offset": offset,
},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(body["reservations"])
offset += limit
if offset >= body["count"]:
break
return sum(res["quantity"] for res in out)
def decide_inventory_repair(level, open_reservations_total_value):
"""Pure: no I/O. level is {inventoryItemId, locationId, stockedQuantity,
reservedQuantity, allowBackorder}."""
stocked = level["stockedQuantity"]
reserved = level["reservedQuantity"]
available = stocked - reserved
is_oversold = (available < 0 or reserved > stocked) and not level["allowBackorder"]
if not is_oversold:
return {
"isOversold": False,
"available": available,
"reason": "ok",
"proposedStockedQuantity": None,
}
reason = "reserved_exceeds_stock" if reserved > stocked else "negative_available"
proposed = max(stocked, open_reservations_total_value)
return {
"isOversold": True,
"available": available,
"reason": reason,
"proposedStockedQuantity": proposed,
}
def write_location_level(token, inventory_item_id, location_id, real_count):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/inventory-items/{inventory_item_id}/location-levels/{location_id}",
json={"stocked_quantity": real_count},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()
def run():
confirm = "--confirm" in sys.argv
token = get_token()
items = list_inventory_items(token)
flagged = 0
for item in items:
for lvl in item.get("location_levels") or []:
level = {
"inventoryItemId": item["id"],
"locationId": lvl["location_id"],
"stockedQuantity": lvl["stocked_quantity"],
"reservedQuantity": lvl["reserved_quantity"],
"allowBackorder": bool(lvl.get("allow_backorder")),
}
reserved_total = open_reservations_total(token, level["inventoryItemId"], level["locationId"])
decision = decide_inventory_repair(level, reserved_total)
if not decision["isOversold"]:
continue
flagged += 1
log.warning(
"Inventory item %s (sku %s) at location %s: %s. stocked=%s reserved=%s "
"available=%s proposed realCount=%s",
item["id"], item.get("sku"), level["locationId"], decision["reason"],
level["stockedQuantity"], level["reservedQuantity"],
decision["available"], decision["proposedStockedQuantity"],
)
if not DRY_RUN and confirm:
write_location_level(
token, level["inventoryItemId"], level["locationId"],
decision["proposedStockedQuantity"],
)
log.info("Wrote stocked_quantity=%s for item %s at location %s.",
decision["proposedStockedQuantity"], item["id"], level["locationId"])
if flagged == 0:
log.info("No oversold inventory levels found across %d item(s).", len(items))
return
if DRY_RUN or not confirm:
log.info("Done. %d level(s) flagged. Re-run with DRY_RUN=false and --confirm to write.", flagged)
else:
log.info("Done. %d level(s) flagged and repaired.", flagged)
if __name__ == "__main__":
run()
/**
* Find Medusa inventory levels where reserved quantity exceeds stocked
* quantity (an oversold variant with negative available stock).
* Never writes a location level without --confirm. Safe to run again and again.
*/
import { pathToFileURL } from "node:url";
import Medusa from "@medusajs/js-sdk";
const BASE_URL = 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 ITEM_FIELDS = "id,sku,*location_levels,*location_levels.location";
export function decideInventoryRepair(level, openReservationsTotal) {
// Pure: no I/O. level is { inventoryItemId, locationId, stockedQuantity,
// reservedQuantity, allowBackorder }.
const { stockedQuantity: stocked, reservedQuantity: reserved, allowBackorder } = level;
const available = stocked - reserved;
const isOversold = (available < 0 || reserved > stocked) && !allowBackorder;
if (!isOversold) {
return { isOversold: false, available, reason: "ok", proposedStockedQuantity: null };
}
const reason = reserved > stocked ? "reserved_exceeds_stock" : "negative_available";
const proposedStockedQuantity = Math.max(stocked, openReservationsTotal);
return { isOversold: true, available, reason, proposedStockedQuantity };
}
async function login() {
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
async function listInventoryItems(sdk) {
const out = [];
let offset = 0;
const limit = 200;
while (true) {
const body = await sdk.admin.inventoryItem.list({ fields: ITEM_FIELDS, limit, offset });
out.push(...body.inventory_items);
offset += limit;
if (offset >= body.count) return out;
}
}
async function openReservationsTotal(sdk, inventoryItemId, locationId) {
const out = [];
let offset = 0;
const limit = 200;
while (true) {
const body = await sdk.admin.reservation.list({
location_id: locationId,
inventory_item_id: inventoryItemId,
limit,
offset,
});
out.push(...body.reservations);
offset += limit;
if (offset >= body.count) break;
}
return out.reduce((sum, res) => sum + res.quantity, 0);
}
async function writeLocationLevel(sdk, inventoryItemId, locationId, realCount) {
return sdk.admin.inventoryItem.updateLocationLevel(inventoryItemId, locationId, {
stocked_quantity: realCount,
});
}
export async function run() {
const confirm = process.argv.includes("--confirm");
const sdk = await login();
const items = await listInventoryItems(sdk);
let flagged = 0;
for (const item of items) {
for (const lvl of item.location_levels || []) {
const level = {
inventoryItemId: item.id,
locationId: lvl.location_id,
stockedQuantity: lvl.stocked_quantity,
reservedQuantity: lvl.reserved_quantity,
allowBackorder: Boolean(lvl.allow_backorder),
};
const reservedTotal = await openReservationsTotal(sdk, level.inventoryItemId, level.locationId);
const decision = decideInventoryRepair(level, reservedTotal);
if (!decision.isOversold) continue;
flagged++;
console.warn(
`Inventory item ${item.id} (sku ${item.sku}) at location ${level.locationId}: ${decision.reason}. ` +
`stocked=${level.stockedQuantity} reserved=${level.reservedQuantity} ` +
`available=${decision.available} proposed realCount=${decision.proposedStockedQuantity}`
);
if (!DRY_RUN && confirm) {
await writeLocationLevel(sdk, level.inventoryItemId, level.locationId, decision.proposedStockedQuantity);
console.log(`Wrote stocked_quantity=${decision.proposedStockedQuantity} for item ${item.id} at location ${level.locationId}.`);
}
}
}
if (flagged === 0) {
console.log(`No oversold inventory levels found across ${items.length} item(s).`);
return;
}
if (DRY_RUN || !confirm) {
console.log(`Done. ${flagged} level(s) flagged. Re-run with DRY_RUN=false and --confirm to write.`);
} else {
console.log(`Done. ${flagged} level(s) flagged and repaired.`);
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides which level gets flagged and what the proposed recount is. Because it is pure, the tests feed in plain objects and a reservation total, no Medusa backend required.
from repair_oversold_inventory import decide_inventory_repair
def level(**over):
base = {
"inventoryItemId": "iitem_1",
"locationId": "sloc_1",
"stockedQuantity": 10,
"reservedQuantity": 4,
"allowBackorder": False,
}
base.update(over)
return base
def test_ok_when_available_is_non_negative():
result = decide_inventory_repair(level(), 4)
assert result == {
"isOversold": False,
"available": 6,
"reason": "ok",
"proposedStockedQuantity": None,
}
def test_flags_reserved_exceeds_stock():
result = decide_inventory_repair(level(stockedQuantity=5, reservedQuantity=8), 8)
assert result["isOversold"] is True
assert result["reason"] == "reserved_exceeds_stock"
assert result["available"] == -3
assert result["proposedStockedQuantity"] == 8
def test_flags_when_stocked_itself_is_negative():
# available < 0 always implies reserved > stocked, so this is still
# reported as reserved_exceeds_stock.
result = decide_inventory_repair(level(stockedQuantity=-2, reservedQuantity=0), 0)
assert result["isOversold"] is True
assert result["reason"] in ("reserved_exceeds_stock", "negative_available")
assert result["available"] == -2
def test_backorder_variant_is_never_flagged():
result = decide_inventory_repair(level(stockedQuantity=5, reservedQuantity=8, allowBackorder=True), 8)
assert result["isOversold"] is False
assert result["reason"] == "ok"
def test_proposed_count_never_drops_below_open_reservations():
result = decide_inventory_repair(level(stockedQuantity=5, reservedQuantity=8), 12)
assert result["proposedStockedQuantity"] == 12
def test_proposed_count_never_drops_below_current_stock():
result = decide_inventory_repair(level(stockedQuantity=9, reservedQuantity=10), 3)
assert result["proposedStockedQuantity"] == 9
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideInventoryRepair } from "./repair-oversold-inventory.js";
const level = (over = {}) => ({
inventoryItemId: "iitem_1",
locationId: "sloc_1",
stockedQuantity: 10,
reservedQuantity: 4,
allowBackorder: false,
...over,
});
test("ok when available is non-negative", () => {
const result = decideInventoryRepair(level(), 4);
assert.deepEqual(result, {
isOversold: false,
available: 6,
reason: "ok",
proposedStockedQuantity: null,
});
});
test("flags reserved exceeds stock", () => {
const result = decideInventoryRepair(level({ stockedQuantity: 5, reservedQuantity: 8 }), 8);
assert.equal(result.isOversold, true);
assert.equal(result.reason, "reserved_exceeds_stock");
assert.equal(result.available, -3);
assert.equal(result.proposedStockedQuantity, 8);
});
test("flags when stocked itself is negative", () => {
const result = decideInventoryRepair(level({ stockedQuantity: -2, reservedQuantity: 0 }), 0);
assert.equal(result.isOversold, true);
assert.ok(["reserved_exceeds_stock", "negative_available"].includes(result.reason));
assert.equal(result.available, -2);
});
test("backorder variant is never flagged", () => {
const result = decideInventoryRepair(level({ stockedQuantity: 5, reservedQuantity: 8, allowBackorder: true }), 8);
assert.equal(result.isOversold, false);
assert.equal(result.reason, "ok");
});
test("proposed count never drops below open reservations", () => {
const result = decideInventoryRepair(level({ stockedQuantity: 5, reservedQuantity: 8 }), 12);
assert.equal(result.proposedStockedQuantity, 12);
});
test("proposed count never drops below current stock", () => {
const result = decideInventoryRepair(level({ stockedQuantity: 9, reservedQuantity: 10 }), 3);
assert.equal(result.proposedStockedQuantity, 9);
});
Case studies
The last unit sold twice in the same second
A store ran a flash sale on a single limited-run item with one unit left in stock. Two shoppers on different devices completed checkout within the same second. Both reservations succeeded, and the inventory level ended up with reserved_quantity at two against a stocked_quantity of one, leaving available at negative one with no error in the logs.
The audit script flagged the level immediately as reserved_exceeds_stock, cross-checked the two open reservations against real unfulfilled orders, and logged a proposed recount of two so the team could see exactly what to buy or apologize for, instead of shipping a promise they could not keep.
The nightly stock sync that ignored reservations
A merchant ran a nightly job that pushed warehouse counts from an external ERP straight into Medusa's stocked_quantity, overwriting whatever was there. The sync had no idea how many units were already reserved by same-day orders, so it regularly set the count below the reserved total, leaving several variants oversold every morning.
Running the script in dry run after the sync surfaced the exact SKUs and locations affected, with the reservation-aware recount that respected orders already in flight. The team fixed the sync to add stock on top of reservations instead of replacing the count outright, and the morning oversells stopped.
Run this audit after a sales spike, after any external stock sync, or on a regular schedule alongside other inventory checks. It never rewrites a location level on its own. It tells you exactly which levels are oversold, why, and the recount that respects every legitimate open reservation, and it only writes once a human passes --confirm. Backorder-enabled variants stay untouched, since going negative there is expected, not a bug.
FAQ
Why does a Medusa variant show negative available stock?
Available stock is computed as stocked_quantity minus reserved_quantity on an inventory level. Under concurrent checkout traffic, retried webhooks, or a direct external write to stock levels, the reserve-inventory step's read then write is not always serialized, so reserved_quantity can end up higher than stocked_quantity. Medusa has no non-negative CHECK constraint on stocked_quantity, so the row simply persists with negative available stock until someone corrects it.
Is a negative available quantity always a bug?
No. When a variant has allow_backorder enabled, a negative or zero available quantity is expected by design, since Medusa intentionally lets reservations exceed stock for backorder items. The problem is specifically reserved quantity exceeding stocked quantity on a variant where backorder is not enabled.
Is it safe to fix an oversold inventory level with a script?
Yes, when the script only recomputes a proposed stocked_quantity from real data such as the sum of open reservations tied to unfulfilled orders, runs in dry run first so a human reviews the numbers, and requires an explicit confirm step before it writes a location level. It should never guess a value like zero, and it should never bulk-delete reservations without matching each one to a live order.
Related field notes
Citations
On the problem:
- Medusa Documentation: Inventory module concepts, including stocked quantity, reserved quantity, and allow_backorder. docs.medusajs.com/resources/commerce-modules/inventory/concepts
- medusajs/medusa GitHub issue #12796: cancel fulfillment not working when an item has negative stock. github.com/medusajs/medusa/issues/12796
- Medusa Admin User Guide: manage reservations. docs.medusajs.com/user-guide/inventory/reservations
On the solution:
- Medusa Documentation: the Inventory module. docs.medusajs.com/resources/commerce-modules/inventory
- Medusa Documentation: the Inventory module in flows, including the reserve-inventory workflow step. docs.medusajs.com/resources/commerce-modules/inventory/inventory-in-flows
- Medusa Documentation: product variant inventory. docs.medusajs.com/resources/commerce-modules/product/variant-inventory
Stuck on a tricky one?
If you have a problem in Medusa pricing, 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 your oversold stock?
If this saved you a broken promise to a customer or a messy inventory reconciliation, 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