Skip to content

Reconciler Inventory & Reservations

manage_inventory false variants still lose stock quantity

You turned off inventory tracking on a variant because you never want it to sell out, or a supplier drop-ships it and stock does not apply. Then months later someone notices its stocked_quantity has been quietly falling anyway, sale after sale, refund after refund, with no error anywhere. Here is why Medusa does not always honor manage_inventory false across every workflow step, and a script that finds every variant this has happened to.

Python and Node.js Medusa Admin API Report only, no auto-write
Open cardboard boxes
Photo by Luke Heibert on Unsplash
The short answer

Medusa's documented contract is that a variant with manage_inventory set to false is always in stock and the Inventory Module should never touch its levels. In practice, several order and fulfillment workflow steps call reserveQuantity or adjustInventory style steps keyed off the inventory_item without re-checking the owning variant's manage_inventory flag first, so a level-decrement runs anyway (reported for the single warehouse path in medusajs/medusa#13082). If that untracked variant still happens to carry a linked inventory_item and location_level, its stocked_quantity silently drops on every sale, refund, or manual adjustment. Run a small Python or Node.js script that pulls every variant with its manage_inventory flag and current location levels, filters to the ones that are untracked but still have levels, and diffs each one against a saved baseline snapshot to flag any that dropped. It only reports. It never guesses a corrected value.

The problem in plain words

Setting manage_inventory to false on a variant is supposed to be a clean opt-out. The Product Module is documented to treat that variant as always purchasable, and the Inventory Module is documented to leave its stock levels alone, since there is nothing to track.

But that opt-out is only as good as every code path that touches inventory actually checking it first. Reservation creation when a payment is authorized, and inventory adjustment on fulfillment or return, both call inventory workflow steps keyed off the inventory_item_id. Some of those steps, like confirmInventory, do check the variant's manage_inventory flag before acting. Others do not. If the untracked variant still has an inventory_item linked, with a real location_level attached, for example because tracking was switched off after stock already existed, or a shared inventory_item is reused across variants, the level gets decremented anyway. Nothing errors. Nothing warns you. The number just moves.

Variant, manage_inventory set to false Sale, refund, or return runs an inventory step Step keyed off inventory_item does not re-check manage_inventory first no error, no warning stocked_quantity decremented anyway Silent drift
The opt-out flag is real, but not every inventory workflow step re-checks it before acting on the linked inventory_item. The level drifts down with nothing surfaced to the merchant.

Why it happens

The contract for manage_inventory is correct as documented, but it depends on every inventory-touching step honoring it consistently, and that is not always true in practice. A few concrete ways this shows up:

This is a known, reported pattern. The single warehouse path is documented in medusajs/medusa#13082, and a related gap in the other direction, reservations not being created at all for tracked variants on new orders, was tracked in medusajs/medusa#3034. Both point at the same root cause: the manage_inventory check is not uniformly wired into every workflow step that can move a stock level. See the citations at the end for the exact docs and issues.

The key insight

Once a decrement has already run against an untracked variant, there is no way to compute the "true" stock level from data Medusa has, because that variant was never supposed to be counted in the first place. There is no safe restore value to infer. So the right move is not to guess a correction. It is to detect every variant where manage_inventory is false but a level still exists and has changed, report exactly what changed and by how much, and let a human pick the baseline to restore, only under an explicit confirmation.

The fix, as a flow

We do not patch every workflow step. We build a periodic audit that expands every variant with its manage_inventory flag and location levels in one call, keeps only the variants that are untracked but still carry levels, and compares each one's current stocked_quantity against a baseline snapshot the script itself maintains between runs. Anything that changed gets reported as drift. A write only ever happens if an operator explicitly confirms a baseline value to restore.

List variants + levels manage_inventory, location_levels Filter untracked with levels manage_inventory false Diff vs baseline last-known stocked_quantity Delta nonzero? yes no, still clean Report drift human picks baseline
The script only reports drift. A restore write only happens when a human picks the baseline value and the run is explicitly confirmed with DRY_RUN=false.

Build it step by step

1

Get an admin session and the base URL

Point the script at your Medusa backend and an admin user with rights to read products, variants, inventory items, and reservations. 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.

setup (shell)
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 restore write also needs a human-picked baseline
setup (shell)
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 restore write also needs a human-picked baseline
2

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.

step2.py
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"]
step2.js
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;
}
3

