Skip to content

Reconciler Inventory & Reservations

Existing reservation blocks fulfillment of its own order

A customer buys the last unit of a variant. The order is real, paid, and sitting there waiting to ship. But when staff try to fulfill it, Medusa says there is no available stock, so the fulfillment button is a dead end. The stock is not actually missing. It is held by the order's own reservation, and Medusa's availability check never carves that reservation back out before deciding whether to let the fulfillment through. Here is why that deadlock happens and a small script that finds only the orphaned reservations causing it and clears them safely.

Python and Node.js Medusa Admin API Safe by default (dry run)
A large warehouse of shelves
Photo by Lance Chang on Unsplash
The short answer

Medusa v2 computes an inventory level's available quantity strictly as stocked_quantity minus reserved_quantity for an (inventory_item_id, location_id) pair, and both the admin fulfillment UI and the underlying create-fulfillment checks gate on that number being greater than zero. They do not subtract out the reservation belonging to the very line item or order being fulfilled. So once reserved_quantity reaches stocked_quantity, on the last unit or units sold, the same order that legitimately holds the reservation is told there is zero available and fulfillment is blocked. This gets worse when reservations become orphaned, left behind after an order is canceled or archived, or after a known fulfillment bug fails to delete them, since those stale rows permanently occupy the stock and cause the same deadlock for other, still open orders sharing that inventory item and location. Run a script that scans reservations, resolves each one's order, and deletes only the confirmed orphans. Full code, tests, and a dry run guard are below.

The problem in plain words

Availability in Medusa v2 is a subtraction. Every inventory level has a stocked_quantity, the number of units physically on hand at a location, and a reserved_quantity, the number spoken for by orders and carts that have not shipped yet. Available quantity is stocked_quantity minus reserved_quantity, and nothing more nuanced than that.

That subtraction has no concept of whose reservation is whose. When the admin fulfillment UI, or the workflow step underneath it, checks whether an item can be fulfilled, it looks at the location's available quantity. It never asks "does this particular order's own reservation account for the shortfall." So when the last unit of a variant sells, reserved_quantity climbs to equal stocked_quantity, available quantity hits zero, and the very order that placed that reservation gets told there is nothing to fulfill it with.

Order reserves the last unit in stock reserved_quantity equals stocked_quantity available quantity is zero check ignores whose reservation Fulfillment check sees available <= 0 Fulfillment blocked on its own order Order cannot ship
The order's own reservation is the reason available quantity hit zero, but the fulfillment check does not know that, so it blocks the order anyway.

Why it happens

The subtraction itself is correct behavior on a healthy store: it is what stops you from overselling. The deadlock shows up because of how it interacts with a few common situations:

The common thread is that Medusa's admin UI and create-fulfillment checks read a single aggregate number, available quantity at a location, and never ask which reservation is whose. See the citations at the end for the exact issues and docs.

The key insight

A reservation is only safe to delete once you know it is not backing a real, still open order. That means resolving every reservation's line_item_id against its actual order and checking that order's status and fulfillment status. If the order was canceled or archived, or if it is already fulfilled or shipped and the reservation should have been cleaned up but was not, the reservation is an orphan. Anything tied to an open, unfulfilled order must be left alone, even if its inventory level shows reserved_quantity equal to stocked_quantity, because that is exactly what a legitimate last-unit sale looks like. In that case the fix is not to delete anything, it is to flag the order for a human to review.

The fix, as a flow

We do not touch live checkouts and we never bump stock or force a fulfillment on someone's behalf. The job lists reservations, resolves each one's order, runs a pure decision function to classify it, and only deletes the ones confirmed as orphans. Anything still tied to an open order gets reported for manual review, not auto-fixed.

List reservations id, line_item_id, quantity Resolve each order status, fulfillment_status Pure decision fn classifyReservation Confirmed orphan? yes, dry run off no, keep Kept, or flagged for manual review DELETE reservation reserved_quantity drops
Only a confirmed orphan is ever deleted. A reservation still tied to an open order, even one stuck at reserved equal to stocked, is flagged for a human, never auto-fixed.

