Reconciler Inventory and stock

Medusa variant not purchasable, no inventory level

The variant looks fine everywhere you check. It is active, it has a price, and the admin shows it sitting in the catalog like every other variant. But the storefront refuses to add it to the cart, or the Store API reports it out of stock no matter what you do. Nine times out of ten, the variant tracks inventory but never got an InventoryLevel row at a stock location the storefront's sales channel actually uses. Here is why that missing row is enough to make a variant unbuyable and a small script that finds it and fixes it safely.

Python and Node.js Medusa Admin API Safe by default (dry run)
The short answer

In Medusa v2, a variant with manage_inventory set to true is only purchasable when its linked inventory item has an InventoryLevel row at a stock location that is itself linked to the sales channel making the request, through the SalesChannel to StockLocation module link. Availability is computed at read time from stocked_quantity minus reserved_quantity at that scoped location, so a missing level, not just a zero one, makes the variant read as permanently out of stock even though the admin still shows a price and an active status. Run a small Python or Node.js script that lists managed variants, checks their inventory levels against the locations the storefront's sales channel needs, and creates a zeroed level only where one is fully absent with POST /admin/inventory-items/{id}/location-levels. Full code, tests, and a dry run guard are below.

The problem in plain words

In Medusa v2, tracking stock for a variant is not automatic just because manage_inventory is true. That flag only tells Medusa the variant should be tracked. Whether it is actually purchasable comes down to a second, separate thing: does its inventory item have an InventoryLevel row at a stock location that matters for this request.

Every storefront request is scoped to a sales channel, and every sales channel is linked to one or more stock locations through the SalesChannel to StockLocation module link. When Medusa computes availability for a variant, it looks for a level at one of those linked locations. If the inventory item has no level row anywhere, or its only level rows sit at a stock location that was never linked to this sales channel, Medusa treats the variant as having zero available quantity there. The admin can still show the variant active with a price, because those are separate fields entirely.

Variant manage_inventory: true Look up level at sales channel's locations no level found Zero available at that location Not purchasable
The variant is active with a price. It is the missing InventoryLevel row at a sales-channel-linked stock location that leaves it unpurchasable.

Why it happens

Since availability is computed at read time from stocked_quantity minus reserved_quantity at a sales-channel-scoped location, a variant can look completely normal in every other field while still being unbuyable. A few common ways stores end up here:

This is a common source of confusion because nothing about it looks like an error. The admin UI can still show the variant as active with a normal price, and GET /store/products/{id} returns a clean 200, just with purchasable: false or an inventory quantity of null or zero. It is easy to spend time checking prices, publishing status, and sales channel assignment on the product itself before anyone checks whether an inventory level row exists at all. See the citations at the end for the exact docs and threads.

The key insight

A missing inventory level is not the same problem as a zero quantity. Zero is a real, honest number: the row exists and says there are no units right now. Missing means Medusa has nothing to compute availability from at that location at all. So the safe repair is not "set every managed variant's quantity to something." It is "create the level only where one is fully absent, and always at zero." The real stock count is business data the script does not know, so it reports the new zeroed level for a human to correct.

The fix, as a flow

We do not touch prices, publishing status, or existing stock numbers. We add a check that lists managed variants, reads each linked inventory item's location levels, and compares them against the stock locations that matter for the sales channel in use. A level that is fully missing gets created at zero. A level that exists only at the wrong location is flagged, since linking a location to a channel is a decision for a human, not a guess.

List variants manage_inventory: true Read location levels per inventory item Compare to required sales channel's locations Level fully missing? yes no, flag it create level, qty 0 human sets real count
The script only creates a level when one is fully absent, and always at zero. A level at the wrong location is flagged, not silently duplicated.

Build it step by step

1

Get an admin token

Exchange an admin email and password for a JWT at POST /auth/user/emailpass, then send it as Authorization: Bearer <token> on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.

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, change to false to write
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, change to false to write
2