Pull every variant with its manage_inventory flag and location levels

Expand products down to each variant's manage_inventory flag and the inventory_items with their location_levels, paging with limit and offset so a large catalog is fully covered. This one call gives the audit everything it needs per variant.

step3.py
PRODUCT_FIELDS = (
    "id,title,*variants,variants.manage_inventory,"
    "variants.inventory_items.inventory.id,"
    "variants.inventory_items.inventory.location_levels.stocked_quantity,"
    "variants.inventory_items.inventory.location_levels.reserved_quantity,"
    "variants.inventory_items.inventory.location_levels.location_id"
)

def list_products(token):
    headers = {"Authorization": f"Bearer {token}"}
    out, offset, limit = [], 0, 100
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/products",
            params={"fields": PRODUCT_FIELDS, "limit": limit, "offset": offset},
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        out.extend(body["products"])
        offset += limit
        if offset >= body["count"]:
            return out
step3.js
const PRODUCT_FIELDS = [
  "id,title,*variants,variants.manage_inventory,",
  "variants.inventory_items.inventory.id,",
  "variants.inventory_items.inventory.location_levels.stocked_quantity,",
  "variants.inventory_items.inventory.location_levels.reserved_quantity,",
  "variants.inventory_items.inventory.location_levels.location_id",
].join("");

async function listProducts(sdk) {
  const out = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const body = await sdk.admin.product.list({ fields: PRODUCT_FIELDS, limit, offset });
    out.push(...body.products);
    offset += limit;
    if (offset >= body.count) return out;
  }
}
4

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. Skip variants where manageInventory is true, since tracking is expected to change quantity there. Skip variants with no linked inventoryItemId or an empty locationLevels list, since there is nothing to drift. For every remaining untracked variant, look up the baseline quantity per location and compute the delta. Only a nonzero delta is drift, since any change at all on a supposedly untracked variant is suspect.

decide.py
def detect_untracked_quantity_drift(variants, baseline):
    """Pure: no I/O. variants is a list of {variantId, manageInventory,
    inventoryItemId, locationLevels: [{locationId, stockedQuantity}]}.
    baseline is inventoryItemId -> locationId -> lastKnownStockedQuantity."""
    drifted = []
    for variant in variants:
        if variant.get("manageInventory"):
            continue
        item_id = variant.get("inventoryItemId")
        levels = variant.get("locationLevels") or []
        if not item_id or not levels:
            continue

        item_baseline = baseline.get(item_id) or {}
        for level in levels:
            location_id = level["locationId"]
            current = level["stockedQuantity"]
            if location_id not in item_baseline:
                continue
            base_qty = item_baseline[location_id]
            delta = current - base_qty
            if delta != 0:
                drifted.append({
                    "variantId": variant["variantId"],
                    "inventoryItemId": item_id,
                    "locationId": location_id,
                    "baselineQuantity": base_qty,
                    "currentQuantity": current,
                    "delta": delta,
                })
    return drifted
decide.js
export function detectUntrackedQuantityDrift(variants, baseline) {
  // Pure: no I/O. variants is [{ variantId, manageInventory, inventoryItemId,
  // locationLevels: [{ locationId, stockedQuantity }] }]. baseline is a Map of
  // inventoryItemId -> Map of locationId -> lastKnownStockedQuantity.
  const drifted = [];
  for (const variant of variants) {
    if (variant.manageInventory) continue;
    const itemId = variant.inventoryItemId;
    const levels = variant.locationLevels || [];
    if (!itemId || levels.length === 0) continue;

    const itemBaseline = baseline.get(itemId);
    if (!itemBaseline) continue;

    for (const level of levels) {
      const { locationId, stockedQuantity: current } = level;
      if (!itemBaseline.has(locationId)) continue;
      const baselineQuantity = itemBaseline.get(locationId);
      const delta = current - baselineQuantity;
      if (delta !== 0) {
        drifted.push({
          variantId: variant.variantId,
          inventoryItemId: itemId,
          locationId,
          baselineQuantity,
          currentQuantity: current,
          delta,
        });
      }
    }
  }
  return drifted;
}
5