Build it step by step

1

Authenticate against the Admin API

Exchange the admin email and password for a JWT at /auth/user/emailpass, then send it as a 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)
// Node 18+ has fetch built in, no dependencies needed

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 reservations and expand each one's order

Ask for every reservation with id, quantity, line_item_id, inventory_item_id, location_id, and created_at, paginating with limit and offset. For any reservation with a line_item_id, expand *line_item.order so you get the order's status and fulfillment_status in the same call. A reservation with no line_item_id is a manual or custom reservation and is skipped entirely.

step2.py
import os, requests

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]

def admin_get(token, path, params=None):
    r = requests.get(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        params=params or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def list_reservations(token):
    reservations = []
    offset = 0
    limit = 200
    while True:
        data = admin_get(token, "/admin/reservations", {
            "fields": "id,quantity,line_item_id,inventory_item_id,location_id,created_at,*line_item.order",
            "limit": limit,
            "offset": offset,
        })
        reservations.extend(data["reservations"])
        offset += limit
        if offset >= data["count"]:
            return reservations
step2.js
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL;

async function adminGet(token, path, params = {}) {
  const url = new URL(`${BACKEND_URL}${path}`);
  for (const [key, value] of Object.entries(params)) {
    url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
  return res.json();
}

async function listReservations(token) {
  const reservations = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const data = await adminGet(token, "/admin/reservations", {
      fields: "id,quantity,line_item_id,inventory_item_id,location_id,created_at,*line_item.order",
      limit,
      offset,
    });
    reservations.push(...data.reservations);
    offset += limit;
    if (offset >= data.count) return reservations;
  }
}
3

Decide, with one pure function

Keep the classification in its own function that takes the reservation, the resolved order info, and the inventory level rows for that location, and returns a plain answer. It never touches the network, so it is trivial to unit test with fixture data. A reservation with no line_item_id is always manual_keep. One whose order or line item cannot be resolved at all is orphan_missing_order. One whose order is canceled or archived is orphan_canceled_order. One whose order is already fulfilled, shipped, or delivered, meaning fulfillment should have deleted this reservation already, is orphan_already_fulfilled. Everything else, a reservation legitimately backing an open, unfulfilled order, is keep.

decide.py
ORPHAN_ORDER_STATUSES = {"canceled", "archived"}
ALREADY_FULFILLED_STATUSES = {"fulfilled", "shipped", "delivered"}

def classify_reservation(reservation, order_info, levels):
    """Pure decision function. No I/O.

    reservation: {"id": str, "line_item_id": str | None, "quantity": int, "location_id": str}
    order_info: {"exists": bool, "status": str | None, "fulfillment_status": str | None} | None
    levels: [{"location_id": str, "stocked_quantity": int, "reserved_quantity": int}, ...]

    Returns "keep" | "orphan_canceled_order" | "orphan_missing_order"
          | "orphan_already_fulfilled" | "manual_keep".
    """
    if not reservation.get("line_item_id"):
        return "manual_keep"

    if order_info is None or not order_info.get("exists"):
        return "orphan_missing_order"

    if order_info.get("status") in ORPHAN_ORDER_STATUSES:
        return "orphan_canceled_order"

    if order_info.get("fulfillment_status") in ALREADY_FULFILLED_STATUSES:
        return "orphan_already_fulfilled"

    return "keep"
decide.js
const ORPHAN_ORDER_STATUSES = new Set(["canceled", "archived"]);
const ALREADY_FULFILLED_STATUSES = new Set(["fulfilled", "shipped", "delivered"]);

/**
 * Pure decision function. No I/O.
 *
 * @param {{ id: string, line_item_id: string | null, quantity: number, location_id: string }} reservation
 * @param {{ exists: boolean, status?: string, fulfillment_status?: string } | null} orderInfo
 * @param {{ location_id: string, stocked_quantity: number, reserved_quantity: number }[]} levels
 * @returns {"keep" | "orphan_canceled_order" | "orphan_missing_order" | "orphan_already_fulfilled" | "manual_keep"}
 */
export function classifyReservation(reservation, orderInfo, levels) {
  if (!reservation.line_item_id) return "manual_keep";

  if (!orderInfo || !orderInfo.exists) return "orphan_missing_order";

  if (ORPHAN_ORDER_STATUSES.has(orderInfo.status)) return "orphan_canceled_order";

  if (ALREADY_FULFILLED_STATUSES.has(orderInfo.fulfillment_status)) return "orphan_already_fulfilled";

  return "keep";
}
4

Confirm the stuck signature before repair

Before deleting anything, cross-check the flagged reservation's inventory_item_id against GET /admin/inventory-items/{id}/location-levels to confirm reserved_quantity equals stocked_quantity at that location_id. That is the "stuck" signature the issue describes: the orphan is silently consuming the entire stock buffer and blocking fulfillment of legitimate, still open orders sharing that inventory item and location.

step4.py
def get_location_levels(token, inventory_item_id):
    data = admin_get(token, f"/admin/inventory-items/{inventory_item_id}/location-levels")
    return data.get("inventory_levels") or []

def is_stuck_level(levels, location_id):
    for level in levels:
        if level["location_id"] == location_id:
            return level["reserved_quantity"] == level["stocked_quantity"]
    return False
step4.js
async function getLocationLevels(token, inventoryItemId) {
  const data = await adminGet(token, `/admin/inventory-items/${inventoryItemId}/location-levels`);
  return data.inventory_levels || [];
}

function isStuckLevel(levels, locationId) {
  const level = levels.find((l) => l.location_id === locationId);
  return level ? level.reserved_quantity === level.stocked_quantity : false;
}
5

Delete only the confirmed orphans, dry run guarded

When a reservation is classified as orphan_canceled_order, orphan_missing_order, or orphan_already_fulfilled, call DELETE /admin/reservations/{id}. That triggers Medusa's inventory workflow to decrement reserved_quantity at the associated location level, freeing capacity so available quantity rises above zero again. A reservation classified as keep or manual_keep is never touched. If one of those still shows reserved_quantity equal to stocked_quantity, report the order for manual review instead of writing anything, since bumping stock or force-creating a fulfillment on someone's behalf is a financial decision, not a safe automated write.

step5.py
ORPHAN_OUTCOMES = {"orphan_canceled_order", "orphan_missing_order", "orphan_already_fulfilled"}

def admin_delete(token, path):
    r = requests.delete(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def delete_reservation(token, reservation_id):
    return admin_delete(token, f"/admin/reservations/{reservation_id}")
step5.js
const ORPHAN_OUTCOMES = new Set(["orphan_canceled_order", "orphan_missing_order", "orphan_already_fulfilled"]);

async function adminDelete(token, path) {
  const res = await fetch(`${BACKEND_URL}${path}`, {
    method: "DELETE",
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status} on DELETE ${path}`);
  return res.json();
}

async function deleteReservation(token, reservationId) {
  return adminDelete(token, `/admin/reservations/${reservationId}`);
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs the {id, inventory_item_id, location_id, quantity, reason} tuples it would delete, along with the pre and post reserved_quantity that would result. Read the output, agree with it, then switch it off to let it write. Keep or manual_keep reservations that still show a stuck level get printed as a manual review list, never auto-fixed.

Run it safe

Always start with DRY_RUN=true, and never delete a reservation classified as keep or manual_keep. If one of those still shows reserved_quantity equal to stocked_quantity, that is a real order waiting on stock, and the fix is a human decision, either expedite restock or contact the customer, not a script bumping numbers.

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 is safe to run again and again because it only ever deletes a reservation confirmed as an orphan, never one still backing an open order.

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.
clear_blocking_reservations.py
"""Clear Medusa reservations that block fulfillment of their own order.

Medusa v2 computes an inventory level's available quantity as stocked_quantity
minus reserved_quantity, and the admin fulfillment checks gate on that number
being above zero. They never subtract out the reservation belonging to the
order being fulfilled, so once reserved_quantity reaches stocked_quantity on
the last unit sold, the very order that holds the reservation is told there is
zero available. This is worse when reservations are orphaned: left behind
after an order is canceled or archived, or after a fulfillment bug fails to
delete them. This scans reservations, resolves each one's order, and deletes
only the ones confirmed orphaned. Anything tied to an open order is left
alone and, if stuck, reported for manual review.
Run 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("clear_blocking_reservations")

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"

ORPHAN_ORDER_STATUSES = {"canceled", "archived"}
ALREADY_FULFILLED_STATUSES = {"fulfilled", "shipped", "delivered"}
ORPHAN_OUTCOMES = {"orphan_canceled_order", "orphan_missing_order", "orphan_already_fulfilled"}


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 admin_get(token, path, params=None):
    r = requests.get(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        params=params or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def admin_delete(token, path):
    r = requests.delete(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def classify_reservation(reservation, order_info, levels):
    """Pure decision function. No I/O.

    reservation: {"id": str, "line_item_id": str | None, "quantity": int, "location_id": str}
    order_info: {"exists": bool, "status": str | None, "fulfillment_status": str | None} | None
    levels: [{"location_id": str, "stocked_quantity": int, "reserved_quantity": int}, ...]

    Returns "keep" | "orphan_canceled_order" | "orphan_missing_order"
          | "orphan_already_fulfilled" | "manual_keep".
    """
    if not reservation.get("line_item_id"):
        return "manual_keep"

    if order_info is None or not order_info.get("exists"):
        return "orphan_missing_order"

    if order_info.get("status") in ORPHAN_ORDER_STATUSES:
        return "orphan_canceled_order"

    if order_info.get("fulfillment_status") in ALREADY_FULFILLED_STATUSES:
        return "orphan_already_fulfilled"

    return "keep"


def list_reservations(token):
    reservations = []
    offset = 0
    limit = 200
    while True:
        data = admin_get(token, "/admin/reservations", {
            "fields": "id,quantity,line_item_id,inventory_item_id,location_id,created_at,*line_item.order",
            "limit": limit,
            "offset": offset,
        })
        reservations.extend(data["reservations"])
        offset += limit
        if offset >= data["count"]:
            return reservations


def resolve_order_info(reservation):
    """Turns the expanded *line_item.order payload into order_info, or None if missing."""
    line_item = reservation.get("line_item")
    if not line_item:
        return None
    order = line_item.get("order")
    if not order:
        return None
    return {
        "exists": True,
        "status": order.get("status"),
        "fulfillment_status": order.get("fulfillment_status"),
    }


def get_location_levels(token, inventory_item_id):
    data = admin_get(token, f"/admin/inventory-items/{inventory_item_id}/location-levels")
    return data.get("inventory_levels") or []


def find_level(levels, location_id):
    for level in levels:
        if level["location_id"] == location_id:
            return level
    return None


def run():
    token = get_admin_token()
    reservations = list_reservations(token)

    cleared = 0
    flagged_for_review = 0
    for reservation in reservations:
        if not reservation.get("line_item_id"):
            continue  # manual_keep, never touched

        order_info = resolve_order_info(reservation)
        levels = get_location_levels(token, reservation["inventory_item_id"])
        outcome = classify_reservation(reservation, order_info, levels)

        if outcome == "keep":
            level = find_level(levels, reservation["location_id"])
            if level and level["reserved_quantity"] == level["stocked_quantity"]:
                order_id = (reservation.get("line_item") or {}).get("order", {}).get("id")
                log.warning(
                    "Order %s: reservation %s keeps reserved_quantity == stocked_quantity "
                    "at location %s. Flagging for manual review, not touching stock or fulfillment.",
                    order_id, reservation["id"], reservation["location_id"],
                )
                flagged_for_review += 1
            continue

        if outcome not in ORPHAN_OUTCOMES:
            continue

        level = find_level(levels, reservation["location_id"])
        before_reserved = level["reserved_quantity"] if level else None
        stocked = level["stocked_quantity"] if level else None
        after_reserved = (before_reserved - reservation["quantity"]) if before_reserved is not None else None

        log.warning(
            "Reservation %s classified as %s. inventory_item_id=%s location_id=%s quantity=%s "
            "reserved_quantity %s -> %s (stocked_quantity=%s). %s",
            reservation["id"], outcome, reservation["inventory_item_id"], reservation["location_id"],
            reservation["quantity"], before_reserved, after_reserved, stocked,
            "Would delete" if DRY_RUN else "Deleting",
        )

        if not DRY_RUN:
            admin_delete(token, f"/admin/reservations/{reservation['id']}")

        cleared += 1

    log.info(
        "Done. %d orphaned reservation(s) %s. %d order(s) flagged for manual review.",
        cleared, "to clear" if DRY_RUN else "cleared", flagged_for_review,
    )


if __name__ == "__main__":
    run()
clear-blocking-reservations.js
/**
 * Clear Medusa reservations that block fulfillment of their own order.
 *
 * Medusa v2 computes an inventory level's available quantity as stocked_quantity
 * minus reserved_quantity, and the admin fulfillment checks gate on that number
 * being above zero. They never subtract out the reservation belonging to the
 * order being fulfilled, so once reserved_quantity reaches stocked_quantity on
 * the last unit sold, the very order that holds the reservation is told there is
 * zero available. This is worse when reservations are orphaned: left behind
 * after an order is canceled or archived, or after a fulfillment bug fails to
 * delete them. This scans reservations, resolves each one's order, and deletes
 * only the ones confirmed orphaned. Anything tied to an open order is left
 * alone and, if stuck, reported for manual review.
 * Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/medusa/reservation-blocks-own-order-fulfillment/
 */
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 ORPHAN_ORDER_STATUSES = new Set(["canceled", "archived"]);
const ALREADY_FULFILLED_STATUSES = new Set(["fulfilled", "shipped", "delivered"]);
const ORPHAN_OUTCOMES = new Set(["orphan_canceled_order", "orphan_missing_order", "orphan_already_fulfilled"]);

/**
 * Pure decision function. No I/O.
 *
 * @param {{ id: string, line_item_id: string | null, quantity: number, location_id: string }} reservation
 * @param {{ exists: boolean, status?: string, fulfillment_status?: string } | null} orderInfo
 * @param {{ location_id: string, stocked_quantity: number, reserved_quantity: number }[]} levels
 * @returns {"keep" | "orphan_canceled_order" | "orphan_missing_order" | "orphan_already_fulfilled" | "manual_keep"}
 */
export function classifyReservation(reservation, orderInfo, levels) {
  if (!reservation.line_item_id) return "manual_keep";

  if (!orderInfo || !orderInfo.exists) return "orphan_missing_order";

  if (ORPHAN_ORDER_STATUSES.has(orderInfo.status)) return "orphan_canceled_order";

  if (ALREADY_FULFILLED_STATUSES.has(orderInfo.fulfillment_status)) return "orphan_already_fulfilled";

  return "keep";
}

async function getAdminToken() {
  const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  const body = await res.json();
  return body.token;
}

async function adminGet(token, path, params = {}) {
  const url = new URL(`${BACKEND_URL}${path}`);
  for (const [key, value] of Object.entries(params)) {
    url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
  return res.json();
}

async function adminDelete(token, path) {
  const res = await fetch(`${BACKEND_URL}${path}`, {
    method: "DELETE",
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status} on DELETE ${path}`);
  return res.json();
}

async function listReservations(token) {
  const reservations = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const data = await adminGet(token, "/admin/reservations", {
      fields: "id,quantity,line_item_id,inventory_item_id,location_id,created_at,*line_item.order",
      limit,
      offset,
    });
    reservations.push(...data.reservations);
    offset += limit;
    if (offset >= data.count) return reservations;
  }
}

function resolveOrderInfo(reservation) {
  const lineItem = reservation.line_item;
  if (!lineItem) return null;
  const order = lineItem.order;
  if (!order) return null;
  return {
    exists: true,
    status: order.status,
    fulfillment_status: order.fulfillment_status,
  };
}

async function getLocationLevels(token, inventoryItemId) {
  const data = await adminGet(token, `/admin/inventory-items/${inventoryItemId}/location-levels`);
  return data.inventory_levels || [];
}

function findLevel(levels, locationId) {
  return levels.find((l) => l.location_id === locationId) || null;
}

export async function run() {
  const token = await getAdminToken();
  const reservations = await listReservations(token);

  let cleared = 0;
  let flaggedForReview = 0;
  for (const reservation of reservations) {
    if (!reservation.line_item_id) continue; // manual_keep, never touched

    const orderInfo = resolveOrderInfo(reservation);
    const levels = await getLocationLevels(token, reservation.inventory_item_id);
    const outcome = classifyReservation(reservation, orderInfo, levels);

    if (outcome === "keep") {
      const level = findLevel(levels, reservation.location_id);
      if (level && level.reserved_quantity === level.stocked_quantity) {
        const orderId = reservation.line_item?.order?.id;
        console.warn(
          `Order ${orderId}: reservation ${reservation.id} keeps reserved_quantity == stocked_quantity ` +
          `at location ${reservation.location_id}. Flagging for manual review, not touching stock or fulfillment.`
        );
        flaggedForReview++;
      }
      continue;
    }

    if (!ORPHAN_OUTCOMES.has(outcome)) continue;

    const level = findLevel(levels, reservation.location_id);
    const beforeReserved = level ? level.reserved_quantity : undefined;
    const stocked = level ? level.stocked_quantity : undefined;
    const afterReserved = beforeReserved !== undefined ? beforeReserved - reservation.quantity : undefined;

    console.warn(
      `Reservation ${reservation.id} classified as ${outcome}. inventory_item_id=${reservation.inventory_item_id} ` +
      `location_id=${reservation.location_id} quantity=${reservation.quantity} ` +
      `reserved_quantity ${beforeReserved} -> ${afterReserved} (stocked_quantity=${stocked}). ` +
      `${DRY_RUN ? "Would delete" : "Deleting"}`
    );

    if (!DRY_RUN) {
      await adminDelete(token, `/admin/reservations/${reservation.id}`);
    }

    cleared++;
  }

  console.log(
    `Done. ${cleared} orphaned reservation(s) ${DRY_RUN ? "to clear" : "cleared"}. ` +
    `${flaggedForReview} order(s) flagged for manual review.`
  );
}

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

Add a test

classify_reservation is the part most worth testing, because it decides which reservations are safe to delete. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain data structures and checks the answer.

test_reservation_blocks_fulfillment.py
from clear_blocking_reservations import classify_reservation

LEVELS = [{"location_id": "sloc_1", "stocked_quantity": 1, "reserved_quantity": 1}]


def reservation(**over):
    base = {"id": "res_1", "line_item_id": "item_1", "quantity": 1, "location_id": "sloc_1"}
    base.update(over)
    return base


def test_manual_keep_when_no_line_item_id():
    r = reservation(line_item_id=None)
    assert classify_reservation(r, None, LEVELS) == "manual_keep"


def test_orphan_missing_order_when_order_info_is_none():
    r = reservation()
    assert classify_reservation(r, None, LEVELS) == "orphan_missing_order"


def test_orphan_missing_order_when_order_does_not_exist():
    r = reservation()
    order_info = {"exists": False}
    assert classify_reservation(r, order_info, LEVELS) == "orphan_missing_order"


def test_orphan_canceled_order():
    r = reservation()
    order_info = {"exists": True, "status": "canceled", "fulfillment_status": "not_fulfilled"}
    assert classify_reservation(r, order_info, LEVELS) == "orphan_canceled_order"


def test_orphan_archived_order():
    r = reservation()
    order_info = {"exists": True, "status": "archived", "fulfillment_status": "not_fulfilled"}
    assert classify_reservation(r, order_info, LEVELS) == "orphan_canceled_order"


def test_orphan_already_fulfilled():
    r = reservation()
    order_info = {"exists": True, "status": "completed", "fulfillment_status": "fulfilled"}
    assert classify_reservation(r, order_info, LEVELS) == "orphan_already_fulfilled"


def test_orphan_already_shipped():
    r = reservation()
    order_info = {"exists": True, "status": "completed", "fulfillment_status": "shipped"}
    assert classify_reservation(r, order_info, LEVELS) == "orphan_already_fulfilled"


def test_keep_when_order_is_open_and_unfulfilled():
    r = reservation()
    order_info = {"exists": True, "status": "pending", "fulfillment_status": "not_fulfilled"}
    assert classify_reservation(r, order_info, LEVELS) == "keep"
reservation-blocks-fulfillment.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyReservation } from "./clear-blocking-reservations.js";