List managed variants and their inventory items

Ask the Admin API for products with variants expanded, and expand each variant's inventory items too. Filter client-side to variants where manage_inventory is true, since those are the only ones availability even applies to. Page through with count, offset, and limit so a large catalog is fully covered.

step2.py
import os, requests

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]

def get_admin_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]

def list_managed_variants(token):
    fields = "id,title,status,*variants,variants.manage_inventory,variants.id,*variants.inventory_items,variants.inventory_items.inventory_item_id"
    offset = 0
    limit = 100
    variants = []
    while True:
        r = requests.get(
            f"{BACKEND_URL}/admin/products",
            headers={"Authorization": f"Bearer {token}"},
            params={"fields": fields, "limit": limit, "offset": offset},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        for product in body["products"]:
            for variant in product.get("variants") or []:
                if variant.get("manage_inventory"):
                    variants.append(variant)
        offset += limit
        if offset >= body["count"]:
            return variants
step2.js
import Medusa from "@medusajs/js-sdk";

const sdk = new Medusa({
  baseUrl: process.env.MEDUSA_BACKEND_URL,
  auth: { type: "jwt" },
});

async function login() {
  return sdk.auth.login("user", "emailpass", {
    email: process.env.MEDUSA_ADMIN_EMAIL,
    password: process.env.MEDUSA_ADMIN_PASSWORD,
  });
}

const FIELDS = "id,title,status,*variants,variants.manage_inventory,variants.id,*variants.inventory_items,variants.inventory_items.inventory_item_id";

async function listManagedVariants() {
  let offset = 0;
  const limit = 100;
  const variants = [];
  while (true) {
    const { products, count } = await sdk.admin.product.list({ fields: FIELDS, limit, offset });
    for (const product of products) {
      for (const variant of product.variants || []) {
        if (variant.manage_inventory) variants.push(variant);
      }
    }
    offset += limit;
    if (offset >= count) return variants;
  }
}
3

Read existing location levels and the required locations

For each variant's inventory item, ask for its location levels. Separately, resolve the stock locations that matter, the ones linked to the sales channel the storefront actually uses, either from the sales channel's expanded stock_locations or the stock location's expanded sales_channels.

step3.py
def get_location_levels(token, inventory_item_id):
    r = requests.get(
        f"{BACKEND_URL}/admin/inventory-items/{inventory_item_id}",
        headers={"Authorization": f"Bearer {token}"},
        params={"fields": "id,*location_levels"},
        timeout=30,
    )
    r.raise_for_status()
    levels = r.json()["inventory_item"].get("location_levels") or []
    return [{"locationId": lv["location_id"], "stockedQuantity": lv["stocked_quantity"]} for lv in levels]

def get_required_location_ids(token, sales_channel_id):
    r = requests.get(
        f"{BACKEND_URL}/admin/sales-channels/{sales_channel_id}",
        headers={"Authorization": f"Bearer {token}"},
        params={"fields": "id,name,*stock_locations"},
        timeout=30,
    )
    r.raise_for_status()
    locations = r.json()["sales_channel"].get("stock_locations") or []
    return [loc["id"] for loc in locations]
step3.js
async function getLocationLevels(inventoryItemId) {
  const { inventory_item } = await sdk.admin.inventoryItem.retrieve(inventoryItemId, {
    fields: "id,*location_levels",
  });
  const levels = inventory_item.location_levels || [];
  return levels.map((lv) => ({ locationId: lv.location_id, stockedQuantity: lv.stocked_quantity }));
}

async function getRequiredLocationIds(salesChannelId) {
  const { sales_channel } = await sdk.admin.salesChannel.retrieve(salesChannelId, {
    fields: "id,name,*stock_locations",
  });
  const locations = sales_channel.stock_locations || [];
  return locations.map((loc) => loc.id);
}
4

Decide, with one pure function

Keep the decision in its own function that takes the variant, its existing levels, and the required location ids, and returns an action. Untracked variants are skipped outright. A variant with no inventory item is flagged rather than guessed at. Otherwise the function computes exactly which required locations have no level row at all, and only those become a repair action.

decide.py
def decide_inventory_repair(variant, existing_levels, required_location_ids):
    if not variant.get("manageInventory"):
        return {"action": "skip", "missingLocationIds": []}

    if not variant.get("inventoryItemId"):
        return {"action": "flag_no_inventory_item", "missingLocationIds": []}

    existing_ids = {lv["locationId"] for lv in existing_levels}
    missing = [loc_id for loc_id in required_location_ids if loc_id not in existing_ids]

    if not missing:
        return {"action": "ok", "missingLocationIds": []}

    return {"action": "create_zero_level", "missingLocationIds": missing}
decide.js
export function decideInventoryRepair(variant, existingLevels, requiredLocationIds) {
  if (!variant.manageInventory) {
    return { action: "skip", missingLocationIds: [] };
  }

  if (!variant.inventoryItemId) {
    return { action: "flag_no_inventory_item", missingLocationIds: [] };
  }

  const missingLocationIds = requiredLocationIds.filter(
    (id) => !existingLevels.some((l) => l.locationId === id)
  );

  if (missingLocationIds.length === 0) {
    return { action: "ok", missingLocationIds: [] };
  }

  return { action: "create_zero_level", missingLocationIds };
}
5

Create the missing level at zero, never overwrite an existing one

When the decision is create_zero_level, call POST /admin/inventory-items/{inventory_item_id}/location-levels once per missing location id with stocked_quantity: 0. This only ever adds a row that was fully absent. It never touches a level that already exists, so a real stock count already on file is never at risk.

apply.py
def create_zero_level(token, inventory_item_id, location_id):
    r = requests.post(
        f"{BACKEND_URL}/admin/inventory-items/{inventory_item_id}/location-levels",
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        json={"location_id": location_id, "stocked_quantity": 0},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
apply.js
async function createZeroLevel(inventoryItemId, locationId) {
  return sdk.admin.inventoryItem.updateLocationLevel(inventoryItemId, locationId, {
    stocked_quantity: 0,
  });
}
6

Wire it together with a dry run guard

The run loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs which inventory item it would give a zeroed level at which location. Read the output, agree with it, then switch it off to let it write. It is safe to run again and again, since a location that already has a level is never touched twice.

Run it safe

Always start with DRY_RUN=true, and never set a nonzero stocked_quantity automatically. A zeroed level makes the variant manageable, not in stock. After it runs, a human still has to enter the real count, and a level that exists only at the wrong location should be flagged for a person to decide whether to link the location to the sales channel or add a level at the correct one.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and only ever creates a level where one was fully absent, always at zero.

View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.

create_missing_levels.py
"""Find Medusa variants with a missing inventory level and create it, safely, at zero.

A variant with manage_inventory true is only purchasable if its inventory item has
an InventoryLevel row at a stock location linked to the sales channel making the
request. This lists managed variants, reads each inventory item's existing levels,
decides what to do with a pure function, and only creates a level where one is
fully absent, always at stocked_quantity zero. A level that exists only at the
wrong location is flagged, not silently duplicated. Run once, or on a schedule.
Safe to run again and again.
"""
import os
import logging
import requests

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

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SALES_CHANNEL_ID = os.environ["SALES_CHANNEL_ID"]

VARIANT_FIELDS = "id,title,status,*variants,variants.manage_inventory,variants.id,*variants.inventory_items,variants.inventory_items.inventory_item_id"


def get_admin_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def list_managed_variants(token):
    offset = 0
    limit = 100
    variants = []
    while True:
        r = requests.get(
            f"{BACKEND_URL}/admin/products",
            headers={"Authorization": f"Bearer {token}"},
            params={"fields": VARIANT_FIELDS, "limit": limit, "offset": offset},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        for product in body["products"]:
            for variant in product.get("variants") or []:
                if variant.get("manage_inventory"):
                    items = variant.get("inventory_items") or []
                    inventory_item_id = items[0]["inventory_item_id"] if items else None
                    variants.append({
                        "id": variant["id"],
                        "manageInventory": True,
                        "inventoryItemId": inventory_item_id,
                    })
        offset += limit
        if offset >= body["count"]:
            return variants


def get_location_levels(token, inventory_item_id):
    r = requests.get(
        f"{BACKEND_URL}/admin/inventory-items/{inventory_item_id}",
        headers={"Authorization": f"Bearer {token}"},
        params={"fields": "id,*location_levels"},
        timeout=30,
    )
    r.raise_for_status()
    levels = r.json()["inventory_item"].get("location_levels") or []
    return [{"locationId": lv["location_id"], "stockedQuantity": lv["stocked_quantity"]} for lv in levels]


def get_required_location_ids(token, sales_channel_id):
    r = requests.get(
        f"{BACKEND_URL}/admin/sales-channels/{sales_channel_id}",
        headers={"Authorization": f"Bearer {token}"},
        params={"fields": "id,name,*stock_locations"},
        timeout=30,
    )
    r.raise_for_status()
    locations = r.json()["sales_channel"].get("stock_locations") or []
    return [loc["id"] for loc in locations]


def decide_inventory_repair(variant, existing_levels, required_location_ids):
    """Pure decision function. No I/O.

    variant: {"manageInventory": bool, "inventoryItemId": str | None}
    existing_levels: [{"locationId": str, "stockedQuantity": number}, ...]
    required_location_ids: [str, ...]

    Returns {"action": "skip" | "flag_no_inventory_item" | "create_zero_level" | "ok",
             "missingLocationIds": [str, ...]}.
    """
    if not variant.get("manageInventory"):
        return {"action": "skip", "missingLocationIds": []}

    if not variant.get("inventoryItemId"):
        return {"action": "flag_no_inventory_item", "missingLocationIds": []}

    existing_ids = {lv["locationId"] for lv in existing_levels}
    missing = [loc_id for loc_id in required_location_ids if loc_id not in existing_ids]

    if not missing:
        return {"action": "ok", "missingLocationIds": []}

    return {"action": "create_zero_level", "missingLocationIds": missing}


def create_zero_level(token, inventory_item_id, location_id):
    r = requests.post(
        f"{BACKEND_URL}/admin/inventory-items/{inventory_item_id}/location-levels",
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        json={"location_id": location_id, "stocked_quantity": 0},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    token = get_admin_token()
    variants = list_managed_variants(token)
    required_location_ids = get_required_location_ids(token, SALES_CHANNEL_ID)

    created = 0
    flagged = 0
    for variant in variants:
        if not variant["manageInventory"]:
            continue

        if not variant["inventoryItemId"]:
            log.warning("Variant %s tracks inventory but has no inventory item. Flagging.", variant["id"])
            flagged += 1
            continue

        existing_levels = get_location_levels(token, variant["inventoryItemId"])
        decision = decide_inventory_repair(variant, existing_levels, required_location_ids)

        if decision["action"] in ("skip", "ok"):
            continue
        if decision["action"] == "flag_no_inventory_item":
            flagged += 1
            continue

        for location_id in decision["missingLocationIds"]:
            log.info(
                "Inventory item %s missing level at %s. %s",
                variant["inventoryItemId"], location_id,
                "would create stocked_quantity=0" if DRY_RUN else "creating stocked_quantity=0",
            )
            if not DRY_RUN:
                create_zero_level(token, variant["inventoryItemId"], location_id)
            created += 1

    log.info(
        "Done. %d level(s) %s, %d variant(s) flagged for review.",
        created, "to create" if DRY_RUN else "created", flagged,
    )


if __name__ == "__main__":
    run()
create-missing-levels.js
/**
 * Find Medusa variants with a missing inventory level and create it, safely, at zero.
 *
 * A variant with manage_inventory true is only purchasable if its inventory item has
 * an InventoryLevel row at a stock location linked to the sales channel making the
 * request. This lists managed variants, reads each inventory item's existing levels,
 * decides what to do with a pure function, and only creates a level where one is
 * fully absent, always at stocked_quantity zero. A level that exists only at the
 * wrong location is flagged, not silently duplicated. Run once, or on a schedule.
 *
 * Guide: https://www.allanninal.dev/medusa/variant-not-purchasable-no-inventory-level/
 */
import { pathToFileURL } from "node:url";

const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SALES_CHANNEL_ID = process.env.SALES_CHANNEL_ID || "sc_default";

const VARIANT_FIELDS = "id,title,status,*variants,variants.manage_inventory,variants.id,*variants.inventory_items,variants.inventory_items.inventory_item_id";

export function decideInventoryRepair(variant, existingLevels, requiredLocationIds) {
  if (!variant.manageInventory) {
    return { action: "skip", missingLocationIds: [] };
  }

  if (!variant.inventoryItemId) {
    return { action: "flag_no_inventory_item", missingLocationIds: [] };
  }

  const missingLocationIds = requiredLocationIds.filter(
    (id) => !existingLevels.some((l) => l.locationId === id)
  );

  if (missingLocationIds.length === 0) {
    return { action: "ok", missingLocationIds: [] };
  }

  return { action: "create_zero_level", missingLocationIds };
}

async function getSdk() {
  const { default: Medusa } = await import("@medusajs/js-sdk");
  const sdk = new Medusa({ baseUrl: BACKEND_URL, auth: { type: "jwt" } });
  await sdk.auth.login("user", "emailpass", { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
  return sdk;
}

async function listManagedVariants(sdk) {
  let offset = 0;
  const limit = 100;
  const variants = [];
  while (true) {
    const { products, count } = await sdk.admin.product.list({ fields: VARIANT_FIELDS, limit, offset });
    for (const product of products) {
      for (const variant of product.variants || []) {
        if (variant.manage_inventory) {
          const items = variant.inventory_items || [];
          const inventoryItemId = items.length ? items[0].inventory_item_id : null;
          variants.push({ id: variant.id, manageInventory: true, inventoryItemId });
        }
      }
    }
    offset += limit;
    if (offset >= count) return variants;
  }
}

async function getLocationLevels(sdk, inventoryItemId) {
  const { inventory_item } = await sdk.admin.inventoryItem.retrieve(inventoryItemId, {
    fields: "id,*location_levels",
  });
  const levels = inventory_item.location_levels || [];
  return levels.map((lv) => ({ locationId: lv.location_id, stockedQuantity: lv.stocked_quantity }));
}

async function getRequiredLocationIds(sdk, salesChannelId) {
  const { sales_channel } = await sdk.admin.salesChannel.retrieve(salesChannelId, {
    fields: "id,name,*stock_locations",
  });
  const locations = sales_channel.stock_locations || [];
  return locations.map((loc) => loc.id);
}

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

export async function run() {
  const sdk = await getSdk();
  const variants = await listManagedVariants(sdk);
  const requiredLocationIds = await getRequiredLocationIds(sdk, SALES_CHANNEL_ID);

  let created = 0;
  let flagged = 0;
  for (const variant of variants) {
    if (!variant.manageInventory) continue;

    if (!variant.inventoryItemId) {
      console.warn(`Variant ${variant.id} tracks inventory but has no inventory item. Flagging.`);
      flagged++;
      continue;
    }

    const existingLevels = await getLocationLevels(sdk, variant.inventoryItemId);
    const decision = decideInventoryRepair(variant, existingLevels, requiredLocationIds);

    if (decision.action === "skip" || decision.action === "ok") continue;
    if (decision.action === "flag_no_inventory_item") {
      flagged++;
      continue;
    }

    for (const locationId of decision.missingLocationIds) {
      console.log(
        `Inventory item ${variant.inventoryItemId} missing level at ${locationId}. ${DRY_RUN ? "would create stocked_quantity=0" : "creating stocked_quantity=0"}`
      );
      if (!DRY_RUN) await createZeroLevel(sdk, variant.inventoryItemId, locationId);
      created++;
    }
  }

  console.log(`Done. ${created} level(s) ${DRY_RUN ? "to create" : "created"}, ${flagged} variant(s) flagged for review.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides whether a variant gets a new inventory level. Because we kept decide_inventory_repair pure, the test needs no network and no Medusa backend. It just feeds in plain objects and checks the answer.

test_variant_repair.py
from create_missing_levels import decide_inventory_repair


def variant(**over):
    base = {"manageInventory": True, "inventoryItemId": "iitem_1"}
    base.update(over)
    return base


def test_untracked_variant_is_skipped():
    result = decide_inventory_repair(variant(manageInventory=False), [], ["sloc_1"])
    assert result == {"action": "skip", "missingLocationIds": []}


def test_managed_variant_without_inventory_item_is_flagged():
    result = decide_inventory_repair(variant(inventoryItemId=None), [], ["sloc_1"])
    assert result == {"action": "flag_no_inventory_item", "missingLocationIds": []}


def test_managed_variant_with_all_required_levels_is_ok():
    levels = [{"locationId": "sloc_1", "stockedQuantity": 5}]
    result = decide_inventory_repair(variant(), levels, ["sloc_1"])
    assert result == {"action": "ok", "missingLocationIds": []}


def test_managed_variant_with_no_levels_at_all_needs_repair():
    result = decide_inventory_repair(variant(), [], ["sloc_1"])
    assert result == {"action": "create_zero_level", "missingLocationIds": ["sloc_1"]}


def test_managed_variant_with_level_at_wrong_location_needs_repair():
    levels = [{"locationId": "sloc_wrong", "stockedQuantity": 10}]
    result = decide_inventory_repair(variant(), levels, ["sloc_1"])
    assert result == {"action": "create_zero_level", "missingLocationIds": ["sloc_1"]}


def test_only_missing_locations_are_returned_when_some_exist():
    levels = [{"locationId": "sloc_1", "stockedQuantity": 3}]
    result = decide_inventory_repair(variant(), levels, ["sloc_1", "sloc_2"])
    assert result == {"action": "create_zero_level", "missingLocationIds": ["sloc_2"]}
decide.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideInventoryRepair } from "./create-missing-levels.js";

const variant = (over = {}) => ({ manageInventory: true, inventoryItemId: "iitem_1", ...over });

test("untracked variant is skipped", () => {
  const result = decideInventoryRepair(variant({ manageInventory: false }), [], ["sloc_1"]);
  assert.deepEqual(result, { action: "skip", missingLocationIds: [] });
});

test("managed variant without inventory item is flagged", () => {
  const result = decideInventoryRepair(variant({ inventoryItemId: null }), [], ["sloc_1"]);
  assert.deepEqual(result, { action: "flag_no_inventory_item", missingLocationIds: [] });
});

test("managed variant with all required levels is ok", () => {
  const levels = [{ locationId: "sloc_1", stockedQuantity: 5 }];
  const result = decideInventoryRepair(variant(), levels, ["sloc_1"]);
  assert.deepEqual(result, { action: "ok", missingLocationIds: [] });
});

test("managed variant with no levels at all needs repair", () => {
  const result = decideInventoryRepair(variant(), [], ["sloc_1"]);
  assert.deepEqual(result, { action: "create_zero_level", missingLocationIds: ["sloc_1"] });
});

test("managed variant with level at wrong location needs repair", () => {
  const levels = [{ locationId: "sloc_wrong", stockedQuantity: 10 }];
  const result = decideInventoryRepair(variant(), levels, ["sloc_1"]);
  assert.deepEqual(result, { action: "create_zero_level", missingLocationIds: ["sloc_1"] });
});

test("only missing locations are returned when some exist", () => {
  const levels = [{ locationId: "sloc_1", stockedQuantity: 3 }];
  const result = decideInventoryRepair(variant(), levels, ["sloc_1", "sloc_2"]);
  assert.deepEqual(result, { action: "create_zero_level", missingLocationIds: ["sloc_2"] });
});

Case studies

Bulk import

The catalog migration that stopped one step short

A store moved a few thousand SKUs into Medusa with a custom import script. The script created products, variants, and inventory items, and set manage_inventory to true on every one, but the step that would have created an initial location level never ran, since it was written for a different stock location setup than the one the store ended up using.

Every variant looked perfect in the admin, active with a price, but the storefront treated the entire catalog as out of stock. Running the detection script surfaced thousands of inventory items with zero location levels in seconds. A dry run showed exactly which ones needed a zeroed level, and turning it off created them in one pass, leaving the real counts for the warehouse team to enter.

Sales channel split

The warehouse that was never linked to the new storefront

A brand launched a second storefront on its own sales channel while keeping the original site running. The new channel's products carried over fine, but their inventory levels only existed at the original warehouse's stock location, which had never been linked to the new sales channel.

The script's location-level check flagged every one of those variants as missing a level at the required location, distinct from variants that had genuinely never been stocked anywhere. That distinction let the team see immediately that the fix was a sales-channel-to-location link, not a data repair, and they made that call deliberately instead of the script guessing at it.

What good looks like

After this runs, every managed variant either has a real inventory level at every stock location its sales channel needs, or sits in a clear flagged list with a reason attached. No level gets a stock count nobody entered, and a variant that already works is never touched. The gap between "looks active in the admin" and "actually purchasable on the storefront" closes before a customer ever hits an add-to-cart error.

FAQ

Why does my Medusa variant show a price but still say out of stock?

When a variant has manage_inventory set to true, Medusa computes availability at read time from the InventoryLevel rows on its linked inventory item, scoped to the stock locations tied to the sales channel making the request. If no InventoryLevel row exists at all, or the only rows that exist are at a stock location never linked to that sales channel, the variant reads as permanently out of stock, even though the admin still shows it active with a price.

Is it safe to automatically create a missing inventory level?

Yes, as long as the script only creates a level that is fully absent and always sets stocked_quantity to zero. That makes the variant manageable without inventing a stock count. The real quantity is business data the script cannot know, so it flags the new zeroed level for a human to update once the correct number is confirmed.

What is the difference between a missing inventory level and a zero quantity?

A zero stocked_quantity is a real InventoryLevel row that says there are zero units right now, which is honest and can be restocked later. A missing level means the row was never created at that location, so Medusa cannot compute availability there at all. The fix for a missing row is to create it at zero, not to guess a number, then let a human correct the quantity.

Related field notes

Citations

On the problem:

  1. Medusa Documentation: Product Variant Inventory. docs.medusajs.com/resources/commerce-modules/product/variant-inventory
  2. GitHub Issue: Inventory items not created or updated on import. github.com/medusajs/medusa/issues/5981
  3. GitHub Issue: Problems when using Inventory module, purchasable property returns incorrect information. github.com/medusajs/medusa/issues/4554

On the solution:

  1. Medusa Documentation: Inventory Module, Links to Other Modules. docs.medusajs.com/resources/commerce-modules/inventory/links-to-other-modules
  2. Medusa Documentation: Stock Location Module, Links to Other Modules. docs.medusajs.com/resources/commerce-modules/stock-location/links-to-other-modules
  3. Medusa V2 Admin API Reference. docs.medusajs.com/api/admin

Stuck on a tricky one?

If you have a problem in Medusa storefront access, pricing, inventory, orders, 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 get your variant purchasable again?

If this saved you a confusing afternoon chasing a phantom out-of-stock variant, 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