Maintain the baseline snapshot between runs

Medusa does not expose a levels changelog, so the script keeps its own. On every run, load the last snapshot from disk, run the drift check against it, then write the current quantities back as the new baseline for next time. The very first run has no baseline yet, so nothing is flagged, it only seeds the file.

baseline.py
import json

def load_baseline(path):
    try:
        with open(path) as f:
            raw = json.load(f)
    except FileNotFoundError:
        return {}
    return {item_id: dict(locs) for item_id, locs in raw.items()}

def save_baseline(path, variants):
    snapshot = {}
    for variant in variants:
        item_id = variant.get("inventoryItemId")
        if not item_id:
            continue
        locs = snapshot.setdefault(item_id, {})
        for level in variant.get("locationLevels") or []:
            locs[level["locationId"]] = level["stockedQuantity"]
    with open(path, "w") as f:
        json.dump(snapshot, f, indent=2, sort_keys=True)
baseline.js
import { readFileSync, writeFileSync } from "node:fs";

export function loadBaseline(path) {
  let raw;
  try {
    raw = JSON.parse(readFileSync(path, "utf8"));
  } catch {
    return new Map();
  }
  const baseline = new Map();
  for (const [itemId, locs] of Object.entries(raw)) {
    baseline.set(itemId, new Map(Object.entries(locs)));
  }
  return baseline;
}

export function saveBaseline(path, variants) {
  const snapshot = {};
  for (const variant of variants) {
    const itemId = variant.inventoryItemId;
    if (!itemId) continue;
    const locs = (snapshot[itemId] ||= {});
    for (const level of variant.locationLevels || []) {
      locs[level.locationId] = level.stockedQuantity;
    }
  }
  writeFileSync(path, JSON.stringify(snapshot, null, 2));
}
6

Report drift, restore only with a human-picked baseline

For every drifted record, log the variant, SKU, location, baseline quantity, current quantity, and delta so a human can review it. Do not auto-write a corrected quantity, since the true untracked stock level is unknowable once decremented. Only under an explicit DRY_RUN=false and an operator-supplied baseline value does the script call POST /admin/inventory-items/{inventory_item_id}/location-levels/{location_id} with {"stocked_quantity": baseline}, logging the previous value first.

Run it safe

Always start with DRY_RUN=true and read the drift report before anything writes. Never let the script infer a "correct" quantity for an untracked variant. A human must pick the baseline to restore, and the restore write only runs when that value is passed explicitly alongside DRY_RUN=false.

The full code

Here is the complete script in one file for each language. It authenticates, pulls every variant with its manage_inventory flag and location levels, loads and updates a baseline snapshot, flags drift with a pure function, and only writes a restore when a human passes an explicit baseline with DRY_RUN=false.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
detect_untracked_drift.py
"""Find Medusa variants with manage_inventory false whose stocked_quantity
has still drifted from a saved baseline (untracked stock that should never
change, but does). Report only, never auto-writes a corrected quantity.
Safe to run again and again.
"""
import os
import sys
import json
import logging

import requests

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

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"
BASELINE_PATH = os.environ.get("BASELINE_PATH", "untracked_drift_baseline.json")

PRODUCT_FIELDS = (
    "id,title,*variants,variants.manage_inventory,"
    "variants.inventory_items.inventory.id,"
    "variants.inventory_items.inventory.location_levels.stocked_quantity,"
    "variants.inventory_items.inventory.location_levels.reserved_quantity,"
    "variants.inventory_items.inventory.location_levels.location_id"
)


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_products(token):
    headers = {"Authorization": f"Bearer {token}"}
    out, offset, limit = [], 0, 100
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/products",
            params={"fields": PRODUCT_FIELDS, "limit": limit, "offset": offset},
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        out.extend(body["products"])
        offset += limit
        if offset >= body["count"]:
            return out