const LEVELS = [{ location_id: "sloc_1", stocked_quantity: 1, reserved_quantity: 1 }];

const reservation = (over = {}) => ({
  id: "res_1",
  line_item_id: "item_1",
  quantity: 1,
  location_id: "sloc_1",
  ...over,
});

test("manual_keep when no line_item_id", () => {
  const r = reservation({ line_item_id: null });
  assert.equal(classifyReservation(r, null, LEVELS), "manual_keep");
});

test("orphan_missing_order when order info is null", () => {
  const r = reservation();
  assert.equal(classifyReservation(r, null, LEVELS), "orphan_missing_order");
});

test("orphan_missing_order when order does not exist", () => {
  const r = reservation();
  assert.equal(classifyReservation(r, { exists: false }, LEVELS), "orphan_missing_order");
});

test("orphan_canceled_order", () => {
  const r = reservation();
  const orderInfo = { exists: true, status: "canceled", fulfillment_status: "not_fulfilled" };
  assert.equal(classifyReservation(r, orderInfo, LEVELS), "orphan_canceled_order");
});

test("orphan_archived_order", () => {
  const r = reservation();
  const orderInfo = { exists: true, status: "archived", fulfillment_status: "not_fulfilled" };
  assert.equal(classifyReservation(r, orderInfo, LEVELS), "orphan_canceled_order");
});