def flatten_variants(products):
    """Reshape raw product/variant payloads into the plain records the pure
    decision function expects."""
    flat = []
    for product in products:
        for variant in product.get("variants") or []:
            inventory_items = variant.get("inventory_items") or []
            first_item = inventory_items[0]["inventory"] if inventory_items else None
            item_id = first_item["id"] if first_item else None
            levels = []
            if first_item:
                for lvl in first_item.get("location_levels") or []:
                    levels.append({
                        "locationId": lvl["location_id"],
                        "stockedQuantity": lvl["stocked_quantity"],
                    })
            flat.append({
                "variantId": variant["id"],
                "sku": variant.get("sku"),
                "productTitle": product.get("title"),
                "manageInventory": bool(variant.get("manage_inventory")),
                "inventoryItemId": item_id,
                "locationLevels": levels,
            })
    return flat


def load_baseline(path):
    try:
        with open(path) as f:
            raw = json.load(f)
    except FileNotFoundError:
        return {}
    return {item_id: dict(locs) for item_id, locs in raw.items()}


def save_baseline(path, variants):
    snapshot = {}
    for variant in variants:
        item_id = variant.get("inventoryItemId")
        if not item_id:
            continue
        locs = snapshot.setdefault(item_id, {})
        for level in variant.get("locationLevels") or []:
            locs[level["locationId"]] = level["stockedQuantity"]
    with open(path, "w") as f:
        json.dump(snapshot, f, indent=2, sort_keys=True)


def detect_untracked_quantity_drift(variants, baseline):
    """Pure: no I/O. variants is a list of {variantId, manageInventory,
    inventoryItemId, locationLevels: [{locationId, stockedQuantity}]}.
    baseline is inventoryItemId -> locationId -> lastKnownStockedQuantity."""
    drifted = []
    for variant in variants:
        if variant.get("manageInventory"):
            continue
        item_id = variant.get("inventoryItemId")
        levels = variant.get("locationLevels") or []
        if not item_id or not levels:
            continue

        item_baseline = baseline.get(item_id) or {}
        for level in levels:
            location_id = level["locationId"]
            current = level["stockedQuantity"]
            if location_id not in item_baseline:
                continue
            base_qty = item_baseline[location_id]
            delta = current - base_qty
            if delta != 0:
                drifted.append({
                    "variantId": variant["variantId"],
                    "inventoryItemId": item_id,
                    "locationId": location_id,
                    "baselineQuantity": base_qty,
                    "currentQuantity": current,
                    "delta": delta,
                })
    return drifted


def restore_baseline_quantity(token, inventory_item_id, location_id, baseline_quantity):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/inventory-items/{inventory_item_id}/location-levels/{location_id}",
        json={"stocked_quantity": baseline_quantity},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    # An operator can pass --restore VARIANT_ID=QTY pairs to approve a specific
    # restore. Nothing is ever inferred automatically.
    restore_map = {}
    for arg in sys.argv[1:]:
        if arg.startswith("--restore="):
            pair = arg[len("--restore="):]
            variant_id, qty = pair.split("=")
            restore_map[variant_id] = int(qty)

    token = get_token()
    products = list_products(token)
    variants = flatten_variants(products)

    baseline = load_baseline(BASELINE_PATH)
    drift = detect_untracked_quantity_drift(variants, baseline)

    if not drift:
        log.info("No drift found across %d variant(s).", len(variants))
    else:
        for record in drift:
            log.warning(
                "Drift: variant %s, inventory_item %s, location %s. "
                "baseline=%s current=%s delta=%s",
                record["variantId"], record["inventoryItemId"], record["locationId"],
                record["baselineQuantity"], record["currentQuantity"], record["delta"],
            )
        log.info("Done. %d drifted record(s) found.", len(drift))

    if not DRY_RUN and restore_map:
        by_variant = {v["variantId"]: v for v in variants}
        for variant_id, target_qty in restore_map.items():
            variant = by_variant.get(variant_id)
            if not variant or not variant["inventoryItemId"]:
                log.warning("Skipping restore for %s, variant or inventory item not found.", variant_id)
                continue
            for level in variant["locationLevels"]:
                log.info(
                    "Restoring variant %s location %s from %s to operator-confirmed %s.",
                    variant_id, level["locationId"], level["stockedQuantity"], target_qty,
                )
                restore_baseline_quantity(token, variant["inventoryItemId"], level["locationId"], target_qty)

    save_baseline(BASELINE_PATH, variants)


if __name__ == "__main__":
    run()
detect-untracked-drift.js
/**
 * Find Medusa variants with manage_inventory false whose stocked_quantity has
 * still drifted from a saved baseline (untracked stock that should never
 * change, but does). Report only, never auto-writes a corrected quantity.
 * Safe to run again and again.
 */
import { pathToFileURL } from "node:url";
import { readFileSync, writeFileSync } from "node:fs";
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 BASELINE_PATH = process.env.BASELINE_PATH || "untracked_drift_baseline.json";

const PRODUCT_FIELDS = [
  "id,title,*variants,variants.manage_inventory,",
  "variants.inventory_items.inventory.id,",
  "variants.inventory_items.inventory.location_levels.stocked_quantity,",
  "variants.inventory_items.inventory.location_levels.reserved_quantity,",
  "variants.inventory_items.inventory.location_levels.location_id",
].join("");

export function detectUntrackedQuantityDrift(variants, baseline) {
  // Pure: no I/O. variants is [{ variantId, manageInventory, inventoryItemId,
  // locationLevels: [{ locationId, stockedQuantity }] }]. baseline is a Map of
  // inventoryItemId -> Map of locationId -> lastKnownStockedQuantity.
  const drifted = [];
  for (const variant of variants) {
    if (variant.manageInventory) continue;
    const itemId = variant.inventoryItemId;
    const levels = variant.locationLevels || [];
    if (!itemId || levels.length === 0) continue;

    const itemBaseline = baseline.get(itemId);
    if (!itemBaseline) continue;

    for (const level of levels) {
      const { locationId, stockedQuantity: current } = level;
      if (!itemBaseline.has(locationId)) continue;
      const baselineQuantity = itemBaseline.get(locationId);
      const delta = current - baselineQuantity;
      if (delta !== 0) {
        drifted.push({
          variantId: variant.variantId,
          inventoryItemId: itemId,
          locationId,
          baselineQuantity,
          currentQuantity: current,
          delta,
        });
      }
    }
  }
  return drifted;
}

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 listProducts(sdk) {
  const out = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const body = await sdk.admin.product.list({ fields: PRODUCT_FIELDS, limit, offset });
    out.push(...body.products);
    offset += limit;
    if (offset >= body.count) return out;
  }
}

function flattenVariants(products) {
  const flat = [];
  for (const product of products) {
    for (const variant of product.variants || []) {
      const inventoryItems = variant.inventory_items || [];
      const firstItem = inventoryItems[0]?.inventory || null;
      const itemId = firstItem?.id || null;
      const levels = (firstItem?.location_levels || []).map((lvl) => ({
        locationId: lvl.location_id,
        stockedQuantity: lvl.stocked_quantity,
      }));
      flat.push({
        variantId: variant.id,
        sku: variant.sku,
        productTitle: product.title,
        manageInventory: Boolean(variant.manage_inventory),
        inventoryItemId: itemId,
        locationLevels: levels,
      });
    }
  }
  return flat;
}

function loadBaseline(path) {
  let raw;
  try {
    raw = JSON.parse(readFileSync(path, "utf8"));
  } catch {
    return new Map();
  }
  const baseline = new Map();
  for (const [itemId, locs] of Object.entries(raw)) {
    baseline.set(itemId, new Map(Object.entries(locs)));
  }
  return baseline;
}