test("orphan_already_fulfilled", () => {
  const r = reservation();
  const orderInfo = { exists: true, status: "completed", fulfillment_status: "fulfilled" };
  assert.equal(classifyReservation(r, orderInfo, LEVELS), "orphan_already_fulfilled");
});

test("orphan_already_shipped", () => {
  const r = reservation();
  const orderInfo = { exists: true, status: "completed", fulfillment_status: "shipped" };
  assert.equal(classifyReservation(r, orderInfo, LEVELS), "orphan_already_fulfilled");
});

test("orphan_already_delivered", () => {
  const r = reservation();
  const orderInfo = { exists: true, status: "completed", fulfillment_status: "delivered" };
  assert.equal(classifyReservation(r, orderInfo, LEVELS), "orphan_already_fulfilled");
});

test("keep when order is open and unfulfilled", () => {
  const r = reservation();
  const orderInfo = { exists: true, status: "pending", fulfillment_status: "not_fulfilled" };
  assert.equal(classifyReservation(r, orderInfo, LEVELS), "keep");
});

test("keep when order is completed but not yet fulfilled", () => {
  const r = reservation();
  const orderInfo = { exists: true, status: "completed", fulfillment_status: "not_fulfilled" };
  assert.equal(classifyReservation(r, orderInfo, LEVELS), "keep");
});