function saveBaseline(path, variants) {
  const snapshot = {};
  for (const variant of variants) {
    const itemId = variant.inventoryItemId;
    if (!itemId) continue;
    const locs = (snapshot[itemId] ||= {});
    for (const level of variant.locationLevels || []) {
      locs[level.locationId] = level.stockedQuantity;
    }
  }
  writeFileSync(path, JSON.stringify(snapshot, null, 2));
}

async function restoreBaselineQuantity(sdk, inventoryItemId, locationId, baselineQuantity) {
  return sdk.admin.inventoryItem.updateLocationLevel(inventoryItemId, locationId, {
    stocked_quantity: baselineQuantity,
  });
}

export async function run() {
  // An operator can pass --restore=VARIANT_ID=QTY to approve a specific
  // restore. Nothing is ever inferred automatically.
  const restoreMap = new Map();
  for (const arg of process.argv.slice(2)) {
    if (arg.startsWith("--restore=")) {
      const pair = arg.slice("--restore=".length);
      const [variantId, qty] = pair.split("=");
      restoreMap.set(variantId, Number(qty));
    }
  }

  const sdk = await login();
  const products = await listProducts(sdk);
  const variants = flattenVariants(products);

  const baseline = loadBaseline(BASELINE_PATH);
  const drift = detectUntrackedQuantityDrift(variants, baseline);

  if (drift.length === 0) {
    console.log(`No drift found across ${variants.length} variant(s).`);
  } else {
    for (const record of drift) {
      console.warn(
        `Drift: variant ${record.variantId}, inventory_item ${record.inventoryItemId}, ` +
        `location ${record.locationId}. baseline=${record.baselineQuantity} ` +
        `current=${record.currentQuantity} delta=${record.delta}`
      );
    }
    console.log(`Done. ${drift.length} drifted record(s) found.`);
  }

  if (!DRY_RUN && restoreMap.size > 0) {
    const byVariant = new Map(variants.map((v) => [v.variantId, v]));
    for (const [variantId, targetQty] of restoreMap) {
      const variant = byVariant.get(variantId);
      if (!variant || !variant.inventoryItemId) {
        console.warn(`Skipping restore for ${variantId}, variant or inventory item not found.`);
        continue;
      }
      for (const level of variant.locationLevels) {
        console.log(
          `Restoring variant ${variantId} location ${level.locationId} from ` +
          `${level.stockedQuantity} to operator-confirmed ${targetQty}.`
        );
        await restoreBaselineQuantity(sdk, variant.inventoryItemId, level.locationId, targetQty);
      }
    }
  }

  saveBaseline(BASELINE_PATH, variants);
}

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 variant gets reported as drifted. Because it is pure, the tests feed in plain objects and a baseline map, no Medusa backend required.

test_untracked_drift.py
from detect_untracked_drift import detect_untracked_quantity_drift


def variant(**over):
    base = {
        "variantId": "variant_1",
        "manageInventory": False,
        "inventoryItemId": "iitem_1",
        "locationLevels": [{"locationId": "sloc_1", "stockedQuantity": 8}],
    }
    base.update(over)
    return base


def baseline_of(qty, item_id="iitem_1", location_id="sloc_1"):
    return {item_id: {location_id: qty}}


def test_no_drift_when_quantity_unchanged():
    result = detect_untracked_quantity_drift([variant()], baseline_of(8))
    assert result == []


def test_flags_drop_in_stocked_quantity():
    result = detect_untracked_quantity_drift([variant()], baseline_of(10))
    assert len(result) == 1
    record = result[0]
    assert record["variantId"] == "variant_1"
    assert record["inventoryItemId"] == "iitem_1"
    assert record["locationId"] == "sloc_1"
    assert record["baselineQuantity"] == 10
    assert record["currentQuantity"] == 8
    assert record["delta"] == -2


def test_flags_increase_too_since_any_change_is_suspect():
    result = detect_untracked_quantity_drift([variant()], baseline_of(5))
    assert len(result) == 1
    assert result[0]["delta"] == 3


def test_skips_tracked_variants():
    result = detect_untracked_quantity_drift([variant(manageInventory=True)], baseline_of(999))
    assert result == []


def test_skips_variant_with_no_inventory_item():
    result = detect_untracked_quantity_drift([variant(inventoryItemId=None)], baseline_of(999))
    assert result == []


def test_skips_variant_with_no_location_levels():
    result = detect_untracked_quantity_drift([variant(locationLevels=[])], baseline_of(999))
    assert result == []


def test_skips_location_missing_from_baseline():
    result = detect_untracked_quantity_drift([variant()], {"iitem_1": {}})
    assert result == []


def test_skips_inventory_item_missing_from_baseline_entirely():
    result = detect_untracked_quantity_drift([variant()], {})
    assert result == []


def test_multiple_locations_only_flags_the_changed_one():
    v = variant(locationLevels=[
        {"locationId": "sloc_1", "stockedQuantity": 8},
        {"locationId": "sloc_2", "stockedQuantity": 4},
    ])
    baseline = {"iitem_1": {"sloc_1": 10, "sloc_2": 4}}
    result = detect_untracked_quantity_drift([v], baseline)
    assert len(result) == 1
    assert result[0]["locationId"] == "sloc_1"
untracked-drift.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectUntrackedQuantityDrift } from "./detect-untracked-drift.js";

const variant = (over = {}) => ({
  variantId: "variant_1",
  manageInventory: false,
  inventoryItemId: "iitem_1",
  locationLevels: [{ locationId: "sloc_1", stockedQuantity: 8 }],
  ...over,
});

const baselineOf = (qty, itemId = "iitem_1", locationId = "sloc_1") =>
  new Map([[itemId, new Map([[locationId, qty]])]]);

test("no drift when quantity unchanged", () => {
  const result = detectUntrackedQuantityDrift([variant()], baselineOf(8));
  assert.deepEqual(result, []);
});

test("flags drop in stocked quantity", () => {
  const result = detectUntrackedQuantityDrift([variant()], baselineOf(10));
  assert.equal(result.length, 1);
  const [record] = result;
  assert.equal(record.variantId, "variant_1");
  assert.equal(record.inventoryItemId, "iitem_1");
  assert.equal(record.locationId, "sloc_1");
  assert.equal(record.baselineQuantity, 10);
  assert.equal(record.currentQuantity, 8);
  assert.equal(record.delta, -2);
});

test("flags increase too since any change is suspect", () => {
  const result = detectUntrackedQuantityDrift([variant()], baselineOf(5));
  assert.equal(result.length, 1);
  assert.equal(result[0].delta, 3);
});

test("skips tracked variants", () => {
  const result = detectUntrackedQuantityDrift([variant({ manageInventory: true })], baselineOf(999));
  assert.deepEqual(result, []);
});

test("skips variant with no inventory item", () => {
  const result = detectUntrackedQuantityDrift([variant({ inventoryItemId: null })], baselineOf(999));
  assert.deepEqual(result, []);
});

test("skips variant with no location levels", () => {
  const result = detectUntrackedQuantityDrift([variant({ locationLevels: [] })], baselineOf(999));
  assert.deepEqual(result, []);
});

test("skips location missing from baseline", () => {
  const baseline = new Map([["iitem_1", new Map()]]);
  const result = detectUntrackedQuantityDrift([variant()], baseline);
  assert.deepEqual(result, []);
});

test("skips inventory item missing from baseline entirely", () => {
  const result = detectUntrackedQuantityDrift([variant()], new Map());
  assert.deepEqual(result, []);
});

test("multiple locations only flags the changed one", () => {
  const v = variant({
    locationLevels: [
      { locationId: "sloc_1", stockedQuantity: 8 },
      { locationId: "sloc_2", stockedQuantity: 4 },
    ],
  });
  const baseline = new Map([["iitem_1", new Map([["sloc_1", 10], ["sloc_2", 4]])]]);
  const result = detectUntrackedQuantityDrift([v], baseline);
  assert.equal(result.length, 1);
  assert.equal(result[0].locationId, "sloc_1");
});