Case studies

Last unit sold

A single order deadlocked on its own stock

A small apparel brand sold the very last unit of a limited colorway. The order was paid, the warehouse had the item in hand, but the fulfillment button in the admin was greyed out with zero available stock showing. Staff assumed the inventory count was wrong and spent an hour trying to correct stocked_quantity before realizing the number was accurate, it was the check that was the problem.

Running the classifier in dry run showed that reservation as keep, correctly tied to an open, unfulfilled order, with reserved_quantity equal to stocked_quantity at that location. The script flagged it for manual review instead of deleting anything. The team understood the deadlock was expected for a legitimate last-unit sale and fulfilled the order directly through the workflow that bypasses the availability gate, rather than touching stock numbers.

Orphaned after cancellation

A cancelled order kept blocking a different customer's fulfillment

An order for the last two units of a SKU was canceled during a support call, but its reservation was never cleaned up. Weeks later, a different customer ordered the same SKU after a restock, and that new order's fulfillment was blocked because the old, canceled order's reservation was still eating the stock at that location.

The scan resolved the stale reservation's line item back to the canceled order and classified it orphan_canceled_order. Deleting it dropped reserved_quantity back down, and the new customer's order fulfilled normally on the very next check, with no changes to stocked_quantity or any fulfillment created on anyone's behalf.

What good looks like

After this runs on a schedule, a deadlock caused by a genuinely orphaned reservation clears itself before anyone notices, and available quantity goes back to reflecting stock a real order can still claim. A legitimate last-unit sale that shows the same reserved-equals-stocked signature is never touched automatically. It is surfaced to a human, because deciding to bump stock or push a fulfillment through by hand is a financial call the script should never make for you.

FAQ

Why does Medusa say an order has zero available stock when it clearly has a reservation?

Medusa computes available quantity as stocked_quantity minus reserved_quantity for an inventory item at a location. It does not subtract out the reservation belonging to the very order you are trying to fulfill. So once reserved_quantity reaches stocked_quantity on the last unit sold, the admin fulfillment check sees zero available and blocks the order even though its own reservation is the legitimate reason the stock is at zero.

Is it safe to delete a reservation to unblock fulfillment?

Only when the reservation is confirmed orphaned: its order was canceled or archived, its order was already fulfilled or shipped so the reservation should have been deleted already, or the order or line item no longer exists. A reservation still tied to an open, unfulfilled order must never be deleted, since that stock is legitimately spoken for.

What happens when you delete a stuck reservation in Medusa?

Deleting a reservation through DELETE /admin/reservations/{id} triggers Medusa's inventory workflow to decrement reserved_quantity at the associated location level. That raises stocked_quantity minus reserved_quantity, the available quantity, back above zero, which is what the fulfillment check reads before allowing a fulfillment.

Related field notes

Citations

On the problem:

  1. Unable to Fulfill Orders When Reservations Exhaust Available Inventory. Medusa GitHub Issue #6500. github.com/medusajs/medusa/issues/6500
  2. Fulfilment no longer possible when all items have been reserved. Medusa GitHub Issue #9821. github.com/medusajs/medusa/issues/9821
  3. Order fulfillment is not deleting inventory item reservations sometimes. Medusa GitHub Issue #11266. github.com/medusajs/medusa/issues/11266

On the solution:

  1. Medusa Documentation: Inventory Module Concepts, stocked_quantity and reserved_quantity. docs.medusajs.com/resources/commerce-modules/inventory/concepts
  2. Medusa Admin User Guide: Manage Reservations. docs.medusajs.com/user-guide/inventory/reservations
  3. Medusa V2 Admin API Reference: Reservations and Inventory Items. docs.medusajs.com/api/admin

Stuck on a tricky one?

If you have a problem in Medusa storefront access, 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 unblock your fulfillment?

If this saved you from a phantom out of stock item or a support ticket about an order that would not ship, 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