Case studies

Drop-ship SKU

The supplier-fulfilled variant that should never move

A store sold a drop-shipped accessory with manage_inventory turned off, since the supplier handled stock and it should always show as purchasable. Months later, someone noticed the variant's inventory item still had a location level, left over from before tracking was disabled, and its stocked_quantity had quietly fallen with every order.

Running the audit script surfaced the exact variant, its inventory item, and the location where the drift happened, along with the baseline from before it started slipping. The team confirmed the baseline value with the supplier's real count and restored it explicitly, then removed the stray inventory item link so there was nothing left for a workflow step to touch.

Shared inventory item

The bundle variant riding on a tracked sibling's stock row

A merchandising team reused one inventory_item across a tracked standard variant and an untracked promotional bundle variant, thinking it would not matter since the bundle had tracking off. Sales on the tracked sibling, and returns processed against the bundle, both moved the same shared location level, so the bundle's number kept drifting even though nothing was ever meant to touch it directly.

The nightly drift report caught the pattern within a day of going live, showing the same inventory_item id appearing under two different variant records with changing quantities. The fix was structural: give the bundle its own dedicated inventory item so no shared row could be touched by the other variant's real sales.

What good looks like

Run this audit on a schedule right alongside your other inventory checks. It never guesses a corrected quantity and it never writes anything on its own. It tells you exactly which untracked variants have drifted, from what baseline, by how much, and it only restores a value once a human has confirmed it under an explicit DRY_RUN=false. Over time the baseline snapshot becomes a running record of exactly when and where the manage_inventory contract was violated.

FAQ

Why does a manage_inventory false variant still lose stock in Medusa?

manage_inventory false is meant to tell Medusa to treat a variant as always in stock and never touch its inventory levels. In practice several order and fulfillment workflow steps call the same reserveQuantity and adjustInventory style steps keyed off the inventory item without re-checking the owning variant's manage_inventory flag first. If that untracked variant still has a linked inventory_item and location_level, for example because tracking was turned off after stock already existed, the level gets decremented anyway on each sale, refund, or manual adjustment.

Is this the same as the single warehouse manage_inventory bug reported on GitHub?

It is the same underlying pattern reported in medusajs/medusa issue 13082 for the single warehouse path. The check is applied inconsistently across code paths, some steps like confirmInventory do check manage_inventory first, others do not, so the exact trigger can vary by which workflow step runs, but the result is the same, a stocked_quantity that quietly drops on a variant that opted out of tracking.

Is it safe to fix the drifted stocked_quantity automatically?

No, not automatically. Once an untracked variant's stocked_quantity has been decremented there is no way for a script to know the true original value, so auto-writing a guess would just replace one wrong number with another. The safe pattern is to detect and report the drift, then let a human pick a baseline value and confirm the write under an explicit DRY_RUN=false flag.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #13082: inventory_quantity decreases despite manage_inventory set to false when using single warehouse. github.com/medusajs/medusa/issues/13082
  2. Medusa Documentation: product variant inventory, including the manage_inventory contract. docs.medusajs.com/resources/commerce-modules/product/variant-inventory
  3. medusajs/medusa GitHub issue #3034: Manage Inventory doesn't reserve for new orders. github.com/medusajs/medusa/issues/3034

On the solution:

  1. Medusa Documentation: the Inventory module. docs.medusajs.com/resources/commerce-modules/inventory
  2. Medusa Documentation: Inventory module concepts, including stocked quantity and reserved quantity. docs.medusajs.com/resources/commerce-modules/inventory/concepts
  3. Medusa Admin User Guide: manage reservations. docs.medusajs.com/user-guide/inventory/reservations

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.

Contact me on LinkedIn

Did this catch your drifting stock?

If this saved you a confusing inventory reconciliation or a variant that quietly went wrong, 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 Medusa field notes